Compare commits

...
Author SHA1 Message Date
paseo-bot[bot] f02537727a test(claude): split the thinking-restart regression into two tests
Each case now states its own setup instead of branching inside one test body.
2026-10-13 16:42:51 +02:00
870c8dc9c5 fix(claude): keep the first turn working after a settings change before it
Changing thinking (or another query setting) on a Claude agent that has no
live query left the restart flag set after a fresh query was created, so
the next prompt failed with "Cannot read properties of null (reading
'push')". A fresh query now consumes the pending restart, and the query
pump drains the query the turn created instead of re-running ensureQuery.

Keeps the submitted-message emission as it was on main; the immediate-
failure rewind change from #5950 is not part of this fix.

Adds a real Claude daemon test for the reported path: create an agent,
set thinking, then send the first message.

Co-authored-by: louis1618 <louis.devop@gmail.com>
Co-authored-by: oyzh888 <oyzh888@users.noreply.github.com>
2026-10-13 16:42:51 +02:00
louis1618 d3c1cc54bb fix(claude): preserve prompt delivery and rewind identity across query restarts 2026-10-13 16:42:51 +02:00
Elowen 5805b776c2 fix(git): open commit diffs from subdirectory workspaces (#6801)
* fix(git): resolve commit file diffs from the worktree root

* test(git): cover commit diffs from nested workspaces through daemon RPC
2026-10-13 15:52:54 +02:00
paseo-bot[bot] 4400445edb Allow worktree creation after Git initialization (#6785)
* fix(server): allow worktree creation after Git initialization

Fresh project checkout reads could publish Git capability while worktree creation retained a cached non-Git snapshot. Refresh an existing snapshot when the fresh read observes a classification change, before returning the new capability.

* test(server): cover Git initialization in the standard integration suite

* test(server): separate stress-test writes from deletion delivery

Wait for active classifications and queued deliveries between the distinct write and deletion phases. Otherwise a late create and delete coalesce as update, leaving the strict deletion assertion waiting indefinitely. Preserve every event assertion without waiting for scheduled native safety audits.

* test(server): compare canonical repository paths on Windows

---------

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-10-13 13:55:25 +02:00
Elowen 337e81a9c6 fix(files): preserve relative symlinks when duplicating entries (#6771)
* fix(files): preserve relative symlinks when duplicating entries

* test(files): verify duplicated symlink targets through daemon RPC
2026-10-13 10:56:43 +02:00
paseo-bot[bot] b923cde6cf fix(claude): show custom model context limits during the first turn (#6767)
Seed active usage from the explicit 1M modifier and the model resolved by Claude Code initialization. Preserve SDK-reported limits on repeated initialization and reset them when the selected model changes.

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-10-13 10:30:03 +02:00
Galaxy Roaming 058375dac8 fix(server): release ACP provider process after history resume (#6766)
* fix(server): release ACP provider process after history resume

Resuming a persisted agent with purpose "history" only replays the
session into the canonical timeline, but ACP sessions kept the spawned
CLI process resident afterwards. Every client that fetches an archived
agent's timeline (the app reconnects fetching every visible agent)
therefore re-materialized a provider process per archived agent and
nothing ever released it — measured ~280 MB resident per dsh agent.

After the replay and configured overrides finish, a history-purpose
resume now settles the commands gate, drops the connection, and
terminates the child. The session object stays registered with the
replayed history so read paths keep working; the existing
`this.child !== child` exit guard already ignores the released
process, and interactive use goes through reloadAgentSession which
spawns a fresh process. This matches the runtime-only contract the
Codex provider already implements.

* fix(server): retain child refs when history-runtime release times out

Addresses Greptile review: if terminateProcess returns kill-timeout the
child stays registered so close() can retry instead of leaking an
untracked process. The test now asserts the process is already dead when
resumeSession resolves, which pins the documented release-before-return
contract instead of a post-hoc poll.

Greptile iteration 1.
2026-10-13 09:05:02 +02:00
paseo-bot[bot] eb051ea81e Render Astro diffs containing empty expressions (#6763)
* fix(highlight): render Astro diffs containing empty expressions

Empty Astro expressions produced zero-length nested JavaScript ranges, which rejected the whole diff. Leave those expressions in the markup tree and preserve highlighting around them.

* test(highlight): make empty Astro expression assertions explicit

---------

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-10-13 08:00:47 +02:00
paseo-bot[bot]andGeorgeR 1839de5b1d Restore provider readiness for mobile plugins (#6760)
* fix(client): restore provider readiness on mobile

React Native uses an AbortSignal without throwIfAborted. Check aborted with the existing API disposal error so readiness works while preserving cleanup and cancellation.

Co-authored-by: GeorgeR <897457+GeorgeR@users.noreply.github.com>

* test(plugins): run mobile readiness regression in CI

---------

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
Co-authored-by: GeorgeR <897457+GeorgeR@users.noreply.github.com>
2026-10-13 06:58:33 +02:00
paseo-bot[bot]andfdkevin ec6393f31f Keep pairing QR codes fully visible on mobile (#6758)
* Keep pairing QR code within compact tile

* Move pairing screenshot after viewport assertion

* test(pairing): verify the complete QR code on Android

The native regression failed with the fixed-size SVG because the code was clipped at both screen edges. Verify the complete square and visible margins through the Android app, and keep browser geometry assertions in the pairing helper.

Co-authored-by: fdkevin <25277753+fdkevin0@users.noreply.github.com>

---------

Co-authored-by: fdkevin <25277753+fdkevin0@users.noreply.github.com>
Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-10-13 05:57:41 +02:00
paseo-bot[bot] b5e51cf839 Run letter shortcuts with non-Latin keyboard layouts (#6755)
* fix(app): run recorded letter shortcuts with non-Latin layouts

Recording unsupported letters saves the physical A-Z key, but dispatch rejected that same event. Accept its letter code while keeping ASCII character matching for layouts such as Dvorak.

* fix(app): run recorded dotted-letter shortcuts

Check the original single-letter key before case folding expands uppercase dotted I into two code points.

---------

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-10-13 05:00:54 +02:00
paseo-bot[bot]andMarcelo Barbosa b0f054de90 Reach IPv6-only workspace services through their proxy URLs (#6753)
* fix(server): reach IPv6-only workspace services

Workspace service forwarding and health probes forced IPv4 loopback,
returning 502 and unhealthy for a reachable IPv6-only service. Select
between both literal loopback addresses for HTTP, upgrades, and probes,
without relying on the system's localhost DNS entries.

Co-authored-by: Marcelo Barbosa <celocb@gmail.com>

* test(server): keep proxy POST mechanics in a helper

---------

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
Co-authored-by: Marcelo Barbosa <celocb@gmail.com>
2026-10-13 03:57:12 +02:00
CtrlCreeper e8108d9092 fix(app): only offer Remove for providers defined in config.json (#6568)
* fix(app): only offer Remove for providers defined in config.json

Plugin-registered providers (Antigravity, Muse Code) share source: "custom"
with config-defined ones, so Settings → Providers offered Remove on them.
The removeProviders patch only deletes config.json entries, so the action
succeeded silently while the plugin re-registered the provider.

Gate the Remove menu on the provider having an extends entry in config —
the actual definition custom providers live behind. Plugin providers keep
their enable/disable switch.

Fixes #6349

* Address review: named ownership check, E2E coverage for plugin providers

- Name the config-owned provider check instead of combining three
  conditions inline in canRemove.
- Replace the JSDOM component tests with a Playwright case in the
  existing provider-removal suite: a plugin-registered provider renders
  without the Remove action and survives a removeProviders patch.
2026-10-13 02:59:26 +02:00
paseo-bot[bot]andOuyang Xingyuan 9eaf7ad0c1 Render local images in Markdown file previews (#6750)
* fix(app): render local relative images in markdown file preview

File preview dropped relative images because it only accepted http/data URIs and never resolved paths against the opened markdown file.

* test(app): cover local relative images in markdown preview e2e

* fix(app): show local images in Markdown file previews

Carry forward document-relative image loading and preserve the host home
directory when resolving images beside home-relative Markdown files.
Decode file URIs once, and require decoded images in the file-pane regression.

Co-authored-by: Ouyang Xingyuan <galaxyroaming@qq.com>

* fix(app): resolve local Markdown image URL suffixes

---------

Co-authored-by: Ouyang Xingyuan <galaxyroaming@qq.com>
Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-10-13 01:51:07 +02:00
paseo-bot[bot] be0acb2935 fix(app): keep Diff updates alive after the first edit (#6745)
Hidden retained panels overwrite shared query metadata when a clean checkout becomes dirty. Resolve subscription demand from attached observers so a hidden panel cannot cancel the visible Diff tab.

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-10-13 00:53:44 +02:00
47 changed files with 1963 additions and 94 deletions
+3
View File
@@ -93,3 +93,6 @@ app.json
# Heap snapshots dumped by --heapsnapshot-near-heap-limit; keep them for debugging.
*.heapsnapshot
# Project-local Serena configuration and memories.
.serena/
+6 -5
View File
@@ -151,11 +151,12 @@ lifecycle queue. Resume chooses its history or interactive purpose from the dura
entering that queue. Shutdown must finish before the manager releases runtime ownership; a failed
close retains the runtime for cleanup and blocks replacement through that close operation.
Authoritative timeline catch-up can use a runtime-only `history` resume purpose. For Codex, that
purpose initializes a temporary app-server, reads the persisted thread and child histories, and
releases the process before returning. It never loads, resumes, or unarchives a native thread,
including legacy records whose native archive failed. The retained history session contains only
the read results. Interactive resume remains responsible for repairing a provider session archived
Authoritative timeline catch-up can use a runtime-only `history` resume purpose. The retained
history session contains only the read results and must release its provider process once replay
finishes. Codex reads the persisted thread and child histories on a temporary app-server and never
loads, resumes, or unarchives a native thread, including legacy records whose native archive failed.
ACP providers spawn the CLI, replay the session through `session/load`, and terminate the process
before returning. Interactive resume remains responsible for repairing a provider session archived
outside Paseo while its Paseo agent is active.
Provider session connection owns every process it spawns until the session is registered with
+1 -1
View File
@@ -4,7 +4,7 @@ Paseo proxies HTTP traffic to services running inside your workspaces. Localhost
## How it works
When a `paseo.json` script of `"type": "service"` starts, Paseo assigns it a local port and registers a route in the service proxy. Incoming requests whose `Host` header matches the script's generated hostname are forwarded to that port.
When a `paseo.json` script of `"type": "service"` starts, Paseo assigns it a local port and registers a route in the service proxy. Incoming requests whose `Host` header matches the script's generated hostname are forwarded to that port. HTTP requests, WebSocket upgrades, and service health checks select the reachable IPv4 or IPv6 loopback address. Services can bind to `127.0.0.1` or `::1` without changing their proxy URL.
The generated hostname is built from the script name, branch, and project:
@@ -0,0 +1,122 @@
import { appendFile } from "node:fs/promises";
import path from "node:path";
import type { Page } from "@playwright/test";
import { test, expect } from "../support/fixtures";
import { openChangesTreePanel } from "../support/helpers/workspace-tabs";
function changesTree(page: Page) {
return page.getByTestId("changes-tree-panel").filter({ visible: true });
}
async function expectEmptyChanges(page: Page) {
await expect(changesTree(page).getByText("No changes to display", { exact: true })).toBeVisible();
}
async function editReadme(repoPath: string, text: string) {
await appendFile(path.join(repoPath, "README.md"), text);
}
async function expectReadmeChanges(page: Page, additions: number) {
await expect(changesTree(page).getByText("README.md", { exact: true })).toBeVisible({
timeout: 30_000,
});
await expect(changesTree(page)).toContainText(`+${additions}`);
}
async function openEmptyDiff(page: Page) {
await changesTree(page).getByTestId("changes-options-menu").click();
await page.getByTestId("changes-open-tab").click();
await expect(
page
.getByTestId("working-diff-panel")
.filter({ visible: true })
.getByText("No changes to display", { exact: true }),
).toBeVisible();
}
async function expectReadmeDiff(page: Page, additions: number) {
await expect(
page.getByTestId("working-diff-panel").filter({ visible: true }).getByTestId("diff-file-0"),
).toHaveAccessibleName(`README.md, +${additions}, -0`, { timeout: 30_000 });
}
async function openDiffFromNewTabMenu(page: Page) {
await page.getByTestId("workspace-new-tab-button").filter({ visible: true }).first().click();
await page.getByTestId("workspace-new-tab-menu-diff").filter({ visible: true }).first().click();
await expect(
page
.getByTestId("working-diff-panel")
.filter({ visible: true })
.getByText("No changes to display", { exact: true }),
).toBeVisible();
}
async function refreshDiff(page: Page) {
await page
.getByTestId("working-diff-panel")
.filter({ visible: true })
.getByTestId("changes-refresh")
.click();
}
async function reloadWorkspace(page: Page) {
await page.reload();
}
test("Changes follows the first edit to a clean workspace without reloading", async ({
page,
withWorkspace,
}) => {
const workspace = await withWorkspace();
await page.setViewportSize({ width: 1400, height: 900 });
await workspace.navigateTo();
await openChangesTreePanel(page);
await expectEmptyChanges(page);
await editReadme(workspace.repoPath, "first edit\n");
await expectReadmeChanges(page, 1);
await editReadme(workspace.repoPath, "second edit\n");
await expectReadmeChanges(page, 2);
});
test("the full diff follows the first edit to a clean workspace without reloading", async ({
page,
withWorkspace,
}) => {
const workspace = await withWorkspace();
await page.setViewportSize({ width: 1400, height: 900 });
await workspace.navigateTo();
await openChangesTreePanel(page);
await expectEmptyChanges(page);
await openEmptyDiff(page);
await editReadme(workspace.repoPath, "first edit\n");
await expectReadmeDiff(page, 1);
await editReadme(workspace.repoPath, "second edit\n");
await expectReadmeDiff(page, 2);
});
test("a directly opened Diff tab follows the first edit without an active Changes tree", async ({
page,
withWorkspace,
}) => {
const workspace = await withWorkspace();
await page.setViewportSize({ width: 1400, height: 900 });
await workspace.navigateTo();
await openDiffFromNewTabMenu(page);
await editReadme(workspace.repoPath, "first edit\n");
await expectReadmeDiff(page, 1);
await editReadme(workspace.repoPath, "second edit\n");
await expectReadmeDiff(page, 2);
await refreshDiff(page);
await expectReadmeDiff(page, 2);
await reloadWorkspace(page);
await expectReadmeDiff(page, 2);
await editReadme(workspace.repoPath, "third edit\n");
await expectReadmeDiff(page, 3);
});
@@ -2,6 +2,7 @@ import { mkdir, readFile, writeFile } from "node:fs/promises";
import path from "node:path";
import { expect, test, type Page } from "../support/fixtures";
import {
expandFolder,
openFileExplorer,
openFileFromExplorer,
expectFileTabOpen,
@@ -70,6 +71,14 @@ async function selectFileView(page: Page, view: "Preview" | "Source"): Promise<v
await expect(option).toHaveAttribute("aria-selected", "true");
}
async function expectMarkdownImageLoaded(page: Page, name: string): Promise<void> {
const image = page.getByRole("img", { name, exact: true });
await expect(image).toBeVisible();
await expect
.poll(() => image.locator("img").evaluate((element: HTMLImageElement) => element.naturalWidth))
.toBeGreaterThan(0);
}
function watchRequestsTo(page: Page, origin: string): string[] {
const requests: string[] = [];
page.on("request", (request) => {
@@ -684,6 +693,40 @@ test.describe("CodeMirror workspace file editing", () => {
await expect.poll(() => image.getAttribute("src")).not.toBe(initialSource);
});
test("renders local relative images inside Markdown preview", async ({ page, withWorkspace }) => {
test.setTimeout(90_000);
const workspace = await withWorkspace({ prefix: "file-md-relative-image-" });
await mkdir(path.join(workspace.repoPath, "docs"), { recursive: true });
await mkdir(path.join(workspace.repoPath, "data", "cache"), { recursive: true });
await writeFile(path.join(workspace.repoPath, "docs", "sibling.png"), RED_PIXEL);
await writeFile(path.join(workspace.repoPath, "data", "cache", "hero.png"), RED_PIXEL);
await writeFile(
path.join(workspace.repoPath, "docs", "guide.md"),
[
"# Guide",
"",
"![sibling](./sibling.png)",
"",
"![hero](../data/cache/hero.png?v=2#preview)",
"",
].join("\n"),
"utf8",
);
await workspace.navigateTo();
await openFileExplorer(page);
await expandFolder(page, "docs");
await openFileFromExplorer(page, "guide.md");
await expectFileTabOpen(page, "docs/guide.md");
const visibleFilePane = page.getByTestId("workspace-file-pane").filter({ visible: true });
await expect(visibleFilePane.getByText("Guide", { exact: true })).toBeVisible();
await expectMarkdownImageLoaded(page, "sibling");
await expectMarkdownImageLoaded(page, "hero");
await visibleFilePane.screenshot({
path: test.info().outputPath("markdown-relative-images.png"),
});
});
test("previews and refreshes an HTML plan while preserving source access", async ({
page,
withWorkspace,
@@ -1,6 +1,8 @@
import { test } from "../support/fixtures";
import { startIsolatedHostDaemon } from "../support/helpers/isolated-host-daemon";
import {
expectPairingOffer,
expectCompletePairingQr,
expectRelayConsent,
openPairDeviceModal,
preparePairingHost,
@@ -18,3 +20,20 @@ test("opens relay consent in browser web", async ({ page }) => {
await daemon.close();
}
});
test("keeps the pairing QR code inside its compact tile", async ({ page }, testInfo) => {
const daemon = await startIsolatedHostDaemon("pair-device-browser-compact", {
mutableRelay: { enabled: true },
});
try {
await preparePairingHost(page, daemon);
await page.setViewportSize({ width: 320, height: 568 });
await openPairDeviceModal(page);
await expectPairingOffer(page);
await expectCompletePairingQr(page);
await page.screenshot({ path: testInfo.outputPath("pairing-compact.png") });
} finally {
await daemon.close();
}
});
@@ -10,16 +10,29 @@ import {
openSettingsHost,
openSettingsHostSection,
} from "../support/helpers/settings";
import { copyPluginExample } from "../support/helpers/plugin-fixture";
const CUSTOM_PROVIDER = {
id: "junie",
name: "Junie",
} as const;
const PLUGIN_PROVIDER = {
id: "direct-example",
name: "Direct provider example",
pluginId: "provider-direct-example",
} as const;
interface ProviderRemovalDaemonClient {
connect(): Promise<void>;
close(): Promise<void>;
patchDaemonConfig(config: { removeProviders?: string[] }): Promise<unknown>;
getDaemonConfig(): Promise<{ config: { pluginsEnabled?: boolean } }>;
patchDaemonConfig(config: {
removeProviders?: string[];
pluginsEnabled?: boolean;
}): Promise<unknown>;
installDirectoryPlugin(path: string): Promise<unknown>;
removePlugin(pluginId: string): Promise<void>;
getProvidersSnapshot(): Promise<{
entries: Array<{ provider: string; source?: "builtin" | "custom" }>;
}>;
@@ -93,4 +106,45 @@ test.describe("provider removal", () => {
await client.close().catch(() => undefined);
}
});
test("does not offer Remove for a plugin-registered provider", async ({ page }) => {
test.setTimeout(120_000);
const client = await connectDaemonClient<ProviderRemovalDaemonClient>({
clientIdPrefix: "provider-removal-e2e",
});
const plugin = await copyPluginExample("provider-direct");
try {
const previous = await client.getDaemonConfig();
try {
await client.patchDaemonConfig({ pluginsEnabled: true });
await client.installDirectoryPlugin(plugin.directory);
await gotoAppShell(page);
await openSettings(page);
await openSettingsHost(page, getServerId());
await openSettingsHostSection(page, getServerId(), "providers");
const pluginProviderRow = page.getByRole("button", {
name: `${PLUGIN_PROVIDER.name} provider details`,
exact: true,
});
await expect(pluginProviderRow).toBeVisible();
await expect(page.getByTestId(`provider-actions-${PLUGIN_PROVIDER.id}`)).toHaveCount(0);
// The provider survives a removeProviders patch pointed at it: the
// plugin, not config.json, owns the definition.
await client.patchDaemonConfig({ removeProviders: [PLUGIN_PROVIDER.id] });
await expect(pluginProviderRow).toBeVisible();
} finally {
await client.removePlugin(PLUGIN_PROVIDER.pluginId).catch(() => undefined);
await client
.patchDaemonConfig({ pluginsEnabled: previous.config.pluginsEnabled })
.catch(() => undefined);
}
} finally {
await client.close().catch(() => undefined);
await plugin.cleanup();
}
});
});
@@ -0,0 +1,43 @@
// Start on Settings > Host > Pair device with relay enabled in the Android app.
// node packages/app/e2e/mobile/pair-device/android.mjs <agent-device-session>
import assert from "node:assert/strict";
import { execFileSync } from "node:child_process";
const [, , session] = process.argv;
assert(session, "Pass the agent-device session for the connected Android app");
function ad(...args) {
return execFileSync("agent-device", [...args, "--session", session], {
encoding: "utf8",
timeout: 60_000,
});
}
ad(
"press",
'label="Pair a device, Scan a QR code or copy a link to connect your phone to this host"',
"--settle",
);
ad("wait", "text", "Pairing QR code", "10000");
const { data } = JSON.parse(ad("snapshot", "-i", "--json"));
const sheet = data.nodes.find((node) => node.label === "Bottom Sheet");
const qr = data.nodes.find((node) => node.label === "Pairing QR code");
assert(sheet?.rect, "Pairing sheet must be visible");
assert(qr?.rect, "Pairing QR code must be visible");
assert(qr.rect.x > sheet.rect.x, "QR must have an unclipped left margin inside the sheet");
assert(
qr.rect.x + qr.rect.width < sheet.rect.x + sheet.rect.width,
"QR must have an unclipped right margin inside the sheet",
);
assert(Math.abs(qr.rect.width - qr.rect.height) <= 1, "Complete QR must remain square");
assert(qr.rect.y >= sheet.rect.y, "QR top must be visible inside the sheet");
assert(
qr.rect.y + qr.rect.height <= sheet.rect.y + sheet.rect.height,
"QR bottom must be visible inside the sheet",
);
assert(
data.nodes.some((node) => node.label === "Copy" && node.enabled),
"Copy must remain available",
);
console.log("PASS: complete square QR fits inside the pairing sheet", qr.rect);
ad("press", 'label="Close"', "--settle");
@@ -220,3 +220,24 @@ export function expectDaemonPidUnchanged(
expect(before).toBeDefined();
expect(after).toBe(before);
}
export async function expectCompletePairingQr(page: Page): Promise<void> {
const qr = page.getByRole("img", { name: "Pairing QR code" });
await qr.scrollIntoViewIfNeeded();
// IntersectionObserver rounds fractional sheet/scroll coordinates.
await expect(qr).toBeInViewport({ ratio: 0.999 });
const bounds = await qr.evaluate((element) => {
const tile = element.parentElement;
if (!tile) throw new Error("Pairing QR tile is missing");
const qrRect = element.getBoundingClientRect();
const tileRect = tile.getBoundingClientRect();
return {
qr: { x: qrRect.x, y: qrRect.y, right: qrRect.right, bottom: qrRect.bottom },
tile: { x: tileRect.x, y: tileRect.y, right: tileRect.right, bottom: tileRect.bottom },
};
});
expect(bounds.qr.x).toBeGreaterThanOrEqual(bounds.tile.x);
expect(bounds.qr.y).toBeGreaterThanOrEqual(bounds.tile.y);
expect(bounds.qr.right).toBeLessThanOrEqual(bounds.tile.right);
expect(bounds.qr.bottom).toBeLessThanOrEqual(bounds.tile.bottom);
}
+65
View File
@@ -199,6 +199,71 @@ function providerUpdate(generatedAt: string): ProvidersSnapshotUpdateMessage {
}
describe("server data push router", () => {
it("keeps a visible diff subscribed when a hidden panel switches to the same comparison", () => {
const queryClient = new QueryClient();
const fake = createFakeClient();
const serverId = "server-1";
const cwd = "/repo";
const options = (mode: "base" | "uncommitted", enabled: boolean) => {
const queryKey = checkoutDiffQueryKey(
serverId,
cwd,
mode,
mode === "base" ? "main" : undefined,
);
return {
queryKey,
queryFn: skipToken as typeof skipToken,
gcTime: Infinity,
meta: checkoutDiffPushRoute({
enabled,
serverId,
cwd,
subscriptionId: `checkoutDiff:${JSON.stringify(queryKey)}`,
compare: { mode, ...(mode === "base" ? { baseRef: "main" } : {}) },
}),
};
};
const visible = new QueryObserver(queryClient, options("base", true));
const hidden = new QueryObserver(queryClient, options("base", false));
const unsubscribeVisible = visible.subscribe(() => {});
const unsubscribeHidden = hidden.subscribe(() => {});
const unmount = mountServerDataPushRouter({ queryClient, client: fake.client, serverId });
try {
visible.setOptions(options("uncommitted", true));
hidden.setOptions(options("uncommitted", false));
const subscription = fake.subscribeCheckoutDiffCalls.findLast(
(call) => call.compare.mode === "uncommitted",
)!;
const files = [
{
path: "README.md",
isNew: false,
isDeleted: false,
additions: 1,
deletions: 0,
hunks: [],
},
];
fake.emit({
type: "checkout_diff_update",
payload: { subscriptionId: subscription.subscriptionId, cwd, files, error: null },
});
expect(queryClient.getQueryData(options("uncommitted", true).queryKey)).toMatchObject({
files,
});
expect(fake.unsubscribeCheckoutDiffCalls).not.toContain(subscription.subscriptionId);
unsubscribeVisible();
expect(fake.unsubscribeCheckoutDiffCalls).toContain(subscription.subscriptionId);
} finally {
unsubscribeVisible();
unsubscribeHidden();
unmount();
queryClient.clear();
}
});
it("retains equal diff files without reconstructing changed file bodies", () => {
const queryClient = new QueryClient();
const fake = createFakeClient();
+9 -1
View File
@@ -494,6 +494,14 @@ function getActiveServerDataRoute(
if (query.getObserversCount() === 0) {
return null;
}
// Query metadata belongs to the last observer that updated its options. A
// retained hidden panel must not cancel demand from another visible panel.
const observerRoutes = query.observers
.map((observer) => getServerDataRoute({ meta: observer.options.meta }))
.filter((route): route is ServerDataRoute => route !== null);
if (observerRoutes.length > 0) {
return observerRoutes.find((route) => route.enabled && route.serverId === serverId) ?? null;
}
const route = getServerDataRoute(query);
if (route) {
return route.enabled && route.serverId === serverId ? route : null;
@@ -590,7 +598,7 @@ function canEventChangeDesiredSubscriptions(type: QueryCacheNotifyEvent["type"])
);
}
function getServerDataRoute(query: Query): ServerDataRoute | null {
function getServerDataRoute(query: Pick<Query, "meta">): ServerDataRoute | null {
const meta = query.meta;
if (!isRecord(meta) || !isRecord(meta.serverData)) {
return null;
@@ -80,7 +80,6 @@ export function PairDeviceSection({ serverId, onClose }: PairDeviceSectionProps)
type: "svg",
errorCorrectionLevel: "M",
margin: 1,
width: 480,
}),
enabled: Boolean(pairingQuery.data?.url),
dataShape: "value",
@@ -275,7 +274,8 @@ function PairingQr({ svg, isError }: { svg: string | null; isError: boolean }) {
return (
<SvgXml
xml={svg}
style={styles.qrImage}
width="100%"
height="100%"
accessibilityRole="image"
accessibilityLabel={t("pairing.device.qrAccessibility")}
/>
@@ -365,10 +365,6 @@ const styles = StyleSheet.create((theme) => ({
borderColor: theme.colors.border,
backgroundColor: theme.colors.palette.white,
},
qrImage: {
width: "100%",
height: "100%",
},
linkRow: {
flexDirection: "row",
alignItems: "center",
@@ -0,0 +1,165 @@
import { describe, expect, it } from "vitest";
import { resolveMarkdownFileDirectory, resolveMarkdownPreviewImageSource } from "./image-source";
describe("resolveMarkdownFileDirectory", () => {
it("uses the parent of an absolute markdown path", () => {
expect(
resolveMarkdownFileDirectory({
markdownPath: "/Users/test/project/docs/guide.md",
workspaceRoot: "/Users/test/project",
}),
).toBe("/Users/test/project/docs");
});
it("joins a workspace-relative markdown path to the workspace root", () => {
expect(
resolveMarkdownFileDirectory({
markdownPath: "docs/星谷云/guide.md",
workspaceRoot: "/Users/test/project",
}),
).toBe("/Users/test/project/docs/星谷云");
});
it("preserves the host home directory for home-relative markdown paths", () => {
expect(
resolveMarkdownFileDirectory({
markdownPath: "~/.paseo/plans/note.md",
workspaceRoot: "/Users/test/project",
}),
).toBe("~/.paseo/plans");
});
});
describe("resolveMarkdownPreviewImageSource", () => {
it("passes through remote and data URIs", () => {
expect(
resolveMarkdownPreviewImageSource({
source: "https://example.com/image.png",
markdownPath: "docs/guide.md",
workspaceRoot: "/Users/test/project",
}),
).toBe("https://example.com/image.png");
expect(
resolveMarkdownPreviewImageSource({
source: "data:image/png;base64,abc",
markdownPath: "docs/guide.md",
workspaceRoot: "/Users/test/project",
}),
).toBe("data:image/png;base64,abc");
});
it("resolves a sibling image against the markdown file", () => {
expect(
resolveMarkdownPreviewImageSource({
source: "./images/hero.png",
markdownPath: "docs/guide.md",
workspaceRoot: "/Users/test/project",
}),
).toBe("/Users/test/project/docs/images/hero.png");
});
it("ignores local URL query strings and fragments", () => {
expect(
resolveMarkdownPreviewImageSource({
source: "./images/hero.png?v=2#preview",
markdownPath: "docs/guide.md",
workspaceRoot: "/workspace",
}),
).toBe("/workspace/docs/images/hero.png");
});
it("preserves encoded filename characters when stripping URL suffixes", () => {
expect(
resolveMarkdownPreviewImageSource({
source: "./hero%23draft%3Fv.png?v=2#preview",
markdownPath: "docs/guide.md",
workspaceRoot: "/workspace",
}),
).toBe("/workspace/docs/hero#draft?v.png");
});
it("preserves remote URL query strings and fragments", () => {
const source = "https://example.com/hero.png?v=2#preview";
expect(resolveMarkdownPreviewImageSource({ source, markdownPath: "docs/guide.md" })).toBe(
source,
);
});
it("resolves parent-relative images that stay inside the workspace", () => {
expect(
resolveMarkdownPreviewImageSource({
source: "../../data/cache/xinggu_docs/12-linkedin/token.png",
markdownPath: "docs/星谷云/12-LinkedIn.md",
workspaceRoot: "/Users/test/project",
}),
).toBe("/Users/test/project/data/cache/xinggu_docs/12-linkedin/token.png");
});
it("keeps absolute and home-relative image paths", () => {
expect(
resolveMarkdownPreviewImageSource({
source: "/tmp/screenshot.png",
markdownPath: "docs/guide.md",
workspaceRoot: "/Users/test/project",
}),
).toBe("/tmp/screenshot.png");
expect(
resolveMarkdownPreviewImageSource({
source: "~/.paseo/screenshots/output.png",
markdownPath: "docs/guide.md",
workspaceRoot: "/Users/test/project",
}),
).toBe("~/.paseo/screenshots/output.png");
});
it("normalizes file URIs into host paths", () => {
expect(
resolveMarkdownPreviewImageSource({
source: "file:///tmp/paseo-preview.png",
markdownPath: "docs/guide.md",
workspaceRoot: "/Users/test/project",
}),
).toBe("/tmp/paseo-preview.png");
});
it("resolves images beside a home-relative document without using the workspace root", () => {
expect(
resolveMarkdownPreviewImageSource({
source: "screenshots/output.png",
markdownPath: "~/.paseo/plans/note.md",
workspaceRoot: "/Users/test/project",
}),
).toBe("~/.paseo/plans/screenshots/output.png");
});
it("decodes a file URI exactly once", () => {
expect(
resolveMarkdownPreviewImageSource({
source: "file:///tmp/hero%2520image.png",
markdownPath: "docs/guide.md",
workspaceRoot: "/workspace",
}),
).toBe("/tmp/hero%20image.png");
});
it("resolves parent segments from a home-relative document", () => {
expect(
resolveMarkdownPreviewImageSource({
source: "../images/hero.png",
markdownPath: "~/docs/guide.md",
workspaceRoot: "/workspace",
}),
).toBe("~/images/hero.png");
});
it("resolves Windows relative images against a drive-rooted markdown file", () => {
expect(
resolveMarkdownPreviewImageSource({
source: "..\\images\\hero.png",
markdownPath: "C:\\Users\\test\\repo\\docs\\guide.md",
workspaceRoot: "C:\\Users\\test\\repo",
}),
).toBe("C:/Users/test/repo/images/hero.png");
});
});
@@ -0,0 +1,150 @@
import { localFileSourceToPath } from "@/attachments/utils";
import { isAbsolutePath } from "@/utils/path";
function trimTrailingSeparators(value: string): string {
if (value === "/" || /^[A-Za-z]:[\\/]?$/.test(value)) {
return value.replace(/\\/g, "/");
}
return value.replace(/[\\/]+$/, "");
}
function normalizePathSegments(value: string): string {
const segments: string[] = [];
for (const segment of value.split("/")) {
if (!segment || segment === ".") {
continue;
}
if (segment === "..") {
if (segments.length > 0) {
segments.pop();
}
continue;
}
segments.push(segment);
}
return segments.join("/");
}
function normalizeAbsolutePath(value: string): string | null {
const normalizedInput = trimTrailingSeparators(value.replace(/\\/g, "/"));
if (isHomeRelativePath(normalizedInput)) {
return `~/${normalizePathSegments(normalizedInput.slice(2))}`;
}
if (!isAbsolutePath(normalizedInput)) {
return null;
}
const drivePath = /^([A-Za-z]:)\/(.*)$/.exec(normalizedInput);
if (drivePath) {
return trimTrailingSeparators(`${drivePath[1]}/${normalizePathSegments(drivePath[2])}`);
}
const prefix = normalizedInput.startsWith("//") ? "//" : "/";
return trimTrailingSeparators(
`${prefix}${normalizePathSegments(normalizedInput.replace(/^\/+/, ""))}`,
);
}
function parentDirectory(path: string): string | null {
const normalized = trimTrailingSeparators(path.replace(/\\/g, "/"));
if (!normalized) {
return null;
}
if (normalized === "/" || /^[A-Za-z]:\/?$/.test(normalized)) {
return normalized.endsWith("/") ? normalized : `${normalized}/`;
}
const index = normalized.lastIndexOf("/");
if (index < 0) {
return null;
}
if (index === 0) {
return "/";
}
const parent = normalized.slice(0, index);
if (/^[A-Za-z]:$/.test(parent)) {
return `${parent}/`;
}
return parent;
}
function joinAbsolute(baseDirectory: string, relativePath: string): string | null {
const directory = trimTrailingSeparators(baseDirectory.replace(/\\/g, "/"));
const relative = relativePath.replace(/\\/g, "/").replace(/^\/+/, "");
if (!directory) {
return null;
}
return normalizeAbsolutePath(`${directory}/${relative}`);
}
function isHomeRelativePath(value: string): boolean {
return value === "~" || value.startsWith("~/") || value.startsWith("~\\");
}
function decodeRelativeSource(source: string): string {
try {
return decodeURIComponent(source);
} catch {
return source;
}
}
export function resolveMarkdownFileDirectory(input: {
markdownPath: string;
workspaceRoot?: string;
}): string | null {
const markdownPath = input.markdownPath.trim().replace(/\\/g, "/");
if (!markdownPath) {
return null;
}
if (isAbsolutePath(markdownPath) || isHomeRelativePath(markdownPath)) {
const absolute = normalizeAbsolutePath(markdownPath);
return absolute ? parentDirectory(absolute) : null;
}
const workspaceRoot = input.workspaceRoot?.trim();
if (!workspaceRoot || !isAbsolutePath(workspaceRoot)) {
return null;
}
const absoluteMarkdown = joinAbsolute(workspaceRoot, markdownPath);
return absoluteMarkdown ? parentDirectory(absoluteMarkdown) : null;
}
export function resolveMarkdownPreviewImageSource(input: {
source: string;
markdownPath: string;
workspaceRoot?: string;
}): string | null {
const source = input.source.trim();
if (!source) {
return null;
}
if (/^(https?:|data:|blob:)/i.test(source)) {
return source;
}
const pathSource = source.replace(/[?#].*$/, "");
const path = localFileSourceToPath(
pathSource.startsWith("file://") ? pathSource : decodeRelativeSource(pathSource),
);
if (!path) {
return null;
}
if (isHomeRelativePath(path) || isAbsolutePath(path)) {
return path;
}
const directory = resolveMarkdownFileDirectory({
markdownPath: input.markdownPath,
workspaceRoot: input.workspaceRoot,
});
if (!directory) {
return path;
}
return joinAbsolute(directory, path);
}
@@ -1,11 +1,59 @@
import { useMemo } from "react";
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
import type { ASTNode, RenderRules } from "react-native-markdown-display";
import { Text, View } from "react-native";
import { StyleSheet } from "react-native-unistyles";
import { MarkdownRenderer } from "@/components/markdown/renderer";
import {
createSharedMarkdownRules,
MarkdownRenderer,
type MarkdownStyles,
} from "@/components/markdown/renderer";
import { parseMarkdownPreviewDocument } from "./document";
import { resolveMarkdownPreviewImageSource } from "./image-source";
import { FileMarkdownPreviewImage } from "./preview-image";
export function FileMarkdownPreview({ source }: { source: string }) {
export function FileMarkdownPreview({
source,
filePath,
workspaceRoot,
client,
serverId,
}: {
source: string;
filePath: string;
workspaceRoot?: string;
client?: DaemonClient | null;
serverId?: string;
}) {
const document = useMemo(() => parseMarkdownPreviewDocument(source), [source]);
const rules = useMemo<RenderRules>(
() => ({
...createSharedMarkdownRules(),
image: (node: ASTNode, _children, _parent: ASTNode[], _styles: MarkdownStyles) => {
const resolved = resolveMarkdownPreviewImageSource({
source: String(node.attributes?.src ?? ""),
markdownPath: filePath,
workspaceRoot,
});
if (!resolved) {
return null;
}
return (
<FileMarkdownPreviewImage
key={node.key}
source={resolved}
occurrenceKey={`${filePath}:${node.key}`}
alt={typeof node.attributes?.alt === "string" ? node.attributes.alt : undefined}
client={client}
workspaceRoot={workspaceRoot}
serverId={serverId}
/>
);
},
}),
[client, filePath, serverId, workspaceRoot],
);
return (
<View style={styles.outerGutter}>
@@ -31,7 +79,7 @@ export function FileMarkdownPreview({ source }: { source: string }) {
))}
</View>
) : null}
<MarkdownRenderer text={document.body} />
<MarkdownRenderer text={document.body} rules={rules} />
</View>
</View>
);
@@ -0,0 +1,151 @@
import { useCallback, useMemo, useState } from "react";
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
import { useTranslation } from "react-i18next";
import { Image, Pressable, Text, View, type StyleProp, type ViewStyle } from "react-native";
import { StyleSheet, withUnistyles } from "react-native-unistyles";
import { useAssistantImage } from "@/assistant-image/use-assistant-image";
import { AttachmentLightbox, type ImageLightboxSource } from "@/components/attachment-lightbox";
import { LoadingSpinner } from "@/components/ui/loading-spinner";
import type { Theme } from "@/styles/theme";
const ThemedLoadingSpinner = withUnistyles(LoadingSpinner);
const foregroundMutedColorMapping = (theme: Theme) => ({
color: theme.colors.foregroundMuted,
});
const PREVIEW_IMAGE_MIN_HEIGHT = 160;
export function FileMarkdownPreviewImage({
source,
occurrenceKey,
alt,
client,
workspaceRoot,
serverId,
}: {
source: string;
occurrenceKey: string;
alt?: string;
client?: DaemonClient | null;
workspaceRoot?: string;
serverId?: string;
}) {
const { t } = useTranslation();
const [viewerOpen, setViewerOpen] = useState(false);
const openViewer = useCallback(() => setViewerOpen(true), []);
const closeViewer = useCallback(() => setViewerOpen(false), []);
const image = useAssistantImage({
source,
occurrenceKey,
client,
workspaceRoot,
serverId,
});
const binding = image.status === "failed" ? null : image.binding;
const aspectRatio = image.status === "failed" ? null : image.aspectRatio;
const imageUri = binding?.uri ?? "";
const imageSource = useMemo(() => ({ uri: imageUri }), [imageUri]);
const imageSizeStyle = useMemo<ViewStyle>(() => {
if (aspectRatio) {
return { aspectRatio };
}
return { height: PREVIEW_IMAGE_MIN_HEIGHT };
}, [aspectRatio]);
const surfaceStyle = useMemo<StyleProp<ViewStyle>>(
() => [styles.surface, imageSizeStyle],
[imageSizeStyle],
);
const lightboxSource = useMemo<ImageLightboxSource | null>(() => {
if (!viewerOpen || !imageUri) return null;
return {
type: "uri",
uri: imageUri,
contentSize: aspectRatio ? { width: aspectRatio, height: 1 } : undefined,
};
}, [aspectRatio, imageUri, viewerOpen]);
if (image.status === "failed") {
return (
<View style={[styles.frame, styles.state]} testID="markdown-preview-image-failed">
<Text style={styles.errorText}>{image.message}</Text>
</View>
);
}
if (!binding) {
return (
<View style={[styles.frame, styles.state]} testID="markdown-preview-image-loading">
<ThemedLoadingSpinner size="small" uniProps={foregroundMutedColorMapping} />
</View>
);
}
return (
<View style={styles.frame} testID="markdown-preview-image">
<Pressable
accessibilityLabel={t("composer.attachments.openImage")}
accessibilityRole="button"
disabled={image.status !== "loaded"}
onPress={openViewer}
style={surfaceStyle}
>
<View style={styles.image} accessibilityRole="image" accessibilityLabel={alt}>
<Image
ref={binding.onRef}
source={imageSource}
style={styles.image}
resizeMode="contain"
onLoad={binding.onLoad}
onError={binding.onError}
/>
{image.status === "loading" ? (
<View pointerEvents="none" style={styles.loadingOverlay}>
<ThemedLoadingSpinner size="small" uniProps={foregroundMutedColorMapping} />
</View>
) : null}
</View>
</Pressable>
<AttachmentLightbox source={lightboxSource} onClose={closeViewer} />
</View>
);
}
const styles = StyleSheet.create((theme) => ({
frame: {
width: "100%",
marginBottom: theme.spacing[4],
},
surface: {
width: "100%",
overflow: "hidden",
borderRadius: theme.borderRadius.md,
backgroundColor: theme.colors.surface2,
},
image: {
width: "100%",
height: "100%",
},
state: {
minHeight: PREVIEW_IMAGE_MIN_HEIGHT,
alignItems: "center",
justifyContent: "center",
paddingHorizontal: theme.spacing[4],
paddingVertical: theme.spacing[6],
backgroundColor: theme.colors.surface2,
borderRadius: theme.borderRadius.md,
},
loadingOverlay: {
position: "absolute",
top: 0,
right: 0,
bottom: 0,
left: 0,
alignItems: "center",
justifyContent: "center",
},
errorText: {
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.base,
textAlign: "center",
},
}));
+28 -1
View File
@@ -54,6 +54,9 @@ interface FilePreviewBodyProps {
location: WorkspaceFileLocation;
navigationRevision: number;
imagePreviewUri: string | null;
workspaceRoot: string;
client: DaemonClient | null;
serverId: string;
}
type TextExplorerFile = ExplorerFile & { kind: "text" };
@@ -135,6 +138,9 @@ function FilePreviewBody({
location,
navigationRevision,
imagePreviewUri,
workspaceRoot,
client,
serverId,
}: FilePreviewBodyProps) {
const { t } = useTranslation();
const filePath = location.path;
@@ -182,7 +188,13 @@ function FilePreviewBody({
style={styles.previewContent}
showsVerticalScrollIndicator
>
<FileMarkdownPreview source={preview.content ?? ""} />
<FileMarkdownPreview
source={preview.content ?? ""}
filePath={filePath}
workspaceRoot={workspaceRoot}
client={client}
serverId={serverId}
/>
</RNScrollView>
</View>
);
@@ -297,6 +309,7 @@ export function FilePane({
return (
<FilePanePresentation
serverId={serverId}
workspaceRoot={normalizedWorkspaceRoot}
client={client}
readTarget={readTarget}
preview={preview}
@@ -338,6 +351,7 @@ function isEditableTextFile(input: {
function FilePanePresentation({
serverId,
workspaceRoot,
client,
readTarget,
preview,
@@ -359,6 +373,7 @@ function FilePanePresentation({
imagePreviewUri,
}: {
serverId: string;
workspaceRoot: string;
client: DaemonClient | null;
readTarget: { cwd: string; path: string } | null;
preview: ExplorerFile | null;
@@ -393,6 +408,8 @@ function FilePanePresentation({
return (
<EditableFilePane
key={`${serverId}:${readTarget.cwd}:${readTarget.path}`}
serverId={serverId}
workspaceRoot={workspaceRoot}
client={client}
cwd={readTarget.cwd}
path={readTarget.path}
@@ -449,12 +466,17 @@ function FilePanePresentation({
location={location}
navigationRevision={navigationRevision}
imagePreviewUri={imagePreviewUri}
workspaceRoot={workspaceRoot}
client={client}
serverId={serverId}
/>
</View>
);
}
function EditableFilePane({
serverId,
workspaceRoot,
client,
cwd,
path,
@@ -470,6 +492,8 @@ function EditableFilePane({
location,
navigationRevision,
}: {
serverId: string;
workspaceRoot: string;
client: DaemonClient;
cwd: string;
path: string;
@@ -622,6 +646,9 @@ function EditableFilePane({
location={location}
navigationRevision={navigationRevision}
imagePreviewUri={null}
workspaceRoot={workspaceRoot}
client={client}
serverId={serverId}
/>
)}
</View>
@@ -1027,6 +1027,71 @@ describe("unassigned shortcuts", () => {
});
describe("matching", () => {
it("dispatches a captured Russian letter shortcut on the same key", () => {
const event = keyboardInput({ key: "и", code: "KeyB", ctrlKey: true });
const combo = keyboardEventToComboString(event as KeyboardEvent);
expect(combo).toBe("Ctrl+B");
const bindings = buildEffectiveBindings({
"sidebar-toggle-left-ctrl-period-non-mac": combo!,
});
expect(resolveShortcut({ event, context: desktopNonMac, bindings }).match?.action).toBe(
"sidebar.toggle.left",
);
});
it("dispatches a recorded dotted-uppercase letter with Shift held", () => {
const event = keyboardInput({ key: "İ", code: "KeyS", ctrlKey: true, shiftKey: true });
const combo = keyboardEventToComboString(event as KeyboardEvent);
expect(combo).toBe("Ctrl+Shift+S");
const bindings = buildEffectiveBindings({
"sidebar-toggle-left-ctrl-period-non-mac": combo!,
});
expect(resolveShortcut({ event, context: desktopNonMac, bindings }).match?.action).toBe(
"sidebar.toggle.left",
);
});
it.each(["и", "И", "β", "ش", "ב", "é"])(
"matches default and recorded letter bindings for unsupported letter %s",
(key) => {
const event = keyboardInput({ key, code: "KeyB", ctrlKey: true });
expectShortcutResolution({
event,
context: desktopNonMac,
action: "sidebar.toggle.left",
});
const combo = keyboardEventToComboString(event as KeyboardEvent);
expect(combo).toBe("Ctrl+B");
expect(
resolveShortcut({
event,
context: desktopNonMac,
bindings: buildEffectiveBindings({
"sidebar-toggle-left-ctrl-period-non-mac": combo!,
}),
}).match?.action,
).toBe("sidebar.toggle.left");
},
);
it.each(["v", "†", "Dead", "Unidentified", ""])(
"does not match an unrelated character or named key %s by physical letter",
(key) => {
expectNoShortcutResolution({
event: { key, code: "KeyB", ctrlKey: true },
context: desktopNonMac,
});
},
);
it("does not match non-letter bindings by physical key for unsupported letters", () => {
expectNoShortcutResolution({
event: { key: "ю", code: "Period", ctrlKey: true },
context: desktopNonMac,
});
});
it("dispatches a captured Dvorak binding on the same logical key", () => {
const event = keyboardInput({ key: "b", code: "KeyN", metaKey: true });
const combo = keyboardEventToComboString(event as KeyboardEvent);
@@ -1285,6 +1285,12 @@ function matchesKeyOrCode(combo: KeyCombo, event: KeyboardShortcutInput): boolea
if (combo.shift === true && combo.shiftedKey !== undefined && eventKey === combo.shiftedKey) {
return true;
}
// Recording an unsupported letter (for example Cyrillic и on KeyB)
// stores its physical A-Z key. Dispatch must accept that same key without
// giving Latin layouts such as Dvorak a second physical-key binding.
if (/^[a-z]$/.test(combo.key) && /^\p{Letter}$/u.test(event.key) && !/^[a-z]$/.test(eventKey)) {
return event.code === combo.code;
}
// macOS rewrites event.key when Option is held (Option+T -> "†",
// Option+[ -> "“"), so Alt-bound letter / bracket bindings can only
// match by event.code. Stay key-first for non-Alt bindings so Dvorak
@@ -328,7 +328,7 @@ export function ProvidersSection({ serverId }: ProvidersSectionProps) {
const isConnected = useHostRuntimeIsConnected(serverId);
const supportsProviderRemoval = useHostFeature(serverId, "providerRemoval");
const { entries, isLoading, refresh } = useProvidersSnapshot(serverId);
const { patchConfig } = useDaemonConfig(serverId);
const { config, patchConfig } = useDaemonConfig(serverId);
const openProviderSettings = useProviderSettingsStore((state) => state.open);
const [pendingProviderId, setPendingProviderId] = useState<string | null>(null);
const [removingProviderId, setRemovingProviderId] = useState<string | null>(null);
@@ -338,6 +338,12 @@ export function ProvidersSection({ serverId }: ProvidersSectionProps) {
const providerDefinitions = useMemo(() => buildProviderDefinitions(entries), [entries]);
const hasServer = serverId.length > 0;
/** Custom providers are deletable only when config.json holds their `extends` definition. */
const isConfigOwnedProvider = useCallback(
(providerId: string) => Boolean(config?.providers?.[providerId]?.extends),
[config],
);
const handleOpenProviderSettings = useCallback(
(providerId: string) => {
openProviderSettings({ serverId, provider: providerId });
@@ -444,7 +450,11 @@ export function ProvidersSection({ serverId }: ProvidersSectionProps) {
enabled={entry.enabled ?? true}
isToggling={pendingProviderId === def.id}
isRemoving={removingProviderId === def.id}
canRemove={supportsProviderRemoval && entry.source === "custom"}
canRemove={
supportsProviderRemoval &&
entry.source === "custom" &&
isConfigOwnedProvider(def.id)
}
isFirst={index === 0}
onPress={handleOpenProviderSettings}
onToggleEnabled={handleToggleEnabled}
+1 -1
View File
@@ -1051,7 +1051,7 @@ async function waitForProvidersReady(
try {
await observation.ready;
signal.throwIfAborted();
if (signal.aborted) throw new Error("Paseo API is disposed");
return await new Promise<PaseoProviderSnapshotResult>((resolve, reject) => {
let settled = false;
let requestId: string | null = null;
@@ -144,6 +144,34 @@ const title = "Hello";
);
});
it("highlights Astro markup containing only an empty expression", () => {
const code = "<p>{}</p>";
const tokens = highlightCode(code, "Empty.astro").flat();
expect(tokens.map((token) => token.text).join("")).toBe(code);
expect(tokens).toContainEqual({ text: "p", style: "tag" });
});
it.each([
"<p>{title} {} {title}</p>",
'<p class="{} {title}">{title}</p>',
"<p class={} title={title}>{title}</p>",
])("highlights Astro containing an empty expression: %s", (markup) => {
const code = `---\nconst title = "Hello";\n---\n${markup}\n<style>.page { color: red; }</style>`;
const lines = highlightCode(code, "Empty.astro");
expect(lines).toHaveLength(code.split("\n").length);
for (const [index, tokens] of lines.entries()) {
expect(tokens.map((token) => token.text).join("")).toBe(code.split("\n")[index]);
}
expect(lines.flat()).toContainEqual({ text: "const", style: "keyword" });
expect(lines.flat()).toContainEqual({ text: "p", style: "tag" });
expect(lines.flat()).toContainEqual({ text: "color", style: "property" });
expect(lines.flat()).toContainEqual({ text: "title", style: "variable" });
});
it("keeps an Astro expression open through nested template strings", () => {
const code = "<p>{`a ${`}`} b`}</p>";
+6 -4
View File
@@ -45,10 +45,12 @@ function maskExpressions(text: string): string {
}
function expressionOverlays(node: SyntaxNodeRef, input: Input): Range[] | null {
const overlays = findExpressions(input.read(node.from, node.to)).map(({ from, to }) => ({
from: node.from + from + 1,
to: node.from + to,
}));
const overlays = findExpressions(input.read(node.from, node.to))
.filter(({ from, to }) => to > from + 1)
.map(({ from, to }) => ({
from: node.from + from + 1,
to: node.from + to,
}));
return overlays.length > 0 ? overlays : null;
}
@@ -1750,6 +1750,7 @@ export class ACPAgentSession implements AgentSession, ACPClient {
private fallbackAssistantMessageId: string | null = null;
private closed = false;
private historyPending = false;
private historyRuntimeReleased = false;
private replayingHistory = false;
private bootstrapThreadEventPending = false;
private readonly terminateProcess: ProcessTerminator;
@@ -1867,11 +1868,38 @@ export class ACPAgentSession implements AgentSession, ACPClient {
}
await this.applyConfiguredOverrides();
if (this.resumePurpose === "history") {
await this.releaseHistoryRuntime();
}
} catch (error) {
await this.closeAfterInitializationFailure(error);
}
}
// History resumes exist to replay persisted events into the canonical
// timeline; the provider process is released once replay finishes so
// archived agents hold no resident runtime. Interactive work goes through
// reloadAgentSession, which spawns a fresh process.
private async releaseHistoryRuntime(): Promise<void> {
const child = this.child;
if (child) {
const result = await this.terminateProcess(child, {
gracefulTimeoutMs: 2_000,
forceTimeoutMs: 2_000,
});
if (result === "kill-timeout") {
// Keep the child registered so close() can retry the shutdown.
this.logger.warn("ACP history resume could not release the provider process");
return;
}
}
this.historyRuntimeReleased = true;
this.connection = null;
this.child = null;
this.settleCommandsReady();
}
private async closeAfterInitializationFailure(error: unknown): Promise<never> {
try {
await this.close();
@@ -2841,7 +2869,7 @@ export class ACPAgentSession implements AgentSession, ACPClient {
});
const spawnError = rejectOnSpawnError(child, stderrChunks);
child.once("exit", (code, signal) => {
if (this.closed) {
if (this.closed || this.historyRuntimeReleased) {
return;
}
if (this.activeForegroundTurnId) {
@@ -1,5 +1,5 @@
import { createServer } from "node:http";
import { mkdir, mkdtemp, rm } from "node:fs/promises";
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
import path from "node:path";
import pino from "pino";
import { expect, test } from "vitest";
@@ -13,12 +13,18 @@ test.each([
{ placement: "delta", cacheTokens: 0 },
{ placement: "start", cacheTokens: 1_000 },
{ placement: "delta", cacheTokens: 1_000 },
{ placement: "start", cacheTokens: 0, model: "glm-5.2[1m]" },
{ placement: "start", cacheTokens: 0, model: "sonnet", resolvedModel: "glm-5.2[1m]" },
])(
"counts $placement input and $cacheTokens cached tokens through Claude Code",
async ({ placement, cacheTokens }) => {
"counts $placement input and $cacheTokens cached tokens through Claude Code ($model)",
async ({ placement, cacheTokens, model = "claude-sonnet-4-6", resolvedModel = model }) => {
const cwd = await mkdtemp(path.resolve(".claude-context-usage-"));
const configDir = path.join(cwd, "config");
await mkdir(configDir);
await writeFile(
path.join(configDir, "settings.json"),
JSON.stringify({ env: { ANTHROPIC_DEFAULT_SONNET_MODEL: resolvedModel } }),
);
let requestCount = 0;
const server = createServer(async (request, response) => {
for await (const chunk of request) {
@@ -45,7 +51,7 @@ test.each([
id: `msg_context_usage_${requestCount}`,
type: "message",
role: "assistant",
model: "claude-sonnet-4-6",
model: resolvedModel,
content: [],
stop_reason: null,
stop_sequence: null,
@@ -86,11 +92,9 @@ test.each([
},
},
});
const session = await client.createSession(
{ provider: "claude", cwd, model: "claude-sonnet-4-6" },
undefined,
{ persistSession: false },
);
const session = await client.createSession({ provider: "claude", cwd, model }, undefined, {
persistSession: false,
});
try {
for (let turn = 0; turn < 2; turn++) {
const events = await collectSessionTurnEvents(session, "say hello", {
@@ -111,7 +115,10 @@ test.each([
expect(events).toContainEqual(
expect.objectContaining({
type: "usage_updated",
usage: expect.objectContaining({ contextWindowUsedTokens: expectedTokens }),
usage: expect.objectContaining({
contextWindowUsedTokens: expectedTokens,
contextWindowMaxTokens: resolvedModel.endsWith("[1m]") ? 1_000_000 : 200_000,
}),
}),
);
}
@@ -0,0 +1,112 @@
import { expect, test } from "vitest";
import { createTestLogger } from "../../../../test-utils/test-logger.js";
import { collectSessionTurnEvents } from "../test-utils/session-stream-adapter.js";
import { ClaudeAgentClient } from "./agent.js";
import type { ClaudeQueryFactory } from "./query.js";
// A query dependency that speaks the same stream interface as Claude Code.
// Each request repeats init so a second turn exercises runtime-limit retention.
function contextQuery(runtimeModel: string, reportedLimit: number): ClaudeQueryFactory {
return ({ prompt }) => {
let model = runtimeModel;
const messages = (async function* () {
if (typeof prompt === "string") throw new Error("Expected streaming input");
for await (const _message of prompt) {
yield {
type: "system",
subtype: "init",
session_id: "context-session",
permissionMode: "default",
model,
};
yield {
type: "stream_event",
session_id: "context-session",
event: { type: "message_start", message: { usage: { input_tokens: 249_950 } } },
};
yield {
type: "stream_event",
session_id: "context-session",
event: { type: "message_delta", usage: { output_tokens: 50 } },
};
yield {
type: "result",
subtype: "success",
session_id: "context-session",
duration_ms: 1,
duration_api_ms: 1,
is_error: false,
num_turns: 1,
result: "done",
stop_reason: null,
total_cost_usd: 0,
usage: { input_tokens: 249_950, output_tokens: 50, cache_read_input_tokens: 0 },
modelUsage: { [model]: { contextWindow: reportedLimit } },
permission_denials: [],
uuid: "context-result",
};
}
})();
return Object.assign(messages, {
close: () => {
void messages.return();
},
interrupt: async () => undefined,
setModel: async (value: string) => {
model = value;
},
setPermissionMode: async () => undefined,
supportedModels: async () => [],
supportedCommands: async () => [],
}) as ReturnType<ClaudeQueryFactory>;
};
}
test.each([
{ selected: "glm-5.2[1m]", runtime: "glm-5.2[1m]", expected: 1_000_000 },
{ selected: "sonnet", runtime: "glm-5.2[1m]", expected: 1_000_000 },
{ selected: "glm-5.2", runtime: "glm-5.2", expected: undefined },
])(
"streams the context limit for $selected resolved as $runtime",
async ({ selected, runtime, expected }) => {
const client = new ClaudeAgentClient({
logger: createTestLogger(),
queryFactory: contextQuery(runtime, 800_000),
resolveBinary: async () => process.execPath,
});
const session = await client.createSession({
provider: "claude",
cwd: process.cwd(),
model: selected,
});
try {
const first = await collectSessionTurnEvents(session, "first turn");
const live = first.filter((event) => event.type === "usage_updated");
expect(live.map((event) => event.usage.contextWindowMaxTokens)).toEqual([expected, expected]);
expect(live.map((event) => event.usage.contextWindowUsedTokens)).toEqual([249_950, 250_000]);
expect(first).toContainEqual(
expect.objectContaining({
type: "turn_completed",
usage: expect.objectContaining({ contextWindowMaxTokens: 800_000 }),
}),
);
const second = await collectSessionTurnEvents(session, "second turn");
expect(
second
.filter((event) => event.type === "usage_updated")
.map((event) => event.usage.contextWindowMaxTokens),
).toEqual([800_000, 800_000]);
await session.setModel?.("another-custom-model");
const switched = await collectSessionTurnEvents(session, "after model switch");
expect(
switched
.filter((event) => event.type === "usage_updated")
.map((event) => event.usage.contextWindowMaxTokens),
).toEqual([undefined, undefined]);
} finally {
await session.close();
}
},
);
@@ -12,6 +12,7 @@ interface QueryMock {
close: ReturnType<typeof vi.fn>;
setPermissionMode: ReturnType<typeof vi.fn>;
setModel: ReturnType<typeof vi.fn>;
applyFlagSettings: ReturnType<typeof vi.fn>;
supportedModels: ReturnType<typeof vi.fn>;
supportedCommands: ReturnType<typeof vi.fn>;
rewindFiles: ReturnType<typeof vi.fn>;
@@ -148,6 +149,7 @@ function createScriptedQuery(params: {
close: vi.fn(() => undefined),
setPermissionMode: vi.fn(async () => undefined),
setModel: vi.fn(async () => undefined),
applyFlagSettings: vi.fn(async () => undefined),
supportedModels: vi.fn(async () => [{ value: "opus", displayName: "Opus" }]),
supportedCommands: vi.fn(async () => []),
rewindFiles: vi.fn(async () => ({ canRewind: true })),
@@ -276,6 +278,64 @@ afterEach(() => {
queryFactory.mockReset();
});
async function createThinkingRestartSession() {
const queries: ScriptedQuery[] = [];
queryFactory.mockImplementation(({ prompt }: { prompt: AsyncIterable<unknown> }) => {
const scriptedQuery = createScriptedQuery({
prompt,
sessionId: "thinking-restart-session",
handlePrompt: ({ query }) => query.emit(buildSuccessResult("thinking-restart-session")),
});
queries.push(scriptedQuery);
return scriptedQuery;
});
const session = await new ClaudeAgentClient({
logger: createTestLogger(),
queryFactory,
resolveBinary: async () => "/test/claude/bin",
}).createSession({ provider: "claude", cwd: process.cwd() });
return { session, queries };
}
test("submits after a thinking change before the first turn", async () => {
const { session, queries } = await createThinkingRestartSession();
try {
await session.setThinkingOption!("medium");
const events = await collectUntilTerminal(
streamSession(session, "continue", { clientMessageId: "client-continue" }),
);
expect(events.filter((event) => event.type === "turn_failed")).toEqual([]);
expect(events.at(-1)?.type).toBe("turn_completed");
expect(queryFactory).toHaveBeenCalledTimes(1);
expect(queries[0].prompts.map((prompt) => prompt.text)).toEqual(["continue"]);
} finally {
await session.close();
}
});
test("submits after a thinking change once the previous query ended", async () => {
const { session, queries } = await createThinkingRestartSession();
try {
await collectUntilTerminal(streamSession(session, "first prompt"));
queries[0].end();
// Let the pump retire the old query before changing thinking.
await new Promise((resolve) => setTimeout(resolve, 0));
await session.setThinkingOption!("medium");
const events = await collectUntilTerminal(
streamSession(session, "continue", { clientMessageId: "client-continue" }),
);
expect(events.filter((event) => event.type === "turn_failed")).toEqual([]);
expect(events.at(-1)?.type).toBe("turn_completed");
expect(queryFactory).toHaveBeenCalledTimes(2);
expect(queries[1].prompts.map((prompt) => prompt.text)).toEqual(["continue"]);
} finally {
await session.close();
}
});
test("interrupt only calls query.interrupt and leaves the query open", async () => {
const logger = createTestLogger();
const queries: ScriptedQuery[] = [];
@@ -2805,6 +2805,7 @@ describe("ClaudeAgentSession context window usage", () => {
type: "usage_updated",
provider: "claude",
usage: {
contextWindowMaxTokens: 200_000,
contextWindowUsedTokens: 150,
},
}),
@@ -2840,7 +2841,13 @@ describe("ClaudeAgentSession context window usage", () => {
test("selected 1M Claude models seed active context window usage from the catalog", async () => {
const session = await createSessionForTurns(
[[createInitMessage(), createMessageStartEvent(), createSuccessResult()]],
[
[
{ ...createInitMessage(), model: "claude-sonnet-5[1m]" },
createMessageStartEvent(),
createSuccessResult(),
],
],
{ model: "claude-sonnet-5[1m]" },
);
@@ -2880,6 +2887,7 @@ describe("ClaudeAgentSession context window usage", () => {
type: "usage_updated",
provider: "claude",
usage: {
contextWindowMaxTokens: 200_000,
contextWindowUsedTokens: 175,
},
}),
@@ -2922,6 +2930,7 @@ describe("ClaudeAgentSession context window usage", () => {
type: "usage_updated",
provider: "claude",
usage: {
contextWindowMaxTokens: 200_000,
contextWindowUsedTokens: 20_300,
},
}),
@@ -2963,6 +2972,7 @@ describe("ClaudeAgentSession context window usage", () => {
type: "usage_updated",
provider: "claude",
usage: {
contextWindowMaxTokens: 200_000,
contextWindowUsedTokens: 1_069,
},
}),
@@ -3026,6 +3036,7 @@ describe("ClaudeAgentSession context window usage", () => {
type: "usage_updated",
provider: "claude",
usage: {
contextWindowMaxTokens: 200_000,
contextWindowUsedTokens: 55,
},
}),
@@ -3035,6 +3046,7 @@ describe("ClaudeAgentSession context window usage", () => {
type: "usage_updated",
provider: "claude",
usage: {
contextWindowMaxTokens: 200_000,
contextWindowUsedTokens: 62,
},
}),
@@ -3072,6 +3084,7 @@ describe("ClaudeAgentSession context window usage", () => {
type: "usage_updated",
provider: "claude",
usage: {
contextWindowMaxTokens: 200_000,
contextWindowUsedTokens: 704,
},
}),
@@ -31,7 +31,7 @@ import {
readTaskNotificationToolUseIdFromHistoryRecord,
} from "./task-notification-tool-call.js";
import {
findClaudeModel,
getClaudeContextWindowMaxTokens,
getClaudeModelsWithSettings,
normalizeClaudeRuntimeModelId,
resolveConfiguredClaudeModel,
@@ -2240,7 +2240,7 @@ class ClaudeAgentSession implements AgentSession {
this.resolveBinary = options.resolveBinary;
this.rewindSdk = options.rewindSdk ?? realClaudeRewindSdk;
this.contextUsage = new ClaudeContextUsageState(
findClaudeModel(this.config.model)?.contextWindowMaxTokens,
getClaudeContextWindowMaxTokens(this.config.model),
);
const handle = options.handle;
@@ -2572,7 +2572,7 @@ class ClaudeAgentSession implements AgentSession {
await this.applyFastModeFeature(false, activeQuery);
}
this.contextUsage.setInitialContextWindowMaxTokens(
findClaudeModel(this.config.model)?.contextWindowMaxTokens,
getClaudeContextWindowMaxTokens(this.config.model),
);
this.lastOptionsModel = normalizedModelId ?? this.lastOptionsModel;
this.lastRuntimeModel = null;
@@ -3275,6 +3275,9 @@ class ClaudeAgentSession implements AgentSession {
}
}
// A fresh query already uses the current settings, even when there was no old query to retire.
this.queryRestartNeeded = false;
// Preserve claudeSessionId across query recreation so buildOptions() passes
// resume: sessionId and the new query continues the existing conversation.
this.persistence = null;
@@ -3877,11 +3880,12 @@ class ClaudeAgentSession implements AgentSession {
}
private startQueryPump(): void {
if (this.closed || this.queryPumpPromise) {
if (this.closed || this.queryPumpPromise || !this.query) {
return;
}
const pump = this.runQueryPump().catch((error) => {
// Drain the initialized query; a pending settings change belongs to the next turn.
const pump = this.runQueryPump(this.query).catch((error) => {
this.logger.trace(
{
agentId: this.agentId,
@@ -3902,25 +3906,7 @@ class ClaudeAgentSession implements AgentSession {
});
}
private async runQueryPump(): Promise<void> {
let activeQuery: Query;
try {
activeQuery = await this.ensureQuery();
} catch (error) {
this.logger.trace(
{
agentId: this.agentId,
provider: "claude",
sessionId: this.claudeSessionId,
turnId: this.activeForegroundTurnId ?? this.autonomousTurn?.id ?? undefined,
err: error,
},
"provider.claude.query_pump.init_failed",
);
this.failActiveTurns(error instanceof Error ? error.message : "Claude stream failed");
return;
}
private async runQueryPump(activeQuery: Query): Promise<void> {
let consecutiveInterruptAbortRecoveries = 0;
const logRawMessage = (message: SDKMessage): void => {
this.logger.trace(
@@ -4821,6 +4807,12 @@ class ClaudeAgentSession implements AgentSession {
this.observePermissionMode(message.permissionMode);
this.persistence = null;
if (message.model) {
const runtimeContextWindowMaxTokens = getClaudeContextWindowMaxTokens(message.model);
// Seed usage before the first result, including settings-resolved aliases.
// A repeated init for the same model must retain the SDK's reported limit.
if (message.model !== this.lastRuntimeModel && runtimeContextWindowMaxTokens !== undefined) {
this.contextUsage.setInitialContextWindowMaxTokens(runtimeContextWindowMaxTokens);
}
const normalizedRuntimeModel = normalizeClaudeRuntimeModelId(message.model);
this.logger.debug(
{ runtimeModel: message.model, normalizedRuntimeModel },
@@ -15,7 +15,27 @@ import {
} from "./model-manifest.js";
import { discoverClaudeModels } from "./model-discovery.js";
import type { ModelInfo } from "@anthropic-ai/claude-agent-sdk";
import { findClaudeModel, getClaudeModels, normalizeClaudeRuntimeModelId } from "./models.js";
import {
findClaudeModel,
getClaudeContextWindowMaxTokens,
getClaudeModels,
normalizeClaudeRuntimeModelId,
} from "./models.js";
describe("getClaudeContextWindowMaxTokens", () => {
it.each([
["glm-5.2[1m]", 1_000_000],
[" glm-5.2[1M] ", 1_000_000],
["claude-sonnet-4-6", 200_000],
["claude-sonnet-4-6[1m]", 1_000_000],
["glm-5.2", undefined],
["glm-5.2[1m]-other", undefined],
[undefined, undefined],
[null, undefined],
])("resolves %s to %s without guessing unknown models", (model, expected) => {
expect(getClaudeContextWindowMaxTokens(model)).toBe(expected);
});
});
const createdClaudeConfigDirs: string[] = [];
@@ -48,6 +48,15 @@ export function findClaudeModel(
return getClaudeModels().find((model) => model.id === normalizedModelId);
}
export function getClaudeContextWindowMaxTokens(
modelId: string | null | undefined,
): number | undefined {
if (modelId?.trim().toLowerCase().endsWith("[1m]")) {
return 1_000_000;
}
return findClaudeModel(modelId)?.contextWindowMaxTokens;
}
export async function getClaudeModelsWithSettings(
logger: Logger,
configDir: string,
@@ -1,4 +1,4 @@
import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
@@ -68,8 +68,60 @@ describe("GenericACPAgentClient archived history", () => {
).rejects.toThrow("ENOENT");
});
});
test("releases the provider process once an archived session's history is replayed", async () => {
await withFakeACPAgent(async ({ command, removedCwd, pidPath }) => {
const client = new GenericACPAgentClient({ logger: createTestLogger(), command });
const session = await client.resumeSession(
{ provider: "acp", sessionId: "archived-session", metadata: { cwd: removedCwd } },
undefined,
undefined,
{ purpose: "history" },
);
try {
const pid = Number(await readFile(pidPath, "utf8"));
expect(isProcessAlive(pid)).toBe(false);
expect(await collectTimelineText(session.streamHistory())).toEqual([
"user_message:What is in the README?",
"assistant_message:It describes the project.",
]);
} finally {
await session.close();
}
});
});
test("keeps the provider process for an interactive resume", async () => {
await withFakeACPAgent(async ({ command, removedCwd, pidPath }) => {
await mkdir(removedCwd, { recursive: true });
const client = new GenericACPAgentClient({ logger: createTestLogger(), command });
const session = await client.resumeSession(
{ provider: "acp", sessionId: "archived-session", metadata: { cwd: removedCwd } },
undefined,
undefined,
{ purpose: "interactive" },
);
try {
const pid = Number(await readFile(pidPath, "utf8"));
expect(isProcessAlive(pid)).toBe(true);
} finally {
await session.close();
}
});
});
});
function isProcessAlive(pid: number): boolean {
try {
process.kill(pid, 0);
return true;
} catch {
return false;
}
}
async function collectTimelineText(events: AsyncGenerator<AgentStreamEvent>): Promise<string[]> {
const text: string[] = [];
for await (const event of events) {
@@ -88,17 +140,20 @@ async function withFakeACPAgent(
command: [string, ...string[]];
removedCwd: string;
loadRequestsPath: string;
pidPath: string;
}) => Promise<void>,
): Promise<void> {
const testDir = await mkdtemp(path.join(tmpdir(), "paseo-acp-archived-history-"));
try {
const scriptPath = path.join(testDir, "fake-acp-agent.cjs");
const loadRequestsPath = path.join(testDir, "load-requests.json");
const pidPath = path.join(testDir, "agent.pid");
await writeFile(scriptPath, fakeACPAgentScript, "utf8");
await run({
command: [process.execPath, scriptPath, loadRequestsPath],
command: [process.execPath, scriptPath, loadRequestsPath, pidPath],
removedCwd: path.join(testDir, "removed-worktree"),
loadRequestsPath,
pidPath,
});
} finally {
await rm(testDir, { recursive: true, force: true });
@@ -110,6 +165,10 @@ const fs = require("node:fs");
const readline = require("node:readline");
const loadRequestsPath = process.argv[2];
const pidPath = process.argv[3];
if (pidPath) {
fs.writeFileSync(pidPath, String(process.pid));
}
const loadRequests = [];
const rl = readline.createInterface({ input: process.stdin });
@@ -115,4 +115,43 @@ describe("daemon E2E (real claude) - thinking effort memory", () => {
rmSync(cwd, { recursive: true, force: true });
}
}, 420_000);
test("a thinking change before the first turn does not fail that turn", async () => {
const logger = pino({ level: "silent" });
const cwd = tmpCwd();
const daemon = await createTestPaseoDaemon({
agentClients: createRealProviderClients(["claude"], logger),
logger,
});
const client = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws` });
try {
await client.connect();
await client.fetchAgents({ subscribe: {} });
const modelList = await client.listProviderModels("claude", { cwd });
const model = modelList.models.find(modelHasLowThinkingOption);
if (!model) {
throw new Error("No Claude Sonnet model with low thinking effort returned");
}
const agent = await client.createAgent({
cwd,
title: "claude-thinking-before-first-turn-real",
...getRealProviderConfig("claude"),
model: model.id,
});
await client.setAgentThinkingOption(agent.id, "low");
await client.sendMessage(agent.id, "Reply exactly: ACK_FIRST");
const finish = await client.waitForFinish(agent.id, 180_000);
expect(finish.final?.lastError).toBeUndefined();
expect(finish.status).toBe("idle");
expect(compactText(await getAssistantText(client, agent.id))).toContain("ack_first");
} finally {
await client.close().catch(() => undefined);
await daemon.close().catch(() => undefined);
rmSync(cwd, { recursive: true, force: true });
}
}, 300_000);
});
@@ -1,7 +1,16 @@
import { describe, test, expect, beforeEach, afterEach } from "vitest";
import { mkdtempSync, writeFileSync, rmSync, mkdirSync, symlinkSync, readdirSync } from "fs";
import { describe, test, expect, beforeEach, afterEach, onTestFinished } from "vitest";
import {
mkdtempSync,
writeFileSync,
rmSync,
mkdirSync,
symlinkSync,
readdirSync,
readlinkSync,
} from "fs";
import { tmpdir } from "os";
import path from "path";
import { execFileSync } from "node:child_process";
import { createDaemonTestContext, type DaemonTestContext } from "../test-utils/index.js";
function tmpCwd(): string {
@@ -387,6 +396,84 @@ describe("file explorer", () => {
}
});
test.skipIf(process.platform === "win32")(
"duplicating a folder keeps relative symlinks inside the copy",
async () => {
const cwd = tmpCwd();
try {
mkdirSync(path.join(cwd, "template"));
writeFileSync(path.join(cwd, "template", "data.txt"), "original payload\n");
symlinkSync("data.txt", path.join(cwd, "template", "alias.txt"));
const duplicated = await ctx.client.duplicateFileEntry({ cwd, path: "template" });
expect(duplicated).toMatchObject({
duplicatedPath: "template copy",
success: true,
error: null,
});
expect(readlinkSync(path.join(cwd, "template copy", "alias.txt"))).toBe("data.txt");
const copiedTarget = await ctx.client.readFile(cwd, "template copy/data.txt");
const targetWrite = await ctx.client.writeFile({
cwd,
path: "template copy/data.txt",
content: "copy payload\n",
expectedModifiedAt: copiedTarget.modifiedAt,
expectedRevision: copiedTarget.revision,
});
expect(targetWrite.status).toBe("written");
const copiedAlias = await ctx.client.readFile(cwd, "template copy/alias.txt");
expect(new TextDecoder().decode(copiedAlias.bytes)).toBe("copy payload\n");
const aliasWrite = await ctx.client.writeFile({
cwd,
path: "template copy/alias.txt",
content: "edited through copied alias\n",
expectedModifiedAt: copiedAlias.modifiedAt,
expectedRevision: copiedAlias.revision,
});
expect(aliasWrite.status).toBe("written");
const original = await ctx.client.readFile(cwd, "template/data.txt");
expect(new TextDecoder().decode(original.bytes)).toBe("original payload\n");
const editedCopy = await ctx.client.readFile(cwd, "template copy/data.txt");
expect(new TextDecoder().decode(editedCopy.bytes)).toBe("edited through copied alias\n");
const deleted = await ctx.client.deleteFileEntry({ cwd, path: "template" });
expect(deleted).toMatchObject({ success: true, error: null });
const survivingAlias = await ctx.client.readFile(cwd, "template copy/alias.txt");
expect(new TextDecoder().decode(survivingAlias.bytes)).toBe(
"edited through copied alias\n",
);
} finally {
rmSync(cwd, { recursive: true, force: true });
}
},
);
test.skipIf(process.platform === "win32")(
"duplicating a file symlink preserves an explicit absolute target",
async () => {
const cwd = tmpCwd();
const targetPath = path.join(cwd, "data.txt");
try {
writeFileSync(targetPath, "absolute target payload\n");
symlinkSync(targetPath, path.join(cwd, "alias.txt"));
const duplicated = await ctx.client.duplicateFileEntry({ cwd, path: "alias.txt" });
expect(duplicated).toMatchObject({
duplicatedPath: "alias copy.txt",
success: true,
error: null,
});
expect(readlinkSync(path.join(cwd, "alias copy.txt"))).toBe(targetPath);
const copy = await ctx.client.readFile(cwd, "alias copy.txt");
expect(new TextDecoder().decode(copy.bytes)).toBe("absolute target payload\n");
} finally {
rmSync(cwd, { recursive: true, force: true });
}
},
);
test("lists directory contents", async () => {
const cwd = tmpCwd();
@@ -489,3 +576,63 @@ describe("file explorer", () => {
rmSync(cwd, { recursive: true, force: true });
}, 60000); // 1 minute timeout
});
describe("commit file diffs", () => {
test.each([".", "apps/web"])("opens committed files from %s workspaces", async (relativePath) => {
const cwd = tmpCwd();
onTestFinished(() =>
rmSync(cwd, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }),
);
const workspace = path.join(cwd, relativePath);
const filePath = "apps/web/notes.txt";
function git(args: string[]): string {
return execFileSync("git", args, { cwd, encoding: "utf8" });
}
git(["init", "-b", "main"]);
git(["config", "user.name", "Test"]);
git(["config", "user.email", "test@example.com"]);
git(["config", "core.autocrlf", "false"]);
mkdirSync(path.join(cwd, "apps", "web"), { recursive: true });
writeFileSync(path.join(cwd, filePath), "before\n");
writeFileSync(path.join(cwd, "apps/web/untouched.txt"), "unchanged\n");
git(["add", "."]);
git(["-c", "commit.gpgsign=false", "commit", "-m", "initial"]);
git(["checkout", "-b", "feature"]);
writeFileSync(path.join(cwd, filePath), "after\n");
git(["add", "."]);
git(["-c", "commit.gpgsign=false", "commit", "-m", "edit"]);
const sha = git(["rev-parse", "HEAD"]).trim();
const history = await ctx.client.listCheckoutCommits(workspace);
const commit = history.commits.find((entry) => entry.sha === sha);
expect(commit?.files).toEqual([
{ path: filePath, additions: 1, deletions: 1, status: "modified" },
]);
const result = await ctx.client.getCommitFileDiff(workspace, sha, filePath);
expect(result).toEqual({
file: {
path: filePath,
isNew: false,
isDeleted: false,
additions: 1,
deletions: 1,
hunks: [
{
oldStart: 1,
oldCount: 1,
newStart: 1,
newCount: 1,
lines: [
{ type: "header", content: "@@ -1 +1 @@" },
{ type: "remove", content: "before" },
{ type: "add", content: "after" },
],
},
],
},
});
expect(await ctx.client.getCommitFileDiff(workspace, sha, "apps/web/untouched.txt")).toEqual({
file: null,
});
});
});
@@ -1,6 +1,6 @@
import { execFile as execFileCallback } from "node:child_process";
import { mkdtempSync, realpathSync } from "node:fs";
import { readFile, rm } from "node:fs/promises";
import { readFile, rm, writeFile } from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { promisify } from "node:util";
@@ -63,10 +63,12 @@ test("an empty project becomes Git without changing its identity or creating a w
cleanupClients.add(client);
await client.connect();
await client.fetchAgents({ subscribe: {} });
const projectEvents = client.observeEvents(["project.update"]);
await projectEvents.ready;
const added = await client.addProject(projectRoot);
expect(added).toEqual({
expect(added).toMatchObject({
requestId: expect.any(String),
project: {
projectId: expect.stringMatching(/^prj_[0-9a-f]{16}$/),
@@ -77,7 +79,11 @@ test("an empty project becomes Git without changing its identity or creating a w
},
error: null,
});
const project = added.project!;
const {
projectIconRevision: _icon,
projectCustomIconRevision: _customIcon,
...project
} = added.project!;
const beforeGitInit = await client.fetchWorkspaces({ filter: { projectId: project.projectId } });
expect(beforeGitInit).toMatchObject({ entries: [], emptyProjects: [project] });
@@ -96,15 +102,15 @@ test("an empty project becomes Git without changing its identity or creating a w
label: "project.update after git init",
});
expect(update).toEqual({
expect(update).toMatchObject({
kind: "upsert",
project: { ...project, projectKind: "git" },
project: { ...project, projectKind: "git", projectKey: expect.any(String) },
});
const afterGitInit = await client.fetchWorkspaces({ filter: { projectId: project.projectId } });
expect(afterGitInit).toMatchObject({
entries: [],
emptyProjects: [{ ...project, projectKind: "git" }],
emptyProjects: [{ ...project, projectKind: "git", projectKey: expect.any(String) }],
});
const persistedProjects = JSON.parse(
@@ -119,5 +125,72 @@ test("an empty project becomes Git without changing its identity or creating a w
createdAt: expect.any(String),
updatedAt: expect.any(String),
archivedAt: null,
projectKey: expect.any(String),
customIconRevision: null,
});
}, 30_000);
test("a failed worktree attempt before git init does not prevent creation afterward", async () => {
const projectRoot = realpathSync(mkdtempSync(path.join(os.tmpdir(), "paseo-init-worktree-")));
const paseoHomeRoot = realpathSync(mkdtempSync(path.join(os.tmpdir(), "paseo-init-home-")));
cleanupPaths.add(projectRoot);
cleanupPaths.add(paseoHomeRoot);
await writeFile(path.join(projectRoot, "README.md"), "hi\n");
const daemon = await createTestPaseoDaemon({ paseoHomeRoot, cleanup: false });
cleanupDaemons.add(daemon);
const client = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws` });
cleanupClients.add(client);
await client.connect();
await client.fetchAgents({ subscribe: {} });
const projectEvents = client.observeEvents(["project.update"]);
await projectEvents.ready;
const added = await client.addProject(projectRoot);
expect(added.project?.projectKind).toBe("non_git");
const source = {
kind: "worktree" as const,
cwd: projectRoot,
projectId: added.project!.projectId,
action: "branch-off" as const,
refName: "main",
branchName: "after-init",
worktreeSlug: "after-init",
};
const before = await client.createWorkspace({ source });
expect(before.error).toBe("Create worktree requires a git repository");
const gitProjectUpdate = waitForProjectUpdate(
client,
(payload) =>
payload.kind === "upsert" &&
payload.project.projectId === added.project!.projectId &&
payload.project.projectKind === "git",
);
await execFile("git", ["init", "-b", "main"], { cwd: projectRoot });
await execFile("git", ["add", "README.md"], { cwd: projectRoot });
await execFile(
"git",
[
"-c",
"user.name=Paseo Test",
"-c",
"user.email=test@getpaseo.dev",
"-c",
"commit.gpgSign=false",
"commit",
"-m",
"initial",
],
{ cwd: projectRoot },
);
await withTimeout({ promise: gitProjectUpdate, timeoutMs: 10_000, label: "project becomes Git" });
const projects = await client.fetchWorkspaces({
filter: { projectId: added.project!.projectId },
});
expect(projects.emptyProjects[0]?.projectKind).toBe("git");
const created = await client.createWorkspace({ source });
expect(created.error).toBeNull();
expect(created.workspace?.workspaceKind).toBe("worktree");
expect(created.workspace?.projectId).toBe(added.project!.projectId);
}, 30_000);
@@ -669,6 +669,7 @@ export async function duplicateExplorerEntry({
errorOnExist: true,
force: false,
preserveTimestamps: true,
verbatimSymlinks: true,
});
return { status: "ok", path: normalizeRelativePath({ root, targetPath }) };
} catch (error) {
@@ -236,7 +236,8 @@ test("observes a thousand concurrent writes and remains healthy after delete and
const deletions = expectFileChanges(removedPaths, "delete");
const movedFiles = expectFileChanges(movedPaths);
const recovery = expectFileChanges([sentinel]);
const subscription = await subscribeToFileChanges(root, (error, events) => {
const observer = createObserver();
const subscription = await observer.subscribe(root, (error, events) => {
expect(error).toBeNull();
for (const event of events) {
population.record(event);
@@ -254,9 +255,18 @@ test("observes a thousand concurrent writes and remains healthy after delete and
await Promise.all(paths.map((path, index) => writeFile(path, `${index}`)));
await writes.complete;
// A native watcher may recover coalesced paths through its safety audit.
// Completion comes from the delivered deletions, not an idle diagnostic or
// a deadline shorter than that recovery cycle.
// The first notice for each write can arrive before its remaining
// classifications. Drain those deliveries before starting a separate deletion
// phase, so a late create cannot coalesce with the expected delete as update.
await expect
.poll(() => observer.getDiagnostics(), { timeout: 10_000 })
.toMatchObject({
pendingEventCount: 0,
pendingClassificationCount: 0,
reconciliationInFlightCount: 0,
});
// Completion still requires the delivered deletion notices.
await Promise.all(removedPaths.map((path) => rm(path)));
await deletions.complete;
@@ -0,0 +1,27 @@
import http from "node:http";
import net from "node:net";
// A host can support IPv6 loopback without listing ::1 for localhost in DNS.
const lookupLoopback: net.LookupFunction = (_hostname, _options, callback) => {
callback(null, [
{ address: "127.0.0.1", family: 4 },
{ address: "::1", family: 6 },
]);
};
export function connectToLocalService(port: number): net.Socket {
return net.connect({ host: "localhost", port, autoSelectFamily: true, lookup: lookupLoopback });
}
export function requestLocalService(
options: Pick<http.RequestOptions, "port" | "path" | "method" | "headers">,
onResponse: (response: http.IncomingMessage) => void,
): http.ClientRequest {
const requestOptions = {
...options,
hostname: "localhost",
autoSelectFamily: true,
lookup: lookupLoopback,
};
return http.request(requestOptions, onResponse);
}
@@ -1,3 +1,7 @@
import { createRequire } from "node:module";
import { runInNewContext } from "node:vm";
import { fileURLToPath } from "node:url";
import { build } from "esbuild";
import { EventEmitter, once } from "node:events";
import { resolveDaemonVersion } from "../daemon-version.js";
import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
@@ -15,6 +19,59 @@ afterEach(async () => {
await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true })));
});
test.each(["React Native", "native"])(
"provider readiness works with %s AbortController",
async (runtime) => {
const require = createRequire(import.meta.url);
const reactNativeRequire = createRequire(require.resolve("react-native/package.json"));
const { AbortController: ReactNativeAbortController } = reactNativeRequire(
"abort-controller/dist/abort-controller",
);
const bundle = await build({
entryPoints: [fileURLToPath(new URL("../../../../client/src/index.ts", import.meta.url))],
bundle: true,
write: false,
format: "iife",
globalName: "paseoSdk",
platform: "browser",
});
// A separate runtime starts with React Native's real abort implementation.
// Node's globals and the daemon's runtime remain unchanged.
const sdk: typeof import("@getpaseo/client") = runInNewContext(
`${bundle.outputFiles[0]!.text}\npaseoSdk`,
{
AbortController: runtime === "React Native" ? ReactNativeAbortController : AbortController,
setTimeout,
clearTimeout,
queueMicrotask,
TextEncoder,
TextDecoder,
},
);
const daemon = await createTestPaseoDaemon({ mcpEnabled: false });
const client = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws` });
const api = sdk.createPaseoApi(client);
try {
await client.connect();
const snapshot = await api.providers.waitForReady({ cwd: daemon.staticDir });
expect(snapshot.entries.length).toBeGreaterThan(0);
expect(snapshot.entries.every((entry) => entry.status !== "loading")).toBe(true);
const waiting = api.providers.waitForReady({ cwd: daemon.staticDir });
const canceled = expect(waiting).rejects.toThrow(/disposed|released/);
await api.dispose();
await canceled;
const { diagnostic } = await client.collectDiagnostics();
expect(diagnostic).toContain("Source registrations: 0");
expect(diagnostic).toContain("Producer listeners: 0");
expect(() => api.providers.waitForReady()).toThrow("disposed");
} finally {
await api.dispose();
await client.close();
await daemon.close();
}
},
);
test("plugin handlers create workspaces and agents through their Paseo API", async () => {
const pluginDirectory = await mkdtemp(path.join(tmpdir(), "paseo-api-plugin-"));
const workspaceDirectory = await mkdtemp(path.join(tmpdir(), "paseo-api-workspace-"));
@@ -99,14 +99,14 @@ function createStubTerminalManager(
};
}
async function startTcpServer(): Promise<TcpServerHandle> {
async function startTcpServer(host = "127.0.0.1"): Promise<TcpServerHandle> {
const server = net.createServer((socket) => {
socket.end();
});
await new Promise<void>((resolve, reject) => {
server.once("error", reject);
server.listen(0, "127.0.0.1", () => {
server.listen(0, host, () => {
server.off("error", reject);
resolve();
});
@@ -153,6 +153,45 @@ describe("ScriptHealthMonitor", () => {
servers.clear();
});
it.each(["127.0.0.1", "::1"])("reports a service bound only to %s as healthy", async (host) => {
const service = await startTcpServer(host);
servers.add(service.server);
const serviceProxy = new ScriptRouteStore();
serviceProxy.registerWorkspaceService({
workspaceId: "workspace-a",
projectSlug: "repo",
branchName: "main",
scriptName: "api",
port: service.port,
});
const updates: ScriptHealthEntry[][] = [];
const monitor = new ScriptHealthMonitor({
serviceProxy,
onChange: (_workspaceId, scripts) => updates.push(scripts),
pollIntervalMs: 10,
probeTimeoutMs: 100,
graceMs: 0,
failuresBeforeStopped: 1,
});
monitor.start();
try {
await vi.waitFor(() => {
expect(updates).toEqual([
[
{
scriptName: "api",
hostname: "api--repo.localhost",
port: service.port,
health: "healthy",
},
],
]);
});
} finally {
monitor.stop();
}
});
it("starts new service routes in pending and transitions to healthy after the grace period", async () => {
vi.useFakeTimers();
@@ -1,4 +1,4 @@
import net from "node:net";
import { connectToLocalService } from "./local-service-connection.js";
import type { ServiceProxyHealthTarget, ServiceProxySubsystem } from "./service-proxy.js";
export type ScriptHealthState = "pending" | "healthy" | "unhealthy";
@@ -208,7 +208,7 @@ export class ScriptHealthMonitor {
private probeRoute(port: number): Promise<boolean> {
return new Promise((resolve) => {
const socket = net.connect({ host: "127.0.0.1", port });
const socket = connectToLocalService(port);
let settled = false;
const finish = (healthy: boolean) => {
@@ -32,15 +32,20 @@ function readServerSourceFiles(dir = path.resolve(import.meta.dirname)): string[
}
interface HttpGetOptions {
hostname?: string;
path?: string;
headers?: Record<string, string>;
}
function httpGet(port: number, host: string, options: HttpGetOptions = {}) {
const { path: requestPath = "/api/health", headers: extraHeaders = {} } = options;
const {
hostname = "127.0.0.1",
path: requestPath = "/api/health",
headers: extraHeaders = {},
} = options;
return new Promise<{ status: number; body: string }>((resolve, reject) => {
const req = http.get(
{ hostname: "127.0.0.1", port, path: requestPath, headers: { host, ...extraHeaders } },
{ hostname, port, path: requestPath, headers: { host, ...extraHeaders } },
(res) => {
let body = "";
res.on("data", (chunk: Buffer) => {
@@ -53,6 +58,31 @@ function httpGet(port: number, host: string, options: HttpGetOptions = {}) {
});
}
function httpPost(port: number, host: string, requestPath: string, payload: string) {
return new Promise<{ status: number; body: string }>((resolve, reject) => {
const request = http.request(
{
hostname: "127.0.0.1",
port,
method: "POST",
path: requestPath,
headers: { host, "content-type": "application/json" },
},
(res) => {
let body = "";
res.on("data", (chunk: Buffer) => {
body += chunk.toString();
});
res.on("end", () => resolve({ status: res.statusCode ?? 0, body }));
res.on("error", reject);
},
);
request.on("error", reject);
request.write(payload.slice(0, 5));
request.end(payload.slice(5));
});
}
describe("service proxy subsystem shape", () => {
it("keeps production imports behind the service-proxy entrypoint", () => {
const offenders: string[] = [];
@@ -303,6 +333,7 @@ describe("service proxy subsystem shape", () => {
});
interface ForwardedFixture {
upstreamPort: number;
daemonPort: number;
hostname: string;
close(): Promise<void>;
@@ -313,11 +344,16 @@ interface ForwardedFixture {
* echoes the headers it received so tests can assert what actually crossed the
* proxy, not what a helper returned.
*/
async function startForwardedHeadersFixture(): Promise<ForwardedFixture> {
const upstreamPort = await findFreePort();
async function startForwardedHeadersFixture(upstreamHost = "127.0.0.1"): Promise<ForwardedFixture> {
const upstream = http.createServer((req, res) => {
res.writeHead(200, { "content-type": "application/json" });
res.end(JSON.stringify(req.headers));
let body = "";
req.on("data", (chunk: Buffer) => {
body += chunk.toString();
});
req.on("end", () => {
res.writeHead(200, { "content-type": "application/json" });
res.end(JSON.stringify({ ...req.headers, method: req.method, url: req.url, body }));
});
});
const upgradeSockets: net.Socket[] = [];
upstream.on("upgrade", (req, socket) => {
@@ -331,7 +367,11 @@ async function startForwardedHeadersFixture(): Promise<ForwardedFixture> {
`HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nX-Echo-Length: ${payload.length}\r\n\r\n${payload}`,
);
});
await new Promise<void>((resolve) => upstream.listen(upstreamPort, "127.0.0.1", resolve));
await new Promise<void>((resolve, reject) => {
upstream.once("error", reject);
upstream.listen(0, upstreamHost, resolve);
});
const upstreamPort = (upstream.address() as net.AddressInfo).port;
const serviceProxy = createServiceProxySubsystem({ logger });
const route = serviceProxy.registerWorkspaceService({
@@ -354,6 +394,7 @@ async function startForwardedHeadersFixture(): Promise<ForwardedFixture> {
await new Promise<void>((resolve) => daemon.listen(daemonPort, "127.0.0.1", resolve));
return {
upstreamPort,
daemonPort,
hostname: route.hostname,
async close() {
@@ -416,6 +457,59 @@ function upgradeThroughProxy(
});
}
describe("service proxy loopback services", () => {
it.each(["127.0.0.1", "::1"])("forwards HTTP to a service bound only to %s", async (host) => {
const fixture = await startForwardedHeadersFixture(host);
try {
const direct = await httpGet(fixture.upstreamPort, fixture.hostname, { hostname: host });
expect(direct.status).toBe(200);
const response = await httpGet(fixture.daemonPort, fixture.hostname);
expect(response.status).toBe(200);
expect(JSON.parse(response.body).host).toBe(fixture.hostname);
} finally {
await fixture.close();
}
});
it.each(["127.0.0.1", "::1"])(
"streams POST bodies to a service bound only to %s",
async (host) => {
const fixture = await startForwardedHeadersFixture(host);
try {
const payload = JSON.stringify({ message: "hello" });
const response = await httpPost(
fixture.daemonPort,
fixture.hostname,
"/submit?q=one%20two",
payload,
);
expect(response.status).toBe(200);
expect(JSON.parse(response.body)).toMatchObject({
host: fixture.hostname,
"content-type": "application/json",
method: "POST",
url: "/submit?q=one%20two",
body: payload,
});
} finally {
await fixture.close();
}
},
);
it.each(["127.0.0.1", "::1"])("forwards upgrades to a service bound only to %s", async (host) => {
const fixture = await startForwardedHeadersFixture(host);
try {
const headers = await upgradeThroughProxy(fixture.daemonPort, fixture.hostname);
expect(headers.host).toBe(fixture.hostname);
expect(headers.upgrade).toBe("websocket");
} finally {
await fixture.close();
}
});
});
describe("service proxy forwarded headers", () => {
it("forwards the client authority with its port so services build reachable URLs", async () => {
const fixture = await startForwardedHeadersFixture();
+4 -3
View File
@@ -1,5 +1,6 @@
import http, { createServer as createHTTPServer } from "node:http";
import net from "node:net";
import { connectToLocalService, requestLocalService } from "./local-service-connection.js";
import { createHash } from "node:crypto";
import { existsSync, unlinkSync } from "node:fs";
import type { IncomingMessage } from "node:http";
@@ -328,9 +329,8 @@ function proxyHttpRequest({
}): void {
const forwardedHeaders = buildForwardedHeaders({ req, route, protocol: req.protocol });
const proxyReq = http.request(
const proxyReq = requestLocalService(
{
hostname: "127.0.0.1",
port: route.port,
path: req.originalUrl,
method: req.method,
@@ -368,7 +368,8 @@ function proxyUpgradeRequest({
route: ServiceProxyRoute;
logger: Logger;
}): void {
const targetSocket = net.connect({ host: "127.0.0.1", port: route.port }, () => {
const targetSocket = connectToLocalService(route.port);
targetSocket.on("connect", () => {
const forwardedHeaders = buildForwardedHeaders({ req, route, protocol: "http" });
forwardedHeaders.connection = "Upgrade";
forwardedHeaders.upgrade = req.headers.upgrade ?? "websocket";
@@ -625,6 +625,28 @@ describe("highlightDiffFromHunks", () => {
});
describe("highlightDiffWithFileContent", () => {
it("renders an Astro diff with an empty expression and keeps surrounding highlighting", async () => {
const diff = ASTRO_DIFF.replace("{title}", "{title} {}");
const file = parseDiff(diff)[0];
const highlighted = await highlightDiffWithFileContent(file, ".", {
oldFileContent:
'---\nconst title = "Hello";\n---\n<main class="page">\n <h1>{title} {}</h1>\n</main>',
newFileContent:
'---\nconst title = "Hello, Astro";\n---\n<main class="page">\n <h1>{title} {}</h1>\n</main>',
});
expect(highlighted.hunks[0].lines.find((line) => line.type === "add")?.tokens).toContainEqual({
text: '"Hello, Astro"',
style: "string",
});
expect(highlighted.hunks[0].lines.map(({ content }) => content)).toEqual(
file.hunks[0].lines.map(({ content }) => content),
);
expect(
highlightDiffFromHunks(file).hunks[0].lines.find((line) => line.type === "add")?.tokens,
).toContainEqual({ text: '"Hello, Astro"', style: "string" });
});
it("leaves files with an oversized line unhighlighted", async () => {
const oversizedLine = `const payload = "${"x".repeat(10_001)}";`;
const [file] = parseDiff(
@@ -63,6 +63,27 @@ afterEach(async () => {
}
});
test("a fresh checkout read makes a newly initialized repository available to cached readers", async () => {
const root = realpathSync(mkdtempSync(path.join(tmpdir(), "paseo-checkout-init-")));
const repo = path.join(root, "repo");
mkdirSync(repo);
const service = new WorkspaceGitServiceImpl({
logger: pino({ enabled: false }),
paseoHome: path.join(root, "home"),
});
cleanup.push(async () => {
await service.dispose();
rmSync(root, { recursive: true, force: true });
});
expect((await service.getSnapshot(repo, { includeForge: false })).git.isGit).toBe(false);
execFileSync("git", ["init", "-b", "main"], { cwd: repo, stdio: "pipe" });
expect((await service.getCheckout(repo)).isGit).toBe(true);
const repoRoot = await service.resolveRepoRoot(repo);
expect(realpathSync.native(repoRoot)).toBe(realpathSync.native(repo));
}, 30_000);
test("recursive observation updates tracked state and prunes ignored storms", async () => {
const tempDir = realpathSync(mkdtempSync(path.join(tmpdir(), "paseo-git-observation-")));
const repoDir = path.join(tempDir, "repo");
@@ -765,6 +765,16 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
worktreesRoot: this.worktreesRoot,
logger: this.logger,
});
const cached = this.peekSnapshot(normalizedCwd);
if (cached && cached.git.isGit !== status.isGit) {
// A fresh checkout read discovered a repository transition. Keep the
// shared snapshot current before exposing the new project capability.
await this.getSnapshot(normalizedCwd, {
force: true,
includeForge: false,
reason: "checkout-classification-changed",
});
}
if (!status.isGit) {
return checkoutLiteFromGitSnapshot(normalizedCwd, {
isGit: false,
+5 -4
View File
@@ -2665,10 +2665,11 @@ export async function getCommitFileDiff({
sha: string;
path: string;
}): Promise<ParsedDiffFile | null> {
const worktreeRoot = await requireGitWorktreeRoot(cwd);
const { stdout } = await runGitCommand(
["show", sha, "--format=", "--diff-merges=first-parent", ...GIT_PATCH_FORMAT_ARGS, "--", path],
{
cwd,
cwd: worktreeRoot,
envOverlay: READ_ONLY_GIT_ENV,
},
);
@@ -2677,9 +2678,9 @@ export async function getCommitFileDiff({
return null;
}
const parsedFiles = await parseAndHighlightDiff(stdout, cwd, {
getOldFileContent: (file) => readGitFileContentAtRef(cwd, `${sha}^`, file.path),
getNewFileContent: (file) => readGitFileContentAtRef(cwd, sha, file.path),
const parsedFiles = await parseAndHighlightDiff(stdout, worktreeRoot, {
getOldFileContent: (file) => readGitFileContentAtRef(worktreeRoot, `${sha}^`, file.path),
getNewFileContent: (file) => readGitFileContentAtRef(worktreeRoot, sha, file.path),
});
// `--` scopes the diff to a single pathspec, so there is at most one real