Compare commits

...
Author SHA1 Message Date
b117e3fb0f fix(claude): allow conversation rewind to messages sent during a turn (#8372)
* fix(server): let a message steered into a running Claude turn be rewound to

A steered message never got a provider message id, because Claude takes it
into the running turn without echoing it back, so rewinding to it refused
with "Cannot rewind before the provider acknowledges the submitted prompt".
The steer admission now reports the uuid Claude minted for the message, and
a Claude conversation rewind to it forks at the entry Claude read it after.

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

* fix(server): keep messages steered into a running Claude turn in the replayed timeline

Claude records a steered message as a queued_command attachment rather than a
user entry, and the history replay skipped it, so every such message, replies
from other agents included, vanished from the timeline after a daemon restart.

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

* test(claude): verify steer identities across lifecycle tests

Match the accepted-steer contract used by conversation rewind and verify that its identity names the message delivered to the provider.

Co-authored-by: tieo <65707274+tieo@users.noreply.github.com>

* fix(claude): release transcript streams after rewind lookup

Closing readline leaves a partially read transcript open after an early match. Destroy the owned stream on exit and cover repeated lookups with unread data.

---------

Co-authored-by: tieo <65707274+tieo@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-10-31 13:03:27 +00:00
paseo-bot[bot] 2ac288f4d0 test(plugins): make global switch recovery deterministic (#8342)
* test(plugins): trace recurring global recovery failure

* test(plugins): control cleanup in global switch recovery

---------

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-10-31 12:26:32 +00:00
Mohamed Boudra 1008e9fffc Insert suggested commands into the composer (#8371)
* feat(composer): insert suggested commands into drafts

* fix(composer): close suggestions after command insertion

* fix(composer): place caret after inserted commands
2026-10-31 19:25:16 +07:00
Matt CowgerandMohamed Boudra 74613e4bc3 feat(opencode): reuse running OpenCode v2 services (#7435)
* feat(opencode): connect v2 client to an existing server

* feat(providers): reuse running agent servers

Keep shared server sessions isolated and retain native tools while refreshes reuse the running service.

* fix(providers): recognize formatted bridge manifests

Managed plugin ownership must survive harmless JSON formatting changes.

* test(providers): distinguish isolated session bindings

Use separate session identities to verify that dedicated helpers keep their environments isolated.

---------

Co-authored-by: Mohamed Boudra <boudra.moha@gmail.com>
2026-10-31 18:55:24 +07:00
paseo-bot[bot]andOuyang Xingyuan 0e5bcbd9f5 fix(app): open relative chat links inside nested repositories (#8361)
* fix(app): open a nested-repo file when the workspace-root path is missing

A workspace that contains another git repo often gets paths written
relative to that nested repo. `schema/x.md` is really
`repo-a/schema/x.md`. A markdown link was joined onto the workspace
root and opened a missing file. Inline code already asked the daemon
for a suffix match.

Resolved workspace-relative paths that contain a slash now use that
same lookup before opening. An exact hit stays on the original path.
A suffix hit replaces it and keeps the line range. A failed lookup
still opens the original path. Bare names, absolute paths, and `~/`
paths are unchanged.

Example:
- Input: workspace `/repo`, link `schema/x.md`, file at `repo-a/schema/x.md`
- Before: open `/repo/schema/x.md` (missing)
- After: open `/repo/repo-a/schema/x.md`

* fix(app): preserve explicit destinations when finding nested files

Relative chat links can name a file inside a nested repository. Retain the suffix lookup while using the original href and document base to preserve absolute paths, preview links, and folder destinations.

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

* fix(app): keep network share links on their absolute destination

Use the shared absolute-path classifier so network share hrefs retain their destination alongside drive and POSIX paths.

* fix(app): keep nested-file lookup inside ordered path opening

Resolve the original directory before considering suffix matches, and allocate click ordering before asynchronous lookup. The existing opener now owns the complete operation so a slow earlier click cannot replace a later selection.

* test(app): load UI modules before test lifecycle hooks

Cold module transformation exceeded the lifecycle hook deadline before these suites could run. Complete module initialization before registering the tests, retaining their assertions and timeout settings.

---------

Co-authored-by: Ouyang Xingyuan <galaxyroaming@qq.com>
Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-10-31 11:05:06 +00:00
a7d9bb34fc fix(plugins): keep rewound conversations on their retained branch (#8368)
Refresh direct plugin history from authoritative native persistence after conversation/combined rewind, matching the built-in providers' history refresh strategy. Reopen/replay rebuilds translation baselines, rewind tokens and child bindings while preserving outward subscriptions; file-only rewind stays unchanged.

Keep replay internal and replace the manager timeline after it completes. Preserve the committed native handle and close routing across refresh failures so retry and reload recover the retained branch.

The full native session reload is intentionally inefficient, pending a smarter timeline cut that restores translation state, reused IDs, children and permissions without reopening.

Closes #4667
Supersedes #4668 and the rewind-history proposal in #4754.

Verified with 72 targeted tests, green CI, and real OMP browser UI A/B/C -> rewind B -> replacement D with native history/timeline equality and a clean browser reload.

Co-authored-by: Matt Cowger <matt@cowger.us>
Co-authored-by: Omer Cohen <639682+omercnet@users.noreply.github.com>
2026-10-31 17:48:42 +07:00
Mohamed Boudra fd65985e9d fix(android): resize header fades after rotation (#8349)
Clear Android SVG descendant geometry caches when the canvas resizes. Cover growth and shrinkage with a native pixel-rendering regression.
2026-10-31 14:45:13 +07:00
Mohamed Boudra 254166e7c1 feat(terminal): add native selection handles (#8348)
* feat(terminal): add native selection handles

* fix(terminal): preserve selection at row boundaries

* fix(terminal): preserve selection boundary when handles cross
2026-10-31 14:23:10 +07:00
97 changed files with 4732 additions and 1151 deletions
+12
View File
@@ -61,6 +61,18 @@ Gradle auto-fetches the platform/build-tools it needs once licenses are accepted
## Local build + install ## Local build + install
The `react-native-svg@15.15.4` patch prevents header fades retaining their old painted size after
rotation. Rebuild native Android after changing it; Metro reloads cannot update this code. Keep the
resize hunks until upstream invalidates descendant geometry on canvas resize. Its instrumentation
regression renders real gradient pixels across growing and shrinking layouts, including a nested
group. From `packages/app/android`, run it on your selected emulator:
```bash
ANDROID_SERIAL=emulator-5562 ./gradlew :react-native-svg:connectedDebugAndroidTest \
-Pandroid.testInstrumentationRunnerArguments.class=com.horcrux.svg.SvgResizeTest \
--no-daemon --max-workers=2 -Dorg.gradle.parallel=false
```
The locked `expo-modules-core@57.0.23` needs the patch installed by `npm install`. The locked `expo-modules-core@57.0.23` needs the patch installed by `npm install`.
It backports It backports
[Expo's Activity-result launcher fix](https://github.com/expo/expo/pull/49634): changing [Expo's Activity-result launcher fix](https://github.com/expo/expo/pull/49634): changing
+30
View File
@@ -65,6 +65,7 @@ per-agent examples and provider-specific keys.
## Table of Contents ## Table of Contents
- [Extending a built-in provider](#extending-a-built-in-provider) - [Extending a built-in provider](#extending-a-built-in-provider)
- [OpenCode with an existing v2 server](#opencode-with-an-existing-v2-server)
- [Z.AI (Zhipu) coding plan](#zai-zhipu-coding-plan) - [Z.AI (Zhipu) coding plan](#zai-zhipu-coding-plan)
- [Alibaba Cloud (Qwen) coding plan](#alibaba-cloud-qwen-coding-plan) - [Alibaba Cloud (Qwen) coding plan](#alibaba-cloud-qwen-coding-plan)
- [Codex with a custom OpenAI-compatible endpoint](#codex-with-a-custom-openai-compatible-endpoint) - [Codex with a custom OpenAI-compatible endpoint](#codex-with-a-custom-openai-compatible-endpoint)
@@ -107,6 +108,35 @@ See [Codex with a custom OpenAI-compatible endpoint](#codex-with-a-custom-openai
--- ---
## OpenCode with an existing v2 server
Paseo first uses an explicitly configured server, then a healthy locally registered OpenCode v2 service, then starts its own helper. It never stops a reused service. To select a server explicitly, set its URL in the provider environment:
```json
{
"agents": {
"providers": {
"opencode": {
"env": {
"PASEO_OPENCODE_SERVER_URL": "http://127.0.0.1:4096",
"PASEO_OPENCODE_SERVER_PASSWORD": "<server password>"
}
}
}
}
}
```
`PASEO_OPENCODE_SERVER_URL` must be an absolute `http` or `https` URL. Omit `PASEO_OPENCODE_SERVER_PASSWORD` when the server has no authentication; when set, Paseo authenticates with HTTP Basic auth as `opencode`.
A configured URL takes precedence even when unreachable; Paseo reports the connection error. The local service registration supplies its own authentication. For a discovered local service, Paseo installs its bridge package in OpenCode's watched global plugin directory without changing the existing configuration. Refresh reads the existing service's catalogs without starting another server.
An explicitly configured server needs the bridge installed separately. Connect once and start a session: the setup error gives the local package directory. Copy that directory to the server's OpenCode global `plugins` directory, then retry. For a server on another machine, set `PASEO_OPENCODE_BRIDGE_PORT` in the Paseo OpenCode provider environment before starting Paseo, and set `PASEO_OPENCODE_BRIDGE_URL` to the URL the OpenCode server can use to reach that port. For example, an SSH reverse tunnel can expose the Paseo loopback port on the OpenCode host. The bridge token is exchanged through the OpenCode connection and is not written into the plugin package.
OpenCode's custom MCP registrations are shared by all sessions in a location. Paseo rejects custom MCP servers and per-agent process environment overrides on a reused server rather than applying them to other sessions or ignoring them. Session identity is applied to each session's shell environment. A Paseo-owned helper supports custom process and MCP settings; it is selected when there is no explicit URL or healthy registered service.
---
## Z.AI (Zhipu) coding plan ## Z.AI (Zhipu) coding plan
[Z.AI](https://z.ai) is a Chinese AI company (Zhipu AI) that offers an Anthropic-compatible API endpoint. Their GLM Coding Plan provides flat-rate access to GLM models through Claude Code's Anthropic API protocol. These are **not** Anthropic Claude models — they are Zhipu's own GLM models exposed through an Anthropic-compatible API. [Z.AI](https://z.ai) is a Chinese AI company (Zhipu AI) that offers an Anthropic-compatible API endpoint. Their GLM Coding Plan provides flat-rate access to GLM models through Claude Code's Anthropic API protocol. These are **not** Anthropic Claude models — they are Zhipu's own GLM models exposed through an Anthropic-compatible API.
+1 -1
View File
@@ -89,7 +89,7 @@ OMP supports native Paseo host tools. The adapter registers the full caller-scop
Pi RPC extension UI dialog requests (`select`, `input`, `editor`, `confirm`) are bridged into Paseo question permissions and answered with `extension_ui_response`. Pi extensions such as `ask_user` may chain dialogs: for example, a `select` can be followed by an optional-comment `input`. When an `ask_user` tool call declares `allowComment: true`, Paseo presents the selection and optional comment as one question permission, answers Pi's initial `select` immediately, then auto-answers the follow-up optional `input` with the comment the user already supplied (or an empty string). Preserve placeholders and optional/skip semantics for standalone optional inputs so the app can still distinguish "skip this optional input" from "cancel the whole dialog." Fire-and-forget extension UI requests such as notifications are intentionally ignored by the provider adapter unless Paseo grows first-class UI for them. Pi RPC extension UI dialog requests (`select`, `input`, `editor`, `confirm`) are bridged into Paseo question permissions and answered with `extension_ui_response`. Pi extensions such as `ask_user` may chain dialogs: for example, a `select` can be followed by an optional-comment `input`. When an `ask_user` tool call declares `allowComment: true`, Paseo presents the selection and optional comment as one question permission, answers Pi's initial `select` immediately, then auto-answers the follow-up optional `input` with the comment the user already supplied (or an empty string). Preserve placeholders and optional/skip semantics for standalone optional inputs so the app can still distinguish "skip this optional input" from "cancel the whole dialog." Fire-and-forget extension UI requests such as notifications are intentionally ignored by the provider adapter unless Paseo grows first-class UI for them.
OpenCode adapters target v1.14.46 and v2.0.10. V2 rejects binaries older than the tested 2.0.10 SDK at runtime selection. Runtime selection uses the configured command and environment. A recognized version is cached until provider configuration reload; a failed, timed-out, or unrecognized probe retains the v1 path and retries detection on the next operation. Load v2 code and materialize its plugin only after positive v2 selection; v1 sessions remain undecorated. Keep upstream SDK types inside the version-specific adapter. OpenCode owns storage migration; a missing native session must fail resume rather than create a replacement. V2 has no native archive/unarchive operation: archiving affects Paseo only. V1 retains native archiving. OpenCode adapters target v1.14.46 and v2.0.10. V2 rejects versions older than 2.0.10. Paseo selects an explicit server, then a healthy local registered service, then its own helper. It never stops a reused service. A local service loads the Paseo bridge from its watched plugin directory; sessions bind their own bridge credentials through OpenCode RPC. See [OpenCode with an existing v2 server](custom-providers.md#opencode-with-an-existing-v2-server) for setup and isolation limits. Load v2 code and materialize its plugin only after positive v2 selection; v1 sessions remain undecorated. Keep upstream SDK types inside the version-specific adapter. OpenCode owns storage migration; a missing native session must fail resume rather than create a replacement. V2 has no native archive/unarchive operation: archiving affects Paseo only. V1 retains native archiving.
Use OpenCode v2 execution events to trigger turn completion, with active-state and durable-log reconciliation after admission, reconnect, and while a turn remains active. Do not use `session.wait`: a healthy turn exceeding Node's HTTP headers deadline produces a transport error while OpenCode keeps working. The live event feed has no replay, and shutdown interruption preserves the previous idle outcome, so the session snapshot alone cannot recover missed execution events. Quiet streams are healthy: v2 heartbeats are SSE comments, not application events. Use OpenCode v2 execution events to trigger turn completion, with active-state and durable-log reconciliation after admission, reconnect, and while a turn remains active. Do not use `session.wait`: a healthy turn exceeding Node's HTTP headers deadline produces a transport error while OpenCode keeps working. The live event feed has no replay, and shutdown interruption preserves the previous idle outcome, so the session snapshot alone cannot recover missed execution events. Quiet streams are healthy: v2 heartbeats are SSE comments, not application events.
@@ -1,4 +1,5 @@
import { writeFile } from "node:fs/promises"; import { execFileSync } from "node:child_process";
import { mkdir, writeFile } from "node:fs/promises";
import path from "node:path"; import path from "node:path";
import { test, expect, type Page } from "../support/fixtures"; import { test, expect, type Page } from "../support/fixtures";
import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent"; import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent";
@@ -67,3 +68,54 @@ test("opening an assistant file link dismisses its tooltip over the file tab", a
await agent.cleanup(); await agent.cleanup();
} }
}); });
async function readLinkedGuide(page: Page, linkName: string, content: string): Promise<void> {
await page.getByRole("link", { name: linkName, exact: true }).click();
await expect(page.getByTestId("markdown-preview-frame").filter({ visible: true })).toContainText(
content,
);
}
async function returnToNestedLinks(page: Page): Promise<void> {
await page.getByRole("button", { name: "Nested file links", exact: true }).click();
await expect(page.getByRole("link", { name: "nested guide", exact: true })).toBeVisible();
}
test("assistant directory paths find nested files and preserve exact workspace paths", async ({
page,
}, testInfo) => {
testInfo.setTimeout(120_000);
const agent = await seedMockAgentWorkspace({
repoPrefix: "nested-file-link-",
title: "Nested file links",
featureValues: {
mockStreamingAssistantResponse:
"Read [nested guide](schema/x.md), [exact guide](schema/exact.md), and [missing guide](schema/missing.md).",
mockStreamingAssistantIntervalMs: 5,
},
});
try {
await mkdir(path.join(agent.cwd, "repo-a/schema"), { recursive: true });
execFileSync("git", ["init", "-b", "main"], { cwd: path.join(agent.cwd, "repo-a") });
await writeFile(
path.join(agent.cwd, "repo-a/schema/x.md"),
"# Nested file content\n\nDetails\n",
);
await mkdir(path.join(agent.cwd, "schema"), { recursive: true });
await writeFile(path.join(agent.cwd, "schema/exact.md"), "# Exact file content\n");
await writeFile(path.join(agent.cwd, "repo-a/schema/exact.md"), "# Wrong nested content\n");
await openAgentRoute(page, agent);
await agent.client.sendAgentMessage(agent.agentId, "Show the file links.");
await agent.client.waitForFinish(agent.agentId, 30_000);
await expect(page.getByRole("textbox", { name: "Message agent..." }).first()).toBeEnabled();
await readLinkedGuide(page, "nested guide", "Nested file content");
await returnToNestedLinks(page);
await readLinkedGuide(page, "exact guide", "Exact file content");
await returnToNestedLinks(page);
await page.getByRole("link", { name: "missing guide", exact: true }).click();
await expect(page.getByText("File not found", { exact: true })).toBeVisible();
} finally {
await agent.cleanup();
}
});
@@ -1,5 +1,9 @@
import { expect, test, type Page } from "../support/fixtures"; import { expect, test, type Page } from "../support/fixtures";
import { composerLocator, expectComposerVisible } from "../support/helpers/composer"; import {
attachFileFromMenu,
composerLocator,
expectComposerVisible,
} from "../support/helpers/composer";
import { import {
openAgentRoute, openAgentRoute,
seedMockAgentWorkspace, seedMockAgentWorkspace,
@@ -365,6 +369,56 @@ function expectPopoverDoesNotDisappearAfterFirstVisible(frames: PopoverFrame[]):
} }
test.describe("Composer autocomplete", () => { test.describe("Composer autocomplete", () => {
test("inserts suggested commands from assistant messages without sending", async ({ page }) => {
const requests = recordCommandRequestsByAgentId(page);
const session = await seedMockAgentWorkspace({
repoPrefix: "suggested-command-",
title: "Suggested commands",
initialPrompt: "Suggest `/mock` without running it",
featureValues: { mockAssistantResponse: "Try `/mock` or [Run mock](/mock)." },
});
try {
await openAgentRoute(page, session);
const message = page.getByTestId("assistant-message").filter({ hasText: "Run mock" });
await expect(message).toBeVisible();
await expect(page.getByTestId("user-message").getByRole("link")).toHaveCount(0);
await expect.poll(() => requests.length).toBe(1);
await attachFileFromMenu(page, {
name: "note.txt",
mimeType: "text/plain",
buffer: Buffer.from("attachment"),
});
await expect(page.getByTestId("composer-file-attachment-pill")).toContainText("note.txt");
await message.getByRole("link", { name: "/mock", exact: true }).click();
await expect(composerLocator(page)).toHaveValue("/mock ");
await expect(
page.getByTestId("composer-autocomplete-popover").filter({ visible: true }),
).toHaveCount(0);
await expect(composerLocator(page)).toBeFocused();
await message.getByRole("link", { name: "Run mock" }).click();
await expect(composerLocator(page)).toHaveValue("/mock /mock ");
await expect(
page.getByTestId("composer-autocomplete-popover").filter({ visible: true }),
).toHaveCount(0);
await expect(composerLocator(page)).toBeFocused();
await expect(page.getByTestId("composer-file-attachment-pill")).toContainText("note.txt");
await composerLocator(page).fill("draft");
await composerLocator(page).evaluate((element: HTMLTextAreaElement) =>
element.setSelectionRange(0, 0),
);
await message.getByRole("link", { name: "/mock", exact: true }).click();
await composerLocator(page).pressSequentially("argument");
await expect(composerLocator(page)).toHaveValue("draft /mock argument");
await composerLocator(page).fill("/");
await expect(
page.getByTestId("composer-autocomplete-popover").filter({ visible: true }),
).toContainText("/mock");
expect(requests).toHaveLength(1);
} finally {
await session.cleanup();
}
});
test("keeps New workspace off the running-agent command list before a project is chosen", async ({ test("keeps New workspace off the running-agent command list before a project is chosen", async ({
page, page,
}) => { }) => {
@@ -81,6 +81,8 @@ for (const folder of [
"docs/guides", "docs/guides",
"docs/guides/", "docs/guides/",
"docs/指南", "docs/指南",
"examples/next.js/",
"examples/next.js",
]) { ]) {
test(`chat folder link opens folder contents: ${folder}`, async ({ page }) => { test(`chat folder link opens folder contents: ${folder}`, async ({ page }) => {
const session = await seedMockAgentWorkspace({ const session = await seedMockAgentWorkspace({
@@ -92,6 +94,9 @@ for (const folder of [
try { try {
await mkdir(path.join(session.cwd, folder), { recursive: true }); await mkdir(path.join(session.cwd, folder), { recursive: true });
await writeFile(path.join(session.cwd, folder, "guide.md"), "# Folder link destination\n"); await writeFile(path.join(session.cwd, folder, "guide.md"), "# Folder link destination\n");
const competingFile = path.join(session.cwd, "repo-a", folder.replace(/\/$/, ""));
await mkdir(path.dirname(competingFile), { recursive: true });
await writeFile(competingFile, "This is a file, not the requested folder.\n");
await session.client.waitForFinish(session.agentId, 30_000); await session.client.waitForFinish(session.agentId, 30_000);
await page.addInitScript(() => { await page.addInitScript(() => {
localStorage.setItem( localStorage.setItem(
@@ -10,6 +10,7 @@ import androidx.test.platform.app.InstrumentationRegistry
import org.json.JSONArray import org.json.JSONArray
import org.json.JSONObject import org.json.JSONObject
import org.junit.Assert.assertTrue import org.junit.Assert.assertTrue
import org.junit.Assert.assertEquals
import org.junit.Test import org.junit.Test
import org.junit.runner.RunWith import org.junit.runner.RunWith
@@ -55,6 +56,40 @@ class TerminalRowLayoutTest {
} }
} }
@Test
fun selectionCompositesOverCellAndTerminalBackgroundsWithoutChangingForeground() {
val red = 0xffff0000.toInt()
val green = 0xff00aa00.toInt()
val overlay = 0x472684ff
val source = TerminalRowSource.decode("1,1;1,4294901760,4278233600,0;M")
val layout = TerminalRowLayout(source, font, TerminalCustomGlyphs.EMPTY)
paints.selectionBackground = overlay
for (terminalBackground in listOf(0xffffffff.toInt(), 0xff000000.toInt())) {
val actual = raster { canvas ->
canvas.drawColor(terminalBackground)
layout.drawSelected(canvas, 0, TerminalSelection(TerminalSelectionRecord()), paints)
}
val expected = raster { canvas ->
canvas.drawColor(terminalBackground)
canvas.drawColor(green)
canvas.drawColor(overlay)
}
assertEquals(expected.getPixel(0, 0), actual.getPixel(0, 0))
assertTrue("selected glyph retains its red foreground", (0 until font.cellHeightPx).any { y ->
(0 until font.cellWidthPx).any { x -> actual.getPixel(x, y) == red }
})
}
val defaultBackground = TerminalRowLayout(TerminalRowSource.decode("1,1;1,4294901760,0,0;M"), font, TerminalCustomGlyphs.EMPTY)
for (terminalBackground in listOf(0xffffffff.toInt(), 0xff000000.toInt())) {
val actual = raster { canvas ->
canvas.drawColor(terminalBackground)
defaultBackground.drawSelected(canvas, 0, TerminalSelection(TerminalSelectionRecord()), paints)
}
val expected = raster { canvas -> canvas.drawColor(terminalBackground); canvas.drawColor(overlay) }
assertEquals(expected.getPixel(0, 0), actual.getPixel(0, 0))
}
}
private fun starts(row: JSONObject, key: String): List<Int> = private fun starts(row: JSONObject, key: String): List<Int> =
row.getJSONArray(key).let { starts -> List(starts.length()) { starts.getInt(it) } } row.getJSONArray(key).let { starts -> List(starts.length()) { starts.getInt(it) } }
@@ -63,7 +63,6 @@ class PaseoTerminalView(context: Context, appContext: AppContext) : ExpoView(con
backgroundColor = argb(value.background) backgroundColor = argb(value.background)
cursorColor = argb(value.cursor) cursorColor = argb(value.cursor)
paints.selectionBackground = argb(value.selectionBackground) paints.selectionBackground = argb(value.selectionBackground)
paints.selectionForeground = argb(value.selectionForeground)
invalidate() invalidate()
} }
@@ -27,7 +27,6 @@ class TerminalThemeRecord : Record {
@Field val background: Double = 0.0 @Field val background: Double = 0.0
@Field val cursor: Double = 0.0 @Field val cursor: Double = 0.0
@Field val selectionBackground: Double = 0.0 @Field val selectionBackground: Double = 0.0
@Field val selectionForeground: Double = 0.0
} }
class TerminalCustomGlyphRecord : Record { class TerminalCustomGlyphRecord : Record {
@@ -23,7 +23,7 @@ private const val CLIP_UNBOUNDED_PX = 100_000f
* either half alone would show an explicit virama. Each resulting group is drawn from the shaped * either half alone would show an explicit virama. Each resulting group is drawn from the shaped
* run (in context) at its first cell's column, centred in the columns it owns, and scaled down only when * run (in context) at its first cell's column, centred in the columns it owns, and scaled down only when
* its glyphs are wider than those columns. Colors never split groups or reshape the row: a group * its glyphs are wider than those columns. Colors never split groups or reshape the row: a group
* whose cells differ in color or selection is drawn once per color, clipped to those cells' columns. * whose cells differ in color is drawn once per color, clipped to those cells' columns.
*/ */
internal class TerminalRowLayout( internal class TerminalRowLayout(
val source: TerminalRowSource, val source: TerminalRowSource,
@@ -195,7 +195,7 @@ internal class TerminalRowLayout(
canvas.drawRenderNode(recorded) canvas.drawRenderNode(recorded)
} }
/** Draws the row with selected cells in selection colors. Selection never reshapes the row. */ /** Draws backgrounds, the selection overlay, then text with its original colors. */
fun drawSelected(canvas: Canvas, row: Int, selection: TerminalSelection, paints: TerminalPaints) { fun drawSelected(canvas: Canvas, row: Int, selection: TerminalSelection, paints: TerminalPaints) {
drawContent(canvas, row, selection, paints) drawContent(canvas, row, selection, paints)
} }
@@ -213,17 +213,17 @@ internal class TerminalRowLayout(
val cellHeight = font.cellHeightPx.toFloat() val cellHeight = font.cellHeightPx.toFloat()
val fill = paints.fill val fill = paints.fill
for (cell in 0 until cellCount) { for (cell in 0 until cellCount) {
val background = val left = cellCol[cell] * cellWidth
if (selected(row, selection, cell)) paints.selectionBackground else cellBackground[cell] val right = (cellCol[cell] + cellSpan[cell]) * cellWidth
if (background ushr 24 == 0) continue val background = cellBackground[cell]
fill.color = background if (background ushr 24 != 0) {
canvas.drawRect( fill.color = background
cellCol[cell] * cellWidth, canvas.drawRect(left, 0f, right, cellHeight, fill)
0f, }
(cellCol[cell] + cellSpan[cell]) * cellWidth, if (selected(row, selection, cell)) {
cellHeight, fill.color = paints.selectionBackground
fill, canvas.drawRect(left, 0f, right, cellHeight, fill)
) }
} }
val baseline = font.baselinePx.toFloat() val baseline = font.baselinePx.toFloat()
@@ -234,9 +234,9 @@ internal class TerminalRowLayout(
val paint = font.paint(cellFlags[group.firstCell]) val paint = font.paint(cellFlags[group.firstCell])
var first = group.firstCell var first = group.firstCell
while (first <= group.lastCell) { while (first <= group.lastCell) {
val color = foreground(row, selection, first, paints) val color = cellForeground[first]
var last = first var last = first
while (last < group.lastCell && foreground(row, selection, last + 1, paints) == color) last += 1 while (last < group.lastCell && cellForeground[last + 1] == color) last += 1
// Only edges between differently colored cells clip; the group's outer edges may overhang. // Only edges between differently colored cells clip; the group's outer edges may overhang.
val clipped = first > group.firstCell || last < group.lastCell val clipped = first > group.firstCell || last < group.lastCell
canvas.save() canvas.save()
@@ -257,7 +257,7 @@ internal class TerminalRowLayout(
for (cell in 0 until cellCount) { for (cell in 0 until cellCount) {
val glyph = cellGlyph[cell] val glyph = cellGlyph[cell]
val flags = cellFlags[cell] val flags = cellFlags[cell]
val foreground = foreground(row, selection, cell, paints) val foreground = cellForeground[cell]
if (glyph != null) { if (glyph != null) {
fill.color = foreground fill.color = foreground
glyph.draw(canvas, cellCol[cell] * cellWidth, cellSpan[cell] * cellWidth, cellHeight, font.density, fill) glyph.draw(canvas, cellCol[cell] * cellWidth, cellSpan[cell] * cellWidth, cellHeight, font.density, fill)
@@ -291,9 +291,6 @@ internal class TerminalRowLayout(
canvas.drawTextRun(text, group.start, group.end, group.contextStart, group.contextEnd, x, baseline, false, paint) canvas.drawTextRun(text, group.start, group.end, group.contextStart, group.contextEnd, x, baseline, false, paint)
} }
private fun foreground(row: Int, selection: TerminalSelection?, cell: Int, paints: TerminalPaints): Int =
if (selected(row, selection, cell)) paints.selectionForeground else cellForeground[cell]
/** The row's text as accessibility reads it. */ /** The row's text as accessibility reads it. */
fun describe(): String = source.text.trimEnd() fun describe(): String = source.text.trimEnd()
} }
@@ -301,6 +298,5 @@ internal class TerminalRowLayout(
/** Paint state shared by every row of one view. */ /** Paint state shared by every row of one view. */
internal class TerminalPaints { internal class TerminalPaints {
val fill = Paint(Paint.ANTI_ALIAS_FLAG) val fill = Paint(Paint.ANTI_ALIAS_FLAG)
var selectionBackground: Int = 0xFFFFFF00.toInt() var selectionBackground: Int = 0x472684FF
var selectionForeground: Int = 0xFF000000.toInt()
} }
@@ -43,7 +43,6 @@ export interface PaseoTerminalTheme {
background: number; background: number;
cursor: number; cursor: number;
selectionBackground: number; selectionBackground: number;
selectionForeground: number;
} }
export interface PaseoTerminalCellMetrics { export interface PaseoTerminalCellMetrics {
@@ -24,8 +24,7 @@ final class PaseoTerminalView: ExpoView {
private var background = TerminalColor(Double(0xff00_0000)) private var background = TerminalColor(Double(0xff00_0000))
private var cursorColor = TerminalColor(Double(0xffff_ffff)) private var cursorColor = TerminalColor(Double(0xffff_ffff))
private var rowPaint = TerminalRowPaint( private var rowPaint = TerminalRowPaint(
selectionBackground: TerminalColor(Double(0xffff_ff00)), selectionBackground: TerminalColor(Double(0x4726_84ff)))
selectionForeground: TerminalColor(Double(0xff00_0000)))
private var rowCache: [String: TerminalRowLayout] = [:] private var rowCache: [String: TerminalRowLayout] = [:]
required init(appContext: AppContext? = nil) { required init(appContext: AppContext? = nil) {
@@ -44,8 +43,7 @@ final class PaseoTerminalView: ExpoView {
background = TerminalColor(record.background) background = TerminalColor(record.background)
cursorColor = TerminalColor(record.cursor) cursorColor = TerminalColor(record.cursor)
rowPaint = TerminalRowPaint( rowPaint = TerminalRowPaint(
selectionBackground: TerminalColor(record.selectionBackground), selectionBackground: TerminalColor(record.selectionBackground))
selectionForeground: TerminalColor(record.selectionForeground))
backgroundColor = UIColor(cgColor: background.cgColor()) backgroundColor = UIColor(cgColor: background.cgColor())
setNeedsDisplay() setNeedsDisplay()
} }
@@ -25,7 +25,6 @@ struct TerminalThemeRecord: Record {
@Field var background: Double = 0 @Field var background: Double = 0
@Field var cursor: Double = 0 @Field var cursor: Double = 0
@Field var selectionBackground: Double = 0 @Field var selectionBackground: Double = 0
@Field var selectionForeground: Double = 0
} }
struct TerminalCustomGlyphRecord: Record { struct TerminalCustomGlyphRecord: Record {
@@ -27,7 +27,6 @@ private let leftToRightAttributes: [NSAttributedString.Key: Any] = {
/// Colors a row is painted with besides its own styles. /// Colors a row is painted with besides its own styles.
struct TerminalRowPaint { struct TerminalRowPaint {
let selectionBackground: TerminalColor let selectionBackground: TerminalColor
let selectionForeground: TerminalColor
} }
/// One row shaped once and placed on the terminal grid. /// One row shaped once and placed on the terminal grid.
@@ -267,31 +266,33 @@ final class TerminalRowLayout {
selection?.contains(row: row, col: cellCol[cell], span: cellSpan[cell]) ?? false selection?.contains(row: row, col: cellCol[cell], span: cellSpan[cell]) ?? false
} }
private func foreground(_ cell: Int, row: Int, selection: TerminalSelection?, paint: TerminalRowPaint) -> TerminalColor { /// Draws cell backgrounds, the selection overlay, then text with its original colors.
isSelected(cell, row: row, selection: selection) ? paint.selectionForeground : cellForeground[cell]
}
/// Draws the row with its top edge at `top`, selected cells in selection colors.
func draw(in context: CGContext, row: Int, top: CGFloat, selection: TerminalSelection?, paint: TerminalRowPaint) { func draw(in context: CGContext, row: Int, top: CGFloat, selection: TerminalSelection?, paint: TerminalRowPaint) {
let cellWidth = font.cellWidth let cellWidth = font.cellWidth
let cellHeight = font.cellHeight let cellHeight = font.cellHeight
for cell in 0..<cellCount { for cell in 0..<cellCount {
let background = isSelected(cell, row: row, selection: selection) ? paint.selectionBackground : cellBackground[cell] let rect = CGRect(x: CGFloat(cellCol[cell]) * cellWidth, y: top, width: CGFloat(cellSpan[cell]) * cellWidth, height: cellHeight)
guard background.isPainted else { continue } let background = cellBackground[cell]
context.setFillColor(background.cgColor()) if background.isPainted {
context.fill(CGRect(x: CGFloat(cellCol[cell]) * cellWidth, y: top, width: CGFloat(cellSpan[cell]) * cellWidth, height: cellHeight)) context.setFillColor(background.cgColor())
context.fill(rect)
}
if isSelected(cell, row: row, selection: selection) {
context.setFillColor(paint.selectionBackground.cgColor())
context.fill(rect)
}
} }
let centerY = top + cellHeight / 2 let centerY = top + cellHeight / 2
let baselineInTextSpace = cellHeight / 2 - font.baseline let baselineInTextSpace = cellHeight / 2 - font.baseline
// A group whose cells differ in color or selection is drawn once per color, clipped to those // A group whose cells differ in color is drawn once per color, clipped to those
// cells' columns. Only edges between differently colored cells clip; outer edges may overhang. // cells' columns. Only edges between differently colored cells clip; outer edges may overhang.
for group in groups { for group in groups {
var first = group.firstCell var first = group.firstCell
while first <= group.lastCell { while first <= group.lastCell {
let color = foreground(first, row: row, selection: selection, paint: paint) let color = cellForeground[first]
var last = first var last = first
while last < group.lastCell && foreground(last + 1, row: row, selection: selection, paint: paint) == color { while last < group.lastCell && cellForeground[last + 1] == color {
last += 1 last += 1
} }
context.saveGState() context.saveGState()
@@ -315,7 +316,7 @@ final class TerminalRowLayout {
let baseline = top + font.baseline let baseline = top + font.baseline
for cell in 0..<cellCount { for cell in 0..<cellCount {
let color = foreground(cell, row: row, selection: selection, paint: paint) let color = cellForeground[cell]
let left = CGFloat(cellCol[cell]) * cellWidth let left = CGFloat(cellCol[cell]) * cellWidth
let width = CGFloat(cellSpan[cell]) * cellWidth let width = CGFloat(cellSpan[cell]) * cellWidth
if let glyph = cellGlyph[cell] { if let glyph = cellGlyph[cell] {
@@ -1,8 +1,16 @@
import type { QueryClient } from "@tanstack/react-query"; import type { QueryClient } from "@tanstack/react-query";
import type { AgentProvider } from "@getpaseo/protocol/agent-types"; import type { AgentProvider } from "@getpaseo/protocol/agent-types";
import type { ProviderSnapshotEntry } from "@getpaseo/protocol/agent-types";
import equal from "fast-deep-equal";
import { normalizeWorkspacePath } from "@/utils/workspace-identity"; import { normalizeWorkspacePath } from "@/utils/workspace-identity";
export const AGENT_COMMANDS_QUERY_ROOT = "agentCommands"; export const AGENT_COMMANDS_QUERY_ROOT = "agentCommands";
export const sessionCommandQueryPolicy = {
staleTime: 600_000,
gcTime: 1_800_000,
refetchOnMount: true,
refetchOnWindowFocus: false,
} as const;
export interface AgentCommandsDraftConfig { export interface AgentCommandsDraftConfig {
provider: AgentProvider; provider: AgentProvider;
@@ -21,6 +29,46 @@ export function agentCommandsQueryRoot(serverId: string) {
return [AGENT_COMMANDS_QUERY_ROOT, serverId] as const; return [AGENT_COMMANDS_QUERY_ROOT, serverId] as const;
} }
/** Snapshot announcements contain provider metadata, not command catalogs. Ignore initial
* discovery and unrelated providers; explicit refresh still invalidates every catalog. */
export function invalidateCommandsForProviderChange(input: {
queryClient: QueryClient;
serverId: string;
cwd: string | null;
previous: readonly ProviderSnapshotEntry[] | undefined;
current: readonly ProviderSnapshotEntry[];
}): Promise<void> {
const previous = new Map((input.previous ?? []).map((entry) => [entry.provider, entry]));
const current = new Map(input.current.map((entry) => [entry.provider, entry]));
const relevant = (entry: ProviderSnapshotEntry | undefined) =>
entry && {
status: entry.status,
enabled: entry.enabled,
source: entry.source,
models: entry.models,
modes: entry.modes,
defaultModeId: entry.defaultModeId,
};
return input.queryClient.invalidateQueries({
queryKey: agentCommandsQueryRoot(input.serverId),
predicate: (query) => {
const isDraft = query.queryKey[2] === "draft";
const provider =
query.meta?.agentCommandProvider ?? (isDraft ? query.queryKey[6] : undefined);
const cwd = query.meta?.agentCommandCwd ?? (isDraft ? query.queryKey[4] : undefined);
if (input.cwd && typeof cwd === "string" && normalizeAgentCommandsCwd(cwd) !== input.cwd)
return false;
if (typeof provider !== "string") return false;
const before = previous.get(provider);
const after = current.get(provider);
if (!before) return after?.status === "ready" && query.state.status === "error";
if (before.status !== "ready")
return after?.status === "ready" && query.state.status === "error";
return !equal(relevant(before), relevant(after));
},
});
}
export function sessionAgentCommandsQueryKey(input: { serverId: string; agentId: string }) { export function sessionAgentCommandsQueryKey(input: { serverId: string; agentId: string }) {
return [...agentCommandsQueryRoot(input.serverId), "session", input.agentId] as const; return [...agentCommandsQueryRoot(input.serverId), "session", input.agentId] as const;
} }
+99
View File
@@ -0,0 +1,99 @@
import { createContext, useCallback, useContext, useMemo, type ReactNode } from "react";
import { useQueryClient } from "@tanstack/react-query";
import type { ComposerEditingSession } from "@/composer/editing-session";
import { agentCommandsQueryKey } from "./cache";
import {
useAgentCommandsQuery,
type AgentSlashCommand,
type DraftCommandConfig,
} from "./use-query";
export type {
AgentSlashCommand,
DraftCommandConfig,
DraftCommandTarget,
AgentCommandsClient,
} from "./use-query";
export { fetchAgentCommands } from "./use-query";
type CommandQuery = ReturnType<typeof useAgentCommandsQuery>;
const QueryContext = createContext<CommandQuery | null>(null);
export interface CommandHints {
names: ReadonlySet<string>;
insert(command: string): void;
}
const HintsContext = createContext<CommandHints | null>(null);
function useLiveAgentCommands() {
return useContext(QueryContext);
}
export function useAutocompleteCommands(input: {
serverId: string;
agentId: string;
enabled: boolean;
draftConfig?: DraftCommandConfig;
}) {
const live = useLiveAgentCommands();
const fallback = useAgentCommandsQuery({
serverId: input.serverId,
agentId: input.agentId,
enabled: !live && input.enabled,
draftConfig: input.draftConfig,
});
return live ?? fallback;
}
export function useAgentCommandHints() {
return useContext(HintsContext);
}
export function matchCommandLink(
url: string,
sourceType: "markdown" | "text" | "inline-code" | undefined,
names: ReadonlySet<string>,
): string | null {
return (sourceType === "markdown" || sourceType === "inline-code") &&
url.startsWith("/") &&
names.has(url.slice(1))
? url
: null;
}
export function AgentCommandsProvider({
serverId,
agentId,
editingSession,
enabled,
children,
}: {
serverId: string;
agentId: string;
editingSession: ComposerEditingSession;
enabled: boolean;
children: ReactNode;
}) {
const query = useAgentCommandsQuery({ serverId, agentId, enabled });
const queryClient = useQueryClient();
const namesKey = JSON.stringify(
[...new Set(query.commands.map((command) => command.name))].sort(),
);
const names = useMemo<ReadonlySet<string>>(
() => new Set(JSON.parse(namesKey) as string[]),
[namesKey],
);
const insert = useCallback(
(token: string) => {
const commands = queryClient.getQueryData<AgentSlashCommand[]>(
agentCommandsQueryKey({ serverId, agentId }),
);
if (token.startsWith("/") && commands?.some((command) => `/${command.name}` === token)) {
editingSession.insertCommand(token);
}
},
[agentId, editingSession, queryClient, serverId],
);
const hints = useMemo(() => ({ names, insert }), [names, insert]);
return (
<QueryContext.Provider value={enabled ? query : null}>
<HintsContext.Provider value={enabled ? hints : null}>{children}</HintsContext.Provider>
</QueryContext.Provider>
);
}
@@ -1,11 +1,61 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { QueryClient, QueryObserver } from "@tanstack/react-query";
import { fetchQueryOptions } from "@/data/query";
import { import {
agentCommandsQueryKey, agentCommandsQueryKey,
agentCommandsQueryRoot, agentCommandsQueryRoot,
draftAgentCommandsQueryKey, draftAgentCommandsQueryKey,
normalizeAgentCommandsCwd, normalizeAgentCommandsCwd,
sessionAgentCommandsQueryKey, sessionAgentCommandsQueryKey,
} from "./agent-commands-query"; sessionCommandQueryPolicy,
} from "./cache";
it("reuses fresh commands and keeps stale commands during a shared refresh and failure", async () => {
const client = new QueryClient({ defaultOptions: { queries: { gcTime: 300_000 } } });
const key = sessionAgentCommandsQueryKey({ serverId: "s", agentId: "a" });
let calls = 0;
let fail = false;
let resolveRefresh: (value: string[]) => void = () => {};
const refresh = new Promise<string[]>((resolve) => {
resolveRefresh = resolve;
});
const options = fetchQueryOptions({
...sessionCommandQueryPolicy,
staleTimeMs: sessionCommandQueryPolicy.staleTime,
dataShape: "value",
queryKey: key,
retry: false,
queryFn: async () => {
calls++;
if (fail) throw new Error("offline");
return refresh;
},
});
client.setQueryData(key, ["old"]);
const fresh = new QueryObserver(client, options);
const unsubscribeFresh = fresh.subscribe(() => {});
expect(calls).toBe(0);
unsubscribeFresh();
client.setQueryData(key, ["old"], { updatedAt: 1 });
const first = new QueryObserver(client, options);
const second = new QueryObserver(client, options);
const unsubscribeFirst = first.subscribe(() => {});
const unsubscribeSecond = second.subscribe(() => {});
expect(calls).toBe(1);
expect(first.getCurrentResult().data).toEqual(["old"]);
expect(client.getQueryCache().find({ queryKey: key })?.gcTime).toBe(1_800_000);
resolveRefresh(["new"]);
await refresh;
await first.refetch({ cancelRefetch: false });
expect(second.getCurrentResult().data).toEqual(["new"]);
fail = true;
await client.invalidateQueries({ queryKey: key });
expect(first.getCurrentResult().data).toEqual(["new"]);
expect(first.getCurrentResult().isError).toBe(true);
unsubscribeFirst();
unsubscribeSecond();
client.clear();
});
describe("agent command query keys", () => { describe("agent command query keys", () => {
it("shares draft commands across draft tabs with the same provider setup", () => { it("shares draft commands across draft tabs with the same provider setup", () => {
@@ -1,9 +1,5 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { import { type AgentCommandsClient, type DraftCommandConfig, fetchAgentCommands } from "./use-query";
type AgentCommandsClient,
type DraftCommandConfig,
fetchAgentCommands,
} from "./use-agent-commands-query";
type ListCommands = AgentCommandsClient["listCommands"]; type ListCommands = AgentCommandsClient["listCommands"];
type ListCommandsResult = Awaited<ReturnType<ListCommands>>; type ListCommandsResult = Awaited<ReturnType<ListCommands>>;
@@ -38,6 +34,12 @@ function commandsPayload(commands: ListCommandsResult["commands"]): ListCommands
} }
describe("fetchAgentCommands", () => { describe("fetchAgentCommands", () => {
it("rejects a discovery error so a previous catalog is retained", async () => {
const client = createClient({ ...commandsPayload([]), error: "provider unavailable" });
await expect(fetchAgentCommands({ client, agentId: "agent-1" })).rejects.toThrow(
"provider unavailable",
);
});
it("loads commands for a draft composer without an agent id", async () => { it("loads commands for a draft composer without an agent id", async () => {
const client = createClient( const client = createClient(
commandsPayload([{ name: "compact", description: "Compact context", argumentHint: "" }]), commandsPayload([{ name: "compact", description: "Compact context", argumentHint: "" }]),
@@ -1,12 +1,15 @@
import { useQuery } from "@tanstack/react-query"; import { useFetchQuery } from "@/data/query";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client"; import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
import { useRetainedPanelActive } from "@/components/retained-panel"; import { useRetainedPanelActive } from "@/components/retained-panel";
import { useSessionStore } from "@/stores/session-store";
import { useShallow } from "zustand/shallow";
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime"; import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
import { agentCommandsQueryKey, type AgentCommandsDraftConfig } from "@/hooks/agent-commands-query"; import {
agentCommandsQueryKey,
const DRAFT_COMMANDS_STALE_TIME = Number.POSITIVE_INFINITY; sessionCommandQueryPolicy,
const SESSION_COMMANDS_STALE_TIME = 60_000; type AgentCommandsDraftConfig,
} from "./cache";
export interface AgentSlashCommand { export interface AgentSlashCommand {
name: string; name: string;
@@ -41,6 +44,7 @@ export async function fetchAgentCommands(input: {
agentId: input.agentId, agentId: input.agentId,
draftConfig: input.draftConfig, draftConfig: input.draftConfig,
}); });
if (response.error) throw new Error(response.error);
return response.commands as AgentSlashCommand[]; return response.commands as AgentSlashCommand[];
} }
@@ -62,8 +66,16 @@ export function useAgentCommandsQuery({
const queryEnabled = enabled && retainedPanelActive; const queryEnabled = enabled && retainedPanelActive;
const client = useHostRuntimeClient(serverId); const client = useHostRuntimeClient(serverId);
const isConnected = useHostRuntimeIsConnected(serverId); const isConnected = useHostRuntimeIsConnected(serverId);
const { provider, cwd } = useSessionStore(
useShallow((state) => {
const agent =
state.sessions[serverId]?.agents?.get(agentId) ??
state.sessions[serverId]?.agentDetails?.get(agentId);
return { provider: agent?.provider, cwd: agent?.cwd };
}),
);
const query = useQuery({ const query = useFetchQuery({
queryKey: agentCommandsQueryKey({ serverId, agentId, draftConfig }), queryKey: agentCommandsQueryKey({ serverId, agentId, draftConfig }),
queryFn: async () => { queryFn: async () => {
if (!client) { if (!client) {
@@ -72,7 +84,15 @@ export function useAgentCommandsQuery({
return fetchAgentCommands({ client, agentId, draftConfig }); return fetchAgentCommands({ client, agentId, draftConfig });
}, },
enabled: queryEnabled && !!client && isConnected && (!!agentId || !!draftConfig), enabled: queryEnabled && !!client && isConnected && (!!agentId || !!draftConfig),
staleTime: draftConfig ? DRAFT_COMMANDS_STALE_TIME : SESSION_COMMANDS_STALE_TIME, dataShape: "value",
meta: {
agentCommandProvider: draftConfig?.provider ?? provider,
agentCommandCwd: draftConfig?.cwd ?? cwd,
},
...(draftConfig
? { immutableWhen: () => true as const }
: { ...sessionCommandQueryPolicy, staleTimeMs: sessionCommandQueryPolicy.staleTime }),
subscribed: queryEnabled,
retry: 3, retry: 3,
retryDelay: (attemptIndex) => Math.min(1000 * 2 ** attemptIndex, 5000), retryDelay: (attemptIndex) => Math.min(1000 * 2 ** attemptIndex, 5000),
}); });
@@ -84,7 +104,7 @@ export function useAgentCommandsQuery({
return { return {
commands: query.data ?? [], commands: query.data ?? [],
isLoading, isLoading,
isError: query.isError, isError: query.isError && query.data === undefined,
error: query.error, error: query.error,
}; };
} }
+1
View File
@@ -468,6 +468,7 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
() => () =>
createInlinePathOpener({ createInlinePathOpener({
workspaceRoot, workspaceRoot,
resolveRelativeFiles: true,
client, client,
openFile: openInlineFile, openFile: openInlineFile,
openDirectory: openInlineDirectory, openDirectory: openInlineDirectory,
@@ -64,7 +64,10 @@ it("ignores an older probe after a newer file link opens", async () => {
const opened: string[] = []; const opened: string[] = [];
const opener = createInlinePathOpener({ const opener = createInlinePathOpener({
workspaceRoot: "/repo", workspaceRoot: "/repo",
client: { listDirectory: () => pendingProbe }, client: {
listDirectory: () => pendingProbe,
getDirectorySuggestions: async () => ({ entries: [], error: null }),
},
openFile: (location) => { openFile: (location) => {
opened.push(location.path); opened.push(location.path);
}, },
@@ -91,7 +94,10 @@ it("ignores a pending probe when its view is left", async () => {
const opened: string[] = []; const opened: string[] = [];
const opener = createInlinePathOpener({ const opener = createInlinePathOpener({
workspaceRoot: "/repo", workspaceRoot: "/repo",
client: { listDirectory: () => pendingProbe }, client: {
listDirectory: () => pendingProbe,
getDirectorySuggestions: async () => ({ entries: [], error: null }),
},
openFile: (location) => { openFile: (location) => {
opened.push(location.path); opened.push(location.path);
}, },
@@ -130,3 +136,45 @@ it("sends folders outside the workspace away from the workspace Files panel", as
expect(outsideFolders).toEqual(["~/Downloads/contracts", "/tmp/reports"]); expect(outsideFolders).toEqual(["~/Downloads/contracts", "/tmp/reports"]);
expect(workspaceFolders).toEqual(["docs"]); expect(workspaceFolders).toEqual(["docs"]);
}); });
it("keeps a later selection when an earlier nested-file lookup finishes last", async () => {
const { createInlinePathOpener } = await import("./path-open");
const lookup = Promise.withResolvers<{
entries: Array<{ path: string; kind: "file" }>;
error: null;
}>();
const queries: string[] = [];
const opened: string[] = [];
const opener = createInlinePathOpener({
workspaceRoot: "/repo",
resolveRelativeFiles: true,
client: {
listDirectory: async () => {
throw new Error("Not a directory");
},
getDirectorySuggestions: async ({ query }: { query: string }) => {
queries.push(query);
return lookup.promise;
},
},
openFile: (location) => {
opened.push(location.path);
},
openDirectory: (directory) => {
opened.push(directory);
},
openOutsideDirectory: (directory) => {
opened.push(directory);
},
});
const earlier = opener.open(
{ raw: "schema/x.md:2", path: "/repo/schema/x.md", lineStart: 2 },
"side",
);
await Promise.resolve();
expect(queries).toEqual(["schema/x.md"]);
await opener.open({ raw: "./guide.md:2", path: "/repo/guide.md", lineStart: 2 }, "side");
lookup.resolve({ entries: [{ path: "repo-a/schema/x.md", kind: "file" }], error: null });
await earlier;
expect(opened).toEqual(["guide.md"]);
});
@@ -5,6 +5,7 @@ import {
type OpenFileDisposition, type OpenFileDisposition,
} from "@/workspace/file-open"; } from "@/workspace/file-open";
import { isAbsolutePath } from "@/utils/path"; import { isAbsolutePath } from "@/utils/path";
import { resolveMissingNestedRelativeFile, type GetDirectorySuggestions } from "./resolver";
interface PathOpenInput { interface PathOpenInput {
file?: string; file?: string;
@@ -50,7 +51,11 @@ export async function resolveInlinePathOpen(input: PathOpenInput): Promise<PathO
export function createInlinePathOpener(input: { export function createInlinePathOpener(input: {
workspaceRoot: string; workspaceRoot: string;
client: PathOpenInput["client"]; client: {
listDirectory(root: string, path: string): Promise<unknown>;
getDirectorySuggestions: GetDirectorySuggestions;
} | null;
resolveRelativeFiles?: boolean;
openFile: (location: WorkspaceFileLocation, disposition: OpenFileDisposition) => void; openFile: (location: WorkspaceFileLocation, disposition: OpenFileDisposition) => void;
openDirectory: (path: string) => void; openDirectory: (path: string) => void;
openOutsideDirectory: (path: string) => void; openOutsideDirectory: (path: string) => void;
@@ -73,10 +78,21 @@ export function createInlinePathOpener(input: {
} else if (destination.kind === "outsideDirectory") { } else if (destination.kind === "outsideDirectory") {
input.openOutsideDirectory(destination.directoryPath); input.openOutsideDirectory(destination.directoryPath);
} else if (normalized.file) { } else if (normalized.file) {
const resolvedTarget =
input.resolveRelativeFiles && input.client
? await resolveMissingNestedRelativeFile({
target,
workspaceRoot: input.workspaceRoot,
getDirectorySuggestions: input.client.getDirectorySuggestions.bind(input.client),
})
: target;
if (ownGeneration !== generation) return;
const resolved = normalizeInlinePathTarget(resolvedTarget.path, input.workspaceRoot);
if (!resolved?.file) return;
const location = normalizeWorkspaceFileLocation({ const location = normalizeWorkspaceFileLocation({
path: normalized.file, path: resolved.file,
lineStart: target.lineStart, lineStart: resolvedTarget.lineStart,
lineEnd: target.lineEnd, lineEnd: resolvedTarget.lineEnd,
}); });
if (location) input.openFile(location, disposition); if (location) input.openFile(location, disposition);
} }
@@ -7,6 +7,7 @@ import {
classifyForResolution, classifyForResolution,
fetchDaemonResolution, fetchDaemonResolution,
getAssistantFileLinkToken, getAssistantFileLinkToken,
resolveMissingNestedRelativeFile,
UnresolvedFileLinkError, UnresolvedFileLinkError,
type AssistantFileLinkContext, type AssistantFileLinkContext,
type DirectorySuggestionEntry, type DirectorySuggestionEntry,
@@ -424,6 +425,174 @@ describe("fetchDaemonResolution", () => {
}); });
}); });
describe("resolveMissingNestedRelativeFile", () => {
const target = {
raw: "schema/x.md:12-14",
path: "/Users/test/project/schema/x.md",
lineStart: 12,
lineEnd: 14,
};
it("opens a nested-repo suffix when the workspace-root path is missing", async () => {
const { getDirectorySuggestions, searches } = suggestionsFromMap({
"schema/x.md": [{ path: "repo-a/schema/x.md", kind: "file" }],
});
const result = await resolveMissingNestedRelativeFile({
target,
workspaceRoot: "/Users/test/project",
getDirectorySuggestions,
});
expect(searches).toEqual([
{
query: "schema/x.md",
cwd: "/Users/test/project",
matchMode: "suffix",
limit: 1,
},
]);
expect(result).toEqual({
...target,
path: "/Users/test/project/repo-a/schema/x.md",
});
});
it("keeps the exact workspace path when suggestions return that relative path", async () => {
const { getDirectorySuggestions } = suggestionsFromMap({
"schema/x.md": [{ path: "schema/x.md", kind: "file" }],
});
const result = await resolveMissingNestedRelativeFile({
target,
workspaceRoot: "/Users/test/project",
getDirectorySuggestions,
});
expect(result).toEqual(target);
});
it("keeps the original target when suggestions are empty", async () => {
const { getDirectorySuggestions } = suggestionsFromMap({});
await expect(
resolveMissingNestedRelativeFile({
target,
workspaceRoot: "/Users/test/project",
getDirectorySuggestions,
}),
).resolves.toEqual(target);
});
it("keeps the original target when the daemon throws", async () => {
await expect(
resolveMissingNestedRelativeFile({
target,
workspaceRoot: "/Users/test/project",
getDirectorySuggestions: unavailableSuggestions,
}),
).resolves.toEqual(target);
});
it.each([
["a bare filename", { ...target, path: "/Users/test/project/file.ts" }],
["a path outside the workspace", { ...target, path: "/tmp/schema/x.md" }],
["a home-relative path", { ...target, path: "~/schema/x.md" }],
])("does not search %s", async (_label, candidate) => {
const { getDirectorySuggestions, searches } = suggestionsFromMap({
"schema/x.md": [{ path: "repo-a/schema/x.md", kind: "file" }],
});
const result = await resolveMissingNestedRelativeFile({
target: candidate,
workspaceRoot: "/Users/test/project",
getDirectorySuggestions,
});
expect(searches).toEqual([]);
expect(result).toEqual(candidate);
});
it("does not search when the workspace root is blank", async () => {
const { getDirectorySuggestions, searches } = suggestionsFromMap({});
const result = await resolveMissingNestedRelativeFile({
target,
workspaceRoot: " ",
getDirectorySuggestions,
});
expect(searches).toEqual([]);
expect(result).toEqual(target);
});
it.each([
["/Users/test/project/schema/x.md", "/Users/test/project"],
["file:///Users/test/project/schema/x.md", "/Users/test/project"],
["C:/repo/schema/x.md", "C:/repo"],
["\\\\server\\share\\schema\\x.md", "//server/share"],
])("keeps absolute destination %s", async (raw, workspaceRoot) => {
const { getDirectorySuggestions, searches } = suggestionsFromMap({
"schema/x.md": [{ path: "repo-a/schema/x.md", kind: "file" }],
});
const absoluteTarget = { raw, path: `${workspaceRoot}/schema/x.md` };
await expect(
resolveMissingNestedRelativeFile({
target: absoluteTarget,
workspaceRoot,
getDirectorySuggestions,
}),
).resolves.toEqual(absoluteTarget);
expect(searches).toEqual([]);
});
it.each(["docs/guides", "./docs/guides", "docs/guides/"])(
"keeps folder href %s direct",
async (raw) => {
const { getDirectorySuggestions, searches } = suggestionsFromMap({
"docs/guides": [{ path: "repo-a/docs/guides", kind: "file" }],
});
const folderTarget = { raw, path: "/Users/test/project/docs/guides" };
await expect(
resolveMissingNestedRelativeFile({
target: folderTarget,
workspaceRoot: "/Users/test/project",
getDirectorySuggestions,
}),
).resolves.toEqual(folderTarget);
expect(searches).toEqual([]);
},
);
it("normalizes a Windows workspace root before the suffix lookup", async () => {
const windowsTarget = {
raw: "schema/x.md",
path: "C:\\repo\\schema\\x.md",
lineStart: undefined,
lineEnd: undefined,
};
const { getDirectorySuggestions, searches } = suggestionsFromMap({
"schema/x.md": [{ path: "repo-a/schema/x.md", kind: "file" }],
});
const result = await resolveMissingNestedRelativeFile({
target: windowsTarget,
workspaceRoot: "C:\\repo\\",
getDirectorySuggestions,
});
expect(searches).toEqual([
{
query: "schema/x.md",
cwd: "C:\\repo\\",
matchMode: "suffix",
limit: 1,
},
]);
expect(result.path).toBe("C:/repo/repo-a/schema/x.md");
});
});
describe("getAssistantFileLinkToken", () => { describe("getAssistantFileLinkToken", () => {
it("uses rendered text for markdown-it linkified tokens and href for explicit links", () => { it("uses rendered text for markdown-it linkified tokens and href for explicit links", () => {
expect( expect(
@@ -5,6 +5,7 @@ import {
type InlinePathTarget, type InlinePathTarget,
} from "./parse"; } from "./parse";
import { i18n } from "@/i18n/i18next"; import { i18n } from "@/i18n/i18next";
import { isAbsolutePath } from "@/utils/path";
export interface AssistantFileLinkSource { export interface AssistantFileLinkSource {
href: string; href: string;
@@ -105,6 +106,62 @@ export async function fetchDaemonResolution({
}; };
} }
/** Only file-looking paths nested inside the workspace need suffix search. */
function nestedRelativeLookupQuery(
targetPath: string,
workspaceRoot: string | undefined,
): string | null {
const root = workspaceRoot?.trim();
if (!root) {
return null;
}
const normalizedRoot = root.replace(/\\/g, "/").replace(/\/+$/, "");
const normalizedPath = targetPath.replace(/\\/g, "/");
const prefix = `${normalizedRoot}/`;
if (!normalizedPath.startsWith(prefix)) {
return null;
}
const relative = normalizedPath.slice(prefix.length);
if (!relative.includes("/") || !isFileLookingAssistantToken(encodeURI(relative))) {
return null;
}
return relative;
}
/** When a markdown link points at workspaceRoot/schema/x.md and that file is
* missing, ask the same suffix lookup bare names already use. An exact hit
* stays on that path. A suffix hit such as repo-a/schema/x.md replaces it.
* Lookup failure keeps the original target.
*/
export async function resolveMissingNestedRelativeFile(input: {
target: InlinePathTarget;
workspaceRoot?: string;
getDirectorySuggestions: GetDirectorySuggestions;
}): Promise<InlinePathTarget> {
if (
isAbsoluteInlineCodeToken(input.target.raw) ||
input.target.raw.startsWith("~/") ||
input.target.raw.endsWith("/")
) {
return input.target;
}
const query = nestedRelativeLookupQuery(input.target.path, input.workspaceRoot);
if (!query) {
return input.target;
}
try {
return await fetchDaemonResolution({
ambiguousQuery: query,
token: input.target.raw,
target: input.target,
workspaceRoot: input.workspaceRoot,
getDirectorySuggestions: input.getDirectorySuggestions,
});
} catch {
return input.target;
}
}
export function classifyForResolution( export function classifyForResolution(
source: AssistantFileLinkSource, source: AssistantFileLinkSource,
context: AssistantFileLinkContext, context: AssistantFileLinkContext,
@@ -215,11 +272,7 @@ export function shouldResolveDirectFileThroughSuggestions(input: {
} }
function isAbsoluteInlineCodeToken(token: string): boolean { function isAbsoluteInlineCodeToken(token: string): boolean {
return ( return isAbsolutePath(token) || token.toLowerCase().startsWith("file://");
token.startsWith("/") ||
token.toLowerCase().startsWith("file://") ||
/^[A-Za-z]:[\\/]/.test(token)
);
} }
function isLinkifiedSource(source: AssistantFileLinkSource): boolean { function isLinkifiedSource(source: AssistantFileLinkSource): boolean {
@@ -35,11 +35,15 @@ export interface MarkdownImageContext {
filePath?: string; filePath?: string;
} }
export function useMarkdownSource(text: string, context: MarkdownImageContext) { export function useMarkdownSource(
text: string,
context: MarkdownImageContext,
commandNames?: ReadonlySet<string>,
) {
const occurrenceKey = useId(); const occurrenceKey = useId();
const prepared = useMemo( const prepared = useMemo(
() => prepareMarkdownSource(text, context.workspaceRoot, { supportsSvg: isWeb }), () => prepareMarkdownSource(text, context.workspaceRoot, { supportsSvg: isWeb, commandNames }),
[text, context.workspaceRoot], [text, context.workspaceRoot, commandNames],
); );
const sources = [...new Set(prepared.images.map((image) => image.source))]; const sources = [...new Set(prepared.images.map((image) => image.source))];
const acquisitions = sources.map((source) => { const acquisitions = sources.map((source) => {
@@ -2,6 +2,8 @@ import { fromMarkdown } from "mdast-util-from-markdown";
import { toNativeLinkRecognition } from "react-native-enriched-markdown/src/utils/regexParser"; import { toNativeLinkRecognition } from "react-native-enriched-markdown/src/utils/regexParser";
import { parseMarkdown } from "react-native-enriched-markdown/src/web/parseMarkdown"; import { parseMarkdown } from "react-native-enriched-markdown/src/web/parseMarkdown";
import { recognizeTextLinks } from "react-native-enriched-markdown/src/web/recognizeTextLinks"; import { recognizeTextLinks } from "react-native-enriched-markdown/src/web/recognizeTextLinks";
import { matchCommandLink } from "@/agent-commands";
import { classifyAssistantFileLink } from "@/assistant-file-links"; import { classifyAssistantFileLink } from "@/assistant-file-links";
import { markInlineImages } from "react-native-enriched-markdown/src/web/utils"; import { markInlineImages } from "react-native-enriched-markdown/src/web/utils";
import type { ASTNode } from "react-native-enriched-markdown/src/web/types"; import type { ASTNode } from "react-native-enriched-markdown/src/web/types";
@@ -13,6 +15,28 @@ import { createMarkdownStyles } from "./styles";
import { prepareMarkdownSource, PENDING_MARKDOWN_IMAGE } from "./source"; import { prepareMarkdownSource, PENDING_MARKDOWN_IMAGE } from "./source";
import { normalizeMarkdownHtml } from "./html"; import { normalizeMarkdownHtml } from "./html";
it("recognizes complete known commands in inline code", () => {
const names = new Set(["code-review", "review:fast"]);
const prepared = prepareMarkdownSource("`/code-review` `/unknown`", undefined, {
commandNames: names,
});
expect(prepared.linkRecognition.inlineCode.test("/code-review")).toBe(true);
expect(prepared.linkRecognition.inlineCode.test("/unknown")).toBe(
prepareMarkdownSource("`/unknown`").linkRecognition.inlineCode.test("/unknown"),
);
const nonPath = prepareMarkdownSource("`/review:fast`", undefined, { commandNames: names });
expect(nonPath.linkRecognition.inlineCode.test("/review:fast")).toBe(true);
expect(matchCommandLink("/code-review", "inline-code", names)).toBe("/code-review");
});
it("resolves authored root-relative links only when the target is a known command", () => {
const names = new Set(["code-review"]);
expect(matchCommandLink("/code-review", "markdown", names)).toBe("/code-review");
expect(matchCommandLink("/code-review/file", "markdown", names)).toBeNull();
expect(matchCommandLink("/code-review", "text", names)).toBeNull();
expect(matchCommandLink("https://example.com/code-review", "markdown", names)).toBeNull();
});
describe("shared Markdown source adaptation", () => { describe("shared Markdown source adaptation", () => {
it("keeps paragraphs, fences and quotes together for the library", () => { it("keeps paragraphs, fences and quotes together for the library", () => {
const source = "First paragraph.\n\nSecond paragraph.\n\n```ts\nconst n = 1;\n```\n\n> Quote"; const source = "First paragraph.\n\nSecond paragraph.\n\n```ts\nconst n = 1;\n```\n\n> Quote";
@@ -6,6 +6,7 @@ import {
useAssistantFileLinkEnvironment, useAssistantFileLinkEnvironment,
} from "@/assistant-file-links"; } from "@/assistant-file-links";
import { useStableEvent } from "@/hooks/use-stable-event"; import { useStableEvent } from "@/hooks/use-stable-event";
import { matchCommandLink, useAgentCommandHints, type CommandHints } from "@/agent-commands";
import { AttachmentLightbox, type ImageLightboxSource } from "@/components/attachment-lightbox"; import { AttachmentLightbox, type ImageLightboxSource } from "@/components/attachment-lightbox";
import type { Theme } from "@/styles/theme"; import type { Theme } from "@/styles/theme";
import { isWeb } from "@/constants/platform"; import { isWeb } from "@/constants/platform";
@@ -48,6 +49,7 @@ export interface MarkdownRendererProps extends MarkdownImageContext {
selectable?: boolean; selectable?: boolean;
streaming?: boolean; streaming?: boolean;
onLinkPress?: (url: string) => unknown; onLinkPress?: (url: string) => unknown;
agentCommands?: boolean;
} }
/** All Markdown surfaces share the same engine and source adaptation. */ /** All Markdown surfaces share the same engine and source adaptation. */
@@ -59,15 +61,21 @@ export const MarkdownRenderer = memo(function MarkdownRenderer(props: MarkdownRe
() => splitStreamingMarkdown(completeLines, true).head.length, () => splitStreamingMarkdown(completeLines, true).head.length,
[completeLines], [completeLines],
); );
if (!streaming || boundary === 0) return <MarkdownPart {...props} />; const Part = props.agentCommands ? CommandMarkdownPart : MarkdownPart;
if (!streaming || boundary === 0) return <Part {...props} />;
return ( return (
<MarkdownBlockStack> <MarkdownBlockStack>
<MarkdownPart {...props} text={props.text.slice(0, boundary)} /> <Part {...props} text={props.text.slice(0, boundary)} />
<MarkdownPart {...props} text={props.text.slice(boundary)} /> <Part {...props} text={props.text.slice(boundary)} />
</MarkdownBlockStack> </MarkdownBlockStack>
); );
}); });
const CommandMarkdownPart = memo(function CommandMarkdownPart(props: MarkdownRendererProps) {
const commandHints = useAgentCommandHints();
return <MarkdownPart {...props} commandHints={commandHints} />;
});
const MarkdownPart = memo(function MarkdownPart({ const MarkdownPart = memo(function MarkdownPart({
text, text,
compact = false, compact = false,
@@ -75,8 +83,10 @@ const MarkdownPart = memo(function MarkdownPart({
selectable = true, selectable = true,
streaming = false, streaming = false,
onLinkPress, onLinkPress,
agentCommands: _agentCommands,
commandHints = null,
...imageContext ...imageContext
}: MarkdownRendererProps) { }: MarkdownRendererProps & { commandHints?: CommandHints | null }) {
const environment = useAssistantFileLinkEnvironment(); const environment = useAssistantFileLinkEnvironment();
const actions = useAssistantFileLinkActions(); const actions = useAssistantFileLinkActions();
const client = const client =
@@ -84,14 +94,23 @@ const MarkdownPart = memo(function MarkdownPart({
(environment.client?.readFile (environment.client?.readFile
? { readFile: environment.client.readFile.bind(environment.client) } ? { readFile: environment.client.readFile.bind(environment.client) }
: undefined); : undefined);
const { markdown, imageSources, linkRecognition, linkSource } = useMarkdownSource(text, { const { markdown, imageSources, linkRecognition, linkSource } = useMarkdownSource(
workspaceRoot: environment.workspaceRoot, text,
serverId: environment.serverId, {
...imageContext, workspaceRoot: environment.workspaceRoot,
client, serverId: environment.serverId,
}); ...imageContext,
client,
},
commandHints?.names,
);
const [image, setImage] = useState<ImageLightboxSource | null>(null); const [image, setImage] = useState<ImageLightboxSource | null>(null);
const pressLink = useStableEvent(({ url, sourceType }: LinkPressEvent) => { const pressLink = useStableEvent(({ url, sourceType }: LinkPressEvent) => {
const command = commandHints && matchCommandLink(url, sourceType, commandHints.names);
if (command) {
commandHints.insert(command);
return;
}
const source = linkSource(url, sourceType); const source = linkSource(url, sourceType);
if (onLinkPress && !actions.canResolveFile(source)) onLinkPress(url); if (onLinkPress && !actions.canResolveFile(source)) onLinkPress(url);
else actions.open(source, "preferred"); else actions.open(source, "preferred");
+14 -4
View File
@@ -56,7 +56,11 @@ function linkMetadata(inlineCodePaths: ReadonlySet<string>) {
}; };
} }
function collectInlineCodePaths(source: string, workspaceRoot?: string): ReadonlySet<string> { function collectInlineCodePaths(
source: string,
workspaceRoot?: string,
commandNames?: ReadonlySet<string>,
): ReadonlySet<string> {
const paths = new Set<string>(); const paths = new Set<string>();
const previousEnds = new Map<number, number>(); const previousEnds = new Map<number, number>();
// Collect candidates between consecutive runs of equal length. The engine owns // Collect candidates between consecutive runs of equal length. The engine owns
@@ -74,7 +78,11 @@ function collectInlineCodePaths(source: string, workspaceRoot?: string): Readonl
value = value.slice(1, -1); value = value.slice(1, -1);
} }
const classification = classifyAssistantFileLink(value, { workspaceRoot }); const classification = classifyAssistantFileLink(value, { workspaceRoot });
if (classification && classification.kind !== "external") paths.add(value); if (
(classification && classification.kind !== "external") ||
(value.startsWith("/") && commandNames?.has(value.slice(1)))
)
paths.add(value);
} }
return paths; return paths;
} }
@@ -112,7 +120,7 @@ function workspaceHoverTitle(link: Link, markup: string, workspaceRoot?: string)
export function prepareMarkdownSource( export function prepareMarkdownSource(
source: string, source: string,
workspaceRoot?: string, workspaceRoot?: string,
options: { supportsSvg?: boolean } = {}, options: { supportsSvg?: boolean; commandNames?: ReadonlySet<string> } = {},
) { ) {
source = normalizeMarkdownHtml(source); source = normalizeMarkdownHtml(source);
const images: MarkdownImage[] = []; const images: MarkdownImage[] = [];
@@ -122,7 +130,9 @@ export function prepareMarkdownSource(
extensions: [gfmTable()], extensions: [gfmTable()],
mdastExtensions: [gfmTableFromMarkdown()], mdastExtensions: [gfmTableFromMarkdown()],
})); }));
const metadata = linkMetadata(collectInlineCodePaths(source, workspaceRoot)); const metadata = linkMetadata(
collectInlineCodePaths(source, workspaceRoot, options.commandNames),
);
const edits: { start: number; end: number; text: string }[] = []; const edits: { start: number; end: number; text: string }[] = [];
if ( if (
!source.includes("![") && !source.includes("![") &&
+1
View File
@@ -1199,6 +1199,7 @@ export const AssistantMessage = memo(function AssistantMessage({
> >
<MarkdownRenderer <MarkdownRenderer
text={revealedMessage} text={revealedMessage}
agentCommands
streaming={phase !== "complete" || revealedMessage.length < renderedMessage.text.length} streaming={phase !== "complete" || revealedMessage.length < renderedMessage.text.length}
client={client} client={client}
workspaceRoot={workspaceRoot} workspaceRoot={workspaceRoot}
@@ -1,14 +1,13 @@
import Svg, { Path } from "react-native-svg";
import { useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState } from "react"; import { useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState } from "react";
import { import {
Keyboard, Keyboard,
type LayoutChangeEvent, type LayoutChangeEvent,
PanResponder, PanResponder,
type PanResponderGestureState,
Pressable, Pressable,
StyleSheet, StyleSheet,
Text, Text,
View, View,
type GestureResponderEvent,
type StyleProp, type StyleProp,
type ViewStyle, type ViewStyle,
} from "react-native"; } from "react-native";
@@ -47,13 +46,10 @@ import {
type TerminalSelectionViewport, type TerminalSelectionViewport,
} from "../terminal/native-renderer/terminal-selection"; } from "../terminal/native-renderer/terminal-selection";
import { import {
TERMINAL_GESTURE_LONG_PRESS_MS, createTerminalSelectionController,
TERMINAL_GESTURE_TAP_TOLERANCE_PX, terminalSelectionHandles,
advanceTerminalScrollGesture, TERMINAL_SELECTION_GRIP_SIZE,
classifyTerminalGestureIntent, } from "../terminal/native-renderer/terminal-selection-controller";
resolveTerminalGestureReleaseAction,
type TerminalGestureIntent,
} from "../terminal/native-renderer/terminal-selection-gesture";
import { resolveNativeTerminalMountAction } from "../terminal/native-renderer/terminal-mount-policy"; import { resolveNativeTerminalMountAction } from "../terminal/native-renderer/terminal-mount-policy";
import { import {
forwardNativeTerminalKey, forwardNativeTerminalKey,
@@ -87,38 +83,6 @@ const DEFAULT_XTERM_THEME: ITheme = {
foreground: "#e6e6e6", foreground: "#e6e6e6",
cursor: "#e6e6e6", cursor: "#e6e6e6",
}; };
interface NativeTerminalPanState {
lastDy: number;
rowRemainder: number;
didScroll: boolean;
didNavigate: boolean;
movedBeyondTapTolerance: boolean;
intent: TerminalGestureIntent | null;
}
interface NativeTerminalSelectionPress {
startX: number;
startY: number;
startedAt: number;
startedWithSelection: boolean;
status: "pressing" | "selecting";
}
function resolvePanResponderPoint(
press: NativeTerminalSelectionPress,
event: GestureResponderEvent,
gesture: PanResponderGestureState,
): { x: number; y: number } {
const eventDx = event.nativeEvent.locationX - press.startX;
const eventDy = event.nativeEvent.locationY - press.startY;
const dx = Math.abs(eventDx) > Math.abs(gesture.dx) ? eventDx : gesture.dx;
const dy = Math.abs(eventDy) > Math.abs(gesture.dy) ? eventDy : gesture.dy;
return {
x: press.startX + dx,
y: press.startY + dy,
};
}
function initialNativeTerminalSize(): TerminalMeasuredSize { function initialNativeTerminalSize(): TerminalMeasuredSize {
return { rows: MIN_NATIVE_TERMINAL_ROWS, cols: MIN_NATIVE_TERMINAL_COLS }; return { rows: MIN_NATIVE_TERMINAL_ROWS, cols: MIN_NATIVE_TERMINAL_COLS };
} }
@@ -163,19 +127,14 @@ function NativeTerminalEmulator({
const outputDrainRef = useRef<NativeTerminalOutputDrain | null>(null); const outputDrainRef = useRef<NativeTerminalOutputDrain | null>(null);
const screenModelRef = useRef<TerminalScreenModel | null>(null); const screenModelRef = useRef<TerminalScreenModel | null>(null);
const selectionModelRef = useRef<TerminalSelectionModel>(createTerminalSelectionModel()); const selectionModelRef = useRef<TerminalSelectionModel>(createTerminalSelectionModel());
const selectionLongPressTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null); const selectionControllerRef = useRef<ReturnType<
const selectionPressRef = useRef<NativeTerminalSelectionPress | null>(null); typeof createTerminalSelectionController
> | null>(null);
const gestureStartRef = useRef({ x: 0, y: 0 });
const [cellMetrics, setCellMetrics] = useState<TerminalGridCellMetrics | null>(null);
const latestResolvedScreenRef = useRef<TerminalScreenState | null>(null); const latestResolvedScreenRef = useRef<TerminalScreenState | null>(null);
const paintFrameRef = useRef<number | null>(null); const paintFrameRef = useRef<number | null>(null);
const outputDecoderRef = useRef(new TextDecoder()); const outputDecoderRef = useRef(new TextDecoder());
const panStateRef = useRef<NativeTerminalPanState>({
lastDy: 0,
rowRemainder: 0,
didScroll: false,
didNavigate: false,
movedBeyondTapTolerance: false,
intent: null,
});
const inputModeTrackerRef = useRef(new TerminalInputModeTracker()); const inputModeTrackerRef = useRef(new TerminalInputModeTracker());
const lastInputModeStateRef = useRef<TerminalInputModeState>({ const lastInputModeStateRef = useRef<TerminalInputModeState>({
...inputModeTrackerRef.current.getState(), ...inputModeTrackerRef.current.getState(),
@@ -245,6 +204,7 @@ function NativeTerminalEmulator({
}, []); }, []);
const clearSelection = useCallback(() => { const clearSelection = useCallback(() => {
selectionControllerRef.current?.cancel();
const snapshot = selectionModelRef.current.clear(); const snapshot = selectionModelRef.current.clear();
commitSelectionRange(snapshot.range); commitSelectionRange(snapshot.range);
}, [commitSelectionRange]); }, [commitSelectionRange]);
@@ -255,20 +215,14 @@ function NativeTerminalEmulator({
const syncSelectionWithTerminal = useCallback( const syncSelectionWithTerminal = useCallback(
(terminal: NativeHeadlessTerminal) => { (terminal: NativeHeadlessTerminal) => {
const previous = selectionModelRef.current.getSnapshot().range;
const snapshot = selectionModelRef.current.sync({ bounds: terminal.getBufferBounds() }); const snapshot = selectionModelRef.current.sync({ bounds: terminal.getBufferBounds() });
if (previous && !snapshot.range) selectionControllerRef.current?.cancel();
commitSelectionRange(snapshot.range); commitSelectionRange(snapshot.range);
}, },
[commitSelectionRange], [commitSelectionRange],
); );
const clearSelectionLongPressTimeout = useCallback(() => {
if (selectionLongPressTimeoutRef.current === null) {
return;
}
clearTimeout(selectionLongPressTimeoutRef.current);
selectionLongPressTimeoutRef.current = null;
}, []);
const resolveVisibleRows = useCallback((terminal: NativeHeadlessTerminal): number => { const resolveVisibleRows = useCallback((terminal: NativeHeadlessTerminal): number => {
return ( return (
resolveMeasuredNativeTerminalSize({ resolveMeasuredNativeTerminalSize({
@@ -523,8 +477,7 @@ function NativeTerminalEmulator({
emitMeasuredSize({ source: "measure" }); emitMeasuredSize({ source: "measure" });
return () => { return () => {
callbacksRef.current.onRendererReadyChange?.({ streamKey, isReady: false }); callbacksRef.current.onRendererReadyChange?.({ streamKey, isReady: false });
clearSelectionLongPressTimeout(); selectionControllerRef.current?.cancel();
selectionPressRef.current = null;
if (paintFrameRef.current !== null) { if (paintFrameRef.current !== null) {
cancelAnimationFrame(paintFrameRef.current); cancelAnimationFrame(paintFrameRef.current);
paintFrameRef.current = null; paintFrameRef.current = null;
@@ -534,7 +487,7 @@ function NativeTerminalEmulator({
terminalRef.current?.dispose(); terminalRef.current?.dispose();
terminalRef.current = null; terminalRef.current = null;
}; };
}, [clearSelectionLongPressTimeout, emitMeasuredSize, resetTerminal, streamKey]); }, [emitMeasuredSize, resetTerminal, streamKey]);
useEffect(() => { useEffect(() => {
if (focusRequestToken <= 0) return; if (focusRequestToken <= 0) return;
@@ -591,6 +544,7 @@ function NativeTerminalEmulator({
const handleCellMetricsChange = useCallback( const handleCellMetricsChange = useCallback(
(metrics: TerminalGridCellMetrics) => { (metrics: TerminalGridCellMetrics) => {
metricsRef.current = metrics; metricsRef.current = metrics;
setCellMetrics(metrics);
emitMeasuredSize({ source: "measure" }); emitMeasuredSize({ source: "measure" });
}, },
[emitMeasuredSize], [emitMeasuredSize],
@@ -663,14 +617,6 @@ function NativeTerminalEmulator({
if (!coordinate) { if (!coordinate) {
return; return;
} }
const existingPress = selectionPressRef.current;
selectionPressRef.current = {
startX: point.x,
startY: point.y,
startedAt: existingPress?.startedAt ?? Date.now(),
startedWithSelection: existingPress?.startedWithSelection ?? false,
status: "selecting",
};
const bounds = terminal.getBufferBounds(); const bounds = terminal.getBufferBounds();
const wordSelection = resolveTerminalWordSelection({ terminal, coordinate }); const wordSelection = resolveTerminalWordSelection({ terminal, coordinate });
let snapshot = selectionModelRef.current.begin({ let snapshot = selectionModelRef.current.begin({
@@ -685,222 +631,61 @@ function NativeTerminalEmulator({
[commitSelectionRange, resolveSelectionCoordinate], [commitSelectionRange, resolveSelectionCoordinate],
); );
const updateSelectionAtPoint = useCallback( const selectionController = useMemo(
(point: { x: number; y: number }) => { () =>
const terminal = terminalRef.current; createTerminalSelectionController({
if (!terminal || selectionPressRef.current?.status !== "selecting") { getGeometry: () => {
return; const viewport = resolveSelectionViewport();
} const metrics = metricsRef.current;
const coordinate = resolveSelectionCoordinate(point); return viewport && metrics
if (!coordinate) { ? { range: selectionRangeRef.current, viewport, metrics }
return; : null;
} },
const snapshot = selectionModelRef.current.update({ onBeginHandle: (endpoint, cols) =>
coordinate, selectionModelRef.current.beginEndpointDrag(endpoint, cols),
bounds: terminal.getBufferBounds(), onMoveHandle: (coordinate) => {
}); const terminal = terminalRef.current;
commitSelectionRange(snapshot.range); if (!terminal) return;
}, commitSelectionRange(
[commitSelectionRange, resolveSelectionCoordinate], selectionModelRef.current.update({ coordinate, bounds: terminal.getBufferBounds() })
); .range,
);
const handlePanResponderScroll = useCallback( },
(currentDy: number) => { onLongPress: beginSelectionAtPoint,
const cellHeight = metricsRef.current?.cellHeight; onTap: focusTerminalFromTap,
if (!cellHeight || cellHeight <= 0) { onScroll: applyScroll,
return; schedule: (callback, delay) => setTimeout(callback, delay) as unknown as number,
} cancel: (timer) => clearTimeout(timer),
const panState = panStateRef.current; }),
const update = advanceTerminalScrollGesture({ [
state: panState, applyScroll,
currentDy, beginSelectionAtPoint,
cellHeight, commitSelectionRange,
}); focusTerminalFromTap,
panState.lastDy = update.state.lastDy; resolveSelectionViewport,
panState.rowRemainder = update.state.rowRemainder; ],
panState.didScroll = update.state.didScroll;
const { rowDelta } = update;
if (rowDelta === 0) {
return;
}
if (rowDelta > 0) {
applyScroll("up", rowDelta);
return;
}
applyScroll("down", Math.abs(rowDelta));
},
[applyScroll],
); );
selectionControllerRef.current = selectionController;
const panResponder = useMemo( const panResponder = useMemo(
() => () =>
PanResponder.create({ PanResponder.create({
onStartShouldSetPanResponder: () => true, onStartShouldSetPanResponder: () => true,
onStartShouldSetPanResponderCapture: () => true, onStartShouldSetPanResponderCapture: () => true,
onMoveShouldSetPanResponder: (_event, gesture) => {
const intent = classifyTerminalGestureIntent({
status: selectionRangeRef.current ? "selecting" : "pressing",
dx: gesture.dx,
dy: gesture.dy,
vx: gesture.vx,
vy: gesture.vy,
});
return intent === "scroll" || intent === "select";
},
onMoveShouldSetPanResponderCapture: (_event, gesture) => {
const intent = classifyTerminalGestureIntent({
status: selectionRangeRef.current ? "selecting" : "pressing",
dx: gesture.dx,
dy: gesture.dy,
vx: gesture.vx,
vy: gesture.vy,
});
return intent === "scroll" || intent === "select";
},
onPanResponderGrant: (event) => { onPanResponderGrant: (event) => {
panStateRef.current = { const point = { x: event.nativeEvent.locationX, y: event.nativeEvent.locationY };
lastDy: 0, gestureStartRef.current = point;
rowRemainder: 0, selectionController.press(point);
didScroll: false,
didNavigate: false,
movedBeyondTapTolerance: false,
intent: null,
};
const point = {
x: event.nativeEvent.locationX,
y: event.nativeEvent.locationY,
};
const pressStatus = selectionRangeRef.current ? "selecting" : "pressing";
selectionPressRef.current = {
startX: point.x,
startY: point.y,
startedAt: Date.now(),
startedWithSelection: pressStatus === "selecting",
status: pressStatus,
};
clearSelectionLongPressTimeout();
if (pressStatus === "selecting") {
return;
}
selectionLongPressTimeoutRef.current = setTimeout(() => {
selectionLongPressTimeoutRef.current = null;
const press = selectionPressRef.current;
if (press?.status !== "pressing") {
return;
}
beginSelectionAtPoint({ x: press.startX, y: press.startY });
}, TERMINAL_GESTURE_LONG_PRESS_MS);
},
onPanResponderMove: (event, gesture) => {
const selectionPress = selectionPressRef.current;
const panState = panStateRef.current;
if (Math.hypot(gesture.dx, gesture.dy) > TERMINAL_GESTURE_TAP_TOLERANCE_PX) {
panState.movedBeyondTapTolerance = true;
}
if (selectionPress?.status === "selecting") {
if (panState.movedBeyondTapTolerance) {
updateSelectionAtPoint(resolvePanResponderPoint(selectionPress, event, gesture));
}
return;
}
if (panState.intent === "scroll") {
handlePanResponderScroll(gesture.dy);
return;
}
if (panState.didNavigate) {
return;
}
if (selectionPress?.status === "pressing") {
const point = resolvePanResponderPoint(selectionPress, event, gesture);
const dx = point.x - selectionPress.startX;
const dy = point.y - selectionPress.startY;
const intent = classifyTerminalGestureIntent({
status: "pressing",
dx,
dy,
vx: gesture.vx,
vy: gesture.vy,
elapsedMs: Date.now() - selectionPress.startedAt,
});
if (intent === "tap" || intent === "pending") {
return;
}
clearSelectionLongPressTimeout();
panState.intent = intent;
if (intent === "scroll") {
selectionPressRef.current = null;
handlePanResponderScroll(gesture.dy);
return;
}
if (intent === "select") {
beginSelectionAtPoint({ x: selectionPress.startX, y: selectionPress.startY });
updateSelectionAtPoint(point);
return;
}
}
},
onPanResponderRelease: (event, gesture) => {
clearSelectionLongPressTimeout();
const selectionPress = selectionPressRef.current;
if (
selectionPress?.status === "selecting" &&
panStateRef.current.movedBeyondTapTolerance
) {
updateSelectionAtPoint(resolvePanResponderPoint(selectionPress, event, gesture));
}
selectionPressRef.current = null;
const { didScroll, didNavigate, movedBeyondTapTolerance } = panStateRef.current;
panStateRef.current = {
lastDy: 0,
rowRemainder: 0,
didScroll: false,
didNavigate: false,
movedBeyondTapTolerance: false,
intent: null,
};
const releaseAction = resolveTerminalGestureReleaseAction({
status: selectionPress?.status ?? "idle",
startedWithSelection: selectionPress?.startedWithSelection,
didScroll,
didNavigate,
movedBeyondTapTolerance,
pressDurationMs: selectionPress ? Date.now() - selectionPress.startedAt : 0,
longPressMs: TERMINAL_GESTURE_LONG_PRESS_MS,
scrollMode: terminalScreenRef.current?.scroll.mode ?? "following",
});
if (releaseAction === "select" && selectionPress) {
beginSelectionAtPoint({ x: selectionPress.startX, y: selectionPress.startY });
return;
}
if (releaseAction === "focus" || releaseAction === "clear") {
focusTerminalFromTap();
}
},
onPanResponderTerminate: () => {
clearSelectionLongPressTimeout();
selectionPressRef.current = null;
panStateRef.current = {
lastDy: 0,
rowRemainder: 0,
didScroll: false,
didNavigate: false,
movedBeyondTapTolerance: false,
intent: null,
};
}, },
onPanResponderMove: (_event, gesture) =>
selectionController.move({
x: gestureStartRef.current.x + gesture.dx,
y: gestureStartRef.current.y + gesture.dy,
}),
onPanResponderRelease: () => selectionController.release(),
onPanResponderTerminate: () => selectionController.cancel(),
}), }),
[ [selectionController],
beginSelectionAtPoint,
clearSelectionLongPressTimeout,
focusTerminalFromTap,
handlePanResponderScroll,
updateSelectionAtPoint,
],
); );
const backgroundColor = xtermTheme.background ?? "#0b0b0b"; const backgroundColor = xtermTheme.background ?? "#0b0b0b";
@@ -911,6 +696,11 @@ function NativeTerminalEmulator({
const terminalState = terminalScreen?.viewport ?? null; const terminalState = terminalScreen?.viewport ?? null;
const isScrolled = terminalScreen?.scroll.mode === "scrolled"; const isScrolled = terminalScreen?.scroll.mode === "scrolled";
const viewport = resolveSelectionViewport();
const handles =
viewport && cellMetrics
? terminalSelectionHandles({ range: selectionRange, viewport, metrics: cellMetrics })
: [];
const terminalGrid = terminalState ? ( const terminalGrid = terminalState ? (
<TerminalGridView <TerminalGridView
state={terminalState} state={terminalState}
@@ -927,6 +717,41 @@ function NativeTerminalEmulator({
<View onLayout={handleLayout} style={rootStyle} testID={testId}> <View onLayout={handleLayout} style={rootStyle} testID={testId}>
<View {...panResponder.panHandlers} accessible={false} style={styles.nativeSurface}> <View {...panResponder.panHandlers} accessible={false} style={styles.nativeSurface}>
{terminalGrid} {terminalGrid}
<View pointerEvents="none" style={StyleSheet.absoluteFill}>
{handles.map((handle) => (
<View key={handle.endpoint}>
<View
style={[
styles.selectionStem,
{
left: Math.max(
0,
Math.min(handle.x - 1, viewport!.cols * cellMetrics!.cellWidth - 2),
),
top: handle.stemTop,
height: handle.stemHeight,
},
]}
/>
<Svg
width={TERMINAL_SELECTION_GRIP_SIZE}
height={TERMINAL_SELECTION_GRIP_SIZE}
viewBox="0 0 18 18"
style={[styles.selectionGrip, { left: handle.gripX - 9, top: handle.gripY - 9 }]}
>
<Path
fill="#2684ff"
d={
handle.gripDirection === "left"
? "M18 0H9C4 0 0 4 0 9S4 18 9 18 18 14 18 9Z"
: "M0 0H9C14 0 18 4 18 9S14 18 9 18 0 14 0 9Z"
}
transform={handle.gripAbove ? "translate(0 18) scale(1 -1)" : undefined}
/>
</Svg>
</View>
))}
</View>
<TerminalInput <TerminalInput
ref={inputRef} ref={inputRef}
isKeyboardVisible={isKeyboardVisible} isKeyboardVisible={isKeyboardVisible}
@@ -969,6 +794,8 @@ const styles = StyleSheet.create({
nativeGrid: { nativeGrid: {
flex: 1, flex: 1,
}, },
selectionGrip: { position: "absolute" },
selectionStem: { position: "absolute", width: 2, backgroundColor: "#2684ff" },
followButton: { followButton: {
position: "absolute", position: "absolute",
right: 12, right: 12,
@@ -2,7 +2,7 @@
import React, { act } from "react"; import React, { act } from "react";
import { createRoot, type Root } from "react-dom/client"; import { createRoot, type Root } from "react-dom/client";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { useDraftStore } from "@/stores/draft-store"; import { useDraftStore } from "@/stores/draft-store";
import type { AttachmentMetadata, ComposerAttachment } from "@/attachments/types"; import type { AttachmentMetadata, ComposerAttachment } from "@/attachments/types";
import { createWorkspaceFileAttachment } from "@/attachments/workspace-file"; import { createWorkspaceFileAttachment } from "@/attachments/workspace-file";
@@ -126,17 +126,13 @@ afterEach(async () => {
}); });
}); });
let useAgentInputDraft: typeof import("./input-draft").useAgentInputDraft;
type DraftRecordForTest = ReturnType<typeof useDraftStore.getState>["drafts"][string]; type DraftRecordForTest = ReturnType<typeof useDraftStore.getState>["drafts"][string];
beforeAll(async () => { Object.defineProperty(globalThis, "IS_REACT_ACT_ENVIRONMENT", {
Object.defineProperty(globalThis, "IS_REACT_ACT_ENVIRONMENT", { value: true,
value: true, configurable: true,
configurable: true,
});
({ useAgentInputDraft } = await import("./input-draft"));
}); });
const { useAgentInputDraft } = await import("./input-draft");
describe("useAgentInputDraft live contract", () => { describe("useAgentInputDraft live contract", () => {
beforeEach(() => { beforeEach(() => {
@@ -2,7 +2,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import type { UserComposerAttachment } from "@/attachments/types"; import type { UserComposerAttachment } from "@/attachments/types";
import type { TextReplacement } from "@/composer/types"; import type { TextReplacement } from "@/composer/types";
import type { DraftAgentControlsProps } from "@/composer/agent-controls"; import type { DraftAgentControlsProps } from "@/composer/agent-controls";
import type { DraftCommandTarget } from "@/hooks/use-agent-commands-query"; import type { DraftCommandTarget } from "@/agent-commands";
import { import {
useAgentFormState, useAgentFormState,
type CreateAgentInitialValues, type CreateAgentInitialValues,
@@ -2,6 +2,31 @@ import { describe, expect, it } from "vitest";
import { ComposerEditingSession, ComposerEditingSessions } from "./index"; import { ComposerEditingSession, ComposerEditingSessions } from "./index";
describe("composer editing session", () => { describe("composer editing session", () => {
it("appends commands to the current editor buffer and focuses it", () => {
const session = new ComposerEditingSession();
let text = "draft typed since publication";
let focuses = 0;
let selection = { start: 0, end: 0 };
const detach = session.attachEditor({
getText: () => text,
replaceText: (next, nextSelection) => {
text = next;
selection = nextSelection;
},
focus: () => {
focuses += 1;
},
});
session.insertCommand("/review");
expect(selection).toEqual({ start: text.length, end: text.length });
session.insertCommand("/test");
expect(text).toBe("draft typed since publication /review /test ");
expect(focuses).toBe(2);
expect(selection).toEqual({ start: text.length, end: text.length });
detach();
session.insertCommand("/ignored");
expect(text).toBe("draft typed since publication /review /test ");
});
it("publishes fullscreen changes once per transition", () => { it("publishes fullscreen changes once per transition", () => {
const session = new ComposerEditingSession(); const session = new ComposerEditingSession();
const changes: boolean[] = []; const changes: boolean[] = [];
@@ -30,10 +30,29 @@ export interface ComposerRetainedEditor {
*/ */
export class ComposerEditingSession { export class ComposerEditingSession {
private fullscreen = false; private fullscreen = false;
private editor: {
getText(): string;
replaceText(text: string, selection: ComposerSelection): void;
focus(): void;
} | null = null;
private retained: ComposerRetainedEditor | null = null; private retained: ComposerRetainedEditor | null = null;
private listeners = new Set<() => void>(); private listeners = new Set<() => void>();
constructor(private readonly onChange?: () => void) {} constructor(private readonly onChange?: () => void) {}
getFullscreen = () => this.fullscreen; getFullscreen = () => this.fullscreen;
attachEditor(editor: NonNullable<ComposerEditingSession["editor"]>) {
this.editor = editor;
return () => {
if (this.editor === editor) this.editor = null;
};
}
insertCommand(command: string) {
const editor = this.editor;
if (!editor) return;
const text = editor.getText();
const nextText = `${text}${text && !/\s$/.test(text) ? " " : ""}${command} `;
editor.replaceText(nextText, { start: nextText.length, end: nextText.length });
editor.focus();
}
/** Called by the unmounting editor; the next mount of this session takes it. */ /** Called by the unmounting editor; the next mount of this session takes it. */
retainEditor(retained: ComposerRetainedEditor) { retainEditor(retained: ComposerRetainedEditor) {
this.retained = retained; this.retained = retained;
+1 -1
View File
@@ -71,7 +71,7 @@ import {
} from "./input/input"; } from "./input/input";
import type { ImageAttachment, MessagePayload, TextReplacement } from "./types"; import type { ImageAttachment, MessagePayload, TextReplacement } from "./types";
import { ICON_SIZE, type Theme } from "@/styles/theme"; import { ICON_SIZE, type Theme } from "@/styles/theme";
import type { DraftCommandTarget } from "@/hooks/use-agent-commands-query"; import type { DraftCommandTarget } from "@/agent-commands";
import { encodeImages } from "@/utils/encode-images"; import { encodeImages } from "@/utils/encode-images";
import { focusWithRetries } from "@/utils/web-focus"; import { focusWithRetries } from "@/utils/web-focus";
import { import {
+10
View File
@@ -1407,6 +1407,16 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
[onChangeText, updateComposerHeightForText, updateLiveTextPresence], [onChangeText, updateComposerHeightForText, updateLiveTextPresence],
); );
useLayoutEffect(
() =>
editingSession.attachEditor({
getText: () => textInputRef.current?.getText() ?? valueRef.current,
replaceText,
focus: () => textInputRef.current?.focus(),
}),
[editingSession, replaceText],
);
useImperativeHandle(ref, () => ({ useImperativeHandle(ref, () => ({
focus: () => { focus: () => {
textInputRef.current?.focus(); textInputRef.current?.focus();
+1 -1
View File
@@ -8,7 +8,7 @@ import {
} from "./provider-snapshot-cache"; } from "./provider-snapshot-cache";
import { queryClient as singletonQueryClient } from "./query-client"; import { queryClient as singletonQueryClient } from "./query-client";
import { replaceProviderSnapshotIcons } from "@/data/provider-icons"; import { replaceProviderSnapshotIcons } from "@/data/provider-icons";
import { agentCommandsQueryRoot } from "@/hooks/agent-commands-query"; import { agentCommandsQueryRoot } from "@/agent-commands/cache";
import type { AgentProvider } from "@getpaseo/protocol/agent-types"; import type { AgentProvider } from "@getpaseo/protocol/agent-types";
import { normalizeWorkspacePath } from "@/utils/workspace-identity"; import { normalizeWorkspacePath } from "@/utils/workspace-identity";
+11 -9
View File
@@ -1,11 +1,12 @@
import type { OwnedSubscription } from "@getpaseo/client"; import type { OwnedSubscription } from "@getpaseo/client";
import type { ProviderSnapshotEntry } from "@getpaseo/protocol/agent-types";
import type { Query, QueryCacheNotifyEvent, QueryClient, QueryKey } from "@tanstack/react-query"; import type { Query, QueryCacheNotifyEvent, QueryClient, QueryKey } from "@tanstack/react-query";
import type { import type {
ListTerminalsResponse, ListTerminalsResponse,
MutableDaemonConfig, MutableDaemonConfig,
SessionOutboundMessage, SessionOutboundMessage,
} from "@getpaseo/protocol/messages"; } from "@getpaseo/protocol/messages";
import { agentCommandsQueryRoot } from "@/hooks/agent-commands-query"; import { invalidateCommandsForProviderChange } from "@/agent-commands/cache";
import { shareCheckoutDiff } from "@/git/diff-sharing"; import { shareCheckoutDiff } from "@/git/diff-sharing";
import { orderCheckoutDiffFiles } from "@/git/diff-order"; import { orderCheckoutDiffFiles } from "@/git/diff-order";
import { daemonConfigQueryKey } from "@/data/daemon-config"; import { daemonConfigQueryKey } from "@/data/daemon-config";
@@ -186,10 +187,10 @@ export async function applyProvidersSnapshotUpdate(input: {
const snapshot = { ...input.message.payload, requestId: "providers_snapshot_update" }; const snapshot = { ...input.message.payload, requestId: "providers_snapshot_update" };
const cwd = normalizeProvidersSnapshotCwd(snapshot.cwd); const cwd = normalizeProvidersSnapshotCwd(snapshot.cwd);
const queryKey = providersSnapshotQueryKey(input.serverId, cwd); const queryKey = providersSnapshotQueryKey(input.serverId, cwd);
const previous = input.queryClient.getQueryData<{ snapshotHash?: string }>(queryKey); const previous = input.queryClient.getQueryData<{ entries: ProviderSnapshotEntry[] }>(queryKey);
let announcement: typeof snapshot | undefined = snapshot; let announcement: typeof snapshot | undefined = snapshot;
void input.queryClient.cancelQueries({ queryKey, exact: true }); void input.queryClient.cancelQueries({ queryKey, exact: true });
await input.queryClient.fetchQuery({ const current = await input.queryClient.fetchQuery({
queryKey, queryKey,
staleTime: 0, staleTime: 0,
structuralSharing: false, structuralSharing: false,
@@ -205,12 +206,13 @@ export async function applyProvidersSnapshotUpdate(input: {
}); });
}, },
}); });
if (!snapshot.snapshotHash || previous?.snapshotHash !== snapshot.snapshotHash) { void invalidateCommandsForProviderChange({
void input.queryClient.invalidateQueries({ queryClient: input.queryClient,
queryKey: agentCommandsQueryRoot(input.serverId), serverId: input.serverId,
exact: false, cwd,
}); previous: previous?.entries,
} current: current.entries,
});
} }
export function mountServerDataPushRouter(input: PushRouterInput): () => void { export function mountServerDataPushRouter(input: PushRouterInput): () => void {
+2 -2
View File
@@ -27,7 +27,7 @@ type ReplicaQueryInput<TQueryFnData, TError, TData, TQueryKey extends QueryKey>
type FetchQueryInput<TQueryFnData, TError, TData, TQueryKey extends QueryKey> = Omit< type FetchQueryInput<TQueryFnData, TError, TData, TQueryKey extends QueryKey> = Omit<
UseQueryOptions<TQueryFnData, TError, TData, TQueryKey>, UseQueryOptions<TQueryFnData, TError, TData, TQueryKey>,
"initialData" | "placeholderData" | "queryFn" | "refetchOnMount" | "staleTime" "initialData" | "placeholderData" | "queryFn" | "staleTime"
> & { > & {
dataShape: "list" | "value"; dataShape: "list" | "value";
queryFn: QueryFnOption<TQueryFnData, TError, TData, TQueryKey>; queryFn: QueryFnOption<TQueryFnData, TError, TData, TQueryKey>;
@@ -113,7 +113,7 @@ export function fetchQueryOptions<
dataShape, dataShape,
}, },
}, },
refetchOnMount: immutableWhen ? true : "always", refetchOnMount: options.refetchOnMount ?? (immutableWhen ? true : "always"),
staleTime: immutableWhen staleTime: immutableWhen
? (query) => ? (query) =>
query.state.data !== undefined && immutableWhen(query.state.data) ? Infinity : 0 query.state.data !== undefined && immutableWhen(query.state.data) ? Infinity : 0
@@ -16,7 +16,7 @@ import {
resolveWorkingDiffComparison, resolveWorkingDiffComparison,
selectWorkingDiffComparison, selectWorkingDiffComparison,
} from "@/git/working-diff-comparison"; } from "@/git/working-diff-comparison";
import { draftAgentCommandsQueryKey } from "@/hooks/agent-commands-query"; import { draftAgentCommandsQueryKey } from "@/agent-commands/cache";
import { import {
applyCheckoutStatusUpdateFromEvent, applyCheckoutStatusUpdateFromEvent,
ensureCheckoutStatus, ensureCheckoutStatus,
@@ -8,7 +8,7 @@ import {
invalidatePrPaneTimelineForCheckout, invalidatePrPaneTimelineForCheckout,
} from "@/git/query-keys"; } from "@/git/query-keys";
import { type CheckoutPrStatusPayload, normalizeCheckoutPrStatusPayload } from "@/git/pr-status"; import { type CheckoutPrStatusPayload, normalizeCheckoutPrStatusPayload } from "@/git/pr-status";
import { resetDraftAgentCommandsForCheckout } from "@/hooks/agent-commands-query"; import { resetDraftAgentCommandsForCheckout } from "@/agent-commands/cache";
export type CheckoutStatusPayload = CheckoutStatusResponse["payload"]; export type CheckoutStatusPayload = CheckoutStatusResponse["payload"];
export type { CheckoutPrStatusPayload } from "@/git/pr-status"; export type { CheckoutPrStatusPayload } from "@/git/pr-status";
@@ -4,11 +4,11 @@ import type { TFunction } from "i18next";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import type { AutocompleteOption } from "@/components/ui/autocomplete"; import type { AutocompleteOption } from "@/components/ui/autocomplete";
import { import {
useAgentCommandsQuery, useAutocompleteCommands,
type AgentSlashCommand, type AgentSlashCommand,
type DraftCommandConfig, type DraftCommandConfig,
type DraftCommandTarget, type DraftCommandTarget,
} from "./use-agent-commands-query"; } from "@/agent-commands";
import { orderAutocompleteOptions } from "@/components/ui/autocomplete-utils"; import { orderAutocompleteOptions } from "@/components/ui/autocomplete-utils";
import { useAutocomplete } from "./use-autocomplete"; import { useAutocomplete } from "./use-autocomplete";
import { useSessionStore } from "@/stores/session-store"; import { useSessionStore } from "@/stores/session-store";
@@ -432,7 +432,7 @@ export function useAgentAutocomplete(input: UseAgentAutocompleteInput): AgentAut
autocompleteCwd, autocompleteCwd,
}); });
const commandsQuery = useAgentCommandsQuery({ const commandsQuery = useAutocompleteCommands({
serverId, serverId,
agentId, agentId,
enabled: mode === "command" && canLoadCommands && canListCommands, enabled: mode === "command" && canLoadCommands && canListCommands,
@@ -1,4 +1,4 @@
import { beforeAll, describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import type { import type {
DaemonClient, DaemonClient,
FetchAgentHistoryEntry, FetchAgentHistoryEntry,
@@ -13,18 +13,8 @@ import { allAgentHistoryQueryKey } from "./agent-history-query-key";
} }
).__DEV__ = false; ).__DEV__ = false;
type UseAgentHistoryModule = typeof import("./use-agent-history"); const { fetchAgentHistoryBatch, fetchAgentHistoryPage, collectAgentHistoryHostErrors } =
await import("./use-agent-history");
let fetchAgentHistoryBatch: UseAgentHistoryModule["fetchAgentHistoryBatch"];
let fetchAgentHistoryPage: UseAgentHistoryModule["fetchAgentHistoryPage"];
let collectAgentHistoryHostErrors: UseAgentHistoryModule["collectAgentHistoryHostErrors"];
beforeAll(async () => {
const module = await import("./use-agent-history");
fetchAgentHistoryBatch = module.fetchAgentHistoryBatch;
fetchAgentHistoryPage = module.fetchAgentHistoryPage;
collectAgentHistoryHostErrors = module.collectAgentHistoryHostErrors;
});
type FetchAgentHistory = DaemonClient["fetchAgentHistory"]; type FetchAgentHistory = DaemonClient["fetchAgentHistory"];
type FetchAgentHistoryResult = Awaited<ReturnType<FetchAgentHistory>>; type FetchAgentHistoryResult = Awaited<ReturnType<FetchAgentHistory>>;
@@ -1,4 +1,4 @@
import { QueryClient } from "@tanstack/react-query"; import { QueryClient, QueryObserver } from "@tanstack/react-query";
import { beforeEach, describe, expect, it } from "vitest"; import { beforeEach, describe, expect, it } from "vitest";
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client"; import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
@@ -8,7 +8,7 @@ import {
expandProviderSnapshot, expandProviderSnapshot,
} from "@getpaseo/protocol/provider-snapshot-codec"; } from "@getpaseo/protocol/provider-snapshot-codec";
import type { CachedProviderSnapshot, ProviderSnapshotCache } from "@/data/provider-snapshot-cache"; import type { CachedProviderSnapshot, ProviderSnapshotCache } from "@/data/provider-snapshot-cache";
import { draftAgentCommandsQueryKey } from "@/hooks/agent-commands-query"; import { draftAgentCommandsQueryKey, sessionAgentCommandsQueryKey } from "@/agent-commands/cache";
import { providerSnapshotIcons } from "@/data/provider-icons"; import { providerSnapshotIcons } from "@/data/provider-icons";
import { applyProvidersSnapshotUpdate, type ProvidersSnapshotUpdate } from "@/data/push-router"; import { applyProvidersSnapshotUpdate, type ProvidersSnapshotUpdate } from "@/data/push-router";
import { import {
@@ -363,6 +363,153 @@ describe("applyProvidersSnapshotUpdate", () => {
}; };
} }
it("refreshes active commands only when their provider changes after discovery", async () => {
const key = sessionAgentCommandsQueryKey({ serverId, agentId: "agent-1" });
let requests = 0;
const observer = new QueryObserver(queryClient, {
queryKey: key,
queryFn: async () => {
requests++;
return [{ name: "review" }];
},
staleTime: Infinity,
meta: { agentCommandProvider: "codex", agentCommandCwd: "/repo-a" },
});
const unsubscribe = observer.subscribe(() => {});
await observer.refetch({ cancelRefetch: false });
expect(requests).toBe(1);
const codex = codexEntry("ready", [readyCodexModel]);
const other = { provider: "claude", status: "loading", enabled: true } as ProviderSnapshotEntry;
await applyProvidersSnapshotUpdate({
client: createClient(),
serverId,
queryClient,
message: updateMessage([codexEntry("loading"), other], "/repo-a"),
});
expect(requests).toBe(1);
await applyProvidersSnapshotUpdate({
client: createClient(),
serverId,
queryClient,
message: updateMessage([codex, other], "/repo-a"),
});
expect(requests).toBe(1);
await applyProvidersSnapshotUpdate({
client: createClient(),
serverId,
queryClient,
message: updateMessage([codex, { ...other, status: "ready" }], "/repo-a"),
});
expect(requests).toBe(1);
await applyProvidersSnapshotUpdate({
client: createClient(),
serverId,
queryClient,
message: updateMessage(
[
{ ...codex, fetchedAt: "2026-01-01T00:00:02.000Z" },
{ ...other, status: "ready" },
],
"/repo-a",
),
});
expect(requests).toBe(1);
queryClient.setQueryData(
providersSnapshotQueryKey(serverId, "/repo-b"),
providersSnapshot([codex]),
);
await applyProvidersSnapshotUpdate({
client: createClient(),
serverId,
queryClient,
message: updateMessage(
[codexEntry("ready", [{ ...readyCodexModel, label: "Other checkout" }])],
"/repo-b",
),
});
expect(requests).toBe(1);
await applyProvidersSnapshotUpdate({
client: createClient(),
serverId,
queryClient,
message: updateMessage(
[
codexEntry("ready", [{ ...readyCodexModel, label: "Updated" }]),
{ ...other, status: "ready" },
],
"/repo-a",
),
});
await observer.refetch({ cancelRefetch: false });
expect(requests).toBe(2);
unsubscribe();
queryClient.clear();
});
it("retries a failed command discovery when its provider first becomes ready", async () => {
const key = sessionAgentCommandsQueryKey({ serverId, agentId: "agent-1" });
let ready = false;
let requests = 0;
const observer = new QueryObserver(queryClient, {
queryKey: key,
queryFn: async () => {
requests++;
if (!ready) throw new Error("provider unavailable");
return [{ name: "review" }];
},
retry: false,
meta: { agentCommandProvider: "codex", agentCommandCwd: "/repo-a" },
});
const unsubscribe = observer.subscribe(() => {});
await observer.refetch({ cancelRefetch: false });
expect(observer.getCurrentResult().isError).toBe(true);
ready = true;
await applyProvidersSnapshotUpdate({
client: createClient(),
serverId,
queryClient,
message: updateMessage([codexEntry("ready", [readyCodexModel])], "/repo-a"),
});
await observer.refetch({ cancelRefetch: false });
expect(requests).toBe(2);
expect(observer.getCurrentResult().data).toEqual([{ name: "review" }]);
unsubscribe();
queryClient.clear();
});
it("refreshes again when its provider changes during an active refresh", async () => {
const key = sessionAgentCommandsQueryKey({ serverId, agentId: "agent-1" });
queryClient.setQueryData(key, [{ name: "old" }]);
let requests = 0;
const waiting = new Promise<{ name: string }[]>(() => {});
const observer = new QueryObserver(queryClient, {
queryKey: key,
queryFn: async () => (++requests === 1 ? waiting : [{ name: "new" }]),
staleTime: Infinity,
meta: { agentCommandProvider: "codex", agentCommandCwd: "/repo-a" },
});
const unsubscribe = observer.subscribe(() => {});
queryClient.setQueryData(
providersSnapshotQueryKey(serverId, "/repo-a"),
providersSnapshot([codexEntry("ready", [readyCodexModel])]),
);
const send = (label: string) =>
applyProvidersSnapshotUpdate({
client: createClient(),
serverId,
queryClient,
message: updateMessage([codexEntry("ready", [{ ...readyCodexModel, label }])], "/repo-a"),
});
await send("First change");
expect(requests).toBe(1);
await send("Second change");
expect(requests).toBe(2);
await observer.refetch({ cancelRefetch: false });
expect(observer.getCurrentResult().data).toEqual([{ name: "new" }]);
unsubscribe();
queryClient.clear();
});
it("routes updates to the home query cache when the message carries no cwd", async () => { it("routes updates to the home query cache when the message carries no cwd", async () => {
await applyProvidersSnapshotUpdate({ await applyProvidersSnapshotUpdate({
client: createClient(), client: createClient(),
@@ -455,6 +602,10 @@ describe("applyProvidersSnapshotUpdate", () => {
draftConfig: { provider: "codex", cwd: "/repo-a" }, draftConfig: { provider: "codex", cwd: "/repo-a" },
}); });
queryClient.setQueryData(commandsKey, [{ name: "compact", description: "", argumentHint: "" }]); queryClient.setQueryData(commandsKey, [{ name: "compact", description: "", argumentHint: "" }]);
queryClient.setQueryData(
providersSnapshotQueryKey(serverId, "/repo-a"),
providersSnapshot([codexEntry("ready")]),
);
await applyProvidersSnapshotUpdate({ await applyProvidersSnapshotUpdate({
client: createClient(), client: createClient(),
+34 -1
View File
@@ -28,6 +28,7 @@ import { FileDropZone } from "@/components/file-drop/file-drop-zone";
import { useRetainedPanelActive } from "@/components/retained-panel"; import { useRetainedPanelActive } from "@/components/retained-panel";
import { RetainedChatContent } from "./retained-chat-content"; import { RetainedChatContent } from "./retained-chat-content";
import { Composer } from "@/composer"; import { Composer } from "@/composer";
import { AgentCommandsProvider } from "@/agent-commands";
import { import {
useComposerEditingSession, useComposerEditingSession,
useComposerFullscreen, useComposerFullscreen,
@@ -1255,7 +1256,7 @@ const ChatAgentReadyContent = memo(function ChatAgentReadyContent({
</ChatSurface> </ChatSurface>
); );
return ( const content = (
<RewindComposerRestoreProvider <RewindComposerRestoreProvider
textSource={agentInputDraft.textSource} textSource={agentInputDraft.textSource}
setText={agentInputDraft.replaceText} setText={agentInputDraft.replaceText}
@@ -1274,8 +1275,40 @@ const ChatAgentReadyContent = memo(function ChatAgentReadyContent({
</View> </View>
</RewindComposerRestoreProvider> </RewindComposerRestoreProvider>
); );
return wrapAgentCommandProvider({
enabled: showsComposer && hasActiveComposer,
serverId,
agentId,
editingSession: composerEditingSession,
children: content,
});
}); });
function wrapAgentCommandProvider({
enabled,
serverId,
agentId,
editingSession,
children,
}: {
enabled: boolean;
serverId: string;
agentId: string;
editingSession: ComposerEditingSession;
children: ReactNode;
}) {
return (
<AgentCommandsProvider
serverId={serverId}
agentId={agentId}
editingSession={editingSession}
enabled={enabled}
>
{children}
</AgentCommandsProvider>
);
}
function ChatSurface({ function ChatSurface({
children, children,
disabled, disabled,
@@ -5,7 +5,7 @@ import type {
ProviderSnapshotEntry, ProviderSnapshotEntry,
} from "@getpaseo/protocol/agent-types"; } from "@getpaseo/protocol/agent-types";
import type { AgentProviderDefinition } from "@getpaseo/protocol/provider-manifest"; import type { AgentProviderDefinition } from "@getpaseo/protocol/provider-manifest";
import type { DraftCommandTarget } from "@/hooks/use-agent-commands-query"; import type { DraftCommandTarget } from "@/agent-commands";
import { i18n } from "@/i18n/i18next"; import { i18n } from "@/i18n/i18next";
import { compareMatchScores, scoreTextFields } from "@getpaseo/protocol/search/text-match"; import { compareMatchScores, scoreTextFields } from "@getpaseo/protocol/search/text-match";
import { filterSelectableModels } from "./model-catalog"; import { filterSelectableModels } from "./model-catalog";
@@ -48,8 +48,7 @@ export interface TerminalCellStyleResolver {
export const DEFAULT_TERMINAL_THEME = toXtermTheme(darkTheme.colors.terminal); export const DEFAULT_TERMINAL_THEME = toXtermTheme(darkTheme.colors.terminal);
export const NATIVE_TERMINAL_SELECTION_COLORS = { export const NATIVE_TERMINAL_SELECTION_COLORS = {
background: 0xffffff00, background: 0x472684ff,
foreground: 0xff000000,
} as const; } as const;
function build256Palette(theme: ITheme): number[] { function build256Palette(theme: ITheme): number[] {
@@ -77,7 +77,6 @@ export function TerminalGridView({
background: resolver.backgroundColor, background: resolver.backgroundColor,
cursor: resolver.cursorColor, cursor: resolver.cursorColor,
selectionBackground: NATIVE_TERMINAL_SELECTION_COLORS.background, selectionBackground: NATIVE_TERMINAL_SELECTION_COLORS.background,
selectionForeground: NATIVE_TERMINAL_SELECTION_COLORS.foreground,
}), }),
[resolver], [resolver],
); );
@@ -0,0 +1,411 @@
import { describe, expect, it } from "vitest";
import { createNativeHeadlessTerminal } from "./headless-terminal-state";
import {
createTerminalSelectionModel,
extractTerminalSelectedText,
resolveTerminalSelectionRects,
} from "./terminal-selection";
import {
createTerminalSelectionController,
terminalSelectionHandles,
} from "./terminal-selection-controller";
describe("selection handle controller", () => {
it("preserves the grab offset while moving a handle", () => {
const range = { start: { row: 1, col: 2 }, end: { row: 1, col: 5 }, coordinateEpoch: 0 };
const moves: Array<{ row: number; col: number }> = [];
const controller = createTerminalSelectionController({
getGeometry: () => ({
range,
viewport: { firstRow: 0, rows: 10, cols: 10 },
metrics: { cellWidth: 10, cellHeight: 10 },
}),
onBeginHandle: () => {},
onMoveHandle: (coordinate) => moves.push(coordinate),
onLongPress: () => {},
onTap: () => {},
onScroll: () => {},
schedule: () => 1,
cancel: () => {},
});
controller.press({ x: 23, y: 15 });
controller.move({ x: 63, y: 15 });
controller.release();
expect(moves).toEqual([{ row: 1, col: 5 }]);
});
});
it("selects on hold, preserves a handle tap, and cancels an active drag", () => {
const actions: string[] = [];
let timer: (() => void) | null = null;
const controller = createTerminalSelectionController({
getGeometry: () => ({
range: { start: { row: 2, col: 2 }, end: { row: 2, col: 5 }, coordinateEpoch: 0 },
viewport: { firstRow: 0, rows: 10, cols: 10 },
metrics: { cellWidth: 10, cellHeight: 10 },
}),
onBeginHandle: (endpoint) => actions.push(endpoint),
onMoveHandle: () => actions.push("move"),
onLongPress: () => actions.push("long"),
onTap: () => actions.push("tap"),
onScroll: () => actions.push("scroll"),
schedule: (callback) => {
timer = callback;
return 1;
},
cancel: () => {
timer = null;
},
});
controller.press({ x: 90, y: 80 });
(timer as (() => void) | null)?.();
controller.release();
controller.press({ x: 20, y: 25 });
controller.release();
controller.press({ x: 20, y: 25 });
controller.cancel();
controller.move({ x: 50, y: 25 });
expect(actions).toEqual(["long", "start", "start"]);
});
it("scrolls selection into history while holding a handle at the edge", () => {
let firstRow = 10;
let timer: (() => void) | null = null;
const coordinates: Array<{ row: number; col: number }> = [];
const controller = createTerminalSelectionController({
getGeometry: () => ({
range: { start: { row: 12, col: 2 }, end: { row: 13, col: 5 }, coordinateEpoch: 0 },
viewport: { firstRow, rows: 10, cols: 10 },
metrics: { cellWidth: 10, cellHeight: 10 },
}),
onBeginHandle: () => {},
onMoveHandle: (point) => coordinates.push(point),
onLongPress: () => {},
onTap: () => {},
onScroll: (direction) => {
if (direction === "up") firstRow--;
},
schedule: (callback) => {
timer = callback;
return 1;
},
cancel: () => {
timer = null;
},
});
controller.press({ x: 20, y: 25 });
controller.move({ x: 20, y: 1 });
(timer as (() => void) | null)?.();
(timer as (() => void) | null)?.();
controller.release();
expect(coordinates).toEqual([
{ row: 10, col: 2 },
{ row: 9, col: 2 },
{ row: 8, col: 2 },
]);
expect(timer).toBeNull();
});
it("scrolls outside handles without clearing selection and clears on an outside tap", () => {
const actions: string[] = [];
const controller = createTerminalSelectionController({
getGeometry: () => ({
range: { start: { row: 2, col: 2 }, end: { row: 2, col: 5 }, coordinateEpoch: 0 },
viewport: { firstRow: 0, rows: 10, cols: 10 },
metrics: { cellWidth: 10, cellHeight: 10 },
}),
onBeginHandle: () => actions.push("handle"),
onMoveHandle: () => actions.push("move"),
onLongPress: () => actions.push("long"),
onTap: () => actions.push("tap"),
onScroll: (direction, rows) => actions.push(`${direction}:${rows}`),
schedule: () => 1,
cancel: () => {},
});
controller.press({ x: 95, y: 70 });
controller.move({ x: 95, y: 90 });
controller.release();
controller.press({ x: 95, y: 70 });
controller.release();
expect(actions).toEqual(["up:2", "tap"]);
});
it("extends the selection after a long press and drag", () => {
let timer: (() => void) | null = null;
const moves: Array<{ row: number; col: number }> = [];
const controller = createTerminalSelectionController({
getGeometry: () => ({
range: null,
viewport: { firstRow: 0, rows: 10, cols: 10 },
metrics: { cellWidth: 10, cellHeight: 10 },
}),
onBeginHandle: () => {},
onMoveHandle: (coordinate) => moves.push(coordinate),
onLongPress: () => {},
onTap: () => {},
onScroll: () => {},
schedule: (callback) => {
timer = callback;
return 1;
},
cancel: () => {
timer = null;
},
});
controller.press({ x: 25, y: 25 });
(timer as (() => void) | null)?.();
controller.move({ x: 65, y: 35 });
controller.release();
expect(moves).toEqual([{ row: 3, col: 6 }]);
});
it.each([
{
endpoint: "start" as const,
startX: 20,
crossedX: 80,
crossedText: "gh",
crossedStart: 6,
crossedEnd: 7,
backX: 10,
backText: "bcdef",
backStart: 1,
backEnd: 5,
},
{
endpoint: "end" as const,
startX: 60,
crossedX: 10,
crossedText: "b",
crossedStart: 1,
crossedEnd: 1,
backX: 80,
backText: "cdefgh",
backStart: 2,
backEnd: 7,
},
])("tracks the $endpoint handle through crossing and back", async (scenario) => {
const terminal = createNativeHeadlessTerminal({ rows: 4, cols: 10 });
try {
await terminal.write("abcdefghij");
const bounds = terminal.getBufferBounds();
const model = createTerminalSelectionModel();
model.begin({ coordinate: { row: 0, col: 2 }, bounds });
model.update({ coordinate: { row: 0, col: 5 }, bounds });
const viewport = { firstRow: 0, rows: 4, cols: 10 };
const metrics = { cellWidth: 10, cellHeight: 10 };
const geometry = () => ({ range: model.getSnapshot().range, viewport, metrics });
const controller = createTerminalSelectionController({
getGeometry: geometry,
onBeginHandle: (endpoint, cols) => model.beginEndpointDrag(endpoint, cols),
onMoveHandle: (coordinate) => {
model.update({ coordinate, bounds });
},
onLongPress: () => {},
onTap: () => {},
onScroll: () => {},
schedule: () => 1,
cancel: () => {},
});
const check = (start: number, end: number, text: string) => {
const range = model.getSnapshot().range;
expect(range).toEqual({
start: { row: 0, col: start },
end: { row: 0, col: end },
coordinateEpoch: bounds.coordinateEpoch,
});
expect(terminalSelectionHandles(geometry())).toMatchObject([
{ endpoint: "start", x: start * 10, y: 5 },
{ endpoint: "end", x: (end + 1) * 10, y: 5 },
]);
expect(resolveTerminalSelectionRects({ selection: range, viewport, metrics })).toEqual([
{
key: `0:${start}:${end}`,
x: start * 10,
y: 0,
width: (end - start + 1) * 10,
height: 10,
},
]);
expect(extractTerminalSelectedText({ terminal, selection: range })).toBe(text);
};
controller.press({ x: scenario.startX, y: 5 });
controller.move({ x: scenario.crossedX, y: 5 });
check(scenario.crossedStart, scenario.crossedEnd, scenario.crossedText);
controller.move({ x: scenario.backX, y: 5 });
check(scenario.backStart, scenario.backEnd, scenario.backText);
controller.release();
} finally {
terminal.dispose();
}
});
it.each([
{
endpoint: "end" as const,
initialRow: 0,
targetRow: 1,
targetX: 0,
expectedRow: 0,
expectedCol: 9,
text: "cdefghij",
},
{
endpoint: "start" as const,
initialRow: 1,
targetRow: 0,
targetX: 100,
expectedRow: 1,
expectedCol: 0,
text: "KLMNOP",
},
])("preserves a $endpoint boundary at a row edge", async (scenario) => {
const terminal = createNativeHeadlessTerminal({ rows: 4, cols: 10 });
try {
await terminal.write("abcdefghij\r\nKLMNOPQRST");
const bounds = terminal.getBufferBounds();
const model = createTerminalSelectionModel();
model.begin({ coordinate: { row: scenario.initialRow, col: 2 }, bounds });
model.update({ coordinate: { row: scenario.initialRow, col: 5 }, bounds });
const viewport = { firstRow: 0, rows: 4, cols: 10 };
const metrics = { cellWidth: 10, cellHeight: 10 };
const geometry = () => ({ range: model.getSnapshot().range, viewport, metrics });
const controller = createTerminalSelectionController({
getGeometry: geometry,
onBeginHandle: (endpoint, cols) => model.beginEndpointDrag(endpoint, cols),
onMoveHandle: (coordinate) => {
model.update({ coordinate, bounds });
},
onLongPress: () => {},
onTap: () => {},
onScroll: () => {},
schedule: () => 1,
cancel: () => {},
});
controller.press({ x: scenario.endpoint === "end" ? 60 : 20, y: scenario.initialRow * 10 + 5 });
controller.move({ x: scenario.targetX, y: scenario.targetRow * 10 + 5 });
const range = model.getSnapshot().range;
expect(range?.[scenario.endpoint]).toEqual({
row: scenario.expectedRow,
col: scenario.expectedCol,
});
expect(extractTerminalSelectedText({ terminal, selection: range })).toBe(scenario.text);
expect(
terminalSelectionHandles(geometry()).find((handle) => handle.endpoint === scenario.endpoint),
).toMatchObject({
x: scenario.endpoint === "end" ? 100 : 0,
y: scenario.expectedRow * 10 + 5,
});
controller.release();
} finally {
terminal.dispose();
}
});
it("cancels a drag when buffer coordinates are invalidated", () => {
const model = createTerminalSelectionModel();
const bounds = { oldestRow: 0, newestRow: 9, coordinateEpoch: 0 };
model.begin({ coordinate: { row: 2, col: 2 }, bounds });
model.update({ coordinate: { row: 2, col: 5 }, bounds });
const viewport = { firstRow: 0, rows: 10, cols: 10 };
const metrics = { cellWidth: 10, cellHeight: 10 };
const controller = createTerminalSelectionController({
getGeometry: () => ({ range: model.getSnapshot().range, viewport, metrics }),
onBeginHandle: (endpoint, cols) => model.beginEndpointDrag(endpoint, cols),
onMoveHandle: (coordinate) => {
model.update({ coordinate, bounds });
},
onLongPress: () => {},
onTap: () => {},
onScroll: () => {},
schedule: () => 1,
cancel: () => {},
});
controller.press({ x: 20, y: 25 });
const previous = model.getSnapshot().range;
const snapshot = model.sync({ bounds: { ...bounds, coordinateEpoch: 1 } });
if (previous && !snapshot.range) controller.cancel();
controller.move({ x: 80, y: 25 });
controller.release();
expect(model.getSnapshot().range).toBeNull();
});
it("preserves a newly selected word through small movement at the viewport edge", () => {
const model = createTerminalSelectionModel();
const bounds = { oldestRow: 0, newestRow: 9, coordinateEpoch: 0 };
let timer: (() => void) | null = null;
const controller = createTerminalSelectionController({
getGeometry: () => ({
range: model.getSnapshot().range,
viewport: { firstRow: 0, rows: 10, cols: 20 },
metrics: { cellWidth: 10, cellHeight: 10 },
}),
onBeginHandle: (endpoint, cols) => model.beginEndpointDrag(endpoint, cols),
onMoveHandle: (coordinate) => {
model.update({ coordinate, bounds });
},
onLongPress: () => {
model.begin({ coordinate: { row: 0, col: 2 }, bounds });
model.update({ coordinate: { row: 0, col: 6 }, bounds });
},
onTap: () => {},
onScroll: () => {},
schedule: (callback) => {
timer = callback;
return 1;
},
cancel: () => {
timer = null;
},
});
controller.press({ x: 45, y: 5 });
const fire = timer as (() => void) | null;
timer = null;
fire?.();
controller.move({ x: 44, y: 4 });
controller.release();
expect(model.getSnapshot().range).toEqual({
start: { row: 0, col: 2 },
end: { row: 0, col: 6 },
coordinateEpoch: 0,
});
expect(timer).toBeNull();
});
it("places mirrored teardrop grips below the selected row", () => {
const handles = terminalSelectionHandles({
range: { start: { row: 2, col: 2 }, end: { row: 2, col: 5 }, coordinateEpoch: 0 },
viewport: { firstRow: 0, rows: 10, cols: 10 },
metrics: { cellWidth: 10, cellHeight: 10 },
});
expect(handles).toMatchObject([
{ endpoint: "start", x: 20, y: 25, gripX: 11, gripY: 39, gripDirection: "left" },
{ endpoint: "end", x: 60, y: 25, gripX: 69, gripY: 39, gripDirection: "right" },
]);
});
it("keeps edge grips reachable and preserves the offset from the cell when dragging a grip", () => {
const range = { start: { row: 0, col: 0 }, end: { row: 9, col: 9 }, coordinateEpoch: 0 };
const viewport = { firstRow: 0, rows: 10, cols: 10 };
const metrics = { cellWidth: 10, cellHeight: 10 };
const handles = terminalSelectionHandles({ range, viewport, metrics });
expect(handles.map(({ gripX, gripY }) => ({ gripX, gripY }))).toEqual([
{ gripX: 9, gripY: 19 },
{ gripX: 91, gripY: 81 },
]);
const moves: Array<{ row: number; col: number }> = [];
const controller = createTerminalSelectionController({
getGeometry: () => ({ range, viewport, metrics }),
onBeginHandle: () => {},
onMoveHandle: (coordinate) => moves.push(coordinate),
onLongPress: () => {},
onTap: () => {},
onScroll: () => {},
schedule: () => 1,
cancel: () => {},
});
controller.press({ x: 9, y: 19 });
controller.move({ x: 19, y: 29 });
controller.release();
expect(moves).toEqual([{ row: 1, col: 1 }]);
});
@@ -0,0 +1,208 @@
import type { TerminalGridCellMetrics } from "./terminal-grid-metrics";
import type {
TerminalBufferCoordinate,
TerminalSelectionPoint,
TerminalSelectionRange,
TerminalSelectionViewport,
} from "./terminal-selection";
export const TERMINAL_SELECTION_GRIP_SIZE = 18;
const TERMINAL_GESTURE_LONG_PRESS_MS = 450;
const TERMINAL_GESTURE_TAP_TOLERANCE_PX = 8;
type Endpoint = "start" | "end";
interface Geometry {
range: TerminalSelectionRange | null;
viewport: TerminalSelectionViewport;
metrics: TerminalGridCellMetrics;
}
interface Ports {
getGeometry(): Geometry | null;
onBeginHandle(endpoint: Endpoint, cols: number): void;
onMoveHandle(coordinate: TerminalBufferCoordinate): void;
onLongPress(point: TerminalSelectionPoint): void;
onTap(): void;
onScroll(direction: "up" | "down", rows: number): void;
schedule(callback: () => void, delay: number): number;
cancel(timer: number): void;
}
export function terminalSelectionHandles(geometry: Geometry): Array<{
endpoint: Endpoint;
x: number;
y: number;
gripX: number;
gripY: number;
gripDirection: "left" | "right";
gripAbove: boolean;
stemTop: number;
stemHeight: number;
}> {
const { range, viewport, metrics } = geometry;
if (!range) return [];
return (["start", "end"] as const).flatMap((endpoint) => {
const cell = range[endpoint];
if (cell.row < viewport.firstRow || cell.row >= viewport.firstRow + viewport.rows) return [];
const x = (cell.col + (endpoint === "end" ? 1 : 0)) * metrics.cellWidth;
const stemTop = (cell.row - viewport.firstRow) * metrics.cellHeight;
const size = TERMINAL_SELECTION_GRIP_SIZE;
const width = viewport.cols * metrics.cellWidth;
const height = viewport.rows * metrics.cellHeight;
const wantsLeft = endpoint === "start";
const gripDirection =
(wantsLeft && x >= size) || (!wantsLeft && x > width - size) ? "left" : "right";
const gripAbove = stemTop + metrics.cellHeight + size > height;
const gripTop = gripAbove ? Math.max(0, stemTop - size) : stemTop + metrics.cellHeight;
return [
{
endpoint,
x,
y: stemTop + metrics.cellHeight / 2,
gripX: x + (gripDirection === "left" ? -size / 2 : size / 2),
gripY: gripTop + size / 2,
gripDirection,
gripAbove,
stemTop,
stemHeight: metrics.cellHeight,
},
];
});
}
export function createTerminalSelectionController(ports: Ports) {
let press: {
start: TerminalSelectionPoint;
point: TerminalSelectionPoint;
endpoint: Endpoint | null;
offset: TerminalSelectionPoint;
fixed: TerminalBufferCoordinate | null;
moved: boolean;
long: boolean;
scrolling: boolean;
remainder: number;
} | null = null;
let timer: number | null = null;
function stopTimer() {
if (timer !== null) ports.cancel(timer);
timer = null;
}
function updateHandle() {
const geometry = ports.getGeometry();
if (!press || (!press.endpoint && !press.long) || !geometry) return;
const { viewport, metrics } = geometry;
const x = press.point.x - press.offset.x;
const y = press.point.y - press.offset.y;
let row =
viewport.firstRow +
Math.max(0, Math.min(viewport.rows - 1, Math.floor(y / metrics.cellHeight)));
let col = Math.floor(x / metrics.cellWidth);
if (press.endpoint && press.fixed) {
const boundary = Math.round(x / metrics.cellWidth);
const isEnd =
row > press.fixed.row || (row === press.fixed.row && boundary > press.fixed.col);
col = boundary - (isEnd ? 1 : 0);
if (col < 0 && isEnd) {
row -= 1;
col = viewport.cols - 1;
} else if (col >= viewport.cols && !isEnd) {
row += 1;
col = 0;
}
}
col = Math.max(0, Math.min(viewport.cols - 1, col));
ports.onMoveHandle({ row, col });
}
function edgeTick() {
timer = null;
const geometry = ports.getGeometry();
if (!press || (!press.endpoint && !press.long) || !geometry) return;
const y = press.point.y - press.offset.y;
const height = geometry.viewport.rows * geometry.metrics.cellHeight;
let direction: "up" | "down" | null = null;
if (y < geometry.metrics.cellHeight) direction = "up";
else if (y > height - geometry.metrics.cellHeight) direction = "down";
if (!direction) return;
ports.onScroll(direction, 1);
if (!press) return;
updateHandle();
if (press) timer = ports.schedule(edgeTick, 100);
}
return {
press(point: TerminalSelectionPoint) {
stopTimer();
const geometry = ports.getGeometry();
const handles = geometry ? terminalSelectionHandles(geometry) : [];
const handle = handles
.map((item) => ({
endpoint: item.endpoint,
x: item.x,
y: item.y,
distance: Math.hypot(point.x - item.gripX, point.y - item.gripY),
}))
.filter((item) => item.distance <= 28)
.sort((a, b) => a.distance - b.distance)[0];
press = {
start: point,
point,
endpoint: handle?.endpoint ?? null,
offset: handle ? { x: point.x - handle.x, y: point.y - handle.y } : { x: 0, y: 0 },
fixed:
handle && geometry?.range
? geometry.range[handle.endpoint === "start" ? "end" : "start"]
: null,
moved: false,
long: false,
scrolling: false,
remainder: 0,
};
if (handle && geometry) {
ports.onBeginHandle(handle.endpoint, geometry.viewport.cols);
return;
}
timer = ports.schedule(() => {
timer = null;
if (press && !press.moved) {
press.long = true;
ports.onLongPress(press.start);
}
}, TERMINAL_GESTURE_LONG_PRESS_MS);
},
move(point: TerminalSelectionPoint) {
if (!press) return;
const previous = press.point;
press.point = point;
const dx = point.x - press.start.x,
dy = point.y - press.start.y;
if (Math.hypot(dx, dy) > TERMINAL_GESTURE_TAP_TOLERANCE_PX) {
press.moved = true;
if (!press.endpoint) stopTimer();
}
if (press.endpoint || press.long) {
if (press.endpoint || press.moved) {
updateHandle();
stopTimer();
if (press) timer = ports.schedule(edgeTick, 100);
}
} else if (!press.long) {
if (!press.scrolling && Math.abs(dy) > 12 && Math.abs(dy) > Math.abs(dx)) {
press.scrolling = true;
press.remainder = dy;
} else if (press.scrolling) press.remainder += point.y - previous.y;
const height = ports.getGeometry()?.metrics.cellHeight ?? 0;
if (press.scrolling && height > 0) {
const rows = Math.trunc(press.remainder / height);
press.remainder -= rows * height;
if (rows) ports.onScroll(rows > 0 ? "up" : "down", Math.abs(rows));
}
}
},
release() {
stopTimer();
if (press && !press.endpoint && !press.long && !press.moved) ports.onTap();
press = null;
},
cancel() {
stopTimer();
press = null;
},
};
}
@@ -1,354 +0,0 @@
import { describe, expect, it } from "vitest";
import {
TERMINAL_GESTURE_LONG_PRESS_MS,
TERMINAL_GESTURE_TAP_TOLERANCE_PX,
TERMINAL_GESTURE_VERTICAL_SCROLL_THRESHOLD_PX,
advanceTerminalScrollGesture,
classifyTerminalGestureIntent,
resolveTerminalGestureReleaseAction,
} from "./terminal-selection-gesture";
describe("native terminal gesture policy", () => {
describe("advanceTerminalScrollGesture", () => {
it("keeps consuming a slow drag after scroll intent arms below one row", () => {
let state = { lastDy: 0, rowRemainder: 0, didScroll: false };
const rowDeltas: number[] = [];
for (const currentDy of [13, 15, 18, 22, 28]) {
const update = advanceTerminalScrollGesture({
state,
currentDy,
cellHeight: 14,
});
state = update.state;
rowDeltas.push(update.rowDelta);
}
expect({ rowDeltas, state }).toEqual({
rowDeltas: [0, 1, 0, 0, 1],
state: { lastDy: 28, rowRemainder: 0, didScroll: true },
});
});
});
describe("classifyTerminalGestureIntent", () => {
it("treats a motionless press as a tap", () => {
expect(
classifyTerminalGestureIntent({
status: "pressing",
dx: 0,
dy: 0,
vx: 0,
vy: 0,
}),
).toEqual("tap");
});
it("treats a tiny jitter within tap tolerance as a tap", () => {
expect(
classifyTerminalGestureIntent({
status: "pressing",
dx: TERMINAL_GESTURE_TAP_TOLERANCE_PX - 4,
dy: TERMINAL_GESTURE_TAP_TOLERANCE_PX - 4,
vx: 0,
vy: 0,
}),
).toEqual("tap");
});
it("leaves horizontal movement available to terminal selection", () => {
expect(
classifyTerminalGestureIntent({
status: "pressing",
dx: TERMINAL_GESTURE_TAP_TOLERANCE_PX + 20,
dy: 4,
vx: 0,
vy: 0,
}),
).toEqual("pending");
});
it("does not infer selection from slow horizontal drag dwell", () => {
expect(
classifyTerminalGestureIntent({
status: "pressing",
dx: TERMINAL_GESTURE_TAP_TOLERANCE_PX + 40,
dy: 3,
vx: 0,
vy: 0,
elapsedMs: TERMINAL_GESTURE_LONG_PRESS_MS + 50,
}),
).toEqual("pending");
});
it("keeps tiny horizontal jitter inside tap tolerance as a tap", () => {
expect(
classifyTerminalGestureIntent({
status: "pressing",
dx: TERMINAL_GESTURE_TAP_TOLERANCE_PX - 1,
dy: 1,
vx: 2,
vy: 0,
elapsedMs: 20,
}),
).toEqual("tap");
});
it("keeps horizontal movement below navigation distance pending", () => {
expect(
classifyTerminalGestureIntent({
status: "pressing",
dx: TERMINAL_GESTURE_TAP_TOLERANCE_PX + 10,
dy: 2,
vx: 0,
vy: 0,
}),
).toEqual("pending");
});
it("treats a clearly vertical drag as scroll", () => {
expect(
classifyTerminalGestureIntent({
status: "pressing",
dx: 4,
dy: TERMINAL_GESTURE_VERTICAL_SCROLL_THRESHOLD_PX + 5,
vx: 0,
vy: 0.2,
}),
).toEqual("scroll");
});
it("does not navigate on a fast rightward swipe", () => {
expect(
classifyTerminalGestureIntent({
status: "pressing",
dx: TERMINAL_GESTURE_TAP_TOLERANCE_PX + 40,
dy: 4,
vx: 2,
vy: 0,
}),
).toEqual("pending");
});
it("does not navigate on a slow rightward swipe", () => {
expect(
classifyTerminalGestureIntent({
status: "pressing",
dx: TERMINAL_GESTURE_TAP_TOLERANCE_PX + 40,
dy: 4,
vx: 0,
vy: 0,
}),
).toEqual("pending");
});
it("does not navigate on a fast leftward swipe", () => {
expect(
classifyTerminalGestureIntent({
status: "pressing",
dx: -(TERMINAL_GESTURE_TAP_TOLERANCE_PX + 40),
dy: -4,
vx: -2,
vy: 0,
}),
).toEqual("pending");
});
it("does not navigate while movement stays inside tap tolerance", () => {
expect(
classifyTerminalGestureIntent({
status: "pressing",
dx: TERMINAL_GESTURE_TAP_TOLERANCE_PX - 1,
dy: 2,
vx: 2,
vy: 0,
}),
).toEqual("tap");
});
it("keeps selection intent once selection is active", () => {
expect(
classifyTerminalGestureIntent({
status: "selecting",
dx: 100,
dy: 50,
vx: 2,
vy: 1,
}),
).toEqual("select");
});
});
describe("resolveTerminalGestureReleaseAction", () => {
it("focuses the terminal after a normal tap", () => {
expect(
resolveTerminalGestureReleaseAction({
status: "pressing",
didScroll: false,
didNavigate: false,
movedBeyondTapTolerance: false,
pressDurationMs: 80,
longPressMs: TERMINAL_GESTURE_LONG_PRESS_MS,
scrollMode: "following",
}),
).toEqual("focus");
});
it("focuses the terminal after a normal tap while scrolled", () => {
expect(
resolveTerminalGestureReleaseAction({
status: "pressing",
didScroll: false,
didNavigate: false,
movedBeyondTapTolerance: false,
pressDurationMs: 80,
longPressMs: TERMINAL_GESTURE_LONG_PRESS_MS,
scrollMode: "scrolled",
}),
).toEqual("focus");
});
it("starts selection after a long press", () => {
expect(
resolveTerminalGestureReleaseAction({
status: "pressing",
didScroll: false,
didNavigate: false,
movedBeyondTapTolerance: false,
pressDurationMs: TERMINAL_GESTURE_LONG_PRESS_MS + 50,
longPressMs: TERMINAL_GESTURE_LONG_PRESS_MS,
scrollMode: "following",
}),
).toEqual("select");
});
it("starts selection after a 500ms hold", () => {
expect(
resolveTerminalGestureReleaseAction({
status: "pressing",
didScroll: false,
didNavigate: false,
movedBeyondTapTolerance: false,
pressDurationMs: 500,
longPressMs: TERMINAL_GESTURE_LONG_PRESS_MS,
scrollMode: "following",
}),
).toEqual("select");
});
it("keeps a 400ms press as a tap", () => {
expect(
resolveTerminalGestureReleaseAction({
status: "pressing",
didScroll: false,
didNavigate: false,
movedBeyondTapTolerance: false,
pressDurationMs: 400,
longPressMs: TERMINAL_GESTURE_LONG_PRESS_MS,
scrollMode: "following",
}),
).toEqual("focus");
});
it("does nothing after a scroll gesture", () => {
expect(
resolveTerminalGestureReleaseAction({
status: "pressing",
didScroll: true,
didNavigate: false,
movedBeyondTapTolerance: true,
pressDurationMs: 80,
longPressMs: TERMINAL_GESTURE_LONG_PRESS_MS,
scrollMode: "scrolled",
}),
).toEqual("none");
});
it("does nothing after a navigation swipe", () => {
expect(
resolveTerminalGestureReleaseAction({
status: "pressing",
didScroll: false,
didNavigate: true,
movedBeyondTapTolerance: true,
pressDurationMs: 80,
longPressMs: TERMINAL_GESTURE_LONG_PRESS_MS,
scrollMode: "following",
}),
).toEqual("none");
});
it("does nothing while actively selecting", () => {
expect(
resolveTerminalGestureReleaseAction({
status: "selecting",
didScroll: false,
didNavigate: false,
movedBeyondTapTolerance: true,
pressDurationMs: 80,
longPressMs: TERMINAL_GESTURE_LONG_PRESS_MS,
scrollMode: "following",
}),
).toEqual("none");
});
it("clears an existing selection after a short tap", () => {
expect(
resolveTerminalGestureReleaseAction({
status: "selecting",
startedWithSelection: true,
didScroll: false,
didNavigate: false,
movedBeyondTapTolerance: false,
pressDurationMs: 80,
longPressMs: TERMINAL_GESTURE_LONG_PRESS_MS,
scrollMode: "following",
}),
).toEqual("clear");
});
it("preserves a word selection created by the current long press", () => {
expect(
resolveTerminalGestureReleaseAction({
status: "selecting",
startedWithSelection: false,
didScroll: false,
didNavigate: false,
movedBeyondTapTolerance: false,
pressDurationMs: TERMINAL_GESTURE_LONG_PRESS_MS + 50,
longPressMs: TERMINAL_GESTURE_LONG_PRESS_MS,
scrollMode: "following",
}),
).toEqual("none");
});
it("does not focus after an undecided fast drag", () => {
expect(
resolveTerminalGestureReleaseAction({
status: "pressing",
didScroll: false,
didNavigate: false,
movedBeyondTapTolerance: true,
pressDurationMs: 80,
longPressMs: TERMINAL_GESTURE_LONG_PRESS_MS,
scrollMode: "following",
}),
).toEqual("none");
});
it("does nothing from idle", () => {
expect(
resolveTerminalGestureReleaseAction({
status: "idle",
didScroll: false,
didNavigate: false,
movedBeyondTapTolerance: false,
pressDurationMs: 80,
longPressMs: TERMINAL_GESTURE_LONG_PRESS_MS,
scrollMode: "following",
}),
).toEqual("none");
});
});
});
@@ -1,108 +0,0 @@
export type TerminalGestureStatus = "idle" | "pressing" | "selecting";
export type TerminalGestureIntent = "tap" | "pending" | "select" | "scroll";
export type TerminalGestureScrollMode = "following" | "scrolled";
export interface TerminalGestureMoveInput {
status: TerminalGestureStatus;
dx: number;
dy: number;
vx: number;
vy: number;
elapsedMs?: number;
}
export interface TerminalGestureReleaseInput {
status: TerminalGestureStatus;
startedWithSelection?: boolean;
didScroll: boolean;
didNavigate: boolean;
movedBeyondTapTolerance: boolean;
pressDurationMs: number;
longPressMs: number;
scrollMode: TerminalGestureScrollMode;
}
export interface TerminalScrollGestureState {
lastDy: number;
rowRemainder: number;
didScroll: boolean;
}
export interface TerminalScrollGestureUpdate {
state: TerminalScrollGestureState;
rowDelta: number;
}
export type TerminalGestureAction = "none" | "focus" | "select" | "clear";
export const TERMINAL_GESTURE_TAP_TOLERANCE_PX = 8;
export const TERMINAL_GESTURE_LONG_PRESS_MS = 450;
export const TERMINAL_GESTURE_VERTICAL_SCROLL_THRESHOLD_PX = 12;
export function classifyTerminalGestureIntent(
input: TerminalGestureMoveInput,
): TerminalGestureIntent {
if (input.status === "selecting") {
return "select";
}
const absDx = Math.abs(input.dx);
const absDy = Math.abs(input.dy);
if (absDy > TERMINAL_GESTURE_VERTICAL_SCROLL_THRESHOLD_PX && absDy > absDx) {
return "scroll";
}
if (Math.hypot(input.dx, input.dy) > TERMINAL_GESTURE_TAP_TOLERANCE_PX) {
return "pending";
}
return "tap";
}
export function advanceTerminalScrollGesture(input: {
state: TerminalScrollGestureState;
currentDy: number;
cellHeight: number;
}): TerminalScrollGestureUpdate {
const deltaY = input.currentDy - input.state.lastDy;
const accumulatedRows = input.state.rowRemainder + deltaY;
const rowDelta = Math.trunc(accumulatedRows / input.cellHeight);
return {
state: {
lastDy: input.currentDy,
rowRemainder: accumulatedRows - rowDelta * input.cellHeight,
didScroll: input.state.didScroll || rowDelta !== 0,
},
rowDelta,
};
}
export function resolveTerminalGestureReleaseAction(
input: TerminalGestureReleaseInput,
): TerminalGestureAction {
if (input.didScroll || input.didNavigate) {
return "none";
}
if (input.status === "selecting") {
if (!input.startedWithSelection) {
return "none";
}
return input.movedBeyondTapTolerance ? "none" : "clear";
}
if (input.status !== "pressing") {
return "none";
}
if (input.pressDurationMs >= input.longPressMs) {
return "select";
}
if (input.movedBeyondTapTolerance) {
return "none";
}
return "focus";
}
@@ -483,3 +483,51 @@ describe("native terminal selection", () => {
expect(copied).toEqual([]); expect(copied).toEqual([]);
}); });
}); });
describe("endpoint dragging", () => {
it.each([
{
endpoint: "start" as const,
fixed: { row: 2, col: 9 },
target: { row: 3, col: 2 },
expected: { row: 3, col: 0 },
},
{
endpoint: "end" as const,
fixed: { row: 2, col: 0 },
target: { row: 1, col: 7 },
expected: { row: 1, col: 9 },
},
])(
"preserves the fixed boundary across a row edge for $endpoint",
({ endpoint, fixed, target, expected }) => {
const selection = createTerminalSelectionModel();
const bounds = { oldestRow: 0, newestRow: 10, coordinateEpoch: 1 };
selection.begin({ coordinate: fixed, bounds });
selection.update({ coordinate: fixed, bounds });
selection.beginEndpointDrag(endpoint, 10);
selection.update({ coordinate: target, bounds });
expect(selection.getSnapshot().range?.[endpoint === "start" ? "start" : "end"]).toEqual(
expected,
);
},
);
it.each(["start", "end"] as const)(
"keeps the opposite endpoint fixed as %s crosses it",
(endpoint) => {
const selection = createTerminalSelectionModel();
const bounds = { oldestRow: 0, newestRow: 10, coordinateEpoch: 1 };
selection.begin({ coordinate: { row: 2, col: 2 }, bounds });
selection.update({ coordinate: { row: 3, col: 4 }, bounds });
selection.beginEndpointDrag(endpoint, 10);
const coordinate = endpoint === "start" ? { row: 4, col: 5 } : { row: 1, col: 1 };
selection.update({ coordinate, bounds });
expect(selection.getSnapshot().range).toEqual(
endpoint === "start"
? { start: { row: 3, col: 5 }, end: coordinate, coordinateEpoch: 1 }
: { start: coordinate, end: { row: 2, col: 1 }, coordinateEpoch: 1 },
);
},
);
});
@@ -25,6 +25,7 @@ export interface TerminalSelectionBounds {
export interface TerminalSelectionModel { export interface TerminalSelectionModel {
begin(input: TerminalSelectionUpdateInput): TerminalSelectionSnapshot; begin(input: TerminalSelectionUpdateInput): TerminalSelectionSnapshot;
update(input: TerminalSelectionUpdateInput): TerminalSelectionSnapshot; update(input: TerminalSelectionUpdateInput): TerminalSelectionSnapshot;
beginEndpointDrag(endpoint: "start" | "end", cols: number): void;
clear(): TerminalSelectionSnapshot; clear(): TerminalSelectionSnapshot;
sync(input: TerminalSelectionSyncInput): TerminalSelectionSnapshot; sync(input: TerminalSelectionSyncInput): TerminalSelectionSnapshot;
getSnapshot(): TerminalSelectionSnapshot; getSnapshot(): TerminalSelectionSnapshot;
@@ -252,6 +253,7 @@ export function resolveTerminalSelectionRects(
export function createTerminalSelectionModel(): TerminalSelectionModel { export function createTerminalSelectionModel(): TerminalSelectionModel {
let state: TerminalSelectionState = { anchor: null, range: null }; let state: TerminalSelectionState = { anchor: null, range: null };
let drag: { endpoint: "start" | "end"; cols: number } | null = null;
function snapshot(): TerminalSelectionSnapshot { function snapshot(): TerminalSelectionSnapshot {
return { range: state.range }; return { range: state.range };
@@ -259,18 +261,31 @@ export function createTerminalSelectionModel(): TerminalSelectionModel {
function setSelection(input: TerminalSelectionUpdateInput): TerminalSelectionSnapshot { function setSelection(input: TerminalSelectionUpdateInput): TerminalSelectionSnapshot {
const coordinate = clampCoordinate(input.coordinate, input.bounds); const coordinate = clampCoordinate(input.coordinate, input.bounds);
const anchor = state.anchor ?? coordinate; const fixed = state.anchor ?? coordinate;
let anchor = fixed;
if (drag?.endpoint === "start" && compareCoordinates(coordinate, fixed) > 0) {
anchor =
fixed.col + 1 < drag.cols
? { row: fixed.row, col: fixed.col + 1 }
: { row: fixed.row + 1, col: 0 };
} else if (drag?.endpoint === "end" && compareCoordinates(coordinate, fixed) < 0) {
anchor =
fixed.col > 0
? { row: fixed.row, col: fixed.col - 1 }
: { row: fixed.row - 1, col: drag.cols - 1 };
}
const range = normalizeTerminalSelection({ const range = normalizeTerminalSelection({
anchor, anchor,
focus: coordinate, focus: coordinate,
coordinateEpoch: input.bounds.coordinateEpoch, coordinateEpoch: input.bounds.coordinateEpoch,
}); });
state = { anchor, range }; state = { anchor: fixed, range };
return snapshot(); return snapshot();
} }
return { return {
begin(input: TerminalSelectionUpdateInput): TerminalSelectionSnapshot { begin(input: TerminalSelectionUpdateInput): TerminalSelectionSnapshot {
drag = null;
const coordinate = clampCoordinate(input.coordinate, input.bounds); const coordinate = clampCoordinate(input.coordinate, input.bounds);
state = { state = {
anchor: coordinate, anchor: coordinate,
@@ -285,7 +300,14 @@ export function createTerminalSelectionModel(): TerminalSelectionModel {
update(input: TerminalSelectionUpdateInput): TerminalSelectionSnapshot { update(input: TerminalSelectionUpdateInput): TerminalSelectionSnapshot {
return setSelection(input); return setSelection(input);
}, },
beginEndpointDrag(endpoint, cols) {
if (state.range) {
drag = { endpoint, cols };
state = { ...state, anchor: state.range[endpoint === "start" ? "end" : "start"] };
}
},
clear(): TerminalSelectionSnapshot { clear(): TerminalSelectionSnapshot {
drag = null;
state = { anchor: null, range: null }; state = { anchor: null, range: null };
return snapshot(); return snapshot();
}, },
@@ -293,6 +315,7 @@ export function createTerminalSelectionModel(): TerminalSelectionModel {
if (!state.range || rangeIsSafe(state.range, input.bounds)) { if (!state.range || rangeIsSafe(state.range, input.bounds)) {
return snapshot(); return snapshot();
} }
drag = null;
state = { anchor: null, range: null }; state = { anchor: null, range: null };
return snapshot(); return snapshot();
}, },
@@ -1,4 +1,4 @@
import { mkdir } from "node:fs/promises"; import { copyFile, mkdir } from "node:fs/promises";
import path from "node:path"; import path from "node:path";
import { fileURLToPath } from "node:url"; import { fileURLToPath } from "node:url";
@@ -27,4 +27,10 @@ for (const directory of ["", "v2/"]) {
target: "es2022", target: "es2022",
outfile: outputPath, outfile: outputPath,
}); });
if (directory === "v2/") {
await copyFile(
new URL("../src/server/agent/providers/opencode/v2/state.mjs", import.meta.url),
path.join(path.dirname(outputPath), "state.mjs"),
);
}
} }
@@ -1220,6 +1220,74 @@ test("orders buffered pre-steer output before an immediately accepted steer", as
} }
}); });
test("a steer the provider names without echoing it can be rewound to", async () => {
// Claude takes a steered message into the running turn without echoing it back as a user
// message, so the id it minted for the message reaches the manager only through the admission.
class SilentSteeringSession extends SteeringTestSession {
override readonly capabilities = {
...TEST_CAPABILITIES,
supportsRewindFiles: true,
};
rewindMessageIds: string[] = [];
override async steerActiveTurn(
_prompt: AgentPromptInput,
options: import("./agent-sdk-types.js").SteerActiveTurnOptions,
): Promise<import("./agent-sdk-types.js").SteerResult> {
if (options.expectedTurnId !== `active-turn-${this.startCount}`) {
return { status: "unavailable" };
}
return { status: "accepted", providerMessageId: `provider-${options.clientMessageId}` };
}
override async revertFiles({ messageId }: { messageId: string }): Promise<void> {
this.rewindMessageIds.push(messageId);
}
}
const session = new SilentSteeringSession({ provider: "codex", cwd: process.cwd() });
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-steer-rewind-"));
const manager = new AgentManager({
clients: {
codex: new (class extends TestAgentClient {
override async createSession(): Promise<AgentSession> {
return session;
}
})(),
},
logger,
});
let agentId: string | null = null;
try {
const agent = await manager.createAgent({ provider: "codex", cwd: workdir }, undefined, {
workspaceId: undefined,
});
agentId = agent.id;
const run = manager.streamAgent(agent.id, "initial");
void (async () => {
for await (const _event of run) {
}
})();
await manager.waitForAgentRunStart(agent.id);
await expect(
manager.steerAgentRun(agent.id, "steered", { clientMessageId: "steer-client" }),
).resolves.toMatchObject({ status: "accepted" });
const row = manager
.fetchTimeline(agent.id, { limit: 0 })
.rows.find((candidate) => candidate.item.type === "user_message");
expect(row).toMatchObject({
providerMessageId: "provider-steer-client",
item: { text: "steered", clientMessageId: "steer-client" },
});
await manager.rewind(agent.id, "steer-client", "files");
expect(session.rewindMessageIds).toEqual(["provider-steer-client"]);
} finally {
if (agentId) await manager.closeAgent(agentId).catch(() => undefined);
rmSync(workdir, { recursive: true, force: true });
}
});
test("orders a concurrent replacement after a pending accepted steer", async () => { test("orders a concurrent replacement after a pending accepted steer", async () => {
const entered = deferred<void>(); const entered = deferred<void>();
const release = deferred<void>(); const release = deferred<void>();
@@ -3029,7 +3029,13 @@ export class AgentManager {
expectedTurnId, expectedTurnId,
}); });
if (admission.status === "accepted") { if (admission.status === "accepted") {
await this.recordAcceptedSteer(agent, prompt, options?.clientMessageId, expectedTurnId); await this.recordAcceptedSteer(
agent,
prompt,
options?.clientMessageId,
expectedTurnId,
admission.providerMessageId,
);
} }
return admission; return admission;
}); });
@@ -3062,7 +3068,13 @@ export class AgentManager {
expectedTurnId, expectedTurnId,
}); });
if (admission.status === "accepted") { if (admission.status === "accepted") {
await this.recordAcceptedSteer(agent, prompt, options?.clientMessageId, expectedTurnId); await this.recordAcceptedSteer(
agent,
prompt,
options?.clientMessageId,
expectedTurnId,
admission.providerMessageId,
);
} }
return admission; return admission;
}) })
@@ -3167,6 +3179,7 @@ export class AgentManager {
prompt: AgentPromptInput, prompt: AgentPromptInput,
clientMessageId: string | undefined, clientMessageId: string | undefined,
expectedTurnId: string, expectedTurnId: string,
providerMessageId: string | undefined,
): Promise<void> { ): Promise<void> {
if (!clientMessageId) { if (!clientMessageId) {
return; return;
@@ -3174,6 +3187,7 @@ export class AgentManager {
this.recordSubmittedPrompt(agent, prompt, clientMessageId, "accepted", { this.recordSubmittedPrompt(agent, prompt, clientMessageId, "accepted", {
messageId: clientMessageId, messageId: clientMessageId,
turnId: expectedTurnId, turnId: expectedTurnId,
...(providerMessageId ? { providerMessageId } : {}),
}); });
this.emitState(agent); this.emitState(agent);
} }
@@ -3505,6 +3519,10 @@ export class AgentManager {
"agent.rewind.complete", "agent.rewind.complete",
); );
} catch (error) { } catch (error) {
// Native rewind can commit a fork before history refresh fails. Recovery
// must resume that latest handle rather than the abandoned conversation.
this.refreshSessionPersistence(agent);
await this.persistSnapshot(agent);
this.logger.warn( this.logger.warn(
{ err: error, agentId, provider: agent.provider, messageId, mode }, { err: error, agentId, provider: agent.provider, messageId, mode },
"agent.rewind.failed", "agent.rewind.failed",
@@ -208,7 +208,11 @@ export interface AgentSteerOptions extends AgentRunOptions {
clearPendingPermissions?: boolean; clearPendingPermissions?: boolean;
} }
export type SteerResult = { status: "accepted" } | { status: "unavailable" }; // providerMessageId names the steered message in the provider's own history, when the provider
// mints one. Rewinding to the message needs it.
export type SteerResult =
| { status: "accepted"; providerMessageId?: string }
| { status: "unavailable" };
export interface SteerActiveTurnOptions extends AgentSteerOptions { export interface SteerActiveTurnOptions extends AgentSteerOptions {
expectedTurnId: string; expectedTurnId: string;
@@ -53,6 +53,7 @@ const CAPABILITIES = [
interface ProviderHarnessOptions { interface ProviderHarnessOptions {
capabilities?: ProviderConnection["capabilities"]; capabilities?: ProviderConnection["capabilities"];
completeTurn?: boolean; completeTurn?: boolean;
closeAcknowledgementOnly?: boolean;
openChildren?: (rootSessionId: string, emit: (event: ProviderEvent) => void) => void; openChildren?: (rootSessionId: string, emit: (event: ProviderEvent) => void) => void;
handleInput?: (input: ProviderInput, emit: (event: ProviderEvent) => void) => Promise<boolean>; handleInput?: (input: ProviderInput, emit: (event: ProviderEvent) => void) => Promise<boolean>;
} }
@@ -208,6 +209,7 @@ function createProviderHarness(options: ProviderHarnessOptions = {}) {
} }
if (input.type === "session.close") { if (input.type === "session.close") {
emit({ type: "session.closed", sessionId: input.sessionId }); emit({ type: "session.closed", sessionId: input.sessionId });
if (options.closeAcknowledgementOnly) return;
} }
if ("requestId" in input) { if ("requestId" in input) {
emit({ type: "request.completed", requestId: input.requestId }); emit({ type: "request.completed", requestId: input.requestId });
@@ -246,6 +248,572 @@ function eventsOfType(events: AgentStreamEvent[], type: AgentStreamEvent["type"]
return events.filter((event) => event.type === type); return events.filter((event) => event.type === type);
} }
function createRewindProviderHarness(
options: {
reuseAnswerId?: boolean;
forkOnRevert?: boolean;
handleInput?: ProviderHarnessOptions["handleInput"];
} = {},
) {
const nativeMessages: string[] = [];
let originalMessages: string[] | null = null;
const harness = createProviderHarness({
closeAcknowledgementOnly: true,
capabilities: [
...CAPABILITIES,
"session.revert.conversation",
"session.revert.files",
"session.revert.both",
],
async handleInput(input, emit) {
if (await options.handleInput?.(input, emit)) return true;
if (input.type === "session.open" && input.history === "replay") {
emit({
type: "session.opened",
requestId: input.requestId,
sessionId: input.sessionId,
capabilities: [
...CAPABILITIES,
"session.revert.conversation",
"session.revert.files",
"session.revert.both",
],
restoration: "core",
persistence: input.persistence ?? { version: 1, data: { token: "root" } },
cwd: input.config.cwd,
});
const messages =
options.forkOnRevert &&
(input.persistence?.data as { token?: string })?.token === "root" &&
originalMessages
? originalMessages
: nativeMessages;
for (const [index, text] of messages.entries()) {
emit({
type: "timeline.item",
sessionId: input.sessionId,
item: { type: "user_message", id: `user-${text}`, text, revertToken: text },
});
emit({
type: "timeline.item",
sessionId: input.sessionId,
item: {
type: "assistant_message",
id: options.reuseAnswerId ? `answer-${index + 1}` : `answer-${text}`,
text: `Reply ${text}`,
},
});
}
emit({ type: "session.ready", requestId: input.requestId, sessionId: input.sessionId });
return true;
}
if (input.type === "session.prompt") {
if (input.prompt.input.type !== "message") throw new Error("Expected a message");
const text = input.prompt.input.content
.flatMap((part) => (part.type === "text" ? [part.text] : []))
.join("");
nativeMessages.push(text);
const turnId = `turn-${text}`;
emit({
type: "session.prompt_result",
sessionId: input.sessionId,
clientMessageId: input.prompt.clientMessageId,
result: { type: "turn", turnId },
});
emit({ type: "session.turn", sessionId: input.sessionId, turnId, state: "started" });
emit({
type: "timeline.item",
sessionId: input.sessionId,
item: {
type: "user_message",
id: `user-${text}`,
text,
clientMessageId: input.prompt.clientMessageId,
revertToken: text,
},
});
emit({
type: "timeline.item",
sessionId: input.sessionId,
item: {
type: "assistant_message",
id: options.reuseAnswerId ? `answer-${nativeMessages.length}` : `answer-${text}`,
text: `Reply ${text}`,
},
});
emit({ type: "session.turn", sessionId: input.sessionId, turnId, state: "completed" });
return true;
}
if (input.type === "session.revert") {
if (options.forkOnRevert && input.scope !== "files") {
originalMessages = [...nativeMessages];
emit({
type: "session.persistence",
sessionId: input.sessionId,
persistence: { version: 1, data: { token: "fork" } },
});
}
if (input.scope !== "files")
nativeMessages.splice(nativeMessages.indexOf(String(input.token)));
emit({ type: "request.completed", requestId: input.requestId });
return true;
}
return false;
},
});
return { ...harness, nativeMessages };
}
async function withRewindAgent(
run: (context: {
manager: AgentManager;
agentId: string;
nativeMessages: string[];
messages: () => string[];
}) => Promise<void>,
options: Parameters<typeof createRewindProviderHarness>[0] = {},
) {
const harness = createRewindProviderHarness(options);
const logger = createTestLogger();
const registry = new PluginAgentClientRegistry(logger);
registry.replace([harness.registration]);
const manager = new AgentManager({
logger,
clients: registry.clients(),
providerDefinitions: registry.definitions(),
});
const agent = await manager.createAgent(
{ provider: harness.registration.id, cwd: tmpdir() },
undefined,
{ workspaceId: undefined },
);
const messages = () =>
manager
.getTimeline(agent.id)
.flatMap((item) =>
item.type === "user_message" || item.type === "assistant_message" ? [item.text] : [],
);
try {
await manager.runAgent(agent.id, "A", { clientMessageId: "client-A" });
await manager.runAgent(agent.id, "B", { clientMessageId: "client-B" });
await manager.runAgent(agent.id, "C", { clientMessageId: "client-C" });
expect(messages()).toEqual(["A", "Reply A", "B", "Reply B", "C", "Reply C"]);
await run({ manager, agentId: agent.id, nativeMessages: harness.nativeMessages, messages });
} finally {
await manager.closeAgent(agent.id);
await registry.shutdown();
}
}
test("conversation rewind discards withdrawn plugin turns before and after a replacement prompt", async () => {
await withRewindAgent(async ({ manager, agentId, nativeMessages, messages }) => {
await manager.rewind(agentId, "user-B", "conversation");
const afterRewind = messages();
expect(nativeMessages).toEqual(["A"]);
await manager.runAgent(agentId, "D", { clientMessageId: "client-D" });
const afterReplacement = messages();
expect(nativeMessages).toEqual(["A", "D"]);
expect({ afterRewind, afterReplacement }).toEqual({
afterRewind: ["A", "Reply A"],
afterReplacement: ["A", "Reply A", "D", "Reply D"],
});
});
});
test("file-only plugin rewind preserves the conversation before and after a replacement prompt", async () => {
await withRewindAgent(async ({ manager, agentId, nativeMessages, messages }) => {
await manager.rewind(agentId, "user-B", "files");
expect(nativeMessages).toEqual(["A", "B", "C"]);
expect(messages()).toEqual(["A", "Reply A", "B", "Reply B", "C", "Reply C"]);
await manager.runAgent(agentId, "D", { clientMessageId: "client-D" });
expect(messages()).toEqual(["A", "Reply A", "B", "Reply B", "C", "Reply C", "D", "Reply D"]);
});
});
test("plugin rewind restores translation state when a replacement reuses a discarded item id", async () => {
const harness = createRewindProviderHarness({ reuseAnswerId: true });
const registry = new PluginAgentClientRegistry(createTestLogger());
registry.replace([harness.registration]);
const client = registry.clients()[harness.registration.id]!;
const session = await client.createSession({ provider: harness.registration.id, cwd: tmpdir() });
const live: AgentStreamEvent[] = [];
try {
await session.run("A");
await session.run("B");
session.subscribe((event) => live.push(event));
await session.revertConversation!({ messageId: "user-B" });
live.length = 0;
await session.run("B revised");
const assistant = live.flatMap((event) =>
event.type === "timeline" && event.item.type === "assistant_message" ? [event.item.text] : [],
);
expect(harness.nativeMessages).toEqual(["A", "B revised"]);
expect(assistant).toEqual(["Reply B revised"]);
} finally {
await session.close();
await registry.shutdown();
}
});
async function withPluginRewindSession(
options: Parameters<typeof createRewindProviderHarness>[0],
run: (
session: Awaited<ReturnType<AgentClient["createSession"]>>,
harness: ReturnType<typeof createRewindProviderHarness>,
) => Promise<void>,
) {
const harness = createRewindProviderHarness(options);
const registry = new PluginAgentClientRegistry(createTestLogger());
registry.replace([harness.registration]);
const client = registry.clients()[harness.registration.id]!;
const session = await client.createSession({ provider: harness.registration.id, cwd: tmpdir() });
try {
await session.run("A");
await session.run("B");
await session.run("C");
await run(session, harness);
} finally {
await session.close();
await registry.shutdown();
}
}
async function pluginMessages(session: Awaited<ReturnType<AgentClient["createSession"]>>) {
const events = await Array.fromAsync(session.streamHistory());
return events.flatMap((event) =>
event.type === "timeline" &&
(event.item.type === "user_message" || event.item.type === "assistant_message")
? [event.item.text]
: [],
);
}
test("combined plugin rewind replays the retained native branch", async () => {
await withPluginRewindSession({}, async (session) => {
await session.revertBoth!({ messageId: "user-B" });
expect(await pluginMessages(session)).toEqual(["A", "Reply A"]);
await session.revertConversation!({ messageId: "user-A" });
expect(await pluginMessages(session)).toEqual([]);
});
});
test("plugin rewind resumes the latest persistence rather than the original handle", async () => {
await withPluginRewindSession(
{
handleInput: async (input, emit) => {
if (input.type === "session.revert")
emit({
type: "session.persistence",
sessionId: input.sessionId,
persistence: { version: 1, data: { token: "fork" } },
});
return false;
},
},
async (session, harness) => {
await session.revertConversation!({ messageId: "user-B" });
expect(harness.inputs.findLast((input) => input.type === "session.open")).toMatchObject({
persistence: { version: 1, data: { token: "fork" } },
history: "replay",
});
expect(await pluginMessages(session)).toEqual(["A", "Reply A"]);
},
);
});
test("failed native plugin rewind preserves history and does not reopen", async () => {
await withPluginRewindSession(
{
handleInput: async (input, emit) => {
if (input.type !== "session.revert") return false;
emit({
type: "request.failed",
requestId: input.requestId,
error: { message: "native rewind failed" },
});
return true;
},
},
async (session, harness) => {
await expect(session.revertConversation!({ messageId: "user-B" })).rejects.toThrow(
"native rewind failed",
);
expect(await pluginMessages(session)).toEqual([
"A",
"Reply A",
"B",
"Reply B",
"C",
"Reply C",
]);
expect(harness.inputs.filter((input) => input.type === "session.open")).toHaveLength(1);
},
);
});
test("failed plugin rewind replay blocks prompts until retry without repeating native rewind", async () => {
let failReplay = true;
await withPluginRewindSession(
{
handleInput: async (input, emit) => {
if (input.type !== "session.open" || input.history !== "replay" || !failReplay)
return false;
failReplay = false;
emit({
type: "request.failed",
requestId: input.requestId,
error: { message: "replay failed" },
});
return true;
},
},
async (session, harness) => {
await expect(session.revertConversation!({ messageId: "user-B" })).rejects.toThrow(
"replay failed",
);
await expect(session.run("D")).rejects.toThrow("Retry the rewind or reload");
await session.revertConversation!({ messageId: "user-B" });
await session.run("D");
expect(await pluginMessages(session)).toEqual(["A", "Reply A", "D", "Reply D"]);
expect(harness.inputs.filter((input) => input.type === "session.revert")).toHaveLength(1);
},
);
});
test("retrying a failed plugin rewind replay replaces the manager timeline before prompting", async () => {
let failReplay = true;
await withRewindAgent(
async ({ manager, agentId, messages }) => {
await expect(manager.rewind(agentId, "user-B", "conversation")).rejects.toThrow(
"replay failed",
);
await expect(manager.runAgent(agentId, "D")).rejects.toThrow("Retry the rewind or reload");
await manager.rewind(agentId, "user-B", "conversation");
expect(messages()).toEqual(["A", "Reply A"]);
await manager.runAgent(agentId, "D");
expect(messages()).toEqual(["A", "Reply A", "D", "Reply D"]);
},
{
handleInput: async (input, emit) => {
if (input.type !== "session.open" || input.history !== "replay" || !failReplay)
return false;
failReplay = false;
emit({
type: "request.failed",
requestId: input.requestId,
error: { message: "replay failed" },
});
return true;
},
},
);
});
test("reload after failed plugin rewind replay resumes the committed fork", async () => {
let failReplay = true;
await withRewindAgent(
async ({ manager, agentId, messages }) => {
await expect(manager.rewind(agentId, "user-B", "conversation")).rejects.toThrow(
"replay failed",
);
await manager.reloadAgentSession(agentId, undefined, { rehydrateFromDisk: true });
await manager.hydrateTimelineFromProvider(agentId, { force: true });
expect(messages()).toEqual(["A", "Reply A"]);
},
{
forkOnRevert: true,
handleInput: async (input, emit) => {
if (input.type === "session.close") {
emit({ type: "request.completed", requestId: input.requestId });
return true;
}
if (input.type !== "session.open" || input.history !== "replay" || !failReplay)
return false;
failReplay = false;
emit({
type: "request.failed",
requestId: input.requestId,
error: { message: "replay failed" },
});
return true;
},
},
);
});
test("closing during plugin rewind replay closes each native binding once", async () => {
let opened!: () => void;
let release!: () => void;
const replayStarted = new Promise<void>((resolve) => {
opened = resolve;
});
const replayAllowed = new Promise<void>((resolve) => {
release = resolve;
});
await withPluginRewindSession(
{
handleInput: async (input) => {
if (input.type === "session.open" && input.history === "replay") {
opened();
await replayAllowed;
}
return false;
},
},
async (session, harness) => {
const rewind = session.revertConversation!({ messageId: "user-B" });
await replayStarted;
const closing = session.close();
release();
await Promise.all([rewind, closing]);
const closes = harness.inputs.flatMap((input) =>
input.type === "session.close" ? [input.sessionId] : [],
);
expect(closes).toHaveLength(2);
expect(new Set(closes).size).toBe(2);
},
);
});
test("plugin rewind retries a failed close before reopening the native writer", async () => {
let failClose = true;
await withPluginRewindSession(
{
handleInput: async (input, emit) => {
if (input.type !== "session.close" || !failClose) return false;
failClose = false;
emit({
type: "request.failed",
requestId: input.requestId,
error: { message: "close failed" },
});
return true;
},
},
async (session, harness) => {
await expect(session.revertConversation!({ messageId: "user-B" })).rejects.toThrow(
"close failed",
);
expect(harness.inputs.filter((input) => input.type === "session.open")).toHaveLength(1);
expect(await pluginMessages(session)).toEqual(["A", "Reply A"]);
expect(harness.inputs.filter((input) => input.type === "session.revert")).toHaveLength(1);
},
);
});
test("plugin rewind retries permission settlement before retiring its session", async () => {
let failPermission = true;
await withPluginRewindSession(
{
handleInput: async (input) => {
if (input.type !== "session.permission" || !failPermission) return false;
failPermission = false;
throw new Error("permission delivery failed");
},
},
async (session, harness) => {
const opened = harness.inputs.find((input) => input.type === "session.open")!;
if (opened.type !== "session.open") throw new Error("Missing root");
harness.emit({
type: "session.permission",
sessionId: opened.sessionId,
request: { id: "pending", name: "tool", kind: "tool" },
});
await expect(session.revertConversation!({ messageId: "user-B" })).rejects.toThrow(
"permission delivery failed",
);
expect(session.getPendingPermissions()).toHaveLength(1);
expect(harness.inputs.filter((input) => input.type === "session.close")).toHaveLength(0);
expect(await pluginMessages(session)).toEqual(["A", "Reply A"]);
expect(session.getPendingPermissions()).toEqual([]);
expect(harness.inputs.filter((input) => input.type === "session.permission")).toHaveLength(2);
},
);
});
test("plugin rewind replaces child listeners and settles old pending permissions", async () => {
await withPluginRewindSession({}, async (session, harness) => {
const root = harness.inputs.find((input) => input.type === "session.open")!;
if (root.type !== "session.open") throw new Error("Missing root");
harness.emit({
type: "session.permission",
sessionId: root.sessionId,
request: { id: "old-permission", name: "tool", kind: "tool", title: "Run tool" },
});
expect(session.getPendingPermissions()).toHaveLength(1);
await session.revertConversation!({ messageId: "user-B" });
expect(session.getPendingPermissions()).toEqual([]);
expect(harness.inputs).toContainEqual(
expect.objectContaining({
type: "session.permission",
permissionId: "old-permission",
response: { behavior: "deny", message: "Session replaced after rewind" },
}),
);
harness.emit({
type: "timeline.item",
sessionId: "child-1",
item: { type: "assistant_message", id: "child-message", text: "Discarded child update" },
});
const history = await Array.fromAsync(session.streamHistory());
expect(history.filter((event) => event.type === "provider_subagent")).toEqual([]);
const reopened = harness.inputs.findLast((input) => input.type === "session.open")!;
if (reopened.type !== "session.open") throw new Error("Missing reopened root");
harness.emit({
type: "session.opened",
sessionId: "child-1",
parentSessionId: reopened.sessionId,
capabilities: [],
restoration: "parent",
cwd: tmpdir(),
});
harness.emit({
type: "timeline.item",
sessionId: "child-1",
item: { type: "assistant_message", id: "child-message", text: "New child reply" },
});
const next = await Array.fromAsync(session.streamHistory());
expect(next).toContainEqual(
expect.objectContaining({
type: "provider_subagent",
event: expect.objectContaining({
type: "timeline",
item: expect.objectContaining({ text: "New child reply" }),
}),
}),
);
});
});
test("nonpersisted plugin sessions reject conversation rewind before native mutation", async () => {
await withPluginRewindSession(
{
handleInput: async (input, emit) => {
if (input.type !== "session.open") return false;
emit({
type: "session.opened",
requestId: input.requestId,
sessionId: input.sessionId,
capabilities: [...CAPABILITIES, "session.revert.conversation", "session.revert.files"],
restoration: "core",
cwd: input.config.cwd,
});
emit({ type: "session.ready", requestId: input.requestId, sessionId: input.sessionId });
return true;
},
},
async (session, harness) => {
expect(session.capabilities.supportsRewindConversation).toBe(false);
await expect(session.revertConversation!({ messageId: "user-B" })).rejects.toThrow(
"requires session persistence",
);
expect(harness.inputs.filter((input) => input.type === "session.revert")).toEqual([]);
await session.revertFiles!({ messageId: "user-B" });
expect(harness.nativeMessages).toEqual(["A", "B", "C"]);
},
);
});
function openNestedChildren(rootSessionId: string, emit: (event: ProviderEvent) => void) { function openNestedChildren(rootSessionId: string, emit: (event: ProviderEvent) => void) {
for (const [sessionId, parentSessionId] of [ for (const [sessionId, parentSessionId] of [
["a", rootSessionId], ["a", rootSessionId],
@@ -603,6 +603,7 @@ class ProviderRuntimeSession {
>(); >();
private readonly activeTurnIds = new Set<string>(); private readonly activeTurnIds = new Set<string>();
private terminal = false; private terminal = false;
private closePromise: Promise<void> | null = null;
config: ProviderConfigState = { models: [], modes: [], thinkingOptions: [], settings: [] }; config: ProviderConfigState = { models: [], modes: [], thinkingOptions: [], settings: [] };
commands: Array<{ name: string; description: string; argumentHint?: string }> = []; commands: Array<{ name: string; description: string; argumentHint?: string }> = [];
persistence: ProviderPersistence | null = null; persistence: ProviderPersistence | null = null;
@@ -724,6 +725,14 @@ class ProviderRuntimeSession {
this.runtime.removeSession(this.id, this.providerSessionId); this.runtime.removeSession(this.id, this.providerSessionId);
return; return;
} }
this.closePromise ??= this.closeNativeSession().catch((error) => {
this.closePromise = null;
throw error;
});
await this.closePromise;
}
private async closeNativeSession(): Promise<void> {
try { try {
await this.runtime.complete({ await this.runtime.complete({
type: "session.close", type: "session.close",
@@ -732,9 +741,9 @@ class ProviderRuntimeSession {
}); });
} catch (error) { } catch (error) {
if (!this.runtime.isClosed) throw error; if (!this.runtime.isClosed) throw error;
} finally {
this.runtime.removeSession(this.id, this.providerSessionId);
} }
// A failed close still needs its routing for a subsequent session.closed ack.
this.runtime.removeSession(this.id, this.providerSessionId);
} }
beginOpen(requestId: string): Promise<void> { beginOpen(requestId: string): Promise<void> {
@@ -1092,11 +1101,40 @@ class PluginAgentClient implements AgentClient {
persistence: input.persistence, persistence: input.persistence,
history: input.history, history: input.history,
}); });
const session = new PluginAgentSession(this.provider, bridge, () => { const forget = () => {
this.rootsBySession.delete(bridge.id); for (const [id, owner] of this.rootsBySession) {
}); if (owner === session) this.rootsBySession.delete(id);
this.rootsBySession.set(bridge.id, session); }
this.attachPendingChildren(); };
const bind = (next: ProviderRuntimeSession) => {
this.rootsBySession.set(next.id, session);
this.attachPendingChildren();
};
const config = mapSessionConfig(input.config, input.launchContext, input.persist);
const session = new PluginAgentSession(
this.provider,
bridge,
forget,
async (persistence, state) => {
forget();
return await this.runtime.openSession({
sessionId: randomUUID(),
config: {
...config,
model: state.model,
mode: state.mode,
thinkingOption: state.thinkingOption,
settings: Object.fromEntries(
state.settings.map((setting) => [setting.id, setting.value]),
),
},
persistence,
history: "replay",
});
},
bind,
);
bind(bridge);
return session; return session;
} }
@@ -1157,16 +1195,32 @@ class PluginAgentSession implements AgentSession {
private readonly revertTokens = new Map<string, ProviderTimelineItem["revertToken"]>(); private readonly revertTokens = new Map<string, ProviderTimelineItem["revertToken"]>();
private readonly timelineSnapshots = new Map<string, TimelineTextSnapshot>(); private readonly timelineSnapshots = new Map<string, TimelineTextSnapshot>();
private readonly subagentIdsBySession = new Map<string, string | null>(); private readonly subagentIdsBySession = new Map<string, string | null>();
private readonly childUnsubscribes = new Map<string, () => void>(); private readonly children = new Map<
string,
{ session: ProviderRuntimeSession; unsubscribe: () => void }
>();
private readonly childSnapshots = new Map<string, Map<string, TimelineTextSnapshot>>(); private readonly childSnapshots = new Map<string, Map<string, TimelineTextSnapshot>>();
private unsubscribe: (() => void) | null = null; private unsubscribe: (() => void) | null = null;
private currentTurnId: string | null = null; private currentTurnId: string | null = null;
private closed = false; private closed = false;
private replay: {
persistence: ProviderPersistence;
config: ProviderConfigState;
messageId: string;
scope: "conversation" | "both";
closed: boolean;
} | null = null;
private replaying: Promise<void> | null = null;
constructor( constructor(
readonly provider: string, readonly provider: string,
private readonly bridge: ProviderRuntimeSession, private bridge: ProviderRuntimeSession,
private readonly onClose: () => void, private readonly onClose: () => void,
private readonly reopen: (
persistence: ProviderPersistence,
state: ProviderConfigState,
) => Promise<ProviderRuntimeSession>,
private readonly onBind: (bridge: ProviderRuntimeSession) => void,
) { ) {
this.subagentIdsBySession.set(bridge.id, null); this.subagentIdsBySession.set(bridge.id, null);
for (const event of bridge.history) this.accept(event, false); for (const event of bridge.history) this.accept(event, false);
@@ -1183,7 +1237,13 @@ class PluginAgentSession implements AgentSession {
} }
get capabilities(): AgentCapabilityFlags { get capabilities(): AgentCapabilityFlags {
return agentCapabilities(this.bridge.negotiatedCapabilities); const capabilities = agentCapabilities(this.bridge.negotiatedCapabilities);
const resumable = this.canReplayHistory;
return {
...capabilities,
supportsRewindConversation: capabilities.supportsRewindConversation && resumable,
supportsRewindBoth: capabilities.supportsRewindBoth && resumable,
};
} }
get features(): AgentFeature[] { get features(): AgentFeature[] {
@@ -1211,6 +1271,7 @@ class PluginAgentSession implements AgentSession {
prompt: AgentPromptInput, prompt: AgentPromptInput,
options: AgentRunOptions = {}, options: AgentRunOptions = {},
): Promise<{ turnId: string }> { ): Promise<{ turnId: string }> {
this.assertReplayComplete();
const clientMessageId = options.clientMessageId ?? randomUUID(); const clientMessageId = options.clientMessageId ?? randomUUID();
const result = await this.bridge.prompt({ const result = await this.bridge.prompt({
clientMessageId, clientMessageId,
@@ -1233,6 +1294,7 @@ class PluginAgentSession implements AgentSession {
prompt: AgentPromptInput, prompt: AgentPromptInput,
options: SteerActiveTurnOptions, options: SteerActiveTurnOptions,
): Promise<SteerResult> { ): Promise<SteerResult> {
this.assertReplayComplete();
if (!this.bridge.negotiatedCapabilities.includes("prompt.steer")) { if (!this.bridge.negotiatedCapabilities.includes("prompt.steer")) {
return { status: "unavailable" }; return { status: "unavailable" };
} }
@@ -1256,6 +1318,7 @@ class PluginAgentSession implements AgentSession {
} }
async *streamHistory(): AsyncGenerator<AgentStreamEvent> { async *streamHistory(): AsyncGenerator<AgentStreamEvent> {
await this.ensureReplay();
for (const event of this.history.read(this.provider)) yield event; for (const event of this.history.read(this.provider)) yield event;
} }
@@ -1283,6 +1346,7 @@ class PluginAgentSession implements AgentSession {
} }
async setMode(modeId: string): Promise<void> { async setMode(modeId: string): Promise<void> {
this.assertReplayComplete();
await this.bridge.configure({ mode: modeId }); await this.bridge.configure({ mode: modeId });
} }
@@ -1319,17 +1383,10 @@ class PluginAgentSession implements AgentSession {
async close(): Promise<void> { async close(): Promise<void> {
if (this.closed) return; if (this.closed) return;
this.closed = true; this.closed = true;
this.unsubscribe?.(); this.listeners.clear();
this.unsubscribe = null; await this.replaying?.catch(() => undefined);
for (const unsubscribe of this.childUnsubscribes.values()) unsubscribe(); await this.clearBinding();
this.childUnsubscribes.clear(); this.replay = null;
this.subagentIdsBySession.clear();
this.history.clear();
this.timelineSnapshots.clear();
this.childSnapshots.clear();
this.revertTokens.clear();
this.pendingPermissions.clear();
this.permissionResponses.clear();
this.listeners.clear(); this.listeners.clear();
this.onClose(); this.onClose();
await this.bridge.close(); await this.bridge.close();
@@ -1344,14 +1401,17 @@ class PluginAgentSession implements AgentSession {
} }
async setModel(modelId: string | null): Promise<void> { async setModel(modelId: string | null): Promise<void> {
this.assertReplayComplete();
await this.bridge.configure({ model: modelId }); await this.bridge.configure({ model: modelId });
} }
async setThinkingOption(thinkingOptionId: string | null): Promise<void> { async setThinkingOption(thinkingOptionId: string | null): Promise<void> {
this.assertReplayComplete();
await this.bridge.configure({ thinkingOption: thinkingOptionId }); await this.bridge.configure({ thinkingOption: thinkingOptionId });
} }
async setFeature(featureId: string, value: unknown): Promise<void> { async setFeature(featureId: string, value: unknown): Promise<void> {
this.assertReplayComplete();
await this.bridge.configure({ await this.bridge.configure({
settings: { [featureId]: toJsonValue(value, `setting '${featureId}'`) }, settings: { [featureId]: toJsonValue(value, `setting '${featureId}'`) },
}); });
@@ -1415,10 +1475,10 @@ class PluginAgentSession implements AgentSession {
const snapshots = new Map<string, TimelineTextSnapshot>(); const snapshots = new Map<string, TimelineTextSnapshot>();
this.childSnapshots.set(childId, snapshots); this.childSnapshots.set(childId, snapshots);
for (const event of child.history) this.acceptChildEvent(childId, event, snapshots); for (const event of child.history) this.acceptChildEvent(childId, event, snapshots);
this.childUnsubscribes.set( this.children.set(child.id, {
child.id, session: child,
child.onEvent((event) => this.acceptChildEvent(childId, event, snapshots)), unsubscribe: child.onEvent((event) => this.acceptChildEvent(childId, event, snapshots)),
); });
} }
private accept(event: ProviderEvent, live: boolean): void { private accept(event: ProviderEvent, live: boolean): void {
@@ -1636,7 +1696,84 @@ class PluginAgentSession implements AgentSession {
} }
} }
private async clearBinding(): Promise<void> {
this.unsubscribe?.();
this.unsubscribe = null;
for (const child of this.children.values()) {
child.unsubscribe();
await child.session.close();
}
this.children.clear();
this.subagentIdsBySession.clear();
this.history.clear();
this.timelineSnapshots.clear();
this.childSnapshots.clear();
this.revertTokens.clear();
this.pendingPermissions.clear();
this.permissionResponses.clear();
this.currentTurnId = null;
}
private assertReplayComplete(): void {
if (this.replay) {
throw new Error(
"Plugin rewind history refresh is incomplete. Retry the rewind or reload the agent before continuing.",
);
}
}
// A native rewind has already committed. Until replay succeeds, neither history
// nor a new prompt may use the retired binding. Keep its latest handle for retry.
private async ensureReplay(): Promise<void> {
if (!this.replay) return;
if (this.closed) throw new StaleProviderSessionError(this.id);
if (!this.replaying) {
this.replaying = this.replayBinding().finally(() => {
this.replaying = null;
});
}
await this.replaying;
}
private async replayBinding(): Promise<void> {
const replay = this.replay!;
if (!replay.closed) {
for (const request of this.pendingPermissions.values()) {
await this.respondToPermission(request.id, {
behavior: "deny",
message: "Session replaced after rewind",
});
this.pendingPermissions.delete(request.id);
this.permissionResponses.delete(request.id);
}
await this.clearBinding();
await this.bridge.close();
replay.closed = true;
}
const bridge = await this.reopen(replay.persistence, replay.config);
this.bridge = bridge;
this.subagentIdsBySession.set(bridge.id, null);
for (const event of bridge.history) this.accept(event, false);
this.unsubscribe = bridge.onEvent((event) => this.accept(event, true));
this.onBind(bridge);
this.replay = null;
}
private get canReplayHistory(): boolean {
return (
this.bridge.restoration === "core" &&
this.bridge.persistence !== null &&
this.bridge.negotiatedCapabilities.includes("session.persistence")
);
}
private async revert(messageId: string, scope: "conversation" | "files" | "both") { private async revert(messageId: string, scope: "conversation" | "files" | "both") {
const pending = this.replay;
await this.ensureReplay();
if (pending?.messageId === messageId && pending.scope === scope) return;
if (scope !== "files" && !this.canReplayHistory) {
throw new Error("Plugin conversation rewind requires session persistence for history replay");
}
const token = this.revertTokens.get(messageId); const token = this.revertTokens.get(messageId);
if (token === undefined) throw new Error(`No provider revert token for message ${messageId}`); if (token === undefined) throw new Error(`No provider revert token for message ${messageId}`);
await this.bridge.revert({ await this.bridge.revert({
@@ -1646,6 +1783,12 @@ class PluginAgentSession implements AgentSession {
token, token,
scope, scope,
}); });
if (scope !== "files") {
const persistence = this.bridge.persistence;
if (!persistence) throw new Error("Plugin rewind removed session persistence");
this.replay = { persistence, config: this.bridge.config, messageId, scope, closed: false };
await this.ensureReplay();
}
} }
private publish(event: AgentStreamEvent): void { private publish(event: AgentStreamEvent): void {
@@ -22,7 +22,12 @@ interface CreateAgentProviderRuntimeOptions {
export async function createAgentProviderRuntime( export async function createAgentProviderRuntime(
options: CreateAgentProviderRuntimeOptions, options: CreateAgentProviderRuntimeOptions,
): Promise<AgentProviderRuntime> { ): Promise<AgentProviderRuntime> {
const bridge = new OpenCodeBridge({ paseoHome: options.paseoHome, logger: options.logger }); const configuredPort =
options.snapshotManager.runtimeSettings?.opencode?.env?.PASEO_OPENCODE_BRIDGE_PORT;
const port = configuredPort === undefined ? undefined : Number(configuredPort);
if (port !== undefined && (!Number.isInteger(port) || port < 1 || port > 65535))
throw new Error("PASEO_OPENCODE_BRIDGE_PORT must be an integer between 1 and 65535");
const bridge = new OpenCodeBridge({ paseoHome: options.paseoHome, logger: options.logger, port });
try { try {
await bridge.start(); await bridge.start();
const snapshotManager = new ProviderSnapshotManager({ const snapshotManager = new ProviderSnapshotManager({
@@ -614,6 +614,43 @@ describe("ClaudeAgentSession persisted history load", () => {
); );
}); });
test("replays a message steered into a running turn as the user's message", async () => {
const queued = (commandMode: string, prompt: unknown, sourceUuid?: string) => ({
type: "attachment",
uuid: `attachment-${commandMode}`,
parentUuid: "a1",
attachment: {
type: "queued_command",
commandMode,
prompt,
...(sourceUuid ? { source_uuid: sourceUuid } : {}),
},
});
const entries = [
{ type: "user", uuid: "u1", parentUuid: null, message: { content: "first" } },
{ type: "assistant", uuid: "a1", parentUuid: "u1", message: { content: "working" } },
queued("prompt", [{ type: "text", text: "steered while working" }], "steered-1"),
queued("task-notification", "<task-notification><task-id>t1</task-id></task-notification>"),
{ type: "assistant", uuid: "a2", parentUuid: "a1", message: { content: "answer" } },
];
mkdirSync(path.dirname(transcriptPath()), { recursive: true });
writeFileSync(transcriptPath(), entries.map((entry) => JSON.stringify(entry)).join("\n"));
const session = await resume();
try {
const users = [];
for await (const event of session.streamHistory()) {
if (event.type === "timeline" && event.item.type === "user_message") users.push(event.item);
}
expect(users).toEqual([
{ type: "user_message", text: "first", messageId: "u1" },
{ type: "user_message", text: "steered while working", messageId: "steered-1" },
]);
} finally {
await session.close();
}
});
test("loads readable history when the temporary directory is unavailable", async () => { test("loads readable history when the temporary directory is unavailable", async () => {
const file = transcriptPath(); const file = transcriptPath();
const entry = { type: "assistant", message: { content: [{ type: "text", text: "saved" }] } }; const entry = { type: "assistant", message: { content: [{ type: "text", text: "saved" }] } };
@@ -349,8 +349,11 @@ async function startSteeredTurn(sessionId: string): Promise<{
expectedTurnId: start.value.turnId, expectedTurnId: start.value.turnId,
clientMessageId: "steer-client", clientMessageId: "steer-client",
}); });
expect(steered).toEqual({ status: "accepted" });
await waitFor(() => query?.prompts.length === 2); await waitFor(() => query?.prompts.length === 2);
expect(steered).toEqual({
status: "accepted",
providerMessageId: query?.prompts[1]?.uuid,
});
return { session, query: () => query, turn }; return { session, query: () => query, turn };
} }
@@ -972,7 +975,10 @@ test("steers an autonomous turn through its existing query without restarting it
const steeredTimeline = await autonomousEvents.next(); const steeredTimeline = await autonomousEvents.next();
const completion = await autonomousEvents.next(); const completion = await autonomousEvents.next();
expect(steer).toEqual({ status: "accepted" }); expect(steer).toEqual({
status: "accepted",
providerMessageId: queryRef?.prompts[1]?.uuid,
});
expect(queryFactory).toHaveBeenCalledTimes(1); expect(queryFactory).toHaveBeenCalledTimes(1);
expect(queryRef?.interrupt).not.toHaveBeenCalled(); expect(queryRef?.interrupt).not.toHaveBeenCalled();
expect(queryRef?.prompts.map((prompt) => prompt.text)).toEqual(["seed prompt", "steer prompt"]); expect(queryRef?.prompts.map((prompt) => prompt.text)).toEqual(["seed prompt", "steer prompt"]);
@@ -1,7 +1,11 @@
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import path from "node:path";
import { describe, expect, test, vi } from "vitest"; import { describe, expect, test, vi } from "vitest";
import { createTestLogger } from "../../../../test-utils/test-logger.js"; import { createTestLogger } from "../../../../test-utils/test-logger.js";
import { ClaudeAgentClient } from "./agent.js"; import { ClaudeAgentClient } from "./agent.js";
import { claudeProjectDir } from "./project-dir.js";
import { FakeClaudeSdk } from "./test-rewind-claude-sdk.js"; import { FakeClaudeSdk } from "./test-rewind-claude-sdk.js";
import { streamSession } from "../test-utils/session-stream-adapter.js"; import { streamSession } from "../test-utils/session-stream-adapter.js";
import type { AgentSession } from "../../agent-sdk-types.js"; import type { AgentSession } from "../../agent-sdk-types.js";
@@ -267,3 +271,49 @@ describe("Claude rewind after a turn whose last assistant message came from a su
expect(rewindSdk.recordedForks).toEqual([{ upToMessageId: "assistant-1" }]); expect(rewindSdk.recordedForks).toEqual([{ upToMessageId: "assistant-1" }]);
}); });
}); });
describe("Claude rewind to a message steered into a running turn", () => {
test("forks at the entry Claude read the message after", async () => {
const configDir = mkdtempSync(path.join(tmpdir(), "claude-rewind-steer-"));
const previousConfigDir = process.env.CLAUDE_CONFIG_DIR;
process.env.CLAUDE_CONFIG_DIR = configDir;
const conversation = createConversation([{ assistantMessageId: "assistant-1" }]);
const rewindSdk = new FakeClaudeSdk();
const session = await createSession(conversation, rewindSdk);
try {
await runTurns(session, 1);
// A steered message opens no turn: Claude Code records it as a queued_command attachment
// under the uuid Paseo minted for it, hung off the entry it was read after.
const projectDir = await claudeProjectDir(process.cwd(), { configDir });
mkdirSync(projectDir, { recursive: true });
const entries = [
{ type: "user", uuid: conversation.userMessageIds[0], parentUuid: null },
{ type: "assistant", uuid: "assistant-1", parentUuid: conversation.userMessageIds[0] },
{ type: "user", uuid: "tool-result-1", parentUuid: "assistant-1" },
{
type: "attachment",
uuid: "queued-command-1",
parentUuid: "tool-result-1",
attachment: { type: "queued_command", source_uuid: "steered-message-1" },
},
];
writeFileSync(
path.join(projectDir, `${SESSION_ID}.jsonl`),
entries.map((entry) => `${JSON.stringify(entry)}\n`).join(""),
);
await session.revertConversation?.({ messageId: "steered-message-1" });
} finally {
await session.close();
if (previousConfigDir === undefined) {
delete process.env.CLAUDE_CONFIG_DIR;
} else {
process.env.CLAUDE_CONFIG_DIR = previousConfigDir;
}
rmSync(configDir, { recursive: true, force: true });
}
expect(rewindSdk.recordedForks).toEqual([{ upToMessageId: "tool-result-1" }]);
});
});
@@ -1224,16 +1224,17 @@ describe("ClaudeAgentSession features", () => {
const iterator = input[Symbol.asyncIterator](); const iterator = input[Symbol.asyncIterator]();
await expect(iterator.next()).resolves.toMatchObject({ value: { type: "user" } }); await expect(iterator.next()).resolves.toMatchObject({ value: { type: "user" } });
await expect( const admission = await session.steerActiveTurn?.("same turn follow-up", {
session.steerActiveTurn?.("same turn follow-up", { expectedTurnId: turnId,
expectedTurnId: turnId, clientMessageId: "steer-client-id",
clientMessageId: "steer-client-id", });
}), expect(admission).toEqual({ status: "accepted", providerMessageId: expect.any(String) });
).resolves.toEqual({ status: "accepted" }); if (admission?.status !== "accepted") throw new Error("Expected steer admission");
await expect(iterator.next()).resolves.toMatchObject({ await expect(iterator.next()).resolves.toMatchObject({
value: { value: {
type: "user", type: "user",
priority: "next", priority: "next",
uuid: admission.providerMessageId,
message: { content: [{ type: "text", text: "same turn follow-up" }] }, message: { content: [{ type: "text", text: "same turn follow-up" }] },
}, },
}); });
@@ -1291,7 +1292,7 @@ describe("ClaudeAgentSession features", () => {
expectedTurnId: turnId, expectedTurnId: turnId,
clearPendingPermissions: true, clearPendingPermissions: true,
}), }),
).resolves.toEqual({ status: "accepted" }); ).resolves.toEqual({ status: "accepted", providerMessageId: expect.any(String) });
await expect(firstPermission).resolves.toMatchObject({ await expect(firstPermission).resolves.toMatchObject({
behavior: "deny", behavior: "deny",
interrupt: undefined, interrupt: undefined,
@@ -1350,7 +1351,7 @@ describe("ClaudeAgentSession features", () => {
const permission = internal.handlePermissionRequest("Write", {}, { toolUseID: "tool-1" }); const permission = internal.handlePermissionRequest("Write", {}, { toolUseID: "tool-1" });
await expect( await expect(
session.steerActiveTurn?.("system notification", { expectedTurnId: turnId }), session.steerActiveTurn?.("system notification", { expectedTurnId: turnId }),
).resolves.toEqual({ status: "accepted" }); ).resolves.toEqual({ status: "accepted", providerMessageId: expect.any(String) });
expect(session.getPendingPermissions()).toHaveLength(1); expect(session.getPendingPermissions()).toHaveLength(1);
const requestId = session.getPendingPermissions()[0]!.id; const requestId = session.getPendingPermissions()[0]!.id;
await session.respondToPermission(requestId, { behavior: "deny", message: "test cleanup" }); await session.respondToPermission(requestId, { behavior: "deny", message: "test cleanup" });
@@ -82,6 +82,7 @@ import { renderPromptAttachmentAsText } from "../../prompt-attachments.js";
import { discoverClaudeModels } from "./model-discovery.js"; import { discoverClaudeModels } from "./model-discovery.js";
import { claudeQuery, type ClaudeOptions, type ClaudeQueryFactory } from "./query.js"; import { claudeQuery, type ClaudeOptions, type ClaudeQueryFactory } from "./query.js";
import { import {
findSteeredMessageParent,
realClaudeRewindSdk, realClaudeRewindSdk,
revertClaudeConversation, revertClaudeConversation,
revertClaudeFiles, revertClaudeFiles,
@@ -2627,7 +2628,7 @@ class ClaudeAgentSession implements AgentSession {
return { status: "unavailable" }; return { status: "unavailable" };
} }
this.enqueueSteer(input, message, options.clearPendingPermissions === true); this.enqueueSteer(input, message, options.clearPendingPermissions === true);
return { status: "accepted" }; return { status: "accepted", ...(message.uuid ? { providerMessageId: message.uuid } : {}) };
} }
private enqueueSteer( private enqueueSteer(
@@ -3094,7 +3095,7 @@ class ClaudeAgentSession implements AgentSession {
async revertConversation(input: { messageId: string }): Promise<void> { async revertConversation(input: { messageId: string }): Promise<void> {
await this.ensureHistoryReady(); await this.ensureHistoryReady();
const target = this.resolveConversationRewindTarget(input.messageId); const target = await this.resolveConversationRewindTarget(input.messageId);
if (target.kind === "fresh-session") { if (target.kind === "fresh-session") {
this.startFreshConversationSession(); this.startFreshConversationSession();
return; return;
@@ -3429,12 +3430,24 @@ class ClaudeAgentSession implements AgentSession {
return messageId; return messageId;
} }
private resolveConversationRewindTarget(messageId: string): ClaudeConversationRewindTarget { private async resolveConversationRewindTarget(
messageId: string,
): Promise<ClaudeConversationRewindTarget> {
const targetUserMessageId = this.resolveClaudeMessageId(messageId); const targetUserMessageId = this.resolveClaudeMessageId(messageId);
const index = this.rewindTurnAnchors.findIndex( const index = this.rewindTurnAnchors.findIndex(
(anchor) => anchor.userMessageId === targetUserMessageId, (anchor) => anchor.userMessageId === targetUserMessageId,
); );
if (index < 0) { if (index < 0) {
// A message steered into a running turn opens no turn of its own.
const transcriptPath = this.claudeSessionId
? await this.resolveHistoryPath(this.claudeSessionId)
: null;
const steerParent = transcriptPath
? await findSteeredMessageParent(transcriptPath, targetUserMessageId)
: null;
if (steerParent) {
return { kind: "fork", messageId: steerParent };
}
throw new Error(`Claude rewind target ${messageId} is not in the tracked conversation`); throw new Error(`Claude rewind target ${messageId} is not in the tracked conversation`);
} }
@@ -6431,6 +6444,10 @@ function convertClaudeHistoryEntryPreamble(
if (entry.type === "user" && isSyntheticHistoryUserEntry(entry)) { if (entry.type === "user" && isSyntheticHistoryUserEntry(entry)) {
return { shortCircuit: [] }; return { shortCircuit: [] };
} }
const steeredPrompt = readSteeredPromptHistoryItem(entry);
if (steeredPrompt) {
return { shortCircuit: [steeredPrompt] };
}
// Only user and assistant records are conversation turns. Claude Code 2.1.x also journals each // Only user and assistant records are conversation turns. Claude Code 2.1.x also journals each
// API request as `api-request-blob` records whose message replays the system context. // API request as `api-request-blob` records whose message replays the system context.
if (entry.type !== "user" && entry.type !== "assistant") { if (entry.type !== "user" && entry.type !== "assistant") {
@@ -6457,6 +6474,21 @@ function isProviderImageMessage(item: AgentTimelineItem): boolean {
return item.type === "assistant_message" && isProviderImageMarkdown(item.text); return item.type === "assistant_message" && isProviderImageMarkdown(item.text);
} }
/**
* A message steered into a running turn. Claude Code gives it no user entry: it records a
* queued_command attachment in prompt mode, under the uuid Paseo minted for the message, which is
* also what a rewind to it resolves. The other modes carry task notifications, not user messages.
*/
function readSteeredPromptHistoryItem(entry: ClaudeHistoryEntry): AgentTimelineItem | null {
if (entry.type !== "attachment") return null;
const attachment = toObjectRecord(entry.attachment);
if (attachment?.type !== "queued_command" || attachment.commandMode !== "prompt") return null;
const text = extractUserMessageText(attachment.prompt);
if (!text) return null;
const messageId = readTrimmedString(attachment.source_uuid);
return { type: "user_message", text, ...(messageId ? { messageId } : {}) };
}
export async function convertClaudeHistoryEntry( export async function convertClaudeHistoryEntry(
entry: ClaudeHistoryEntry, entry: ClaudeHistoryEntry,
mapBlocks: ( mapBlocks: (
@@ -1,11 +1,20 @@
import { randomUUID } from "node:crypto"; import { randomUUID } from "node:crypto";
import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import {
mkdirSync,
mkdtempSync,
readFileSync,
readdirSync,
readlinkSync,
rmSync,
writeFileSync,
} from "node:fs";
import { tmpdir } from "node:os"; import { tmpdir } from "node:os";
import path from "node:path"; import path from "node:path";
import { afterEach, describe, expect, test } from "vitest"; import { afterEach, describe, expect, test } from "vitest";
import type { Query } from "@anthropic-ai/claude-agent-sdk"; import type { Query } from "@anthropic-ai/claude-agent-sdk";
import { import {
findSteeredMessageParent,
revertClaudeConversation, revertClaudeConversation,
revertClaudeConversationAndFiles, revertClaudeConversationAndFiles,
revertClaudeFiles, revertClaudeFiles,
@@ -217,4 +226,96 @@ describe("Claude rewind against the Claude SDK", () => {
"first reply", "first reply",
]); ]);
}); });
test("forks just before a message steered into a running turn", async () => {
tempRoot = mkdtempSync(path.join(tmpdir(), "claude-rewind-steer-"));
delete process.env.CLAUDE_CONFIG_DIR;
const projectDir = path.join(tempRoot, "projects", "-repo");
mkdirSync(projectDir, { recursive: true });
const sessionId = randomUUID();
const firstUser = randomUUID();
const toolUse = randomUUID();
const toolResult = randomUUID();
const steeredMessage = randomUUID();
const queuedCommand = randomUUID();
const transcriptPath = path.join(projectDir, `${sessionId}.jsonl`);
writeTranscript(transcriptPath, sessionId, [
{ type: "user", uuid: firstUser, parentUuid: null, text: "first" },
{ type: "assistant", uuid: toolUse, parentUuid: firstUser, text: "running a tool" },
{ type: "user", uuid: toolResult, parentUuid: toolUse, text: "tool output" },
// What Claude Code writes when it reads a steered message between two tool calls.
JSON.stringify({
type: "attachment",
uuid: queuedCommand,
parentUuid: toolResult,
sessionId,
attachment: {
type: "queued_command",
prompt: [{ type: "text", text: "steered" }],
source_uuid: steeredMessage,
},
}),
{ type: "assistant", uuid: randomUUID(), parentUuid: queuedCommand, text: "reply to steer" },
]);
const parent = await findSteeredMessageParent(transcriptPath, steeredMessage);
expect(parent).toBe(toolResult);
expect(await findSteeredMessageParent(transcriptPath, randomUUID())).toBeNull();
let currentSessionId = sessionId;
await revertClaudeConversation({
sdk: realClaudeRewindSdk,
sessionId,
transcriptPath,
messageId: parent!,
setSessionId: (nextSessionId) => {
currentSessionId = nextSessionId;
},
});
expect(readMessageTexts(path.join(projectDir, `${currentSessionId}.jsonl`))).toEqual([
"first",
"running a tool",
"tool output",
]);
});
}); });
test.runIf(process.platform === "linux")(
"releases the transcript descriptor after an early steer match",
async () => {
const artifacts = path.resolve(".artifacts");
mkdirSync(artifacts, { recursive: true });
const root = mkdtempSync(path.join(artifacts, "claude-rewind-descriptor-"));
const transcriptPath = path.join(root, "session.jsonl");
const matchingEntry = {
type: "attachment",
parentUuid: "before-steer",
attachment: { type: "queued_command", source_uuid: "steered-message" },
};
const unreadEntry = JSON.stringify({ type: "assistant", message: "x".repeat(1024) });
writeFileSync(
transcriptPath,
JSON.stringify(matchingEntry) + "\n" + (unreadEntry + "\n").repeat(1200),
);
const openDescriptors = () =>
readdirSync("/proc/self/fd").filter((descriptor) => {
try {
return readlinkSync(path.join("/proc/self/fd", descriptor)) === transcriptPath;
} catch (error) {
if ((error as NodeJS.ErrnoException).code === "ENOENT") return false;
throw error;
}
}).length;
try {
expect(openDescriptors()).toBe(0);
for (let attempt = 0; attempt < 5; attempt++) {
await expect(findSteeredMessageParent(transcriptPath, "steered-message")).resolves.toBe(
"before-steer",
);
}
await expect.poll(openDescriptors, { timeout: 1000 }).toBe(0);
} finally {
rmSync(root, { recursive: true, force: true });
}
},
);
@@ -1,5 +1,7 @@
import { createReadStream } from "node:fs";
import { appendFile, readFile } from "node:fs/promises"; import { appendFile, readFile } from "node:fs/promises";
import path from "node:path"; import path from "node:path";
import { createInterface } from "node:readline";
import { import {
forkSession as claudeForkSession, forkSession as claudeForkSession,
type Query, type Query,
@@ -70,6 +72,54 @@ function isSessionStoreEntry(value: unknown): value is SessionStoreEntry {
); );
} }
/**
* The entry a message steered into a running turn follows. Claude Code gives such a message no
* entry of its own: it records a queued_command attachment, under the uuid Paseo minted for the
* message, hung off the entry it was read after. Forking at that entry drops the message and
* everything after it. Null when the transcript holds no such attachment.
*/
export async function findSteeredMessageParent(
transcriptPath: string,
messageId: string,
): Promise<string | null> {
const input = createReadStream(transcriptPath, { encoding: "utf8" });
const lines = createInterface({
input,
crlfDelay: Infinity,
});
try {
for await (const line of lines) {
if (!line.includes(messageId) || !line.includes('"queued_command"')) continue;
let entry: unknown;
try {
entry = JSON.parse(line);
} catch {
continue;
}
const record = entry as {
type?: unknown;
parentUuid?: unknown;
attachment?: { type?: unknown; source_uuid?: unknown };
};
if (
record.type === "attachment" &&
record.attachment?.type === "queued_command" &&
record.attachment.source_uuid === messageId &&
typeof record.parentUuid === "string"
) {
return record.parentUuid;
}
}
} catch (error) {
if ((error as NodeJS.ErrnoException).code === "ENOENT") return null;
throw error;
} finally {
lines.close();
input.destroy();
}
return null;
}
export async function revertClaudeConversation(input: { export async function revertClaudeConversation(input: {
sdk: ClaudeRewindSdk; sdk: ClaudeRewindSdk;
sessionId: string | null; sessionId: string | null;
@@ -80,8 +80,12 @@ describe("OpenCode bridge adapter", () => {
await rm(paseoHome, { recursive: true, force: true }); await rm(paseoHome, { recursive: true, force: true });
}); });
const runtime = new TestOpenCodeHarness(); const runtime = new TestOpenCodeHarness();
runtime.enqueueClient(new TestOpenCodeClient()); const envClient = new TestOpenCodeClient();
runtime.enqueueClient(new TestOpenCodeClient()); const mcpClient = new TestOpenCodeClient();
envClient.sessionCreateResponse = { data: { id: "ses_env" } };
mcpClient.sessionCreateResponse = { data: { id: "ses_mcp" } };
runtime.enqueueClient(envClient);
runtime.enqueueClient(mcpClient);
const client = new OpenCodeAgentClient(createTestLogger(), undefined, { const client = new OpenCodeAgentClient(createTestLogger(), undefined, {
serverManager: runtime, serverManager: runtime,
createClient: runtime.createClient, createClient: runtime.createClient,
@@ -102,6 +106,12 @@ describe("OpenCode bridge adapter", () => {
); );
expect(runtime.acquisitions.map(({ kind }) => kind)).toEqual(["dedicated", "dedicated"]); expect(runtime.acquisitions.map(({ kind }) => kind)).toEqual(["dedicated", "dedicated"]);
await expect(readBridgeContext(bridge, "ses_env")).resolves.toEqual({
env: { PASEO_AGENT_ID: "one", CUSTOM_TOKEN: "secret" },
});
await expect(readBridgeContext(bridge, "ses_mcp")).resolves.toEqual({
env: { PASEO_AGENT_ID: "two" },
});
await customEnv.close(); await customEnv.close();
await customMcp.close(); await customMcp.close();
}); });
@@ -20,6 +20,7 @@ import { createPaseoAgentToolManifest, createPaseoToolCatalog } from "../../tool
import { serializePaseoToolInputParameters } from "../../tools/paseo-tool-serialization.js"; import { serializePaseoToolInputParameters } from "../../tools/paseo-tool-serialization.js";
import { import {
OpenCodeBridge, OpenCodeBridge,
decorateOpenCodeV2Env,
loadOpenCodeBridgePluginArtifact, loadOpenCodeBridgePluginArtifact,
materializeOpenCodeV2Plugin, materializeOpenCodeV2Plugin,
} from "./bridge.js"; } from "./bridge.js";
@@ -137,33 +138,28 @@ describe("OpenCodeBridge", () => {
}; };
expect(ajv.compile(createDefinition.inputSchema)(createInput)).toBe(true); expect(ajv.compile(createDefinition.inputSchema)(createInput)).toBe(true);
const v2Config = z
.object({ plugins: z.array(z.object({ package: z.string() })) })
.parse(JSON.parse((await bridge.decorateV2ServerEnv({})).OPENCODE_CONFIG_CONTENT));
const v2Module: {
default: { setup(context: V2TestPluginContext): Promise<() => Promise<void>> };
} = await import(
pathToFileURL(path.join(fileURLToPath(v2Config.plugins[0]!.package), "server.js")).href
);
const tools = new Map<string, V2TestTool>(); const tools = new Map<string, V2TestTool>();
const dispose = await v2Module.default.setup({ const dispose = await setupBoundV2Plugin(
options: plugin, bridge,
tool: { {
transform: async (transform) => { tool: {
transform({ transform: async (transform) => {
add: (tool) => { transform({
tools.set(tool.name, tool); add: (tool) => {
}, tools.set(tool.name, tool);
}); },
return { dispose: async () => undefined }; });
return { dispose: async () => undefined };
},
},
session: {
context: async () => [],
get: async () => ({ parentID: "bound" }),
hook: async () => ({ dispose: async () => undefined }),
}, },
}, },
session: { ["bound"],
context: async () => [], );
get: async () => ({ parentID: "bound" }),
hook: async () => ({ dispose: async () => undefined }),
},
});
try { try {
const created = await tools const created = await tools
.get("paseo_create_agent")! .get("paseo_create_agent")!
@@ -402,45 +398,35 @@ describe("OpenCodeBridge", () => {
const release = bridge.bindSession({ sessionId: "parent", env: {}, tools: catalog }); const release = bridge.bindSession({ sessionId: "parent", env: {}, tools: catalog });
const releaseDisabled = bridge.bindSession({ sessionId: "disabled", env: {} }); const releaseDisabled = bridge.bindSession({ sessionId: "disabled", env: {} });
try { try {
const env = await bridge.decorateV2ServerEnv({}); const pluginUrl = await bridge.materializeV2Plugin();
const config = z const env = decorateOpenCodeV2Env({}, pluginUrl);
.object({ expect(decorateOpenCodeV2Env(env, pluginUrl)).toEqual(env);
plugins: z.array(
z.object({
package: z.string(),
options: z.object({ baseUrl: z.string(), token: z.string() }),
}),
),
})
.parse(JSON.parse(env.OPENCODE_CONFIG_CONTENT));
expect(await bridge.decorateV2ServerEnv(env)).toEqual(env);
const plugin = config.plugins[0]!;
const tools = new Map<string, V2TestTool>(); const tools = new Map<string, V2TestTool>();
let filter!: (input: V2TestContext) => Promise<void>; let filter!: (input: V2TestContext) => Promise<void>;
const module: { const dispose = await setupBoundV2Plugin(
default: { setup(context: V2TestPluginContext): Promise<() => Promise<void>> }; bridge,
} = await import(pathToFileURL(path.join(fileURLToPath(plugin.package), "server.js")).href); {
const dispose = await module.default.setup({ tool: {
options: plugin.options, transform: async (transform) => {
tool: { transform({
transform: async (transform) => { add: (tool) => {
transform({ tools.set(tool.name, tool);
add: (tool) => { },
tools.set(tool.name, tool); });
}, return { dispose: async () => undefined };
}); },
return { dispose: async () => undefined }; },
session: {
context: async () => [],
get: async ({ sessionID }) => ({ parentID: sessionID === "child" ? "parent" : "" }),
hook: async (_name, callback) => {
filter = callback;
return { dispose: async () => undefined };
},
}, },
}, },
session: { ["parent", "disabled"],
context: async () => [], );
get: async () => ({ parentID: "parent" }),
hook: async (_name, callback) => {
filter = callback;
return { dispose: async () => undefined };
},
},
});
const allowed: V2TestContext = { const allowed: V2TestContext = {
sessionID: "child", sessionID: "child",
tools: { paseo_echo_context: {}, native: {} }, tools: { paseo_echo_context: {}, native: {} },
@@ -453,6 +439,12 @@ describe("OpenCodeBridge", () => {
}; };
await filter(disabled); await filter(disabled);
expect(Object.keys(disabled.tools)).toEqual(["native"]); expect(Object.keys(disabled.tools)).toEqual(["native"]);
const unrelated: V2TestContext = {
sessionID: "unrelated",
tools: { paseo_echo_context: {}, native: {} },
};
await filter(unrelated);
expect(Object.keys(unrelated.tools)).toEqual(["native"]);
await expect( await expect(
tools.get("paseo_echo_context")!.execute({ value: "child result" }, { sessionID: "child" }), tools.get("paseo_echo_context")!.execute({ value: "child result" }, { sessionID: "child" }),
).resolves.toMatchObject({ content: [{ type: "text", text: "child result" }] }); ).resolves.toMatchObject({ content: [{ type: "text", text: "child result" }] });
@@ -486,35 +478,33 @@ describe("OpenCodeBridge", () => {
await bridge.start(); await bridge.start();
const release = bridge.bindSession({ sessionId: "caller", env: {}, tools: catalog }); const release = bridge.bindSession({ sessionId: "caller", env: {}, tools: catalog });
try { try {
const env = await bridge.decorateV2ServerEnv({}); const options = bridge.sharedV2Connection();
const { plugins } = JSON.parse(env.OPENCODE_CONFIG_CONTENT);
const plugin = plugins[0];
const module = await import(
pathToFileURL(path.join(fileURLToPath(plugin.package), "server.js")).href
);
const tools = new Map<string, V2TestTool>(); const tools = new Map<string, V2TestTool>();
const dispose = await module.default.setup({ const dispose = await setupBoundV2Plugin(
options: plugin.options, bridge,
tool: { {
transform: async (transform: (editor: { add(tool: V2TestTool): void }) => void) => { tool: {
transform({ add: (tool) => tools.set(tool.name, tool) }); transform: async (transform: (editor: { add(tool: V2TestTool): void }) => void) => {
return { dispose: async () => undefined }; transform({ add: (tool) => tools.set(tool.name, tool) });
return { dispose: async () => undefined };
},
},
session: {
context: async () => [],
get: async () => ({ parentID: "" }),
hook: async () => ({ dispose: async () => undefined }),
}, },
}, },
session: { ["caller"],
context: async () => [], );
get: async () => ({ parentID: "" }),
hook: async () => ({ dispose: async () => undefined }),
},
} satisfies V2TestPluginContext);
try { try {
const input = { provider: "codex", title: "Invalid provider", initialPrompt: "Hello" }; const input = { provider: "codex", title: "Invalid provider", initialPrompt: "Hello" };
const response = await fetch( const response = await fetch(
`${plugin.options.baseUrl}/_internal/opencode/sessions/caller/tools/create_agent`, `${options.baseUrl}/_internal/opencode/sessions/caller/tools/create_agent`,
{ {
method: "POST", method: "POST",
headers: { headers: {
Authorization: `Bearer ${plugin.options.token}`, Authorization: `Bearer ${options.token}`,
"Content-Type": "application/json", "Content-Type": "application/json",
}, },
body: JSON.stringify(input), body: JSON.stringify(input),
@@ -572,35 +562,24 @@ describe("OpenCodeBridge", () => {
await bridge.start(); await bridge.start();
const release = bridge.bindSession({ sessionId: "session", env: {}, tools: catalog }); const release = bridge.bindSession({ sessionId: "session", env: {}, tools: catalog });
try { try {
const env = await bridge.decorateV2ServerEnv({});
const plugin = z
.object({
plugins: z.array(
z.object({
package: z.string(),
options: z.object({ baseUrl: z.string(), token: z.string() }),
}),
),
})
.parse(JSON.parse(env.OPENCODE_CONFIG_CONTENT)).plugins[0]!;
const pluginTools = new Map<string, V2TestTool>(); const pluginTools = new Map<string, V2TestTool>();
const module: { const dispose = await setupBoundV2Plugin(
default: { setup(context: V2TestPluginContext): Promise<() => Promise<void>> }; bridge,
} = await import(pathToFileURL(path.join(fileURLToPath(plugin.package), "server.js")).href); {
const dispose = await module.default.setup({ tool: {
options: plugin.options, transform: async (transform) => {
tool: { transform({ add: (tool) => pluginTools.set(tool.name, tool) });
transform: async (transform) => { return { dispose: async () => undefined };
transform({ add: (tool) => pluginTools.set(tool.name, tool) }); },
return { dispose: async () => undefined }; },
session: {
context: async () => [],
get: async () => ({ parentID: undefined }),
hook: async () => ({ dispose: async () => undefined }),
}, },
}, },
session: { ["session"],
context: async () => [], );
get: async () => ({ parentID: undefined }),
hook: async () => ({ dispose: async () => undefined }),
},
});
const result = await pluginTools const result = await pluginTools
.get("paseo_browser_screenshot")! .get("paseo_browser_screenshot")!
.execute({}, { sessionID: "session" }); .execute({}, { sessionID: "session" });
@@ -642,7 +621,8 @@ describe("OpenCodeBridge", () => {
const tools = new Map<string, V2TestTool>(); const tools = new Map<string, V2TestTool>();
let hook!: (input: V2TestContext) => Promise<void>; let hook!: (input: V2TestContext) => Promise<void>;
const dispose = await module.default.setup({ const dispose = await module.default.setup({
options: { baseUrl: "", token: "" }, options: { mode: "structured-only" },
location: { directory: root },
tool: { tool: {
transform: async (transform) => { transform: async (transform) => {
transform({ transform({
@@ -657,7 +637,7 @@ describe("OpenCodeBridge", () => {
context: async () => history, context: async () => history,
get: async () => ({ parentID: "parent" }), get: async () => ({ parentID: "parent" }),
hook: async (_name, callback) => { hook: async (_name, callback) => {
hook = callback; hook ??= callback;
return { dispose: async () => undefined }; return { dispose: async () => undefined };
}, },
}, },
@@ -701,9 +681,13 @@ describe("OpenCodeBridge", () => {
name.startsWith("paseo-v2-"), name.startsWith("paseo-v2-"),
), ),
).toEqual([]); ).toEqual([]);
const env = await bridge.decorateV2ServerEnv({ const pluginUrl = await bridge.materializeV2Plugin();
OPENCODE_CONFIG_CONTENT: JSON.stringify({ plugins: ["user-plugin"] }), const env = decorateOpenCodeV2Env(
}); {
OPENCODE_CONFIG_CONTENT: JSON.stringify({ plugins: ["user-plugin"] }),
},
pluginUrl,
);
const config = z const config = z
.object({ plugins: z.tuple([z.literal("user-plugin"), z.object({ package: z.string() })]) }) .object({ plugins: z.tuple([z.literal("user-plugin"), z.object({ package: z.string() })]) })
.parse(JSON.parse(env.OPENCODE_CONFIG_CONTENT)); .parse(JSON.parse(env.OPENCODE_CONFIG_CONTENT));
@@ -711,6 +695,9 @@ describe("OpenCodeBridge", () => {
expect((await stat(directory)).isDirectory()).toBe(true); expect((await stat(directory)).isDirectory()).toBe(true);
const entry = await readFile(path.join(directory, "server.js"), "utf8"); const entry = await readFile(path.join(directory, "server.js"), "utf8");
expect(entry).toContain('id: "paseo"'); expect(entry).toContain('id: "paseo"');
expect(await readFile(path.join(directory, "state.mjs"), "utf8")).toBe(
await readFile(new URL("./v2/state.mjs", import.meta.url), "utf8"),
);
const manifest = JSON.parse(await readFile(path.join(directory, "package.json"), "utf8")); const manifest = JSON.parse(await readFile(path.join(directory, "package.json"), "utf8"));
expect(manifest).toMatchObject({ type: "module", exports: { "./server": "./server.js" } }); expect(manifest).toMatchObject({ type: "module", exports: { "./server": "./server.js" } });
} finally { } finally {
@@ -856,7 +843,8 @@ interface V2TestContext {
system?: Array<{ type: "text"; text: string }>; system?: Array<{ type: "text"; text: string }>;
} }
interface V2TestPluginContext { interface V2TestPluginContext {
options: { baseUrl: string; token: string }; options: Record<string, string>;
location?: { directory: string };
tool: { tool: {
transform( transform(
callback: (editor: { add(tool: V2TestTool): void }) => void, callback: (editor: { add(tool: V2TestTool): void }) => void,
@@ -864,10 +852,33 @@ interface V2TestPluginContext {
}; };
session: { session: {
context(input: { sessionID: string }): Promise<SessionMessageInfo[]>; context(input: { sessionID: string }): Promise<SessionMessageInfo[]>;
get(input: { sessionID: string }): Promise<{ parentID: string }>; get(input: { sessionID: string }): Promise<{ parentID: string | undefined }>;
hook( hook(
name: string, name: string,
callback: (input: V2TestContext) => Promise<void>, callback: (input: V2TestContext) => Promise<void>,
): Promise<{ dispose(): Promise<void> }>; ): Promise<{ dispose(): Promise<void> }>;
}; };
} }
async function setupBoundV2Plugin(
bridge: OpenCodeBridge,
context: Pick<V2TestPluginContext, "tool" | "session">,
sessionIds: string[],
): Promise<() => Promise<void>> {
const directory = fileURLToPath(await bridge.materializeV2Plugin());
const module = await import(pathToFileURL(path.join(directory, "server.js")).href);
let bind!: (input: { sessionID: string; baseUrl: string; token: string }) => Promise<unknown>;
const dispose: () => Promise<void> = await module.default.setup({
...context,
options: {},
location: { directory },
rpc: {
register: async (_definition: unknown, handlers: { bind: typeof bind }) => {
bind = handlers.bind;
return { dispose: async () => undefined };
},
},
});
for (const sessionID of sessionIds) await bind({ sessionID, ...bridge.sharedV2Connection() });
return dispose;
}
@@ -30,6 +30,7 @@ class OpenCodeBridgeClientError extends Error {
interface OpenCodeBridgeOptions { interface OpenCodeBridgeOptions {
paseoHome: string; paseoHome: string;
logger: Logger; logger: Logger;
port?: number;
} }
interface OpenCodeSessionBinding { interface OpenCodeSessionBinding {
@@ -54,6 +55,7 @@ interface OpenCodeConfig {
export class OpenCodeBridge { export class OpenCodeBridge {
private readonly paseoHome: string; private readonly paseoHome: string;
private readonly logger: Logger; private readonly logger: Logger;
private readonly port: number;
private readonly token = randomBytes(32).toString("hex"); private readonly token = randomBytes(32).toString("hex");
private readonly sessions = new Map<string, OpenCodeSessionBinding>(); private readonly sessions = new Map<string, OpenCodeSessionBinding>();
private server: Server | null = null; private server: Server | null = null;
@@ -65,6 +67,7 @@ export class OpenCodeBridge {
constructor(options: OpenCodeBridgeOptions) { constructor(options: OpenCodeBridgeOptions) {
this.paseoHome = options.paseoHome; this.paseoHome = options.paseoHome;
this.logger = options.logger.child({ module: "agent", component: "opencode-bridge" }); this.logger = options.logger.child({ module: "agent", component: "opencode-bridge" });
this.port = options.port ?? 0;
} }
async start(): Promise<void> { async start(): Promise<void> {
@@ -75,7 +78,7 @@ export class OpenCodeBridge {
}); });
await new Promise<void>((resolve, reject) => { await new Promise<void>((resolve, reject) => {
server.once("error", reject); server.once("error", reject);
server.listen(0, "127.0.0.1", () => { server.listen(this.port, "127.0.0.1", () => {
server.off("error", reject); server.off("error", reject);
resolve(); resolve();
}); });
@@ -94,6 +97,8 @@ export class OpenCodeBridge {
} }
bindSession(input: BindOpenCodeSessionInput): () => void { bindSession(input: BindOpenCodeSessionInput): () => void {
if (this.sessions.has(input.sessionId))
throw new Error(`OpenCode session ${input.sessionId} is already bound to a Paseo agent`);
const binding: OpenCodeSessionBinding = { const binding: OpenCodeSessionBinding = {
env: { ...input.env }, env: { ...input.env },
...(input.tools ? { tools: input.tools } : {}), ...(input.tools ? { tools: input.tools } : {}),
@@ -127,12 +132,13 @@ export class OpenCodeBridge {
}; };
} }
async decorateV2ServerEnv(env: Record<string, string>): Promise<Record<string, string>> { async materializeV2Plugin(): Promise<string> {
this.v2PluginUrl ??= await this.materializePlugin(2); this.v2PluginUrl ??= await this.materializePlugin(2);
return decorateOpenCodeV2Env(env, this.v2PluginUrl, { return this.v2PluginUrl;
baseUrl: this.requireBaseUrl(), }
token: this.token,
}); sharedV2Connection(baseUrl = this.requireBaseUrl()): OpenCodePluginOptions {
return { baseUrl, token: this.token };
} }
async close(): Promise<void> { async close(): Promise<void> {
@@ -282,6 +288,8 @@ export async function materializeOpenCodeV2Plugin(paseoHome: string): Promise<st
// V2 ignores configured file paths; it loads a package directory's server entry point. // V2 ignores configured file paths; it loads a package directory's server entry point.
const directory = path.join(paseoHome, "runtime", "opencode", `paseo-v2-${digest}`); const directory = path.join(paseoHome, "runtime", "opencode", `paseo-v2-${digest}`);
await writeFileAtomic(path.join(directory, "server.js"), artifact); await writeFileAtomic(path.join(directory, "server.js"), artifact);
const state = await readFile(new URL("./v2/state.mjs", import.meta.url));
await writeFileAtomic(path.join(directory, "state.mjs"), state);
await writeFileAtomic( await writeFileAtomic(
path.join(directory, "package.json"), path.join(directory, "package.json"),
JSON.stringify({ JSON.stringify({
@@ -66,6 +66,7 @@ test("retries a rejected version probe after the configured binary changes", asy
const script = join(root, "version.cjs"); const script = join(root, "version.cjs");
const client = new OpenCodeRuntimeClient(createTestLogger(), { const client = new OpenCodeRuntimeClient(createTestLogger(), {
command: { mode: "replace", argv: [process.execPath, script] }, command: { mode: "replace", argv: [process.execPath, script] },
env: { XDG_STATE_HOME: root },
}); });
try { try {
await writeFile(script, 'console.log("opencode v2.0.3")'); await writeFile(script, 'console.log("opencode v2.0.3")');
@@ -95,6 +96,7 @@ test.each([
const script = join(root, "version.cjs"); const script = join(root, "version.cjs");
const client = new OpenCodeRuntimeClient(createTestLogger(), { const client = new OpenCodeRuntimeClient(createTestLogger(), {
command: { mode: "replace", argv: [process.execPath, script] }, command: { mode: "replace", argv: [process.execPath, script] },
env: { XDG_STATE_HOME: root },
}); });
try { try {
await writeFile(script, initialSource); await writeFile(script, initialSource);
@@ -193,6 +195,7 @@ test.each([
logger, logger,
{ {
command: { mode: "replace", argv: [process.execPath, script] }, command: { mode: "replace", argv: [process.execPath, script] },
env: { XDG_STATE_HOME: root },
}, },
{ serverManager: new TrackedServerManager({ logger }) }, { serverManager: new TrackedServerManager({ logger }) },
); );
@@ -209,3 +212,38 @@ test.each([
}, },
10000, 10000,
); );
test("selects v2 without probing the binary when an external server is configured", async () => {
const { OpenCodeRuntimeClient } = await import("./runtime-client.js");
const { createTestLogger } = await import("../../../../test-utils/test-logger.js");
const client = new OpenCodeRuntimeClient(createTestLogger(), {
command: { mode: "replace", argv: [process.execPath, "-e", "process.exit(1)"] },
env: { PASEO_OPENCODE_SERVER_URL: "http://127.0.0.1:4096" },
});
try {
const features = await client.listFeatures({ provider: "opencode", cwd: "/tmp/project" });
expect(features.map((feature) => feature.label)).toEqual(["Auto-accept"]);
} finally {
await client.shutdown();
}
});
test("keeps native Paseo tools available when an external server is configured", async () => {
const { OpenCodeRuntimeClient } = await import("./runtime-client.js");
const { createTestLogger } = await import("../../../../test-utils/test-logger.js");
const { OpenCodeBridge } = await import("./bridge.js");
const bridge = new OpenCodeBridge({ paseoHome: "/tmp", logger: createTestLogger() });
const external = new OpenCodeRuntimeClient(
createTestLogger(),
{ env: { PASEO_OPENCODE_SERVER_URL: "http://127.0.0.1:4096" } },
{ bridge },
);
const local = new OpenCodeRuntimeClient(createTestLogger(), undefined, { bridge });
try {
expect(external.capabilities.supportsNativePaseoTools).toBe(true);
expect(local.capabilities.supportsNativePaseoTools).toBe(true);
} finally {
await external.shutdown();
await local.shutdown();
}
});
@@ -19,7 +19,9 @@ import {
import { execCommand } from "../../../../utils/spawn.js"; import { execCommand } from "../../../../utils/spawn.js";
import { OpenCodeAgentClient } from "../opencode-agent.js"; import { OpenCodeAgentClient } from "../opencode-agent.js";
import type { OpenCodeV2AgentClient } from "./v2/agent.js"; import type { OpenCodeV2AgentClient } from "./v2/agent.js";
import { resolveExternalOpenCodeServer } from "./v2/external-server.js";
import { withOpenCodeRuntimeNotice } from "./runtime-notice.js"; import { withOpenCodeRuntimeNotice } from "./runtime-notice.js";
import { selectOpenCodeService } from "./v2/service-selection.js";
// Keep the minimum aligned with the SDK and binary exercised by CI. // Keep the minimum aligned with the SDK and binary exercised by CI.
const MINIMUM_V2: readonly [number, number] = [0, 10]; const MINIMUM_V2: readonly [number, number] = [0, 10];
@@ -67,8 +69,25 @@ export class OpenCodeRuntimeClient implements AgentClient {
this.resolveCreateConfig = this.legacy.resolveCreateConfig; this.resolveCreateConfig = this.legacy.resolveCreateConfig;
this.isCreateConfigUnattended = this.legacy.isCreateConfigUnattended; this.isCreateConfigUnattended = this.legacy.isCreateConfigUnattended;
} }
private selectService() {
return selectOpenCodeService({
explicit: resolveExternalOpenCodeServer({ ...globalThis.process.env, ...this.settings?.env }),
env: this.settings?.env,
});
}
private client(): Promise<OpenCodeAgentClient | OpenCodeV2AgentClient> { private client(): Promise<OpenCodeAgentClient | OpenCodeV2AgentClient> {
this.selected ??= (async () => { this.selected ??= (async () => {
const selection = await this.selectService();
if (selection.kind !== "owned") {
const { OpenCodeV2AgentClient } = await import("./v2/agent.js");
return new OpenCodeV2AgentClient({
logger: this.logger,
settings: this.settings,
managedProcesses: this.options.managedProcesses,
bridge: this.options.bridge,
selection,
});
}
let output: string; let output: string;
try { try {
const launch = await resolveProviderLaunch({ const launch = await resolveProviderLaunch({
@@ -97,6 +116,7 @@ export class OpenCodeRuntimeClient implements AgentClient {
settings: this.settings, settings: this.settings,
managedProcesses: this.options.managedProcesses, managedProcesses: this.options.managedProcesses,
bridge: this.options.bridge, bridge: this.options.bridge,
selection,
}); });
})().catch((error: unknown) => { })().catch((error: unknown) => {
// A failed probe must not poison refresh after the user updates the binary. // A failed probe must not poison refresh after the user updates the binary.
@@ -106,9 +126,14 @@ export class OpenCodeRuntimeClient implements AgentClient {
return this.selected; return this.selected;
} }
async isAvailable() { async isAvailable() {
const selection = await this.selectService();
if (selection.kind !== "owned") return true;
return this.legacy.isAvailable(); return this.legacy.isAvailable();
} }
async getDiagnostic() { async getDiagnostic() {
const selection = await this.selectService();
if (selection.kind !== "owned")
return { diagnostic: `OpenCode server: ${selection.server.url}` };
return this.legacy.getDiagnostic(); return this.legacy.getDiagnostic();
} }
async createSession( async createSession(
@@ -172,6 +172,9 @@ export class V2Harness {
event: { subscribe: (options) => this.events(options?.signal) }, event: { subscribe: (options) => this.events(options?.signal) },
}; };
readonly connection: V2Connection = { readonly connection: V2Connection = {
bindSession: async () => {},
unbindSession: async () => {},
refreshLocation: async () => {},
client: this.api, client: this.api,
release: async () => { release: async () => {
this.releases += 1; this.releases += 1;
@@ -17,6 +17,26 @@ function terminalEvents(events: AgentStreamEvent[]) {
} }
describe("OpenCode v2 session lifecycle", () => { describe("OpenCode v2 session lifecycle", () => {
test("refreshes the existing location before reading a forced catalog", async () => {
const harness = new V2Harness();
const refreshed: string[] = [];
harness.connection.refreshLocation = async (directory) => {
refreshed.push(directory);
};
harness.api.model.list = async () => ({ location: harness.info.location, data: [] });
harness.api.provider.list = async () => ({
location: harness.info.location,
data: [{ id: "test", name: "Test", activation: "enabled", package: "test" }],
});
const client = new OpenCodeV2AgentClient({
logger: createTestLogger(),
runtime: harness.runtime,
});
await client.fetchCatalog({ scope: "workspace", cwd: "/tmp/project", force: true });
await client.fetchCatalog({ scope: "workspace", cwd: "/tmp/project", force: false });
expect(refreshed).toEqual(["/tmp/project"]);
await client.shutdown();
});
test("completes from execution events without a long-lived HTTP wait", async () => { test("completes from execution events without a long-lived HTTP wait", async () => {
const harness = new V2Harness(); const harness = new V2Harness();
harness.prompt = async (input) => { harness.prompt = async (input) => {
@@ -8,7 +8,7 @@ import {
} from "./configuration.js"; } from "./configuration.js";
import { commands } from "./commands.js"; import { commands } from "./commands.js";
import { waitForLocationReady, awaitPaseoPlugin } from "./readiness.js"; import { waitForLocationReady } from "./readiness.js";
import type { SessionInfo } from "@opencode/client"; import type { SessionInfo } from "@opencode/client";
@@ -40,6 +40,7 @@ import { importSessionFromPersistence } from "../../../provider-session-import.j
import type { OpenCodeBridge } from "../bridge.js"; import type { OpenCodeBridge } from "../bridge.js";
import { resolveOpenCodeHomeDir } from "../paths.js"; import { resolveOpenCodeHomeDir } from "../paths.js";
import { V2Runtime, type V2Connection } from "./runtime.js"; import { V2Runtime, type V2Connection } from "./runtime.js";
import type { OpenCodeServiceSelection } from "./service-selection.js";
import { modelRef, modesFromV2, modelsFromV2 } from "./mapping.js"; import { modelRef, modesFromV2, modelsFromV2 } from "./mapping.js";
interface V2AgentOptions { interface V2AgentOptions {
@@ -48,6 +49,7 @@ interface V2AgentOptions {
managedProcesses?: ManagedProcessRegistry; managedProcesses?: ManagedProcessRegistry;
bridge?: OpenCodeBridge; bridge?: OpenCodeBridge;
runtime?: Pick<V2Runtime, "acquire" | "shutdown">; runtime?: Pick<V2Runtime, "acquire" | "shutdown">;
selection?: OpenCodeServiceSelection;
} }
export class OpenCodeV2AgentClient implements AgentClient { export class OpenCodeV2AgentClient implements AgentClient {
@@ -56,12 +58,12 @@ export class OpenCodeV2AgentClient implements AgentClient {
private readonly runtime: Pick<V2Runtime, "acquire" | "shutdown">; private readonly runtime: Pick<V2Runtime, "acquire" | "shutdown">;
private readonly connections = new Map<string, V2Connection>(); private readonly connections = new Map<string, V2Connection>();
constructor(private readonly options: V2AgentOptions) { constructor(private readonly options: V2AgentOptions) {
this.capabilities = { ...V2_CAPABILITIES, supportsNativePaseoTools: Boolean(options.bridge) }; const bridge = options.bridge;
this.capabilities = { ...V2_CAPABILITIES, supportsNativePaseoTools: Boolean(bridge) };
this.runtime = this.runtime =
options.runtime ?? options.runtime ??
new V2Runtime({ new V2Runtime({
...options, ...options,
decorateEnv: options.bridge ? (env) => options.bridge!.decorateV2ServerEnv(env) : undefined,
}); });
} }
async isAvailable() { async isAvailable() {
@@ -85,6 +87,10 @@ export class OpenCodeV2AgentClient implements AgentClient {
await activity("plugin.list", () => await activity("plugin.list", () =>
waitForLocationReady({ client: connection.client, location, signal: request.signal }), waitForLocationReady({ client: connection.client, location, signal: request.signal }),
); );
if (options.force)
await activity("paseo.bridge.refresh", () =>
connection.refreshLocation(location.directory, request.signal),
);
const [models, agents, providers] = await Promise.all([ const [models, agents, providers] = await Promise.all([
activity("model.list", () => connection.client.model.list({ location }, request)), activity("model.list", () => connection.client.model.list({ location }, request)),
activity("agent.list", () => connection.client.agent.list({ location }, request)), activity("agent.list", () => connection.client.agent.list({ location }, request)),
@@ -163,7 +169,8 @@ export class OpenCodeV2AgentClient implements AgentClient {
requiresDedicatedV2Server(config, launch) ? { env: launch?.env, dedicated: true } : {}, requiresDedicatedV2Server(config, launch) ? { env: launch?.env, dedicated: true } : {},
); );
let ownedConnection = connection; let ownedConnection = connection;
const unbind = this.options.bridge?.bindSession({ const bridge = this.options.bridge;
const unbind = bridge?.bindSession({
sessionId: info.id, sessionId: info.id,
env: launch?.env ?? {}, env: launch?.env ?? {},
tools: launch?.paseoTools, tools: launch?.paseoTools,
@@ -172,7 +179,7 @@ export class OpenCodeV2AgentClient implements AgentClient {
const bindChild = (childId: string) => { const bindChild = (childId: string) => {
this.connections.set(childId, ownedConnection); this.connections.set(childId, ownedConnection);
if (bound.has(childId)) return; if (bound.has(childId)) return;
const childUnbind = this.options.bridge?.bindSession({ const childUnbind = bridge?.bindSession({
sessionId: childId, sessionId: childId,
env: launch?.env ?? {}, env: launch?.env ?? {},
tools: launch?.paseoTools, tools: launch?.paseoTools,
@@ -204,10 +211,6 @@ export class OpenCodeV2AgentClient implements AgentClient {
moved, moved,
); );
try { try {
if (this.options.bridge) {
const location = { directory: config.cwd };
await awaitPaseoPlugin({ client: connection.client, location });
}
await session.initialize( await session.initialize(
launch?.env launch?.env
? createProviderEnv({ runtimeSettings: this.options.settings, overlays: [launch.env] }) ? createProviderEnv({ runtimeSettings: this.options.settings, overlays: [launch.env] })
@@ -1,100 +1,15 @@
import { registerStructuredOutput, STRUCTURED_OUTPUT_TOOL } from "./structured-output-plugin.mjs"; import { registerStructuredOutput } from "./structured-output-plugin.mjs";
import { registerSharedBridge } from "./shared-bridge-plugin.mjs";
export default { export default {
id: "paseo", id: "paseo",
async setup(context) { async setup(context) {
const disposeStructuredOutput = await registerStructuredOutput(context); const disposeStructuredOutput = await registerStructuredOutput(context);
const { baseUrl, token } = context.options; if (context.options.mode === "structured-only") return disposeStructuredOutput;
if (!baseUrl || !token) return disposeStructuredOutput; const disposeBridge = await registerSharedBridge(context);
async function request(pathname, body, allowMissing = false) {
const response = await fetch(new URL(`/_internal/opencode${pathname}`, baseUrl), {
method: body === undefined ? "GET" : "POST",
headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" },
...(body === undefined ? {} : { body: JSON.stringify(body) }),
});
if (allowMissing && response.status === 404) return null;
if (!response.ok) {
const payload = await response.json().catch(() => null);
const detail = formatErrorDetail(payload?.issues ?? payload?.details);
const message =
typeof payload?.error === "string" && payload.error
? payload.error
: `Paseo tool bridge returned HTTP ${response.status}`;
const error = new Error(detail ? `${message}: ${detail}` : message);
error.status = response.status;
if (detail) error.details = detail;
throw error;
}
return response.json();
}
async function scope(sessionID) {
const visited = new Set();
while (sessionID && !visited.has(sessionID)) {
visited.add(sessionID);
const binding = await request(
`/sessions/${encodeURIComponent(sessionID)}/context?tools`,
undefined,
true,
);
if (binding) return { ...binding, sessionID };
const session = await context.session.get({ sessionID });
sessionID = session.parentID;
}
throw new Error("OpenCode session is not bound to a Paseo agent");
}
const manifest = await request("/tools");
const registration = await context.tool.transform((editor) => {
for (const definition of manifest.tools) {
editor.add({
name: `paseo_${definition.name}`,
description: definition.description,
input: definition.inputSchema,
options: { codemode: false },
async execute(input, call) {
const binding = await scope(call.sessionID);
const result = await request(
`/sessions/${encodeURIComponent(binding.sessionID)}/tools/${encodeURIComponent(definition.name)}`,
input,
);
return {
content: result.content.map(toOpenCodeContent),
metadata: { paseoTool: definition.name },
};
},
});
}
});
const filtering = await context.session.hook("context", async (input) => {
const binding = await scope(input.sessionID);
const allowed = new Set(binding.tools.map((name) => `paseo_${name}`));
for (const name of Object.keys(input.tools)) {
if (name !== STRUCTURED_OUTPUT_TOOL && name.startsWith("paseo_") && !allowed.has(name))
delete input.tools[name];
}
});
return async () => { return async () => {
await disposeBridge();
await disposeStructuredOutput(); await disposeStructuredOutput();
await filtering.dispose();
await registration.dispose();
}; };
}, },
}; };
function formatErrorDetail(details) {
if (!Array.isArray(details)) return typeof details === "string" ? details : "";
return details
.map((issue) => {
const path =
Array.isArray(issue?.path) && issue.path.length > 0 ? issue.path.join(".") : "input";
return `${path}: ${issue?.message ?? "Invalid value"}`;
})
.join("; ");
}
// OpenCode tool content is text or a file; MCP images arrive as base64 data.
function toOpenCodeContent(part) {
if (part.type === "image") {
return { type: "file", uri: `data:${part.mimeType};base64,${part.data}`, mime: part.mimeType };
}
return part;
}
@@ -0,0 +1,37 @@
import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import { expect, test } from "vitest";
import { hasManagedBridge, provisionLocalBridge } from "./bridge-provisioning.js";
test("installs a watched plugin without changing existing OpenCode configuration", async () => {
const root = await mkdtemp(path.join(tmpdir(), "paseo-opencode-provision-"));
const source = path.join(root, "source.js");
const config = path.join(root, "opencode.json");
await writeFile(source, "export default { id: 'paseo' };");
await writeFile(path.join(root, "state.mjs"), "export const state = new Map();");
await writeFile(config, '{"plugins":["existing"]}\n');
try {
expect(await hasManagedBridge(root)).toBe(false);
await Promise.all([
provisionLocalBridge({ configDirectory: root, artifact: source }),
provisionLocalBridge({ configDirectory: root, artifact: source }),
]);
expect(await hasManagedBridge(root)).toBe(true);
const manifest = path.join(root, "plugins/paseo-managed-bridge/package.json");
const formatted = `${JSON.stringify(JSON.parse(await readFile(manifest, "utf8")), null, 2)}\n`;
await writeFile(manifest, formatted);
expect(await hasManagedBridge(root)).toBe(true);
await provisionLocalBridge({ configDirectory: root, artifact: source });
expect(await readFile(manifest, "utf8")).toBe(formatted);
expect(await readFile(config, "utf8")).toBe('{"plugins":["existing"]}\n');
expect(await readFile(path.join(root, "plugins/paseo-managed-bridge/state.mjs"), "utf8")).toBe(
"export const state = new Map();",
);
expect(
await readFile(path.join(root, "plugins/paseo-managed-bridge/server.js"), "utf8"),
).toContain("id: 'paseo'");
} finally {
await rm(root, { recursive: true, force: true });
}
});
@@ -0,0 +1,89 @@
import { mkdir, readFile } from "node:fs/promises";
import { homedir } from "node:os";
import path from "node:path";
import { setTimeout as delay } from "node:timers/promises";
import { writeFileAtomic } from "../../../../atomic-file.js";
const PACKAGE = JSON.stringify({
name: "paseo-managed-bridge",
private: true,
type: "module",
exports: { "./server": "./server.js" },
});
export function openCodeConfigDirectory(env: NodeJS.ProcessEnv): string {
return (
env.OPENCODE_CONFIG_DIR ??
path.join(env.XDG_CONFIG_HOME ?? path.join(homedir(), ".config"), "opencode")
);
}
export async function hasManagedBridge(configDirectory: string): Promise<boolean> {
const directory = path.join(configDirectory, "plugins", "paseo-managed-bridge");
const [manifest, artifact] = await Promise.all([
readFile(path.join(directory, "package.json"), "utf8").catch(() => ""),
readFile(path.join(directory, "server.js")).catch(() => undefined),
]);
return isManagedPackage(manifest) && Boolean(artifact?.length);
}
export async function provisionLocalBridge(input: {
configDirectory: string;
artifact: string;
}): Promise<void> {
const directory = path.join(input.configDirectory, "plugins", "paseo-managed-bridge");
await mkdir(path.dirname(directory), { recursive: true });
let created = false;
try {
await mkdir(directory);
created = true;
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error;
}
const manifest = path.join(directory, "package.json");
if (created) await writeFileAtomic(manifest, PACKAGE);
else if (!isManagedPackage(await readManagedManifest(manifest)))
throw new Error(`Cannot install OpenCode bridge: ${directory} is not managed by Paseo`);
const artifact = await readFile(input.artifact);
const state = await readFile(path.join(path.dirname(input.artifact), "state.mjs"));
const stateTarget = path.join(directory, "state.mjs");
const existingState = await readFile(stateTarget).catch(() => undefined);
if (!existingState) await writeFileAtomic(stateTarget, state);
else if (!existingState.equals(state))
throw new Error(`Cannot update OpenCode bridge state module at ${stateTarget}`);
const target = path.join(directory, "server.js");
const existing = await readFile(target).catch(() => undefined);
if (!existing?.equals(artifact)) await writeFileAtomic(target, artifact);
}
function isManagedPackage(content: string): boolean {
let value: unknown;
try {
value = JSON.parse(content);
} catch {
return false;
}
if (!value || typeof value !== "object" || Array.isArray(value)) return false;
const manifest = value as Record<string, unknown>;
if (!manifest.exports || typeof manifest.exports !== "object" || Array.isArray(manifest.exports))
return false;
const exports = manifest.exports as Record<string, unknown>;
return (
manifest.name === "paseo-managed-bridge" &&
manifest.private === true &&
manifest.type === "module" &&
exports["./server"] === "./server.js"
);
}
async function readManagedManifest(file: string): Promise<string> {
for (let attempt = 0; attempt < 20; attempt++) {
try {
return await readFile(file, "utf8");
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
await delay(25);
}
}
return "";
}
@@ -0,0 +1,180 @@
import { createServer, type Server } from "node:http";
import { createServer as createNetServer } from "node:net";
import { afterEach, describe, expect, test } from "vitest";
import { createTestLogger } from "../../../../../test-utils/test-logger.js";
import { V2Runtime } from "./runtime.js";
import { EXTERNAL_SERVER_PASSWORD_ENV, EXTERNAL_SERVER_URL_ENV } from "./external-server.js";
function listen(server: Server): Promise<number> {
return new Promise((resolve, reject) => {
server.once("error", reject);
server.listen(0, "127.0.0.1", () => {
const address = server.address();
if (!address || typeof address === "string") {
reject(new Error("Test server did not expose a TCP address"));
return;
}
resolve(address.port);
});
});
}
function close(server: Server): Promise<void> {
return new Promise((resolve, reject) => {
server.close((error) => {
if (error) {
reject(error);
return;
}
resolve();
});
server.closeAllConnections();
});
}
function reserveClosedPort(): Promise<number> {
return new Promise((resolve, reject) => {
const server = createNetServer();
server.once("error", reject);
server.listen(0, "127.0.0.1", () => {
const address = server.address();
if (!address || typeof address === "string") {
server.close();
reject(new Error("Could not reserve a test port"));
return;
}
server.close((error) => {
if (error) {
reject(error);
return;
}
resolve(address.port);
});
});
});
}
describe("V2Runtime against an external server", () => {
let server: Server | undefined;
afterEach(async () => {
if (server) await close(server);
server = undefined;
});
test("reuses the configured server and never spawns a local one", async () => {
const requests: Array<{ url: string | undefined; authorization: string | undefined }> = [];
server = createServer((request, response) => {
requests.push({ url: request.url, authorization: request.headers.authorization });
if (request.url !== "/api/info") {
response.writeHead(404);
response.end();
return;
}
response.writeHead(200, { "content-type": "application/json" });
response.end(JSON.stringify({ version: "2.0.10", pid: 1, urls: [], paths: { tmp: "/tmp" } }));
});
const port = await listen(server);
const runtime = new V2Runtime({
logger: createTestLogger(),
externalServer: { url: `http://127.0.0.1:${port}`, password: "secret" },
});
const first = await runtime.acquire();
const second = await runtime.acquire();
expect(first.client).toBe(second.client);
expect(requests).toEqual([
{
url: "/api/info",
authorization: `Basic ${Buffer.from("opencode:secret").toString("base64")}`,
},
]);
await first.release();
await second.release();
const refreshed = await runtime.acquire({ fresh: true });
expect(refreshed.client).toBe(first.client);
await refreshed.release();
await runtime.shutdown();
expect(requests).toHaveLength(1);
});
test("selects the external server from provider environment", async () => {
const paths: Array<string | undefined> = [];
server = createServer((request, response) => {
paths.push(request.url);
if (request.url !== "/api/info") {
response.writeHead(404);
response.end();
return;
}
response.writeHead(200, { "content-type": "application/json" });
response.end(JSON.stringify({ version: "2.0.10", pid: 1, urls: [], paths: { tmp: "/tmp" } }));
});
const port = await listen(server);
const runtime = new V2Runtime({
logger: createTestLogger(),
settings: {
env: {
[EXTERNAL_SERVER_URL_ENV]: `http://127.0.0.1:${port}`,
[EXTERNAL_SERVER_PASSWORD_ENV]: "",
},
},
});
const connection = await runtime.acquire();
expect(paths).toEqual(["/api/info"]);
await connection.release();
await runtime.shutdown();
});
test("reports an unreachable configured server", async () => {
const port = await reserveClosedPort();
const runtime = new V2Runtime({
logger: createTestLogger(),
externalServer: { url: `http://127.0.0.1:${port}` },
});
await expect(runtime.acquire()).rejects.toThrow(
`Cannot reach OpenCode v2 server at http://127.0.0.1:${port}`,
);
await runtime.shutdown();
});
test("rejects session configuration that requires process isolation", async () => {
const runtime = new V2Runtime({
logger: createTestLogger(),
externalServer: { url: "http://127.0.0.1:1" },
});
await expect(runtime.acquire({ dedicated: true })).rejects.toThrow(
"require an isolated server",
);
await runtime.shutdown();
});
test("rejects provider process environment on an explicit server", async () => {
const runtime = new V2Runtime({
logger: createTestLogger(),
externalServer: { url: "http://127.0.0.1:1" },
settings: { env: { XDG_CONFIG_HOME: "/custom", PROVIDER_KEY: "secret" } },
});
await expect(runtime.acquire()).rejects.toThrow("XDG_CONFIG_HOME, PROVIDER_KEY");
await runtime.shutdown();
});
test("rejects an unsupported explicit server version", async () => {
server = createServer((_request, response) => {
response.writeHead(200, { "content-type": "application/json" });
response.end(JSON.stringify({ version: "2.0.9", pid: 1, urls: [], paths: { tmp: "/tmp" } }));
});
const port = await listen(server);
const runtime = new V2Runtime({
logger: createTestLogger(),
externalServer: { url: `http://127.0.0.1:${port}` },
});
await expect(runtime.acquire()).rejects.toThrow("Unsupported OpenCode version 2.0.9");
await runtime.shutdown();
});
});
@@ -0,0 +1,60 @@
import { describe, expect, test } from "vitest";
import {
EXTERNAL_SERVER_PASSWORD_ENV,
EXTERNAL_SERVER_URL_ENV,
resolveExternalOpenCodeServer,
} from "./external-server.js";
describe("resolveExternalOpenCodeServer", () => {
test("returns undefined when no URL is configured", () => {
expect(resolveExternalOpenCodeServer(undefined)).toBeUndefined();
expect(resolveExternalOpenCodeServer({ [EXTERNAL_SERVER_URL_ENV]: " " })).toBeUndefined();
});
test("reads the URL and password and removes a trailing slash", () => {
expect(
resolveExternalOpenCodeServer({
[EXTERNAL_SERVER_URL_ENV]: " http://127.0.0.1:4096/ ",
[EXTERNAL_SERVER_PASSWORD_ENV]: "secret",
}),
).toEqual({ url: "http://127.0.0.1:4096", password: "secret" });
});
test("omits an empty password", () => {
expect(
resolveExternalOpenCodeServer({
[EXTERNAL_SERVER_URL_ENV]: "https://opencode.example.com",
[EXTERNAL_SERVER_PASSWORD_ENV]: "",
}),
).toEqual({ url: "https://opencode.example.com" });
});
test("preserves spaces in the password", () => {
expect(
resolveExternalOpenCodeServer({
[EXTERNAL_SERVER_URL_ENV]: "https://opencode.example.com",
[EXTERNAL_SERVER_PASSWORD_ENV]: " secret ",
}),
).toEqual({ url: "https://opencode.example.com", password: " secret " });
});
test("rejects relative and non-HTTP URLs", () => {
expect(() =>
resolveExternalOpenCodeServer({ [EXTERNAL_SERVER_URL_ENV]: "127.0.0.1:4096" }),
).toThrow(`${EXTERNAL_SERVER_URL_ENV} must be an absolute URL, received "127.0.0.1:4096"`);
expect(() =>
resolveExternalOpenCodeServer({ [EXTERNAL_SERVER_URL_ENV]: "unix:/tmp/opencode.sock" }),
).toThrow(
`${EXTERNAL_SERVER_URL_ENV} must use http or https, received "unix:/tmp/opencode.sock"`,
);
});
test("rejects credentials embedded in URLs", () => {
expect(() =>
resolveExternalOpenCodeServer({
[EXTERNAL_SERVER_URL_ENV]: "https://user:password@opencode.example.com",
}),
).toThrow("must not contain credentials");
});
});
@@ -0,0 +1,38 @@
export const EXTERNAL_SERVER_URL_ENV = "PASEO_OPENCODE_SERVER_URL";
export const EXTERNAL_SERVER_PASSWORD_ENV = "PASEO_OPENCODE_SERVER_PASSWORD";
export interface ExternalOpenCodeServer {
url: string;
password?: string;
}
/**
* Reads the optional connection to a user-managed OpenCode v2 server from
* provider environment. When absent, Paseo keeps spawning its own server.
*/
export function resolveExternalOpenCodeServer(
env: Record<string, string | undefined> | undefined,
): ExternalOpenCodeServer | undefined {
const rawUrl = env?.[EXTERNAL_SERVER_URL_ENV]?.trim();
if (!rawUrl) return undefined;
let url: URL;
try {
url = new URL(rawUrl);
} catch {
throw new Error(`${EXTERNAL_SERVER_URL_ENV} must be an absolute URL, received "${rawUrl}"`);
}
if (url.protocol !== "http:" && url.protocol !== "https:") {
throw new Error(`${EXTERNAL_SERVER_URL_ENV} must use http or https, received "${rawUrl}"`);
}
if (url.username || url.password)
throw new Error(
`${EXTERNAL_SERVER_URL_ENV} must not contain credentials; use ${EXTERNAL_SERVER_PASSWORD_ENV}`,
);
const password = env?.[EXTERNAL_SERVER_PASSWORD_ENV];
return {
url: url.toString().replace(/\/$/, ""),
...(password ? { password } : {}),
};
}
@@ -0,0 +1,77 @@
import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { expect, test } from "vitest";
import { createTestLogger } from "../../../../../test-utils/test-logger.js";
import { OpenCodeBridge } from "../bridge.js";
import { provisionLocalBridge } from "./bridge-provisioning.js";
import { V2Runtime } from "./runtime.js";
test("an owned helper uses an installed bridge once and survives catalog refresh", async () => {
const root = await mkdtemp(path.join(tmpdir(), "paseo-owned-opencode-"));
const config = path.join(root, "config", "opencode");
const script = path.join(root, "server.cjs");
const record = path.join(root, "env.json");
const bridge = new OpenCodeBridge({ paseoHome: root, logger: createTestLogger() });
await bridge.start();
const artifact = fileURLToPath(await bridge.materializeV2Plugin());
await provisionLocalBridge({
configDirectory: config,
artifact: path.join(artifact, "server.js"),
});
await writeFile(
script,
`
const http = require('node:http');
require('node:fs').writeFileSync(${JSON.stringify(record)}, process.env.OPENCODE_CONFIG_CONTENT);
const server = http.createServer((_request, response) => {
response.writeHead(200, { 'content-type': 'application/json' });
response.end(JSON.stringify({ version: '2.0.10', pid: process.pid, urls: [], paths: { tmp: '/tmp' } }));
});
server.listen(0, '127.0.0.1', () => console.log('server listening on http://127.0.0.1:' + server.address().port));
`,
);
const runtime = new V2Runtime({
logger: createTestLogger(),
bridge,
selection: { kind: "owned" },
settings: {
command: { mode: "replace", argv: [process.execPath, script] },
env: { XDG_CONFIG_HOME: path.join(root, "config"), OPENCODE_CONFIG_CONTENT: "{}" },
},
});
try {
const first = await runtime.acquire();
const pid = (await first.client.server.info()).pid;
expect(JSON.parse(await readFile(record, "utf8"))).toEqual({});
await first.release();
const refreshed = await runtime.acquire({ fresh: true });
expect((await refreshed.client.server.info()).pid).toBe(pid);
await refreshed.release();
} finally {
await runtime.shutdown();
await bridge.close();
await rm(root, { recursive: true, force: true });
}
}, 15_000);
test("reports a bridge port change that has not been applied", async () => {
const root = await mkdtemp(path.join(tmpdir(), "paseo-opencode-port-"));
const bridge = new OpenCodeBridge({ paseoHome: root, logger: createTestLogger() });
await bridge.start();
const actual = Number(new URL(bridge.sharedV2Connection().baseUrl).port);
const runtime = new V2Runtime({
logger: createTestLogger(),
bridge,
selection: { kind: "explicit", server: { url: "http://127.0.0.1:1" } },
settings: { env: { PASEO_OPENCODE_BRIDGE_PORT: actual === 1 ? "2" : "1" } },
});
try {
await expect(runtime.acquire()).rejects.toThrow("restart Paseo to apply the bridge port");
} finally {
await runtime.shutdown();
await bridge.close();
await rm(root, { recursive: true, force: true });
}
});
@@ -16,11 +16,25 @@ import type { ManagedProcessRegistry } from "../../../../managed-processes/manag
import { resolveOpenCodeHomeDir } from "../paths.js"; import { resolveOpenCodeHomeDir } from "../paths.js";
import { OpenCodeHttpError } from "../http-error.js"; import { OpenCodeHttpError } from "../http-error.js";
import { raceProviderRefreshAbort } from "../../../provider-refresh-deadline.js"; import { raceProviderRefreshAbort } from "../../../provider-refresh-deadline.js";
import { resolveExternalOpenCodeServer, type ExternalOpenCodeServer } from "./external-server.js";
import { selectOpenCodeService, type OpenCodeServiceSelection } from "./service-selection.js";
import { bridgeBindings, connectSharedService } from "./shared-service.js";
import {
hasManagedBridge,
openCodeConfigDirectory,
provisionLocalBridge,
} from "./bridge-provisioning.js";
import path from "node:path";
import { fileURLToPath } from "node:url";
import type { OpenCodeBridge } from "../bridge.js";
export interface V2Connection { export interface V2Connection {
client: V2Api; client: V2Api;
release(): Promise<void>; release(): Promise<void>;
retain(): V2Connection; retain(): V2Connection;
bindSession(sessionId: string, cwd: string): Promise<void>;
unbindSession(sessionId: string, cwd: string): Promise<void>;
refreshLocation(cwd: string, signal?: AbortSignal): Promise<void>;
readonly exited: Promise<Error>; readonly exited: Promise<Error>;
} }
interface Generation { interface Generation {
@@ -28,12 +42,17 @@ interface Generation {
users: number; users: number;
stop(): Promise<void>; stop(): Promise<void>;
exited: Promise<Error>; exited: Promise<Error>;
bindSession(sessionId: string, cwd: string): Promise<void>;
unbindSession(sessionId: string, cwd: string): Promise<void>;
refreshLocation(cwd: string, signal?: AbortSignal): Promise<void>;
} }
export interface V2RuntimeOptions { export interface V2RuntimeOptions {
logger: Logger; logger: Logger;
settings?: ProviderRuntimeSettings; settings?: ProviderRuntimeSettings;
managedProcesses?: ManagedProcessRegistry; managedProcesses?: ManagedProcessRegistry;
decorateEnv?: (env: Record<string, string>) => Promise<Record<string, string>>; externalServer?: ExternalOpenCodeServer;
selection?: OpenCodeServiceSelection;
bridge?: OpenCodeBridge;
} }
export interface V2RuntimeAcquireOptions { export interface V2RuntimeAcquireOptions {
@@ -49,17 +68,36 @@ export class V2Runtime {
private generations = new Set<Generation>(); private generations = new Set<Generation>();
private starts = new Set<Promise<Generation>>(); private starts = new Set<Promise<Generation>>();
private closed = false; private closed = false;
private readonly selection: Promise<OpenCodeServiceSelection>;
constructor(private readonly options: V2RuntimeOptions) {} constructor(private readonly options: V2RuntimeOptions) {
this.selection = options.selection
? Promise.resolve(options.selection)
: selectOpenCodeService({
explicit:
options.externalServer ??
resolveExternalOpenCodeServer({
...globalThis.process.env,
...options.settings?.env,
}),
env: options.settings?.env,
});
}
async acquire(input: V2RuntimeAcquireOptions = {}): Promise<V2Connection> { async acquire(input: V2RuntimeAcquireOptions = {}): Promise<V2Connection> {
if (this.closed) throw new Error("OpenCode runtime is closed"); if (this.closed) throw new Error("OpenCode runtime is closed");
input.signal?.throwIfAborted(); input.signal?.throwIfAborted();
this.assertBridgePort();
const selection = await raceProviderRefreshAbort(input.signal, this.selection);
let pending: Promise<Generation>; let pending: Promise<Generation>;
if (input.env || input.dedicated) { if (selection.kind !== "owned") {
this.assertSharedConfiguration(input, selection);
this.current ??= this.startTracked();
pending = this.current;
} else if (input.env || input.dedicated) {
pending = this.startTracked(input.env); pending = this.startTracked(input.env);
} else { } else {
if (input.fresh || !this.current) this.current = this.startTracked(); if (!this.current) this.current = this.startTracked();
pending = this.current; pending = this.current;
} }
let generation: Generation; let generation: Generation;
@@ -97,6 +135,39 @@ export class V2Runtime {
return connection; return connection;
} }
private assertBridgePort(): void {
const port = this.options.settings?.env?.PASEO_OPENCODE_BRIDGE_PORT;
if (!port || !this.options.bridge) return;
const actual = new URL(this.options.bridge.sharedV2Connection().baseUrl).port;
if (Number(port) !== Number(actual))
throw new Error("PASEO_OPENCODE_BRIDGE_PORT changed; restart Paseo to apply the bridge port");
}
private assertSharedConfiguration(
input: V2RuntimeAcquireOptions,
selection: OpenCodeServiceSelection,
): void {
const connectionEnv = new Set([
"PASEO_OPENCODE_SERVER_URL",
"PASEO_OPENCODE_SERVER_PASSWORD",
"PASEO_OPENCODE_BRIDGE_URL",
"PASEO_OPENCODE_BRIDGE_PORT",
]);
if (selection.kind === "discovered") {
connectionEnv.add("XDG_CONFIG_HOME");
connectionEnv.add("XDG_STATE_HOME");
connectionEnv.add("OPENCODE_CONFIG_DIR");
}
const processOverrides = Object.keys(this.options.settings?.env ?? {}).filter(
(key) => !connectionEnv.has(key),
);
if (input.env || input.dedicated || processOverrides.length) {
throw new Error(
`Custom OpenCode MCP servers or process environment require an isolated server and cannot be used with a shared OpenCode service${processOverrides.length ? `: ${processOverrides.join(", ")}` : ""}.`,
);
}
}
private lease(generation: Generation, pending: Promise<Generation>): V2Connection { private lease(generation: Generation, pending: Promise<Generation>): V2Connection {
if (this.closed || !this.generations.has(generation)) if (this.closed || !this.generations.has(generation))
throw new Error("OpenCode helper server is no longer running"); throw new Error("OpenCode helper server is no longer running");
@@ -107,7 +178,7 @@ export class V2Runtime {
released = true; released = true;
generation.users -= 1; generation.users -= 1;
if (generation.users !== 0) return; if (generation.users !== 0) return;
if (this.current === pending) this.current = null; if (this.current === pending) return;
this.generations.delete(generation); this.generations.delete(generation);
await generation.stop(); await generation.stop();
}; };
@@ -115,6 +186,9 @@ export class V2Runtime {
client: generation.client, client: generation.client,
release, release,
retain: () => this.lease(generation, pending), retain: () => this.lease(generation, pending),
bindSession: generation.bindSession,
unbindSession: generation.unbindSession,
refreshLocation: generation.refreshLocation,
exited: generation.exited, exited: generation.exited,
}; };
} }
@@ -146,6 +220,16 @@ export class V2Runtime {
} }
private async start(env: Record<string, string> = {}): Promise<Generation> { private async start(env: Record<string, string> = {}): Promise<Generation> {
const selection = await this.selection;
if (selection.kind !== "owned") {
const generation: Generation = {
...(await connectSharedService(selection, this.options)),
users: 0,
};
this.generations.add(generation);
return generation;
}
const { settings, managedProcesses, logger } = this.options; const { settings, managedProcesses, logger } = this.options;
const launch = await resolveProviderLaunch({ const launch = await resolveProviderLaunch({
commandConfig: settings?.command, commandConfig: settings?.command,
@@ -160,9 +244,20 @@ export class V2Runtime {
...settings?.env, ...settings?.env,
...env, ...env,
}; };
const decorated = this.options.decorateEnv const bridge = this.options.bridge;
? await this.options.decorateEnv(configured) const plugin = bridge
: decorateOpenCodeV2Env(configured, await materializeOpenCodeV2Plugin(resolvePaseoHome())); ? await bridge.materializeV2Plugin()
: await materializeOpenCodeV2Plugin(resolvePaseoHome());
const globalConfig = openCodeConfigDirectory({ ...globalThis.process.env, ...configured });
const managed = await hasManagedBridge(globalConfig);
if (managed && bridge)
await provisionLocalBridge({
configDirectory: globalConfig,
artifact: path.join(fileURLToPath(plugin), "server.js"),
});
const decorated = managed
? configured
: decorateOpenCodeV2Env(configured, plugin, bridge ? {} : { mode: "structured-only" });
const args = [...launch.args, "serve", "--hostname", "127.0.0.1", "--port", "0"]; const args = [...launch.args, "serve", "--hostname", "127.0.0.1", "--port", "0"];
const process = spawnProcess(launch.command, args, { const process = spawnProcess(launch.command, args, {
cwd, cwd,
@@ -253,7 +348,13 @@ export class V2Runtime {
}, },
}); });
await client.server.info({ signal: AbortSignal.timeout(Math.max(1, deadline - Date.now())) }); await client.server.info({ signal: AbortSignal.timeout(Math.max(1, deadline - Date.now())) });
const generation: Generation = { client, users: 0, stop, exited }; const generation: Generation = {
client,
users: 0,
stop,
exited,
...bridgeBindings(client, bridge),
};
this.generations.add(generation); this.generations.add(generation);
process.once("exit", () => { process.once("exit", () => {
this.generations.delete(generation); this.generations.delete(generation);
@@ -0,0 +1,55 @@
import { createServer } from "node:http";
import { mkdtemp, rm, writeFile } from "node:fs/promises";
import path from "node:path";
import { tmpdir } from "node:os";
import { expect, test } from "vitest";
import { selectOpenCodeService } from "./service-selection.js";
test("selects the registered service with its credential and ignores stale registration", async () => {
const directory = await mkdtemp(path.join(tmpdir(), "paseo-service-"));
const file = path.join(directory, "service.json");
let requests = 0;
const server = createServer((request, response) => {
requests++;
if (
request.headers.authorization !== `Basic ${Buffer.from("opencode:secret").toString("base64")}`
) {
response.writeHead(401).end();
return;
}
response.writeHead(200, { "Content-Type": "application/json" });
response.end(JSON.stringify({ version: "2.0.10", pid: process.pid }));
});
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
const address = server.address();
if (!address || typeof address === "string") throw new Error("No test address");
const url = `http://127.0.0.1:${address.port}`;
try {
await writeFile(
file,
JSON.stringify({ url, password: "secret", pid: process.pid, version: "2.0.10" }),
);
expect(await selectOpenCodeService({ file })).toEqual({
kind: "discovered",
server: { url, password: "secret" },
});
expect(requests).toBe(1);
expect(
await selectOpenCodeService({ file, explicit: { url: "http://explicit.test" } }),
).toEqual({ kind: "explicit", server: { url: "http://explicit.test" } });
expect(requests).toBe(1);
await writeFile(file, JSON.stringify({ url, password: "secret", pid: process.pid + 1 }));
expect(await selectOpenCodeService({ file })).toEqual({ kind: "owned" });
} finally {
await new Promise<void>((resolve, reject) => {
server.close((error) => {
if (error) {
reject(error);
return;
}
resolve();
});
});
await rm(directory, { recursive: true, force: true });
}
});
@@ -0,0 +1,43 @@
import { Service } from "@opencode/client/service";
import { homedir, networkInterfaces } from "node:os";
import path from "node:path";
import type { ExternalOpenCodeServer } from "./external-server.js";
export type OpenCodeServiceSelection =
| { kind: "owned" }
| { kind: "explicit" | "discovered"; server: ExternalOpenCodeServer };
export async function selectOpenCodeService(input: {
explicit?: ExternalOpenCodeServer;
file?: string;
env?: Record<string, string | undefined>;
}): Promise<OpenCodeServiceSelection> {
if (input.explicit) return { kind: "explicit", server: input.explicit };
const state =
input.env?.XDG_STATE_HOME ??
process.env.XDG_STATE_HOME ??
path.join(homedir(), ".local", "state");
const endpoint = await Service.discover({
file: input.file ?? path.join(state, "opencode", "service.json"),
version: compatibleVersion,
}).catch(() => undefined);
if (!endpoint) return { kind: "owned" };
if (!isLocalOpenCodeHost(new URL(endpoint.url).hostname)) return { kind: "owned" };
return {
kind: "discovered",
server: { url: endpoint.url, ...(endpoint.auth ? { password: endpoint.auth.password } : {}) },
};
}
export function isLocalOpenCodeHost(input: string): boolean {
const hostname = input.replace(/^\[|\]$/g, "");
const localAddresses = Object.values(networkInterfaces()).flatMap((items) =>
(items ?? []).map((item) => item.address),
);
return ["localhost", "127.0.0.1", "::1", "0.0.0.0", "::", ...localAddresses].includes(hostname);
}
export function compatibleVersion(version: string): boolean {
const match = /^2\.(\d+)\.(\d+)(?:[-+].*)?$/.exec(version);
return Boolean(match && (Number(match[1]) > 0 || Number(match[2]) >= 10));
}
@@ -146,6 +146,7 @@ export class OpenCodeV2Session implements AgentSession {
} }
private async configureConnection() { private async configureConnection() {
const location = { directory: this.config.cwd }; const location = { directory: this.config.cwd };
await this.connection.bindSession(this.id, this.config.cwd);
await waitForLocationReady({ client: this.client, location, signal: this.abort.signal }); await waitForLocationReady({ client: this.client, location, signal: this.abort.signal });
if (this.requiresPaseoPlugin) if (this.requiresPaseoPlugin)
await awaitPaseoPlugin({ client: this.client, location, signal: this.abort.signal }); await awaitPaseoPlugin({ client: this.client, location, signal: this.abort.signal });
@@ -207,6 +208,11 @@ export class OpenCodeV2Session implements AgentSession {
if (!this.acquire) throw new Error("OpenCode helper server exited"); if (!this.acquire) throw new Error("OpenCode helper server exited");
await this.stream; await this.stream;
await this.sync.catch(() => undefined); await this.sync.catch(() => undefined);
await this.connection
.unbindSession(this.id, this.config.cwd)
.catch((error: unknown) =>
this.logger.warn({ error }, "Could not unbind the OpenCode Paseo bridge"),
);
const next = await this.acquire(); const next = await this.acquire();
if (this.closed) { if (this.closed) {
await next.release(); await next.release();
@@ -386,6 +392,7 @@ export class OpenCodeV2Session implements AgentSession {
} }
async startTurn(prompt: AgentPromptInput, options?: AgentRunOptions) { async startTurn(prompt: AgentPromptInput, options?: AgentRunOptions) {
await this.reconnectIfExited(); await this.reconnectIfExited();
await this.connection.bindSession(this.id, this.config.cwd);
return this.turns.startTurn(prompt, options); return this.turns.startTurn(prompt, options);
} }
steerActiveTurn(prompt: AgentPromptInput, options: SteerActiveTurnOptions): Promise<SteerResult> { steerActiveTurn(prompt: AgentPromptInput, options: SteerActiveTurnOptions): Promise<SteerResult> {
@@ -436,6 +443,7 @@ export class OpenCodeV2Session implements AgentSession {
} }
} }
private async reconcileConnection() { private async reconcileConnection() {
await this.connection.bindSession(this.id, this.config.cwd);
// Session environments are process-local. Reapply this agent's snapshot before // Session environments are process-local. Reapply this agent's snapshot before
// reconciling a new connection, including event-stream reconnections. // reconciling a new connection, including event-stream reconnections.
if (this.launchEnv) if (this.launchEnv)
@@ -560,6 +568,11 @@ export class OpenCodeV2Session implements AgentSession {
await this.stream; await this.stream;
await this.reconnecting?.catch(() => undefined); await this.reconnecting?.catch(() => undefined);
await this.sync.catch(() => undefined); await this.sync.catch(() => undefined);
await this.connection
.unbindSession(this.id, this.config.cwd)
.catch((error: unknown) =>
this.logger.warn({ error }, "Could not unbind the OpenCode Paseo bridge"),
);
this.unbind?.(); this.unbind?.();
try { try {
if (!this.persist) await this.client.session.remove({ sessionID: this.id }); if (!this.persist) await this.client.session.remove({ sessionID: this.id });
@@ -0,0 +1,207 @@
import { STRUCTURED_OUTPUT_TOOL } from "./structured-output-plugin.mjs";
// OpenCode owns this plugin for the lifetime of a location. Paseo supplies only
// live, per-session bindings over the authenticated OpenCode RPC channel.
export async function registerSharedBridge(context) {
const state = await import(new URL("./state.mjs", import.meta.url).href);
const { bindings, definitions } = state.stateFor(context.location);
const registrations = new Map();
let updating = Promise.resolve();
async function request(binding, pathname, body, allowMissing = false, signal) {
const requestSignal = body === undefined ? AbortSignal.timeout(5_000) : signal;
const response = await fetch(new URL(`/_internal/opencode${pathname}`, binding.baseUrl), {
method: body === undefined ? "GET" : "POST",
headers: { Authorization: `Bearer ${binding.token}`, "Content-Type": "application/json" },
signal: requestSignal,
...(body === undefined ? {} : { body: JSON.stringify(body) }),
});
if (allowMissing && response.status === 404) return null;
const payload = await response.json().catch(() => null);
if (!response.ok) {
const detail = formatErrorDetail(payload?.issues ?? payload?.details);
const message = payload?.error ?? `Paseo tool bridge returned HTTP ${response.status}`;
const error = new Error(detail ? `${message}: ${detail}` : message);
error.status = response.status;
if (detail) error.details = detail;
throw error;
}
return payload;
}
async function find(sessionID) {
const seen = new Set();
while (sessionID && !seen.has(sessionID)) {
seen.add(sessionID);
const binding = bindings.get(sessionID);
if (binding) return { ...binding, sessionID };
sessionID = (await context.session.get({ sessionID })).parentID;
}
return null;
}
async function install(binding) {
const manifest = await request(binding, "/tools");
for (const definition of manifest.tools) await installDefinition(definition);
}
async function installDefinition(definition) {
const signature = JSON.stringify(definition);
const known = definitions.get(definition.name);
if (known && JSON.stringify(known) !== signature)
throw new Error(`Conflicting Paseo tool definition: ${definition.name}`);
const old = registrations.get(definition.name);
if (old) {
if (old.signature !== signature)
throw new Error(`Conflicting Paseo tool definition: ${definition.name}`);
return;
}
const registration = await context.tool.transform((editor) => {
editor.add({
name: `paseo_${definition.name}`,
description: definition.description,
input: definition.inputSchema,
options: { codemode: false },
async execute(input, call) {
const scope = await find(call.sessionID);
if (!scope) throw new Error("OpenCode session is not bound to a Paseo agent");
const result = await request(
scope,
`/sessions/${encodeURIComponent(scope.sessionID)}/tools/${encodeURIComponent(definition.name)}`,
input,
false,
call.signal,
);
return {
content: result.content.map(toOpenCodeContent),
metadata: { paseoTool: definition.name },
};
},
});
});
registrations.set(definition.name, { signature, registration });
definitions.set(definition.name, definition);
}
async function clearUnusedDefinitions() {
if (bindings.size) return;
await Promise.all(
[...registrations.values()].map(({ registration }) => registration.dispose()),
);
registrations.clear();
definitions.clear();
}
// Reinstall live tools before this plugin generation is made visible.
// Definitions are kept in memory, so unavailable owners cannot delay reload.
for (const definition of definitions.values()) await installDefinition(definition);
const object = { type: "object", additionalProperties: true };
const rpc = await context.rpc.register(
{
id: "paseo.bridge",
methods: {
bind: { input: object, output: object },
unbind: { input: object, output: object },
refresh: { input: object, output: object },
},
events: {},
},
{
bind: async (input) => {
if (
typeof input.sessionID !== "string" ||
typeof input.baseUrl !== "string" ||
typeof input.token !== "string"
)
throw new Error("Invalid Paseo bridge binding");
const next = updating
.catch(() => undefined)
.then(async () => {
const previous = bindings.get(input.sessionID);
if (
previous &&
(previous.baseUrl !== input.baseUrl || previous.token !== input.token)
) {
let active;
try {
active = await request(
previous,
`/sessions/${encodeURIComponent(input.sessionID)}/context`,
undefined,
true,
);
} catch (error) {
// Refusal or rejected credentials mean this endpoint no longer
// serves the previous binding. Other failures are inconclusive.
if (
error?.status !== 401 &&
error?.cause?.code !== "ECONNREFUSED" &&
error?.code !== "ConnectionRefused"
)
throw error;
}
if (active)
return { error: "OpenCode session is already bound to another Paseo bridge" };
bindings.delete(input.sessionID);
await clearUnusedDefinitions();
}
await install(input);
bindings.set(input.sessionID, { baseUrl: input.baseUrl, token: input.token });
return {};
});
updating = next;
return await next;
},
unbind: async (input) => {
const next = updating
.catch(() => undefined)
.then(async () => {
const binding = bindings.get(input.sessionID);
if (binding && binding.token === input.token && binding.baseUrl === input.baseUrl)
bindings.delete(input.sessionID);
await clearUnusedDefinitions();
return {};
});
updating = next;
await next;
return {};
},
refresh: async () => {
await context.provider.reload();
await context.model.reload();
await context.agent.reload();
await context.command.reload();
await context.skill.reload();
return {};
},
},
);
const filter = await context.session.hook("context", async (input) => {
const binding = await find(input.sessionID);
const scope =
binding &&
(await request(binding, `/sessions/${encodeURIComponent(binding.sessionID)}/context?tools`));
const allowed = new Set((scope?.tools ?? []).map((name) => `paseo_${name}`));
for (const name of Object.keys(input.tools)) {
if (name.startsWith("paseo_") && name !== STRUCTURED_OUTPUT_TOOL && !allowed.has(name))
delete input.tools[name];
}
});
return async () => {
await filter.dispose();
await rpc.dispose();
await Promise.all(
[...registrations.values()].map(({ registration }) => registration.dispose()),
);
};
}
function toOpenCodeContent(part) {
if (part.type === "image")
return { type: "file", uri: `data:${part.mimeType};base64,${part.data}`, mime: part.mimeType };
return part;
}
function formatErrorDetail(details) {
if (!Array.isArray(details)) return typeof details === "string" ? details : "";
return details
.map((issue) => {
const path = Array.isArray(issue?.path) && issue.path.length ? issue.path.join(".") : "input";
return `${path}: ${issue?.message ?? "Invalid value"}`;
})
.join("; ");
}
@@ -0,0 +1,168 @@
import { mkdtemp, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import { fileURLToPath, pathToFileURL } from "node:url";
import { expect, test } from "vitest";
import { z } from "zod";
import { createTestLogger } from "../../../../../test-utils/test-logger.js";
import { OpenCodeBridge } from "../bridge.js";
interface Binding {
sessionID: string;
baseUrl: string;
token: string;
}
interface TestTool {
name: string;
execute(input: { value: string }, call: { sessionID: string }): Promise<unknown>;
}
interface TestContext {
sessionID: string;
tools: Record<string, unknown>;
system: unknown[];
}
test("a shared plugin binds Paseo sessions without involving unrelated sessions", async () => {
const home = await mkdtemp(path.join(tmpdir(), "paseo-shared-bridge-"));
const bridge = new OpenCodeBridge({ paseoHome: home, logger: createTestLogger() });
const tool = { name: "echo", description: "echo", inputSchema: { value: z.string() } };
const tools = new Map([[tool.name, tool]]);
bridge.setManifestCatalog({ tools });
await bridge.start();
const unbind = bridge.bindSession({
sessionId: "paseo",
env: {},
tools: {
tools,
getTool: (name) => tools.get(name),
executeTool: async (_name, input) => ({
content: [{ type: "text", text: String((input as { value: string }).value) }],
}),
},
});
expect(() => bridge.bindSession({ sessionId: "paseo", env: {} })).toThrow("already bound");
try {
const plugin = {
package: await bridge.materializeV2Plugin(),
options: bridge.sharedV2Connection(),
};
const module = await import(
pathToFileURL(path.join(fileURLToPath(plugin.package), "server.js")).href
);
const installed = new Map<string, TestTool>();
const hooks: Array<(input: TestContext) => Promise<void>> = [];
const refreshed: string[] = [];
let structured = false;
let rpc: Record<string, (input: Binding) => Promise<unknown>> = {};
const context = {
options: {},
location: { directory: home },
provider: {
reload: async () => {
refreshed.push("provider");
},
},
model: {
reload: async () => {
refreshed.push("model");
},
},
agent: {
reload: async () => {
refreshed.push("agent");
},
},
command: {
reload: async () => {
refreshed.push("command");
},
},
skill: {
reload: async () => {
refreshed.push("skill");
},
},
rpc: {
register: async (_definition: unknown, handlers: typeof rpc) => {
rpc = handlers;
return { dispose: async () => {} };
},
},
tool: {
transform: async (fn: (editor: { add(item: TestTool): void }) => void) => {
fn({ add: (item) => installed.set(item.name, item) });
return { dispose: async () => {} };
},
},
session: {
context: async () =>
structured ? [{ type: "user", metadata: { paseoOutputSchema: { type: "string" } } }] : [],
get: async () => ({ parentID: "" }),
hook: async (_name: string, fn: (input: TestContext) => Promise<void>) => {
hooks.push(fn);
return { dispose: async () => {} };
},
},
};
let dispose = await module.default.setup(context);
await rpc.bind({ sessionID: "paseo", ...plugin.options });
await rpc.refresh({ sessionID: "paseo", ...plugin.options });
expect(refreshed).toEqual(["provider", "model", "agent", "command", "skill"]);
await expect(
rpc.bind({ sessionID: "paseo", ...plugin.options, token: "another-daemon" }),
).resolves.toEqual({ error: "OpenCode session is already bound to another Paseo bridge" });
const own = { sessionID: "paseo", tools: { paseo_echo: {}, native: {} }, system: [] };
for (const hook of hooks) await hook(own);
expect(Object.keys(own.tools)).toEqual(["paseo_echo", "native"]);
expect(
await installed.get("paseo_echo")!.execute({ value: "ok" }, { sessionID: "paseo" }),
).toMatchObject({ content: [{ type: "text", text: "ok" }] });
await dispose();
hooks.length = 0;
installed.clear();
dispose = await module.default.setup(context);
const afterReload = { sessionID: "paseo", tools: { paseo_echo: {}, native: {} }, system: [] };
for (const hook of hooks) await hook(afterReload);
expect(Object.keys(afterReload.tools)).toEqual(["paseo_echo", "native"]);
expect(
await installed.get("paseo_echo")!.execute({ value: "reloaded" }, { sessionID: "paseo" }),
).toMatchObject({ content: [{ type: "text", text: "reloaded" }] });
await bridge.close();
await dispose();
hooks.length = 0;
installed.clear();
dispose = await module.default.setup(context);
const unrelated = { sessionID: "other", tools: { paseo_echo: {}, native: {} }, system: [] };
for (const hook of hooks) await hook(unrelated);
expect(Object.keys(unrelated.tools)).toEqual(["native"]);
structured = true;
const structuredOnly = {
sessionID: "other",
tools: { paseo_structured_output: {} },
system: [],
};
for (const hook of hooks) await hook(structuredOnly);
expect(Object.keys(structuredOnly.tools)).toEqual(["paseo_structured_output"]);
const restarted = new OpenCodeBridge({
paseoHome: home,
logger: createTestLogger(),
port: Number(new URL(plugin.options.baseUrl).port),
});
restarted.setManifestCatalog({ tools });
await restarted.start();
const releaseRestarted = restarted.bindSession({ sessionId: "paseo", env: {} });
try {
await expect(
rpc.bind({ sessionID: "paseo", ...restarted.sharedV2Connection() }),
).resolves.toEqual({});
} finally {
releaseRestarted();
await restarted.close();
}
await dispose();
} finally {
unbind();
await bridge.close();
await rm(home, { recursive: true, force: true });
}
});
@@ -0,0 +1,192 @@
import { OpenCode } from "@opencode/client";
import { mkdir } from "node:fs/promises";
import path from "node:path";
import { fileURLToPath } from "node:url";
import type { Logger } from "pino";
import type { ProviderRuntimeSettings } from "../../../provider-launch-config.js";
import type { OpenCodeBridge } from "../bridge.js";
import { OpenCodeHttpError } from "../http-error.js";
import { resolveOpenCodeHomeDir } from "../paths.js";
import { openCodeConfigDirectory, provisionLocalBridge } from "./bridge-provisioning.js";
import { awaitPaseoPlugin } from "./readiness.js";
import {
compatibleVersion,
isLocalOpenCodeHost,
type OpenCodeServiceSelection,
} from "./service-selection.js";
import type { V2Api } from "./api.js";
interface SharedServiceOptions {
logger: Logger;
settings?: ProviderRuntimeSettings;
bridge?: OpenCodeBridge;
}
export interface SharedService {
client: V2Api;
exited: Promise<Error>;
stop(): Promise<void>;
bindSession(sessionId: string, cwd: string): Promise<void>;
unbindSession(sessionId: string, cwd: string): Promise<void>;
refreshLocation(cwd: string, signal?: AbortSignal): Promise<void>;
}
export async function connectSharedService(
selection: Extract<OpenCodeServiceSelection, { kind: "explicit" | "discovered" }>,
options: SharedServiceOptions,
): Promise<SharedService> {
const server = selection.server;
const { logger } = options;
const lifetime = new AbortController();
const closed = new Error(`OpenCode external server connection closed (${server.url})`);
let resolveExited!: (error: Error) => void;
const exited = new Promise<Error>((resolve) => {
resolveExited = resolve;
});
const authorization = server.password
? `Basic ${Buffer.from(`opencode:${server.password}`).toString("base64")}`
: undefined;
const client = OpenCode.make({
baseUrl: server.url,
...(authorization ? { headers: { Authorization: authorization } } : {}),
fetch: async (request, init) => {
const signal = init?.signal
? AbortSignal.any([init.signal, lifetime.signal])
: lifetime.signal;
const response = await fetch(request, { ...init, signal });
const html = response.headers.get("content-type")?.includes("text/html") ?? false;
if (!response.ok || html) {
const requestUrl = request instanceof Request ? request.url : String(request);
throw new OpenCodeHttpError(new URL(requestUrl).pathname, response.status, html);
}
return response;
},
});
let info: Awaited<ReturnType<typeof client.server.info>>;
try {
info = await client.server.info({
signal: AbortSignal.any([lifetime.signal, AbortSignal.timeout(30_000)]),
});
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
throw new Error(`Cannot reach OpenCode v2 server at ${server.url}: ${message}`, {
cause: error,
});
}
if (!compatibleVersion(info.version))
throw new Error(
`Unsupported OpenCode version ${info.version}; version 2.0.10 or newer is required`,
);
const bridge = options.bridge;
const pluginDirectory = bridge ? fileURLToPath(await bridge.materializeV2Plugin()) : undefined;
if (selection.kind === "discovered" && pluginDirectory) {
const env = { ...globalThis.process.env, ...options.settings?.env };
const configDirectory = openCodeConfigDirectory(env);
const home = resolveOpenCodeHomeDir();
await mkdir(home, { recursive: true });
const entries = await client.config.get({ location: { directory: home } });
if (
!entries.some(
(entry) =>
entry.type === "directory" && path.resolve(entry.path) === path.resolve(configDirectory),
)
)
throw new Error(
`OpenCode service does not use the expected configuration directory ${configDirectory}; configure the bridge on that service explicitly`,
);
await provisionLocalBridge({
configDirectory,
artifact: path.join(pluginDirectory, "server.js"),
});
}
const advertised =
options.settings?.env?.PASEO_OPENCODE_BRIDGE_URL ??
globalThis.process.env.PASEO_OPENCODE_BRIDGE_URL;
validateBridgeAddress(selection, bridge, advertised);
const generation: SharedService = {
client,
stop: async () => {
if (lifetime.signal.aborted) return;
lifetime.abort(closed);
resolveExited(closed);
},
exited,
...bridgeBindings(client, bridge, advertised, pluginDirectory),
};
logger.info({ url: server.url }, "Using existing OpenCode v2 server");
return generation;
}
function validateBridgeAddress(
selection: Extract<OpenCodeServiceSelection, { kind: "explicit" | "discovered" }>,
bridge: OpenCodeBridge | undefined,
advertised: string | undefined,
): void {
if (advertised) {
let bridgeUrl: URL;
try {
bridgeUrl = new URL(advertised);
} catch {
throw new Error("PASEO_OPENCODE_BRIDGE_URL must be an absolute HTTP or HTTPS URL");
}
if (bridgeUrl.protocol !== "http:" && bridgeUrl.protocol !== "https:")
throw new Error("PASEO_OPENCODE_BRIDGE_URL must be an absolute HTTP or HTTPS URL");
}
const hostname = new URL(selection.server.url).hostname;
if (bridge && selection.kind === "explicit" && !advertised && !isLocalOpenCodeHost(hostname))
throw new Error(
"Remote OpenCode requires PASEO_OPENCODE_BRIDGE_URL pointing to a reachable Paseo bridge tunnel and the Paseo plugin installed on the server",
);
}
export function bridgeBindings(
client: ReturnType<typeof OpenCode.make>,
bridge: OpenCodeBridge | undefined,
advertised?: string,
pluginDirectory?: string,
): Pick<SharedService, "bindSession" | "unbindSession" | "refreshLocation"> {
return {
bindSession: async (sessionId, cwd) => {
if (!bridge) return;
const location = { directory: cwd };
let result: Awaited<ReturnType<typeof client.rpc.call>>;
try {
await awaitPaseoPlugin({ client, location });
result = await client.rpc.call({
location,
rpcID: "paseo.bridge",
method: "bind",
input: { sessionID: sessionId, ...bridge.sharedV2Connection(advertised) },
});
} catch (error) {
throw new Error(
`Cannot connect the Paseo tool bridge to OpenCode. Install the plugin from ${pluginDirectory ?? "Paseo's runtime directory"} and configure a reachable bridge URL: ${error instanceof Error ? error.message : String(error)}`,
{ cause: error },
);
}
if (result.output && typeof result.output === "object" && "error" in result.output)
throw new Error(String(result.output.error));
},
unbindSession: async (sessionId, cwd) => {
if (bridge)
await client.rpc.call(
{
location: { directory: cwd },
rpcID: "paseo.bridge",
method: "unbind",
input: { sessionID: sessionId, ...bridge.sharedV2Connection(advertised) },
},
{ signal: AbortSignal.timeout(2_000) },
);
},
refreshLocation: async (cwd, signal) => {
if (!bridge) return;
const location = { directory: cwd };
await awaitPaseoPlugin({ client, location, signal });
await client.rpc.call(
{ location, rpcID: "paseo.bridge", method: "refresh", input: {} },
{ signal },
);
},
};
}
@@ -0,0 +1,11 @@
// OpenCode reloads the plugin entry point independently from this module.
// Keep live bindings and tool definitions in memory across those reloads.
const locations = new Map();
export function stateFor(location) {
const key = JSON.stringify([location.directory, location.workspaceID ?? null]);
if (!locations.has(key)) {
locations.set(key, { bindings: new Map(), definitions: new Map() });
}
return locations.get(key);
}
@@ -46,7 +46,7 @@ describe("daemon E2E (real opencode) - initial prompt wait", () => {
} }
}); });
test("waitForFinish does not resolve before an initial prompt using opencode/big-pickle actually completes", async () => { test("waitForFinish does not resolve before an OpenCode initial prompt completes", async () => {
const cwd = tmpCwd(); const cwd = tmpCwd();
const { client, daemon } = await createHarness(); const { client, daemon } = await createHarness();
@@ -26,7 +26,7 @@ const PI_AUTH_CONFIG_PATH = join(homedir(), ".pi", "agent", "auth.json");
const CODEX_AUTH_CONFIG_PATH = join(homedir(), ".codex", "auth.json"); const CODEX_AUTH_CONFIG_PATH = join(homedir(), ".codex", "auth.json");
const CLAUDE_REAL_TEST_MODEL = "haiku"; const CLAUDE_REAL_TEST_MODEL = "haiku";
const CODEX_REAL_TEST_MODEL = "~openai/gpt-latest"; const CODEX_REAL_TEST_MODEL = "~openai/gpt-latest";
const OPENCODE_REAL_TEST_MODEL = "openrouter/google/gemini-2.5-flash-lite"; const OPENCODE_REAL_TEST_MODEL = "openrouter/google/gemini-3.5-flash-lite";
const PI_OPENROUTER_REAL_TEST_MODEL = "openrouter/google/gemini-2.5-flash-lite"; const PI_OPENROUTER_REAL_TEST_MODEL = "openrouter/google/gemini-2.5-flash-lite";
const PI_CODEX_REAL_TEST_MODEL = "openai-codex/gpt-5.4"; const PI_CODEX_REAL_TEST_MODEL = "openai-codex/gpt-5.4";
const OMP_OPENROUTER_REAL_TEST_MODEL = "openrouter/google/gemini-2.5-flash-lite"; const OMP_OPENROUTER_REAL_TEST_MODEL = "openrouter/google/gemini-2.5-flash-lite";
@@ -1071,9 +1071,38 @@ export default function contribute(plugin: unknown) {
"second", "second",
`export default function contribute(plugin: unknown) { void plugin; return () => undefined; }`, `export default function contribute(plugin: unknown) { void plugin; return () => undefined; }`,
); );
// Hold cleanup open so synchronous detach and queued recovery are tested
// independently of compiler/process startup speed on the runner.
let finishCleanup = () => undefined;
const cleanup = new Promise<void>((resolve) => {
finishCleanup = resolve;
});
const running = new Set<string>();
const runtime: TestPluginRuntime = {
...emptyUsageRuntime,
catalog: () => [...running].map((id) => ({ id, clientBundle: "bundle" })),
invoke: async (pluginId) => {
if (!running.has(pluginId)) throw new Error(`Plugin is not available: ${pluginId}`);
return pluginId;
},
getLogs: () => [],
clearLogs: () => undefined,
startPlugin: async (pluginId, _path, canPublish) => {
if (!canPublish()) throw new Error(`Plugin start cancelled: ${pluginId}`);
running.add(pluginId);
},
stopPluginById: async (pluginId) => {
const existed = running.delete(pluginId);
await cleanup;
return existed;
},
stopAll: async () => running.clear(),
subscribe: () => () => undefined,
bindPaseoSessionHost: () => undefined,
};
const store = createStore(home); const store = createStore(home);
const service = bindTestSessionHost( const service = bindTestSessionHost(
new PluginService(pino({ level: "silent" }), store, "0.4.0"), new PluginService(pino({ level: "silent" }), store, "0.4.0", { runtime }),
); );
try { try {
await service.start(); await service.start();
@@ -1087,6 +1116,8 @@ export default function contribute(plugin: unknown) {
"Plugin is not available", "Plugin is not available",
); );
await store.patch({ pluginsEnabled: true }); await store.patch({ pluginsEnabled: true });
expect(service.catalog()).toEqual([]);
finishCleanup();
// Observe recovery itself. Reloading here restarts a healthy plugin and // Observe recovery itself. Reloading here restarts a healthy plugin and
// would also conceal a failure to restart it after the global enable. // would also conceal a failure to restart it after the global enable.
await expect await expect
@@ -1096,6 +1127,7 @@ export default function contribute(plugin: unknown) {
{ id: "second", status: "running" }, { id: "second", status: "running" },
]); ]);
} finally { } finally {
finishCleanup();
await service.stopAllPlugins(); await service.stopAllPlugins();
} }
}, 20_000); }, 20_000);
+127
View File
@@ -59,3 +59,130 @@ index 0c854d6..7dc2828 100644
} }
return props.transform as TransformsStyle['transform']; return props.transform as TransformsStyle['transform'];
} }
diff --git a/node_modules/react-native-svg/android/build.gradle b/node_modules/react-native-svg/android/build.gradle
--- a/node_modules/react-native-svg/android/build.gradle
+++ b/node_modules/react-native-svg/android/build.gradle
@@ -103,6 +103,7 @@
}
defaultConfig {
+ testInstrumentationRunner 'androidx.test.runner.AndroidJUnitRunner'
minSdkVersion safeExtGet('minSdkVersion', 16)
//noinspection OldTargetApi
targetSdkVersion safeExtGet('targetSdkVersion', 28)
@@ -136,6 +137,8 @@
}
dependencies {
+ androidTestImplementation 'androidx.test:runner:1.6.2'
+ androidTestImplementation 'androidx.test.ext:junit:1.2.1'
implementation 'com.facebook.react:react-native:+'
implementation("com.facebook.fresco:fresco:${FRESCO_VERSION}") {
exclude group: 'com.facebook.soloader'
diff --git a/node_modules/react-native-svg/android/src/main/java/com/horcrux/svg/SvgView.java b/node_modules/react-native-svg/android/src/main/java/com/horcrux/svg/SvgView.java
--- a/node_modules/react-native-svg/android/src/main/java/com/horcrux/svg/SvgView.java
+++ b/node_modules/react-native-svg/android/src/main/java/com/horcrux/svg/SvgView.java
@@ -159,6 +159,8 @@
@Override
protected void onSizeChanged(int w, int h, int oldw, int oldh) {
super.onSizeChanged(w, h, oldw, oldh);
+ // Percentage lengths and paths depend on the canvas size, not just SVG props.
+ clearChildCache();
this.invalidate();
}
diff --git a/node_modules/react-native-svg/android/src/androidTest/java/com/horcrux/svg/SvgResizeTest.java b/node_modules/react-native-svg/android/src/androidTest/java/com/horcrux/svg/SvgResizeTest.java
new file mode 100644
--- /dev/null
+++ b/node_modules/react-native-svg/android/src/androidTest/java/com/horcrux/svg/SvgResizeTest.java
@@ -0,0 +1,90 @@
+package com.horcrux.svg;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertTrue;
+
+import android.graphics.Bitmap;
+import android.graphics.Canvas;
+import android.graphics.Color;
+import android.view.View;
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import androidx.test.platform.app.InstrumentationRegistry;
+import com.facebook.react.bridge.BridgeReactContext;
+import com.facebook.react.bridge.DynamicFromObject;
+import com.facebook.react.bridge.JavaOnlyArray;
+import com.facebook.react.bridge.JavaOnlyMap;
+import com.facebook.react.soloader.OpenSourceMergedSoMapping;
+import com.facebook.react.uimanager.DisplayMetricsHolder;
+import com.facebook.soloader.SoLoader;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+
+@RunWith(AndroidJUnit4.class)
+public class SvgResizeTest {
+ @Test
+ public void percentageFadeFollowsCanvasGrowthAndShrinkage() {
+ InstrumentationRegistry.getInstrumentation().runOnMainSync(() -> {
+ android.content.Context app = InstrumentationRegistry.getInstrumentation().getTargetContext();
+ DisplayMetricsHolder.initDisplayMetricsIfNotInitialized(app);
+ try {
+ SoLoader.init(app, OpenSourceMergedSoMapping.INSTANCE);
+ } catch (java.io.IOException exception) {
+ throw new RuntimeException(exception);
+ }
+ BridgeReactContext context = new DrawingContext(app);
+ SvgView svg = new SvgView(context);
+ LinearGradientView gradient = new LinearGradientView(context);
+ gradient.setName("fade");
+ gradient.setX1(new DynamicFromObject("0%"));
+ gradient.setY1(new DynamicFromObject("0%"));
+ gradient.setX2(new DynamicFromObject("0%"));
+ gradient.setY2(new DynamicFromObject("100%"));
+ gradient.setGradientUnits(0);
+ gradient.setGradient(JavaOnlyArray.of(0.0, Color.RED, 1.0, Color.TRANSPARENT));
+ svg.addView(gradient);
+ // Exercise recursive invalidation as well as the direct child case.
+ GroupView group = new GroupView(context);
+ RectView rectangle = new RectView(context);
+ rectangle.setX(new DynamicFromObject(0.0));
+ rectangle.setY(new DynamicFromObject(0.0));
+ rectangle.setWidth(new DynamicFromObject("100%"));
+ rectangle.setHeight(new DynamicFromObject("100%"));
+ rectangle.setFill(JavaOnlyMap.of("type", 1, "brushRef", "fade"));
+ group.addView(rectangle);
+ svg.addView(group);
+
+ assertFade(svg, 80, 60);
+ assertFade(svg, 160, 120);
+ assertFade(svg, 60, 40);
+ assertFade(svg, 160, 120);
+ });
+ }
+
+ // Pixel rendering needs a React context but does not send layout events to JavaScript.
+ private static final class DrawingContext extends BridgeReactContext
+ implements com.facebook.react.uimanager.events.EventDispatcherProvider {
+ DrawingContext(android.content.Context context) { super(context); }
+
+ @Override
+ public com.facebook.react.uimanager.events.EventDispatcher getEventDispatcher() { return null; }
+ }
+
+ private static void assertFade(SvgView svg, int width, int height) {
+ svg.measure(View.MeasureSpec.makeMeasureSpec(width, View.MeasureSpec.EXACTLY),
+ View.MeasureSpec.makeMeasureSpec(height, View.MeasureSpec.EXACTLY));
+ svg.layout(0, 0, width, height);
+ Bitmap image = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888);
+ svg.draw(new Canvas(image));
+ try {
+ int middle = Color.alpha(image.getPixel(width / 2, height / 2));
+ int right = Color.alpha(image.getPixel(width - 2, height / 2));
+ assertTrue("Gradient midpoint must follow canvas height at " + width + "x" + height
+ + "; alpha=" + middle, middle >= 120 && middle <= 135);
+ assertEquals("Fade must reach the right edge at " + width + "x" + height, middle, right);
+ assertTrue("Fade must end at the new bottom edge",
+ Color.alpha(image.getPixel(width / 2, height - 2)) < 20);
+ } finally {
+ image.recycle();
+ }
+ }
+}
+5
View File
@@ -287,6 +287,11 @@ Paseo refreshes an agent by closing its current provider session and opening it
configuration and persistence. Re-read credentials, environment, global configuration, and MCP configuration and persistence. Re-read credentials, environment, global configuration, and MCP
servers during `session.open`. There is no separate reload operation. servers during `session.open`. There is no separate reload operation.
Conversation and combined rewind require a core-restored session with persistence. After a
successful rewind, Paseo closes the session and reopens its latest persistence value with
`history: "replay"`. Publish the retained native branch before `session.ready`, including its
children. File-only rewind keeps the current session and conversation history.
Use `restoration: "core"` when Paseo can reopen a session from persistence. A provider-owned child Use `restoration: "core"` when Paseo can reopen a session from persistence. A provider-owned child
uses `restoration: "parent"`; emit it as another `session.opened` event with `parentSessionId` and uses `restoration: "parent"`; emit it as another `session.opened` event with `parentSessionId` and
recreate it while restoring the parent. recreate it while restoring the parent.
+2
View File
@@ -59,6 +59,8 @@ const patchedPackages = [
nodeModulesPath: "node_modules/@gorhom/bottom-sheet", nodeModulesPath: "node_modules/@gorhom/bottom-sheet",
patchPrefix: "@gorhom+bottom-sheet+", patchPrefix: "@gorhom+bottom-sheet+",
}, },
// Android resize: retain until upstream clears descendant geometry when the SVG canvas resizes.
// The native pixel regression is included in the patch; preserve the separate transform hunks.
{ {
nodeModulesPath: "packages/app/node_modules/react-native-svg", nodeModulesPath: "packages/app/node_modules/react-native-svg",
patchPrefix: "react-native-svg+", patchPrefix: "react-native-svg+",