Compare commits

...
Author SHA1 Message Date
Mohamed Boudra 5bc78b951b fix(diff): align untracked patches and comparison modes
Read untracked bodies with --no-textconv so they follow the same patch
rules as tracked bodies. Include the comparison-side mode in worktree
identity so a base file type change cannot keep stale hunks. Use object
parameters for the identity inputs.
2026-11-13 11:12:24 +01:00
Mohamed Boudra e2f31e5db2 fix(diff): identify worktree files by content
Stat-only identity changes re-sent unchanged diff bodies after a touch or
git add. Worktree tracked and untracked files now share one content identity
built from the comparison-side blob, working-tree content, and Git
interpretation. Stat metadata no longer forces body delivery, while real
content changes still change the identity.

Closes #9563
2026-11-13 11:00:23 +01:00
Mohamed BoudraandJacek Tomaszewski af6ca46ae3 fix(plugin)!: drop the always-empty timeline from agent.turn.after (#9573)
agent.turn.after no longer declares timeline; plugins read the turn with
timeline.refetch({ direction: "tail", limit: 1, wholeTurns: true }) filtered by event.turnId.
Plugins built against the old SDK keep receiving timeline: [] behind COMPAT(pluginTurnTimeline).

Closes #9315

Co-authored-by: Jacek Tomaszewski <jacek@fullstack.house>
2026-11-13 15:22:53 +07:00
Mohamed Boudra 8dc979ec2f Shift+Tab in the iPad composer no longer inserts a tab (#9561)
* Shift+Tab in the iPad composer no longer inserts a tab

On iPad with a hardware keyboard, Shift+Tab in the composer cycled the
agent mode and the composer also inserted a tab. The native window
reports presses to JavaScript after the text control has handled them,
so the shortcut pipeline cannot prevent the default.

The hardware keyboard source now claims, up front, the Shift and Option
shortcuts whose binding is active in the current focus scope, and
recomputes the claims when the focus surface or command center changes.
iOS registers them as key commands so the text control never sees them.
The window still emits the press, so the shortcut runs once.

Closes #9468

* Claim every step of a rebound chord shortcut

A binding rebound to a chord such as Mod+J Shift+Tab only claimed its
first step, so finishing the chord in the iPad composer still typed a tab.

* Claim only the first step of a chord shortcut

Claiming every step of a chord held keys like Shift+A for as long as the
chord's binding was active, so the composer could no longer type a
capital A. A later step is a shortcut only while its chord is in progress.

* Expect no Option claims now that iPad tab switching uses Cmd+Shift

Main moved tab switching on Apple platforms with desktop bindings to
Cmd+Shift+[ / ], which text controls do not type, so the default claims
are only Shift+Tab while the composer is focused.
2026-11-13 14:12:01 +07:00
Papa Goose 70aeb7b45b fix(pi,omp): treat null optional tool arguments as omitted (#9506)
Models commonly send null for optional tool arguments, such as read offset/limit
(captured in #7011) and bash timeout. Pi and OMP treat null as omitted, but the
Paseo mappers rejected it, so these calls fell back to unknown tool cards that
show raw JSON input and output instead of Read/Shell cards.
2026-11-13 07:00:29 +00:00
Mohamed Boudra 8b3b2a9ea3 Stop re-sending unchanged Changes snapshots to apps older than 0.16 (#9562)
* fix(server): stop re-sending unchanged Changes snapshots to pre-0.16 apps

The shared diff engine commits a revision for every observed input change:
ignored-file writes, same-content saves, mtime touches, index rewrites, and
ref moves that only change the resolved SHAs. Binary-stream clients absorb
those cheaply by reusing unchanged file objects. The legacy adapter turned
each revision into a full JSON snapshot, so old apps were re-sent the whole
diff (5 MB+ on large checkouts) whenever the repository was touched.

The adapter now sends a snapshot only when it differs from the last one it
sent to that subscriber, as 0.15 did. The check lives here, not in the
engine, because the JSON snapshot drops the resolved SHAs: a commit that
moves HEAD in All mode is a real revision for new clients and a no-op for
old ones.

* test(server): wait for the revision end before asserting no legacy push
2026-11-13 13:09:38 +07:00
b6952995be feat(app): use Cmd+Shift+[ / ] for previous/next tab on macOS desktop (#7692)
* feat(app): use Cmd+Shift+[ / ] for previous/next tab on macOS desktop

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

* fix(app): drop Alt+Shift tab navigation on macOS desktop

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

* test(app): cover previous/next tab shortcuts on web and macOS desktop

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

* fix(app): adopt desktopBindings condition for tab navigation shortcuts

Main renamed the binding condition `desktop` to `desktopBindings` and the
test context `isDesktop` to `usesDesktopBindings` (#8625). Rebase the
Cmd+Shift+[ / ] tab shortcuts onto that vocabulary.

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Mohamed Boudra <boudra.moha@gmail.com>
2026-11-13 12:40:17 +07:00
paseo-bot[bot]andRyan Swift c558af91eb fix(usage): show Copilot premium request quota (#8489)
The usage source discarded quota snapshots, leaving the card with only a
plan and reset date. Publish finite premium usage through the existing
window interface and retain its remaining request count.

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
Co-authored-by: Ryan Swift <ryan@mlh.io>
2026-11-13 12:13:38 +07:00
Mohamed Boudra 7791dcee3b Docs: document the public iOS TestFlight beta on the Updates page (#9553)
* Document the public iOS TestFlight beta and how to get betas on every platform

* Show the TestFlight beta on the download page's Beta view
2026-11-13 11:20:12 +07:00
e532a24327 fix(server): start cross-host children in their caller's mode (#9512)
When an agent creates a child on another connected host without passing
settings.modeId, the destination cannot see the caller, so the child fell
back to the provider default instead of inheriting the caller's mode the
way a same-host child does. A bypass caller's remote child then asked for
approval on every tool call.

Resolve the mode on the sending host before routing, as background
already is: an omitted mode with a same-provider caller forwards the
caller's current mode. Explicit modes still win, and cross-provider or
caller-less creates still leave the mode to the destination.

Co-authored-by: Orcun Simsek <1612171+ortschun@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-13 03:57:45 +00:00
paseo-bot[bot] 1aba2a5f23 Measure composer pills after the compact layout replaces them (#9546)
Resizing to a compact width mounts a new composer, so boundingBox() on a
pill could resolve the wide layout's element and return null once it
unmounted. Wait for the compact header, then poll each pill's width.

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-11-13 03:42:48 +00:00
aead84bf0f Interrupting sends no longer fail with "already has an active run" (#9537)
* Interrupting sends no longer fail with "already has an active run"

Two sends that interrupt the same running agent raced: the first left the
foreground lane after cancelling and before reserving its new run, so the
second send's cancel ran in that gap, saw the agent still marked running,
and tracked a phantom run that made the first send's start throw.

Replacements now queue per agent in arrival order, and each cancels and
reserves its run inside one foreground mutation. A replacement that finds a
run still starting waits for it outside the lane, so Stop still reaches it.

Co-authored-by: Michael Wu <421504+michaelmwu@users.noreply.github.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Interrupting sends still recover an agent whose turn never starts

A replacement now waits for a starting run only up to the rescue
timeout, then cancels it the way main did, so sending another message
still unsticks an agent whose provider never acknowledges a start.

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

* A queued replacement acts on the agent as it is when it gets the lane

Resolve the managed agent inside the foreground mutation instead of
before queueing, so a session reload while the send waits cannot leave
it writing state to, or checking steer ownership against, the old agent.

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

---------

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
Co-authored-by: Michael Wu <421504+michaelmwu@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-13 03:09:32 +00:00
paseo-bot[bot] 7980ad008b Keep keystrokes typed while a terminal restores its screen (#9533)
* fix(app): keep keystrokes typed while a terminal restores its screen

When a terminal re-attaches, for example after the host reconnects, the
browser terminal replays the screen snapshot and ignores everything xterm
emits until the replay is parsed, so replies to queries in the restored
output don't reach the shell. That also threw away what the user typed
during the replay: the first characters typed right after a reconnect
were lost.

xterm emits both through the same callback. Replies only come out of
parsing a write, so data emitted while a keyboard, input or paste event
is dispatching on the terminal is the user's, and it now goes through.

* fix(app): keep pastes suppressed while a terminal restores its screen

Until the replayed snapshot restores bracketed paste mode, a multi-line
paste would reach the shell unbracketed and run line by line. Pastes
during a replay are dropped as before; only typed input goes through.

---------

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-11-13 03:01:35 +00:00
7c35ad90b9 Start an edited agent draft after its first start failed (#9534)
* test(app): reproduce edited draft retries after a failed agent start

Resubmitting an agent draft after its start failed reuses the draft ID as
the creation key with different inputs, and the daemon rejects it with
agent_request_key_conflict (or workspace_request_key_conflict from New
workspace).

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

* fix(app): start an edited agent draft after its first start failed

The draft sent its draft ID as the creation key on every submit. After a
failed start the daemon keeps that key's request, so resubmitting an edited
draft was rejected with agent_request_key_conflict. The key now names the
draft's exact request: a retry resumes it, an edit starts a new one.

A retry after New workspace's agent failed to start creates the agent in
the workspace that is already ready, instead of replaying workspace
provisioning with a different agent.

Co-authored-by: michaelmwu <421504+michaelmwu@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>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: michaelmwu <421504+michaelmwu@users.noreply.github.com>
2026-11-13 02:05:34 +00:00
9eccb4722b Model picker profile buttons respond to taps on Android tablets (#9526)
* fix(app): keep the model picker's profile action tappable on Android

On Android tablets the model list scrolls inside an independent viewport whose native scroll gesture cancels a plain Pressable's touches. Rows use the gesture-aware pressable; the profile + and the Profiles pencil were left as plain Pressables. Route both through the same gesture path.

* test(app): cover the tablet model row's profile action on Android

Adds an agent-device journey that taps a model row's `+` in the New
workspace picker on an Android tablet and waits for the seeded profile
form. It fails on main, where the tap is swallowed, and passes with the
gesture-aware profile action.

Co-authored-by: moevis <moevery@gmail.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* test(app): make the tablet profile journey follow the documented setup

The journey now starts from a remembered model like its sibling, so it
runs after model-tablet.android.ad, and asserts that closing the profile
form leaves the remembered model selected, so a tap on `+` that also
selected the row fails it.

Co-authored-by: moevis <moevery@gmail.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* test(app): keep the no-profiles requirement for the tablet profile journey

With a saved profile the picker opens on its root view, which has no
Back, so the journey needs a host with no agent profiles.

Co-authored-by: moevis <moevery@gmail.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(app): hide the model row's profile action through its style

IconActionButton took a pointerEvents prop that only its web branch
applied, so a native caller hiding the action would get an invisible
button that still took taps. The hidden style now carries
pointerEvents: "none", which every pressable branch already applies,
and the prop is gone. The web journey checks the plus ignores taps
until its row is hovered.

Co-authored-by: moevis <moevery@gmail.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: moevis <moevery@gmail.com>
Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-13 01:00:22 +00:00
LiebeLee 536846eb60 fix(omp): persist the resumed session identity when importing (#8854)
OMP import listings return a session file path, and the import helper stored
that path as both sessionId and nativeHandle. Records then carried a path in
sessionId while runtimeInfo carried the real id, which breaks identity-based
lookups and makes a later import of the same file look unrelated.

Prefer the resumed session's own describePersistence() identity and keep the
listed handle as nativeHandle, merging metadata so provider-specific fields
survive.
2026-11-13 00:02:23 +00:00
cc16ac6ac4 fix(app): keep line breaks when copying a selection that crosses part of a code block (#9515)
* Preserve partial code blocks in assistant selection copy

* test(app): cover partial code copies in the real assistant selection journey

Copying from a paragraph into part of a highlighted code block, or from
partway through a block out to the next paragraph, now has browser
journey coverage. On main both copies join the code lines into one line
and drop the fence.

Co-authored-by: Michael Wu <michaelmwu@gmail.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(app): keep indented code lines inside a copied list item

Copying a list item that holds a code block indented only the code lines
that start with a non-space character, so an indented line fell outside
the item and broke the fence. Indent every non-empty line of the item.

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

---------

Co-authored-by: Michael Wu <michaelmwu@gmail.com>
Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-12 21:52:13 +00:00
paseo-bot[bot] fd9aebd622 fix(app): close a bottom sheet hidden before it finished opening (#9519)
A sheet hidden right after it was presented, such as the context usage
sheet when a plugin focuses the composer, could stay open. The dismiss
reached the sheet before it had measured its layout, and the library
drops a close it cannot position yet. The sheet then finished opening,
and the visibility tracker took that as presented while its owner
wanted it hidden.

The tracker now dismisses again when a sheet reports an open position
while its owner wants it hidden.

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-11-12 21:49:05 +00:00
RockandClaude Opus 5.5 21709812bb fix(codex): retry an oversized history page with smaller pages (#9432)
thread/items/list asks for 100 items a page. On image-heavy threads a page
can exceed the app-server's line cap, and the history stopped at the first
such page with a "too large to load" warning, dropping every later item
even when each one fits. The reader now halves the page and retries the
same cursor, and shows the warning only when a single item cannot fit.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-12 21:02:10 +00:00
81 changed files with 2047 additions and 390 deletions
+10 -3
View File
@@ -20,9 +20,16 @@ settings the native composer does not implement.
## Native sources
Use the iOS window press stream for hardware keys. `UIKeyCommand` only takes keys
that must bypass the focused text control: composer Return and the top overlay's
Enter and arrows. Keep the stream as the sole shortcut emitter: a command-owned
Use the iOS window press stream for hardware keys. The stream reaches JavaScript
after the focused text control has already handled the press, so the pipeline's
`preventDefault` cannot stop a key from typing. A key the text control would type
must be claimed up front. `UIKeyCommand` takes only those keys: composer Return, the
top overlay's Enter and arrows, and the Shift or Option shortcuts whose binding is
active in the current focus scope, such as Shift+Tab in the composer.
`sources/native.ts` computes the claims from the published bindings and recomputes
them when the focus scope changes. Cmd and Ctrl combos are never claimed, because
text controls do not type them. Android takes no claims: a focused `EditText`
types nothing for these combos. Keep the stream as the sole shortcut emitter: a command-owned
press is still observed by the window.
The source spike used an iPad Pro 11-inch (M4) simulator on iOS 18.6 with a
+9
View File
@@ -150,6 +150,15 @@ Pi import discovery reads Pi's persisted JSONL session files because Pi RPC does
OMP is a first-class built-in provider, disabled by default. Its launch contract, typed runtime, agent/session behavior, history, permissions, imports, and test fake live under `providers/omp/`; only the provider-neutral JSONL child-process transport is shared with Pi. It launches `omp --mode rpc-ui`, uses OMP's `get_available_commands` RPC for slash-command discovery, bridges OMP `rpc-ui` approval dialogs into Paseo permissions, and imports terminal-started sessions from `~/.omp/agent/sessions` when enabled.
OMP rewind is a branch, not an edit: `branch` writes a new session file whose header names the
previous file as `parentSession` and leaves that previous file untouched. A rewind chain therefore
adds one file to `~/.omp/agent/sessions` per step, and `omp --resume` lists all of them. The
previous file also keeps whatever head it had recorded, which can be an older branch than the one
the user last saw. Treat the provider session file selected by OMP as the authority; do not assume
the newest file or the newest message wins. Import listings hand back a file path, so persist the
resumed session's own identity (`sessionId`) with that path as `nativeHandle`, not the listed
handle as both.
OMP supports native Paseo host tools. The adapter registers the full caller-scoped Paseo tool catalog directly with OMP, matching providers such as Claude that expose the full catalog through MCP. Serialize every OMP host definition with `loadMode: "essential"` so `create_agent`, `send_agent_prompt`, `wait_for_agent`, and related tools remain direct calls; omitting the field makes OMP mount non-built-in names under `xd://` instead. OMP's provider-managed task subagents are surfaced as Paseo subagents through `child_session` imports; the parent keeps the subagents track while the child runtime stays owned by OMP. Custom OMP profiles should extend `omp`; other Pi-compatible forks can still extend `pi`, override `command`, and set `params.sessionDir` to their JSONL session directory.
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.
+2 -1
View File
@@ -248,6 +248,7 @@ npm run release:promote # Promote X.Y.Z-beta.N to stable X.Y.Z
- Beta tags are published GitHub prereleases like `v0.1.41-beta.1`
- Betas publish npm packages with `--tag beta`, so `npm install @getpaseo/cli@beta` opts in while plain `npm install @getpaseo/cli` stays on `latest`
- The user-facing beta instructions, including the public TestFlight join link for the `Paseo Beta` group, live in `public-docs/updates.md` and its `zh-CN` pair. Update them when a beta path is added or removed.
- Betas publish desktop assets and APKs for testing. They also build iOS, upload it to TestFlight, add it to the `Paseo Beta` external group, and submit it for Beta App Review. They do not submit mobile builds to the production stores.
- `release:promote` creates a fresh stable tag like `v0.1.41`; the final release never reuses the beta tag
- Desktop assets now come from the Electron package at `packages/desktop`
@@ -488,7 +489,7 @@ The GitHub Release body is populated automatically by the `Release Notes Sync` w
- The website download page defaults to GitHub's latest published **stable** release.
- A published beta prerelease is offered behind the Stable/Beta switch on `/download` (`?channel=beta`), never as the default. The switch only appears while the newest prerelease leads stable on its core version, so promoting `X.Y.Z-beta.N` to `X.Y.Z` retires the beta channel from the page until the next beta line opens.
- Homebrew, the Play Store, the App Store, and `app.paseo.sh` have no beta. The Beta view drops those rows, and the whole Web section, rather than showing an inert "stable only" placeholder. When a surface gains a beta path — say a public TestFlight link — add its row back in `packages/website/src/routes/download.tsx`.
- Homebrew, the Play Store, and `app.paseo.sh` have no beta. The Beta view drops those rows, and the whole Web section, rather than showing an inert "stable only" placeholder. The iOS row swaps the App Store pill for the public TestFlight link (`testFlightBetaUrl` in `packages/website/src/downloads.tsx`). When another surface gains a beta path, add its row back in `packages/website/src/routes/{-$locale}/download.tsx`.
- The default download target only moves when you publish the final stable release tag like `v0.1.41`.
- The public `/changelog` page renders `CHANGELOG.md` as-is, so the in-flight `-beta.N` entry shows there once it lands on `main` — that's intended, it's where beta users check what's coming. Only the **default download target** stays pinned to the latest stable; the download links read GitHub's releases API, not the changelog, so a `-beta.N` heading on top never affects them.
- The download page's "What's new" link deep-links the **minor group** anchor (`/changelog#release-0.3`), not the exact entry: the beta entry is renamed on each beta and on promotion, so the minor group remains the durable target. A version with no entry in the bundled changelog — a tag whose changelog commit hasn't redeployed the site yet — links the plain `/changelog` instead of a dead anchor.
@@ -644,6 +644,22 @@ test("copying an assistant selection preserves Markdown structure and links", as
'```typescript\nconst answer = "yes";\n return answer;\n```\n\nAfter code.',
);
// Crossing into part of the block keeps the selected code lines as a fenced block.
await selectAssistantAcrossCodeLines(page, "not a generated link.", "return");
await copySelection(page);
expect((await readRichClipboard(page)).plainText).toBe(
'not a generated link.\n\n```typescript\nconst answer = "yes";\n return\n```',
);
// So does starting partway through the block and leaving it.
await selectAssistantAcrossCodeLines(page, "answer", "After code.");
await copySelection(page);
expect((await readRichClipboard(page)).plainText).toBe(
'```typescript\nanswer = "yes";\n return answer;\n```\n\nAfter code.',
);
// Selecting a whole line runs off its end into the newline node. A trailing
// newline auto-executes the line when it is pasted into a terminal.
await selectAssistantAcrossCodeLines(page, "const", " ");
@@ -729,13 +745,25 @@ test.describe("on a touch-capable desktop browser", () => {
// The pan accepts touch only, and a mouse drag must still extend the browser's selection.
test.use({ hasTouch: true });
test("a mouse drag selects across assistant paragraphs", async ({ page }) => {
test("a mouse drag selects across assistant list, quote, and code blocks", async ({ page }) => {
const agent = await seedMockAgentWorkspace({
repoPrefix: "assistant-selection-drag-",
title: "Assistant selection drag",
initialPrompt: "Render three paragraphs.",
initialPrompt: "Render structured text.",
featureValues: {
mockAssistantResponse: "First paragraph.\n\nSecond paragraph.\n\nThird paragraph.",
mockAssistantResponse: [
"First paragraph.",
"",
"- List item.",
"",
"> Quote text.",
"",
"```ts",
"const answer = 1;",
"```",
"",
"Third paragraph.",
].join("\n"),
},
});
@@ -760,6 +788,9 @@ test.describe("on a touch-capable desktop browser", () => {
const selected = await page.evaluate(() => window.getSelection()?.toString() ?? "");
expect(selected).toContain("First paragraph.");
expect(selected).toContain("List item.");
expect(selected).toContain("Quote text.");
expect(selected).toContain("const answer = 1;");
expect(selected).toContain("Third paragraph.");
} finally {
await agent.cleanup();
@@ -3,7 +3,7 @@ import { writeFile } from "node:fs/promises";
import path from "node:path";
import { expect, test, type Page } from "../support/fixtures";
import { seedSubagent } from "../support/helpers/subagents";
import { submitMessage } from "../support/helpers/composer";
import { expectWorkspaceHeaderVisible, submitMessage } from "../support/helpers/composer";
import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent";
import { gateNextAgentMessage } from "../support/helpers/agent-message-gate";
import {
@@ -918,11 +918,12 @@ test.describe("touch composer tracks", () => {
[tasks, subagents, changes].map(async (pill) => (await pill.boundingBox())!.width),
);
await page.setViewportSize({ width: 200, height: 844 });
const narrowWidths = await Promise.all(
[tasks, subagents, changes].map(async (pill) => (await pill.boundingBox())!.width),
);
for (const [index, width] of wideWidths.entries())
expect(narrowWidths[index]).toBeGreaterThanOrEqual(width - 1);
// The compact layout mounts a new composer, so measure only after it replaces the wide one.
await expectWorkspaceHeaderVisible(page);
for (const [index, pill] of [tasks, subagents, changes].entries())
await expect
.poll(async () => (await pill.boundingBox())?.width ?? 0)
.toBeGreaterThanOrEqual(wideWidths[index] - 1);
const track = page.getByTestId("composer-track-scroll");
expect(await track.evaluate((node) => node.scrollWidth)).toBeGreaterThan(
await track.evaluate((node) => node.clientWidth),
@@ -127,8 +127,42 @@ for (const scenario of [
await creation.submitPrompt("Retry this workspace and agent together.");
await creation.expectAgentCount(1);
});
test(`an edited prompt after failed agent initialization starts the agent in its workspace${scenario.suffix}`, async ({
creation,
startup,
page,
}) => {
await creation.openWorkspaceForm("local");
await creation.submitPrompt("Start the original task.", "Create");
await creation.expectAgentStillStarting();
await startup.fail();
await creation.expectStartupFailure();
await creation.expectOneCreatedWorkspace();
await scenario.prepare(creation);
await creation.submitPrompt("Start the edited task.");
await creation.expectPromptVisible("Start the edited task.");
await creation.expectOneCreatedWorkspace();
await creation.expectAgentCount(1);
await expect(page.getByText(/request_key_conflict/)).toHaveCount(0);
});
}
test("an edited agent draft starts after its first start failed", async ({
creation,
startup,
page,
}) => {
await creation.openAgentDraft();
await startup.fail();
await creation.submitPrompt("Start the original task.");
await creation.expectStartupFailure();
await creation.submitPrompt("Start the edited task.");
await creation.expectPromptVisible("Start the edited task.");
await creation.expectAgentCount(1);
await expect(page.getByText(/request_key_conflict/)).toHaveCount(0);
});
for (const entry of [
{
name: "agent draft",
@@ -159,7 +193,9 @@ for (const entry of [
await entry.settled(creation);
await creation.expectCreatedAgentError();
await creation.expectPromptBeforeError(prompt);
await page.screenshot({ path: testInfo.outputPath("created-agent-prompt-error.png") });
await page.screenshot({
path: testInfo.outputPath("created-agent-prompt-error.png"),
});
await creation.reloadAgent();
await creation.expectPromptBeforeError(prompt);
await creation.submitPrompt("emit 1 coalesced agent stream updates for a corrected prompt.");
@@ -0,0 +1,32 @@
import { expect, test } from "../support/fixtures";
import { clickNewTerminal, gotoWorkspace } from "../support/helpers/launcher";
import { seedWorkspace } from "../support/helpers/seed-client";
import { waitForWorkspaceTabsVisible } from "../support/helpers/workspace-tabs";
// The browser owns Cmd+Shift+brackets for its own tabs, so web keeps
// Alt+Shift+brackets on every OS. Desktop bindings live in the desktop suite.
test("web switches to the previous and next tab with Alt+Shift+[ and ]", async ({ page }) => {
const workspace = await seedWorkspace({ repoPrefix: "tab-relative-web-" });
try {
await gotoWorkspace(page, workspace.workspaceId);
await waitForWorkspaceTabsVisible(page);
const terminalTabs = page
.locator('[data-testid^="workspace-tab-terminal_"]')
.filter({ visible: true });
await clickNewTerminal(page);
await expect(terminalTabs).toHaveCount(1, { timeout: 30_000 });
await clickNewTerminal(page);
await expect(terminalTabs).toHaveCount(2, { timeout: 30_000 });
const first = terminalTabs.nth(0);
const second = terminalTabs.nth(1);
await expect(second).toHaveAttribute("aria-selected", "true");
await page.keyboard.press("Alt+Shift+BracketLeft");
await expect(first).toHaveAttribute("aria-selected", "true");
await page.keyboard.press("Alt+Shift+BracketRight");
await expect(second).toHaveAttribute("aria-selected", "true");
} finally {
await workspace.cleanup();
}
});
@@ -85,6 +85,20 @@ agent-device test packages/app/e2e/mobile/modal-sheet/model-tablet.android.ad \
--artifacts-dir /tmp/paseo-model-tablet-qa
```
`model-tablet-profile.android.ad` taps the `+` of a model row in New workspace, asserts the seeded
profile form opens, and asserts that closing the form leaves the remembered model selected. Run it
on the same tablet setup, after `model-tablet.android.ad` or with `MODEL_LABEL` remembered, against a
host with no agent profiles: with a profile saved, the picker opens on its root view, which has no
Back. Set `PROFILE_MODEL_ID` to a different model on the provider's first page:
```sh
agent-device test packages/app/e2e/mobile/modal-sheet/model-tablet-profile.android.ad \
--serial "$ANDROID_SERIAL" --metro-port "$MODEL_QA_METRO_PORT" \
--env FORM_URL="paseo://new?serverId=${SHEET_QA_SERVER_ID}" \
--env PROVIDER_ID=codex --env MODEL_LABEL="$MODEL_QA_MODEL_LABEL" \
--env PROFILE_MODEL_ID="$MODEL_QA_PROFILE_MODEL_ID"
```
The existing-agent journey needs the sidebar pinned so its composer stays narrow. Start each run with no modal open;
a failed provider tap leaves its modal open, so dismiss it before the next run. These host-dependent
journeys remain outside the default mobile suite, alongside the compact sheet journeys above.
@@ -0,0 +1,22 @@
# Connected host with MODEL_LABEL selected and no agent profiles, 1600x2560 at density 320. See README.md.
# FORM_URL=paseo://new?serverId=<serverId>; PROFILE_MODEL_ID is another catalog model with no profile.
context platform=android timeout=60000 retries=0
env APP_ID=sh.paseo.debug
env PROVIDER_ID=codex
open "${APP_ID}" "${FORM_URL}"
wait "label=\"Workspace project\"" 20000
press "id=\"combined-model-selector\""
wait "id=\"combobox-desktop-container\"" 10000
# A remembered model opens its provider's list. Return to the provider page first.
press "label=\"Back\""
wait "id=\"model-provider-${PROVIDER_ID}\"" 10000
press "id=\"model-provider-${PROVIDER_ID}\""
wait "id=\"compact-model-list\"" 3000
# The row's profile action sits inside Android's independent scroll viewport.
press "id=\"model-create-profile-${PROVIDER_ID}-${PROFILE_MODEL_ID}\""
wait "text=\"Add agent profile\"" 5000
# Closing the form keeps the remembered model: the tap did not also select the row.
back
wait "id=\"combined-model-selector\" label=\"Select model (${MODEL_LABEL})\"" 3000
close
@@ -505,10 +505,14 @@ export async function expectCreateProfileFromModelRow(
page: Page,
input: { provider: string; modelId: string; modelLabel: string },
): Promise<void> {
const action = pickerViewport(page).getByTestId(
`model-create-profile-${input.provider}-${input.modelId}`,
);
// Until its row is hovered the plus is invisible, so a tap on its spot reaches the row.
await expect(action).toHaveCSS("pointer-events", "none");
await modelRow(page, input).hover();
await pickerViewport(page)
.getByTestId(`model-create-profile-${input.provider}-${input.modelId}`)
.click();
await expect(action).toHaveCSS("pointer-events", "auto");
await action.click();
const modal = editModal(page);
await expect(modal).toBeVisible({ timeout: 30_000 });
await expect(modal.getByRole("textbox", { name: "Name", exact: true })).toHaveValue(
+6 -1
View File
@@ -142,6 +142,8 @@ export async function createCreationScenario(page: Page) {
const requests = await observeCreationRequests(page);
const project = await seedWorkspace({ repoPrefix: "creation-idempotency-" });
let workspaceId = project.workspaceId;
// Workspaces the scenario opens itself to evict the deck; they are not creations under test.
const evictionWorkspaceIds = new Set<string>();
return {
cleanup: project.cleanup,
async openWorkspaceForm(isolation: "local" | "worktree") {
@@ -232,7 +234,9 @@ export async function createCreationScenario(page: Page) {
await requests.settled();
const workspaces = (await project.client.fetchWorkspaces()).entries.filter(
(workspace) =>
workspace.projectId === project.projectId && workspace.id !== project.workspaceId,
workspace.projectId === project.projectId &&
workspace.id !== project.workspaceId &&
!evictionWorkspaceIds.has(workspace.id),
);
expect(workspaces).toHaveLength(1);
workspaceId = workspaces[0]!.id;
@@ -276,6 +280,7 @@ export async function createCreationScenario(page: Page) {
source: { kind: "directory", path: project.repoPath },
});
if (!result.workspace) throw new Error(result.error ?? "Failed to seed eviction workspace");
evictionWorkspaceIds.add(result.workspace.id);
await switchWorkspaceViaSidebar({
page,
serverId: getServerId(),
@@ -8,6 +8,7 @@ private let hardwareShortcutEventName = "onHardwareKeyboardShortcut"
private weak var activeModule: PaseoHardwareKeyboardModule?
private var isHardwareSubmitEnabled = false
private var overlayKeys: Set<String> = []
private var claimedShortcuts: [ClaimedShortcut] = []
/// UIKit spells non-character keys as sentinel input strings rather than as the
/// character they would type. JS sends the DOM `code` and the mapping lives
@@ -19,6 +20,42 @@ private let namedKeyInputs: [String: String] = [
"Enter": "\r",
]
/// A shortcut the window takes from the focused text control: Shift+Tab would
/// otherwise insert a tab, and Option+Shift+[ would type a quote.
private struct ClaimedShortcut {
let input: String
let modifierFlags: UIKeyModifierFlags
}
struct ClaimedShortcutRecord: Record {
@Field var code: String = ""
@Field var shift: Bool = false
@Field var alt: Bool = false
}
private let claimableKeyInputs: [String: String] = [
"Tab": "\t", "Space": " ", "Enter": "\r", "Escape": UIKeyCommand.inputEscape,
"ArrowUp": UIKeyCommand.inputUpArrow, "ArrowDown": UIKeyCommand.inputDownArrow,
"ArrowLeft": UIKeyCommand.inputLeftArrow, "ArrowRight": UIKeyCommand.inputRightArrow,
"Minus": "-", "Equal": "=", "BracketLeft": "[", "BracketRight": "]", "Backslash": "\\",
"Semicolon": ";", "Quote": "'", "Backquote": "`", "Comma": ",", "Period": ".", "Slash": "/",
]
/// UIKit matches a key command against the unmodified character, so
/// Option+Shift+[ is registered as "[" with both modifier flags.
private func claimedShortcut(_ record: ClaimedShortcutRecord) -> ClaimedShortcut? {
let code = record.code
let input: String?
if code.count == 4 && code.hasPrefix("Key") { input = code.suffix(1).lowercased() }
else if code.count == 6 && code.hasPrefix("Digit") { input = String(code.suffix(1)) }
else { input = claimableKeyInputs[code] }
guard let input else { return nil }
var modifierFlags: UIKeyModifierFlags = []
if record.shift { modifierFlags.insert(.shift) }
if record.alt { modifierFlags.insert(.alternate) }
return ClaimedShortcut(input: input, modifierFlags: modifierFlags)
}
/// Whether the first responder is mid-IME-composition.
///
/// A committing key has to stay out of the way while a CJK candidate is being
@@ -62,6 +99,14 @@ public class PaseoHardwareKeyboardModule: Module {
}
}
Function("setClaimedShortcuts") { (shortcuts: [ClaimedShortcutRecord]) in
let claimed = shortcuts.compactMap(claimedShortcut)
DispatchQueue.main.async {
claimedShortcuts = claimed
UIMenuSystem.main.setNeedsRebuild()
}
}
OnDestroy {
self.keyboardObservers.forEach(NotificationCenter.default.removeObserver)
self.keyboardObservers = []
@@ -69,6 +114,7 @@ public class PaseoHardwareKeyboardModule: Module {
activeModule = nil
isHardwareSubmitEnabled = false
overlayKeys = []
claimedShortcuts = []
}
}
}
@@ -139,7 +185,10 @@ public final class PaseoHardwareKeyboardWindow: UIWindow {
{
commands.append(makeSubmitCommand())
}
if !isComposingText() { commands.append(contentsOf: overlayKeys.compactMap(makeShortcutCommand)) }
if !isComposingText() {
commands.append(contentsOf: overlayKeys.compactMap(makeShortcutCommand))
commands.append(contentsOf: claimedShortcuts.map(makeClaimedCommand))
}
return commands
}
@@ -172,6 +221,18 @@ public final class PaseoHardwareKeyboardWindow: UIWindow {
return command
}
/// The window's press stream still emits the shortcut; the command only keeps
/// the key away from the text control.
private func makeClaimedCommand(_ shortcut: ClaimedShortcut) -> UIKeyCommand {
let command = UIKeyCommand(
input: shortcut.input,
modifierFlags: shortcut.modifierFlags,
action: #selector(handleHardwareKeyboardShortcut(_:))
)
if #available(iOS 15.0, *) { command.wantsPriorityOverSystemBehavior = true }
return command
}
@objc
private func handleHardwareKeyboardSubmit(_ sender: UIKeyCommand) {
guard canSubmitCurrentTextInput() else {
@@ -759,6 +759,56 @@ describe("assistant selection copy inside highlighted code", () => {
"```typescript\nconst answer = 1;\n if (answer) {\n doThing();\n```\n\nAfter the block.",
);
});
it("keeps line breaks when a quote selection ends partway through code", () => {
const message = mountHighlighted();
const quote = document.createElement("div");
quote.setAttribute("data-paseo-markdown-tag", "blockquote");
quote.textContent = "Quoted context";
message.querySelector('[data-paseo-markdown-tag="pre"]')!.before(quote);
const content = copyBetween(
[tokenText(message, "Quoted context"), 0],
[tokenText(message, " (answer)"), 9],
);
expect(content?.plainText).toBe(
"> Quoted context\n\n```typescript\nconst answer = 1;\n if (answer)\n```",
);
});
it("keeps indented code lines inside their list item", () => {
const host = document.createElement("div");
host.innerHTML = [
'<div data-testid="assistant-message">',
'<div data-paseo-markdown-tag="ol" data-paseo-markdown-list-start="1">',
'<div data-paseo-markdown-tag="li">',
'<div data-paseo-markdown-ignore="true" data-paseo-markdown-list-marker="true">1.</div>',
'<div><div data-paseo-markdown-tag="p"><span>Item text</span></div>',
highlightedCodeBlock("typescript"),
"</div></div></div></div>",
].join("");
document.body.append(host);
const content = copyBetween([tokenText(host, "1."), 0], [tokenText(host, " (answer)"), 9]);
expect(content?.plainText).toBe(
"1. Item text\n\n ```typescript\n const answer = 1;\n if (answer)\n ```",
);
});
it("keeps line breaks when a selection starts partway through code", () => {
const message = mountHighlighted();
const content = copyBetween(
[tokenText(message, " answer"), 1],
[tokenText(message, "After the block."), "After the block.".length],
);
expect(content?.plainText).toBe(
"```typescript\nanswer = 1;\n if (answer) {\n doThing();\n```\n\nAfter the block.",
);
});
});
describe("semantic library output", () => {
@@ -81,7 +81,8 @@ turndown.addRule("compactListItem", {
const item = content
.replace(/^\n+/, "")
.replace(/\n+$/, "\n")
.replace(/\n(?=\S)/g, "\n ");
// Every non-empty line, so indented code lines stay inside the item.
.replace(/\n(?=[^\n])/g, "\n ");
const parent = node.parentElement;
if (parent?.nodeName !== "OL") {
return `${options.bulletListMarker} ${item}\n`;
@@ -359,6 +360,9 @@ function shouldPreserveSemanticElement(range: Range, element: Element): boolean
if (tag === "p" || tag === "img" || isTableStructure(tag)) {
return true;
}
if (isCrossBoundaryCodeElement(range, element, tag)) {
return true;
}
const isSelectableSemantic = tag !== null && tag !== "li" && tag !== "ol" && tag !== "ul";
if (
(isSelectableSemantic || element.tagName === "A") &&
@@ -386,6 +390,11 @@ function shouldPreserveSemanticElement(range: Range, element: Element): boolean
return hasSelectedAllContents(range, element, tag === "li");
}
function isCrossBoundaryCodeElement(range: Range, element: Element, tag: string | null): boolean {
const isCode = tag === "pre" || (tag === "code" && Boolean(element.closest(CODE_BLOCK_SELECTOR)));
return isCode && !selectionStaysInsideElement(range, element);
}
function selectionStaysInsideElement(range: Range, element: Element): boolean {
return (
range.startContainer !== element &&
+64 -25
View File
@@ -106,29 +106,76 @@ function ProviderSettingsAction({
);
}
function AgentProfilesEditAction({ onPress }: { onPress: () => void }) {
const { t } = useTranslation();
/**
* An icon button that explains itself in a tooltip on desktop hover. Tooltips
* never open on mobile, so native renders the shared gesture-aware pressable
* instead of a plain Pressable: inside the model list's independent scroll
* viewport (Android, wide windows) the native scroll gesture cancels a plain
* Pressable's touches and the button would never fire.
*/
function IconActionButton({
accessibilityLabel,
testID,
onPress,
style,
children,
}: {
accessibilityLabel: string;
testID: string;
onPress: () => void;
style?:
| StyleProp<ViewStyle>
| ((state: PressableStateCallbackType & { hovered?: boolean }) => StyleProp<ViewStyle>);
children: React.ReactNode;
}) {
if (isNative) {
return (
<ModelBrowserPressable
onPress={onPress}
hitSlop={8}
style={style}
accessibilityLabel={accessibilityLabel}
testID={testID}
>
{children}
</ModelBrowserPressable>
);
}
return (
<Tooltip delayDuration={250} enabledOnDesktop enabledOnMobile={false}>
<TooltipTrigger asChild>
<Pressable
onPress={onPress}
hitSlop={8}
style={iconButtonStyle}
style={style}
accessibilityRole="button"
accessibilityLabel={t("modelSelector.editProfilesLabel")}
testID="model-profiles-edit"
accessibilityLabel={accessibilityLabel}
testID={testID}
>
<ThemedPencil size={ICON_SIZE.xs} uniProps={foregroundExtraMutedMapping} />
{children}
</Pressable>
</TooltipTrigger>
<TooltipContent side="top" align="center" offset={8}>
<Text style={styles.tooltipText}>{t("modelSelector.editProfilesLabel")}</Text>
<Text style={styles.tooltipText}>{accessibilityLabel}</Text>
</TooltipContent>
</Tooltip>
);
}
function AgentProfilesEditAction({ onPress }: { onPress: () => void }) {
const { t } = useTranslation();
return (
<IconActionButton
onPress={onPress}
style={iconButtonStyle}
accessibilityLabel={t("modelSelector.editProfilesLabel")}
testID="model-profiles-edit"
>
<ThemedPencil size={ICON_SIZE.xs} uniProps={foregroundExtraMutedMapping} />
</IconActionButton>
);
}
const IndependentScrollGestureContext = createContext<ReturnType<typeof Gesture.Native> | null>(
null,
);
@@ -637,24 +684,14 @@ function ModelRowProfileAction({
[visible],
);
return (
<Tooltip delayDuration={250} enabledOnDesktop enabledOnMobile={false}>
<TooltipTrigger asChild>
<Pressable
onPress={onPress}
hitSlop={8}
style={pressableStyle}
pointerEvents={visible ? "auto" : "none"}
accessibilityRole="button"
accessibilityLabel={label}
testID={testID}
>
{children}
</Pressable>
</TooltipTrigger>
<TooltipContent side="top" align="center" offset={8}>
<Text style={styles.tooltipText}>{label}</Text>
</TooltipContent>
</Tooltip>
<IconActionButton
onPress={onPress}
style={pressableStyle}
accessibilityLabel={label}
testID={testID}
>
{children}
</IconActionButton>
);
}
@@ -1690,8 +1727,10 @@ const styles = StyleSheet.create((theme) => ({
rowIconButtonPressed: {
backgroundColor: theme.colors.surface1,
},
// Hidden in style so every pressable branch that applies the style also stops taking taps.
profileActionHidden: {
opacity: 0,
pointerEvents: "none",
},
emptyState: {
padding: theme.spacing[4],
@@ -88,6 +88,18 @@ describe("bottom sheet visibility tracker", () => {
expect(sheet.events).toEqual([{ type: "present" }, { type: "dismiss" }]);
});
it("dismisses again when a sheet hidden while opening finishes opening anyway", () => {
const { sheet, tracker } = setup();
tracker.attachController(sheet);
tracker.syncDesired({ visible: true });
// The owner hides the sheet before its open animation reports in; the sheet still lands open.
tracker.syncDesired({ visible: false });
tracker.handleSheetIndexChange(0);
expect(sheet.events).toEqual([{ type: "present" }, { type: "dismiss" }, { type: "dismiss" }]);
});
it("waits to present until the sheet controller becomes available", () => {
const { sheet, tracker } = setup();
tracker.syncDesired({ visible: true });
@@ -120,6 +120,11 @@ export function createBottomSheetVisibilityTracker(opts: {
if (phase === "presenting" || phase === "dismissing") {
setPhase("presented");
}
// A dismiss sent before the sheet measured its layout is dropped, and the sheet still
// finishes opening. The owner wants it hidden, so it goes again.
if (!visible && isEnabled !== false) {
dismiss();
}
return;
}
if (phase === "presenting" || phase === "presented") {
@@ -0,0 +1,30 @@
import { describe, expect, it } from "vitest";
import { draftCreationKey } from "./creation-key";
const request = {
config: { provider: "codex", cwd: "/repo", model: "gpt-6" },
workspaceId: "workspace-1",
initialPrompt: "Start the original task.",
clientMessageId: "draft-1:initial-message",
};
describe("draftCreationKey", () => {
it("keeps the key for a retry of the same request, whatever its key order", () => {
const reordered = {
clientMessageId: request.clientMessageId,
initialPrompt: request.initialPrompt,
workspaceId: request.workspaceId,
config: { model: "gpt-6", cwd: "/repo", provider: "codex" },
};
expect(draftCreationKey("draft-1", reordered)).toBe(draftCreationKey("draft-1", request));
});
it.each([
{ name: "prompt", edited: { ...request, initialPrompt: "Start the edited task." } },
{ name: "model", edited: { ...request, config: { ...request.config, model: "gpt-6-mini" } } },
])("gives an edited $name a new key on the same draft", ({ edited }) => {
const key = draftCreationKey("draft-1", edited);
expect(key).not.toBe(draftCreationKey("draft-1", request));
expect(key.startsWith("draft-1:")).toBe(true);
});
});
@@ -0,0 +1,29 @@
/**
* The creation key for a draft's request. The daemon keeps one request per key, so retrying the
* same draft resumes its creation, while an edited draft is a new request with its own key.
*/
export function draftCreationKey(draftId: string, request: object): string {
return `${draftId}:${hash(canonicalJson(request))}`;
}
function canonicalJson(value: unknown): string {
return JSON.stringify(value, (_key, entry: unknown) =>
entry && typeof entry === "object" && !Array.isArray(entry)
? Object.fromEntries(Object.entries(entry).sort(([a], [b]) => a.localeCompare(b)))
: entry,
);
}
// 53-bit cyrb53: distinguishes the edits of one draft without WebCrypto, which needs HTTPS.
function hash(text: string): string {
let h1 = 0xdeadbeef;
let h2 = 0x41c6ce57;
for (let index = 0; index < text.length; index += 1) {
const code = text.charCodeAt(index);
h1 = Math.imul(h1 ^ code, 2654435761);
h2 = Math.imul(h2 ^ code, 1597334677);
}
h1 = Math.imul(h1 ^ (h1 >>> 16), 2246822507) ^ Math.imul(h2 ^ (h2 >>> 13), 3266489909);
h2 = Math.imul(h2 ^ (h2 >>> 16), 2246822507) ^ Math.imul(h1 ^ (h1 >>> 13), 3266489909);
return (4294967296 * (2097151 & h2) + (h1 >>> 0)).toString(36);
}
@@ -47,6 +47,7 @@ import {
} from "@/workspace-tabs/model";
import { openWorkspaceChanges } from "@/workspace-tabs/open-supporting-view";
import { useSettings } from "@/hooks/use-settings";
import { draftCreationKey } from "@/composer/draft/creation-key";
interface AutoSubmitConfig {
provider: string;
@@ -164,8 +165,7 @@ async function submitDraftCreateRequest(input: {
const attachmentsArray = Array.isArray(attachments) ? attachments : undefined;
const imagesData = await encodeImages(images);
const options = {
idempotencyKey: input.draftId,
const request = {
config,
workspaceId,
initialPrompt: text,
@@ -174,8 +174,10 @@ async function submitDraftCreateRequest(input: {
...(imagesData && imagesData.length > 0 ? { images: imagesData } : {}),
...(attachmentsArray && attachmentsArray.length > 0 ? { attachments: attachmentsArray } : {}),
};
const creation = useWorkspaceDraftSubmissionStore.getState().creationByDraftId[input.draftId];
const result = creation ? await creation.retry(options) : await client.createAgent(options);
const result = await client.createAgent({
...request,
idempotencyKey: draftCreationKey(input.draftId, request),
});
return {
agentId: result.id,
@@ -4,20 +4,32 @@ export type KeyboardFocusSurface = "message-input" | "terminal";
export function createKeyboardFocusScope(hasEditableFocus: () => boolean = () => false) {
const surfaces = new Map<symbol, KeyboardFocusSurface>();
const listeners = new Set<() => void>();
const notify = () => {
for (const listener of listeners) listener();
};
return {
declare(surface: KeyboardFocusSurface) {
const token = Symbol(surface);
surfaces.set(token, surface);
notify();
return () => {
surfaces.delete(token);
if (surfaces.delete(token)) notify();
};
},
resolve(commandCenterOpen: boolean): KeyboardFocusScope {
/** Pass `editableFocused` to resolve for a text control that is not a declared surface. */
resolve(commandCenterOpen: boolean, editableFocused = hasEditableFocus()): KeyboardFocusScope {
if (commandCenterOpen) return "command-center";
let focused: KeyboardFocusScope = hasEditableFocus() ? "editable" : "other";
let focused: KeyboardFocusScope = editableFocused ? "editable" : "other";
for (const surface of surfaces.values()) focused = surface;
return focused;
},
subscribe(listener: () => void) {
listeners.add(listener);
return () => {
listeners.delete(listener);
};
},
};
}
export const focusSurfaces = createKeyboardFocusScope(hasFocusedTextInput);
@@ -10,6 +10,7 @@ export interface KeyboardSourceHandlers {
keyUp(event: KeyboardInputEvent): void;
blur(): void;
commandCenterOpen(): boolean;
subscribeCommandCenter(listener: () => void): () => void;
}
export function nativeKeyboardInput(
event: KeyboardInputEvent,
@@ -23,5 +24,6 @@ export interface KeyboardPolicyInput {
bindings: readonly ParsedShortcutBinding[];
chordState?: ChordState;
isMac: boolean;
usesDesktopBindings: boolean;
enabled: boolean;
}
@@ -366,7 +366,13 @@ describe("Electron guest source through shared resolution", () => {
},
captureFocusRestore: (id) => restored.push(id),
},
{ keyDown: pipeline.keyDown, keyUp() {}, blur() {}, commandCenterOpen: () => false },
{
keyDown: pipeline.keyDown,
keyUp() {},
blur() {},
commandCenterOpen: () => false,
subscribeCommandCenter: () => () => {},
},
);
receive({
browserId: "tab-1",
@@ -1,5 +1,5 @@
import { requireNativeModule } from "expo-modules-core";
import type { HardwareKeyboardEvent } from "./hardware";
import type { ClaimedShortcut, HardwareKeyboardEvent } from "./hardware";
interface HardwareKeyboardModule {
getHardwareKeyboardPresent(): boolean;
addListener(event: "onHardwareKeyboardPresenceChanged", handler: () => void): { remove(): void };
@@ -19,6 +19,9 @@ export function subscribeHardwareKeyboard(
export function setHardwareKeyboardOverlayKeys(keys: readonly string[]): void {
module.setOverlayKeys(keys);
}
// A focused Android EditText types nothing for Tab, Shift+Tab or Alt+Shift+[,
// so there is no character to take from it.
export function setHardwareKeyboardClaimedShortcuts(_shortcuts: readonly ClaimedShortcut[]): void {}
export function getHardwareKeyboardPresent(): boolean {
return module.getHardwareKeyboardPresent();
@@ -1,9 +1,10 @@
import { requireNativeModule } from "expo-modules-core";
import type { HardwareKeyboardEvent } from "./hardware";
import type { ClaimedShortcut, HardwareKeyboardEvent } from "./hardware";
interface HardwareKeyboardModule {
getHardwareKeyboardPresent(): boolean;
addListener(event: "onHardwareKeyboardPresenceChanged", handler: () => void): { remove(): void };
setOverlayKeys(keys: readonly string[]): void;
setClaimedShortcuts(shortcuts: readonly ClaimedShortcut[]): void;
addListener(
event: "onHardwareKeyboardShortcut",
handler: (event: HardwareKeyboardEvent) => void,
@@ -19,6 +20,9 @@ export function subscribeHardwareKeyboard(
export function setHardwareKeyboardOverlayKeys(keys: readonly string[]): void {
module.setOverlayKeys(keys);
}
export function setHardwareKeyboardClaimedShortcuts(shortcuts: readonly ClaimedShortcut[]): void {
module.setClaimedShortcuts(shortcuts);
}
export function getHardwareKeyboardPresent(): boolean {
return module.getHardwareKeyboardPresent();
@@ -3,12 +3,19 @@ export interface HardwareKeyboardEvent extends KeyboardShortcutInput {
isComposing: boolean;
phase: "down" | "up";
}
/** A shortcut the native window takes from the focused text control, by DOM `code`. */
export interface ClaimedShortcut {
code: string;
shift: boolean;
alt: boolean;
}
export function subscribeHardwareKeyboard(
_handler: (event: HardwareKeyboardEvent) => void,
): () => void {
return () => {};
}
export function setHardwareKeyboardOverlayKeys(_keys: readonly string[]): void {}
export function setHardwareKeyboardClaimedShortcuts(_shortcuts: readonly ClaimedShortcut[]): void {}
export function getHardwareKeyboardPresent(): boolean {
return false;
@@ -1,11 +1,19 @@
import type { KeyboardSourceHandlers } from "./contract";
import { setHardwareKeyboardOverlayKeys, subscribeHardwareKeyboard } from "./hardware";
import { attachHardwareKeyboardSource } from "./native";
import type { KeyboardPolicyInput, KeyboardSourceHandlers } from "./contract";
import {
setHardwareKeyboardClaimedShortcuts,
setHardwareKeyboardOverlayKeys,
subscribeHardwareKeyboard,
} from "./hardware";
import { createHardwareKeyboardSource } from "./native";
const hardwareKeyboard = createHardwareKeyboardSource({
subscribe: subscribeHardwareKeyboard,
setOverlayKeys: setHardwareKeyboardOverlayKeys,
setClaimedShortcuts: setHardwareKeyboardClaimedShortcuts,
});
export function attachKeyboardSources(handlers: KeyboardSourceHandlers): () => void {
return attachHardwareKeyboardSource(
{ subscribe: subscribeHardwareKeyboard, setOverlayKeys: setHardwareKeyboardOverlayKeys },
handlers,
);
return hardwareKeyboard.attach(handlers);
}
export function publishKeyboardPolicy(_input: import("./contract").KeyboardPolicyInput): void {}
export function publishKeyboardPolicy(input: KeyboardPolicyInput): void {
hardwareKeyboard.publishPolicy(input);
}
@@ -1,4 +1,3 @@
import { usesDesktopBindings } from "../conventions";
import { getDesktopHost } from "@/desktop/host";
import { getResidentBrowserWebview } from "@/desktop/browser/resident-webviews";
import { setCommandCenterFocusRestoreElement } from "@/utils/command-center-focus-restore";
@@ -26,7 +25,7 @@ export function attachKeyboardSources(handlers: KeyboardSourceHandlers): () => v
}
export function publishKeyboardPolicy(input: import("./contract").KeyboardPolicyInput): void {
const policy = input.enabled
? buildBrowserKeyboardPolicy({ ...input, usesDesktopBindings: usesDesktopBindings })
? buildBrowserKeyboardPolicy(input)
: { menuPrefixes: [], prefixes: [] };
void getDesktopHost()?.browser?.setShortcutPolicy?.(policy);
}
@@ -1,31 +1,92 @@
import { matchesKeyboardShortcutContext } from "../../keyboard-shortcuts";
import { focusSurfaces } from "../focus-surfaces";
import { overlays } from "../overlays";
import { nativeKeyboardInput, type KeyboardSourceHandlers } from "./contract";
import type { HardwareKeyboardEvent } from "./hardware";
import {
nativeKeyboardInput,
type KeyboardPolicyInput,
type KeyboardSourceHandlers,
} from "./contract";
import type { ClaimedShortcut, HardwareKeyboardEvent } from "./hardware";
interface HardwareKeySource {
subscribe(handler: (event: HardwareKeyboardEvent) => void): () => void;
setOverlayKeys(keys: readonly string[]): void;
setClaimedShortcuts(shortcuts: readonly ClaimedShortcut[]): void;
}
export function attachHardwareKeyboardSource(
source: HardwareKeySource,
handlers: KeyboardSourceHandlers,
): () => void {
const updateKeys = () => source.setOverlayKeys(overlays.getKeys());
updateKeys();
const unsubscribeOverlays = overlays.subscribe(updateKeys);
const unsubscribeHardware = source.subscribe((input) => {
const event = { ...input, preventDefault() {}, stopPropagation() {} };
if (input.phase === "up") {
handlers.keyUp(event);
return;
const DIGIT_CODES = Array.from({ length: 9 }, (_, index) => `Digit${index + 1}`);
/**
* The shortcuts a focused text control would otherwise type or act on.
*
* The native window observes every press but cannot wait for the pipeline to
* decide, so it has to claim these keys up front: without a claim, Shift+Tab
* cycles the agent mode and the composer also inserts a tab. Cmd and Ctrl
* combos do not type, and the platform's own editing combos stay with the text
* control through `editable: false`. Unmodified keys belong to overlays and the
* composer's Return.
*/
function claimedShortcuts(
policy: KeyboardPolicyInput | null,
commandCenterOpen: boolean,
): ClaimedShortcut[] {
if (!policy?.enabled) return [];
const context = {
isMac: policy.isMac,
usesDesktopBindings: policy.usesDesktopBindings,
commandCenterOpen,
focusScope: focusSurfaces.resolve(commandCenterOpen, true),
};
const claimed = new Map<string, ClaimedShortcut>();
for (const binding of policy.bindings) {
const combo = binding.parsedChord[0];
if (!combo || binding.preventDefault === false) continue;
if (combo.meta || combo.ctrl || combo.mod || !(combo.shift || combo.alt)) continue;
if (!matchesKeyboardShortcutContext(binding.when, context)) continue;
const shift = combo.shift === true;
const alt = combo.alt === true;
for (const code of combo.code === "Digit" ? DIGIT_CODES : [combo.code]) {
claimed.set(`${code}:${shift}:${alt}`, { code, shift, alt });
}
handlers.keyDown(
nativeKeyboardInput(event, focusSurfaces.resolve(handlers.commandCenterOpen())),
);
});
return () => {
unsubscribeHardware();
unsubscribeOverlays();
source.setOverlayKeys([]);
}
return [...claimed.values()];
}
export function createHardwareKeyboardSource(source: HardwareKeySource) {
let policy: KeyboardPolicyInput | null = null;
let updateClaims = () => {};
return {
publishPolicy(next: KeyboardPolicyInput) {
policy = next;
updateClaims();
},
attach(handlers: KeyboardSourceHandlers): () => void {
const updateKeys = () => source.setOverlayKeys(overlays.getKeys());
updateClaims = () =>
source.setClaimedShortcuts(claimedShortcuts(policy, handlers.commandCenterOpen()));
updateKeys();
updateClaims();
const unsubscribeOverlays = overlays.subscribe(updateKeys);
const unsubscribeFocus = focusSurfaces.subscribe(() => updateClaims());
const unsubscribeCommandCenter = handlers.subscribeCommandCenter(() => updateClaims());
const unsubscribeHardware = source.subscribe((input) => {
const event = { ...input, preventDefault() {}, stopPropagation() {} };
if (input.phase === "up") {
handlers.keyUp(event);
return;
}
handlers.keyDown(
nativeKeyboardInput(event, focusSurfaces.resolve(handlers.commandCenterOpen())),
);
});
return () => {
unsubscribeHardware();
unsubscribeFocus();
unsubscribeCommandCenter();
unsubscribeOverlays();
updateClaims = () => {};
source.setOverlayKeys([]);
source.setClaimedShortcuts([]);
};
},
};
}
@@ -226,6 +226,20 @@ describe("keyboard-shortcuts", () => {
action: "workspace.tab.navigate.relative",
payload: { delta: 1 },
},
{
name: "matches tab relative navigation on macOS desktop via Cmd+Shift+[",
event: { key: "{", code: "BracketLeft", metaKey: true, shiftKey: true },
context: { isMac: true, usesDesktopBindings: true },
action: "workspace.tab.navigate.relative",
payload: { delta: -1 },
},
{
name: "matches tab relative navigation on macOS desktop via Cmd+Shift+]",
event: { key: "}", code: "BracketRight", metaKey: true, shiftKey: true },
context: { isMac: true, usesDesktopBindings: true },
action: "workspace.tab.navigate.relative",
payload: { delta: 1 },
},
{
name: "matches Mod+T to open new tab",
event: { key: "t", code: "KeyT", metaKey: true },
@@ -479,6 +493,11 @@ describe("keyboard-shortcuts", () => {
event: { key: "w", code: "KeyW", ctrlKey: true },
context: { isMac: false, usesDesktopBindings: true, focusScope: "terminal" },
},
{
name: "does not switch tabs with Alt+Shift+] on mac desktop (Cmd+Shift+] only)",
event: { key: "’", code: "BracketRight", altKey: true, shiftKey: true },
context: { isMac: true, usesDesktopBindings: true },
},
{
name: "does not match Ctrl+T on mac (Cmd only)",
event: { key: "t", code: "KeyT", ctrlKey: true },
@@ -1306,6 +1325,17 @@ describe("unassigned shortcuts", () => {
expect(getBindingIdForAction("workspace-tab-new", desktopNonMac)).toBe(TAB_NEW_BINDING);
});
it("advertises Cmd+Shift+brackets for tab navigation on macOS desktop", () => {
const desktopMac = { isMac: true, usesDesktopBindings: true };
expect(getDefaultKeysForAction("workspace-tab-prev", desktopMac)).toEqual([
["mod", "shift", "["],
]);
expect(getDefaultKeysForAction("workspace-tab-next", desktopMac)).toEqual([
["mod", "shift", "]"],
]);
});
it("still lists the default keys when nothing is overridden", () => {
const sections = buildKeyboardShortcutHelpSections(desktopNonMac);
@@ -715,11 +715,37 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [
},
// --- Tab relative navigation ---
// macOS desktop uses Cmd+Shift+brackets; the browser owns those keys, so
// macOS web keeps Alt+Shift alongside every non-mac platform.
{
id: "workspace-tab-navigate-relative-cmd-shift-left-mac",
action: "workspace.tab.navigate.relative",
combo: "Cmd+Shift+[",
when: { mac: true, desktopBindings: true, commandCenter: false },
payload: { type: "delta", delta: -1 },
help: {
id: "workspace-tab-prev",
section: "tabs-panes",
label: "Previous tab",
},
},
{
id: "workspace-tab-navigate-relative-cmd-shift-right-mac",
action: "workspace.tab.navigate.relative",
combo: "Cmd+Shift+]",
when: { mac: true, desktopBindings: true, commandCenter: false },
payload: { type: "delta", delta: 1 },
help: {
id: "workspace-tab-next",
section: "tabs-panes",
label: "Next tab",
},
},
{
id: "workspace-tab-navigate-relative-alt-shift-left",
action: "workspace.tab.navigate.relative",
combo: "Alt+Shift+[",
when: { commandCenter: false },
when: { mac: false, commandCenter: false },
payload: { type: "delta", delta: -1 },
help: {
id: "workspace-tab-prev",
@@ -731,7 +757,31 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [
id: "workspace-tab-navigate-relative-alt-shift-right",
action: "workspace.tab.navigate.relative",
combo: "Alt+Shift+]",
when: { commandCenter: false },
when: { mac: false, commandCenter: false },
payload: { type: "delta", delta: 1 },
help: {
id: "workspace-tab-next",
section: "tabs-panes",
label: "Next tab",
},
},
{
id: "workspace-tab-navigate-relative-alt-shift-left-mac-web",
action: "workspace.tab.navigate.relative",
combo: "Alt+Shift+[",
when: { mac: true, desktopBindings: false, commandCenter: false },
payload: { type: "delta", delta: -1 },
help: {
id: "workspace-tab-prev",
section: "tabs-panes",
label: "Previous tab",
},
},
{
id: "workspace-tab-navigate-relative-alt-shift-right-mac-web",
action: "workspace.tab.navigate.relative",
combo: "Alt+Shift+]",
when: { mac: true, desktopBindings: false, commandCenter: false },
payload: { type: "delta", delta: 1 },
help: {
id: "workspace-tab-next",
@@ -25,6 +25,7 @@ describe("DOM source adapter", () => {
keyUp() {},
blur() {},
commandCenterOpen: () => false,
subscribeCommandCenter: () => () => {},
});
const event = new KeyboardEvent("keydown", {
key: "j",
@@ -130,7 +130,7 @@ describe("hardware source adapter", () => {
it.each([true, false])(
"delivers hardware down/up and declared focus with mac=%s",
async (isMac) => {
const { attachHardwareKeyboardSource } = await import("./internal/sources/native");
const { createHardwareKeyboardSource } = await import("./internal/sources/native");
const { focusSurfaces } = await import("./internal/focus-surfaces");
const actions: string[] = [];
const releases: string[] = [];
@@ -148,23 +148,22 @@ describe("hardware source adapter", () => {
},
overlays: createOverlayRegistry(),
});
const detach = attachHardwareKeyboardSource(
{
subscribe(handler) {
events.add(handler);
return () => {
events.delete(handler);
};
},
setOverlayKeys() {},
const detach = createHardwareKeyboardSource({
subscribe(handler) {
events.add(handler);
return () => {
events.delete(handler);
};
},
{
keyDown: pipeline.keyDown,
keyUp: (event) => releases.push(event.key),
blur() {},
commandCenterOpen: () => false,
},
);
setOverlayKeys() {},
setClaimedShortcuts() {},
}).attach({
keyDown: pipeline.keyDown,
keyUp: (event) => releases.push(event.key),
blur() {},
commandCenterOpen: () => false,
subscribeCommandCenter: () => () => {},
});
const down = {
...keyboardEvent("k", "KeyK", isMac),
isComposing: false,
@@ -180,4 +179,94 @@ describe("hardware source adapter", () => {
expect(events.size).toBe(0);
},
);
it("claims the Shift and Option shortcuts a focused text control would otherwise type", async () => {
const { createHardwareKeyboardSource } = await import("./internal/sources/native");
const { focusSurfaces } = await import("./internal/focus-surfaces");
let claimed: string[] = [];
const source = createHardwareKeyboardSource({
subscribe: () => () => {},
setOverlayKeys() {},
setClaimedShortcuts(shortcuts) {
claimed = shortcuts
.map(({ code, shift, alt }) => [alt && "Alt", shift && "Shift", code])
.map((parts) => parts.filter(Boolean).join("+"))
.sort();
},
});
source.publishPolicy({
bindings: buildEffectiveBindings({}),
isMac: true,
usesDesktopBindings: true,
enabled: true,
});
let commandCenterOpen = false;
const commandCenterListeners = new Set<() => void>();
const detach = source.attach({
keyDown() {},
keyUp() {},
blur() {},
commandCenterOpen: () => commandCenterOpen,
subscribeCommandCenter(listener) {
commandCenterListeners.add(listener);
return () => commandCenterListeners.delete(listener);
},
});
const setCommandCenterOpen = (open: boolean) => {
commandCenterOpen = open;
for (const listener of commandCenterListeners) listener();
};
// iPad tab switching uses Cmd+Shift+[ / ], which no text control types.
expect(claimed).toEqual([]);
const releaseComposer = focusSurfaces.declare("message-input");
expect(claimed).toEqual(["Shift+Tab"]);
setCommandCenterOpen(true);
expect(claimed).toEqual([]);
setCommandCenterOpen(false);
expect(claimed).toEqual(["Shift+Tab"]);
releaseComposer();
expect(claimed).toEqual([]);
source.publishPolicy({
bindings: buildEffectiveBindings({ "message-input-mode-cycle-shift-tab": "Alt+M" }),
isMac: true,
usesDesktopBindings: true,
enabled: true,
});
const releaseRebound = focusSurfaces.declare("message-input");
expect(claimed).toEqual(["Alt+KeyM"]);
releaseRebound();
// A chord's later step is only a shortcut while the chord is in progress;
// claiming it always would stop the composer typing a capital A.
source.publishPolicy({
bindings: buildEffectiveBindings({ "message-input-mode-cycle-shift-tab": "Mod+J Shift+A" }),
isMac: true,
usesDesktopBindings: true,
enabled: true,
});
const releaseChord = focusSurfaces.declare("message-input");
expect(claimed).toEqual([]);
releaseChord();
const releaseFinal = focusSurfaces.declare("message-input");
source.publishPolicy({
bindings: buildEffectiveBindings({}),
isMac: true,
usesDesktopBindings: true,
enabled: false,
});
expect(claimed).toEqual([]);
source.publishPolicy({
bindings: buildEffectiveBindings({}),
isMac: true,
usesDesktopBindings: true,
enabled: true,
});
expect(claimed).toEqual(["Shift+Tab"]);
detach();
expect(claimed).toEqual([]);
releaseFinal();
});
});
+5 -1
View File
@@ -62,7 +62,7 @@ export function useKeyboardShortcuts({
const keyboardWorkspaceSelectionRef = useRef<ActiveWorkspaceSelection | null>(null);
const publishBrowserShortcutPolicy = useCallback(
(chordState?: ChordState) => {
publishKeyboardPolicy({ bindings, chordState, isMac, enabled });
publishKeyboardPolicy({ bindings, chordState, isMac, usesDesktopBindings, enabled });
},
[bindings, enabled, isMac],
);
@@ -265,6 +265,10 @@ export function useKeyboardShortcuts({
keyUp: handleKeyUp,
blur: resetModifiers,
commandCenterOpen: () => useKeyboardShortcutsStore.getState().commandCenterOpen,
subscribeCommandCenter: (listener) =>
useKeyboardShortcutsStore.subscribe((state, previous) => {
if (state.commandCenterOpen !== previous.commandCenterOpen) listener();
}),
});
return () => {
detach();
@@ -134,6 +134,7 @@ describe("DOM source through the shared resolver", () => {
keyUp() {},
blur() {},
commandCenterOpen: () => false,
subscribeCommandCenter: () => () => {},
});
const event = new KeyboardEvent("keydown", {
key: "j",
@@ -1,8 +1,5 @@
import { useAddHost } from "@/hosts/add-host";
import type {
CreateAgentRequestOptions,
CreateWorkspaceRequestOptions,
} from "@getpaseo/client/internal/daemon-client";
import type { CreateWorkspaceRequestOptions } from "@getpaseo/client/internal/daemon-client";
import type { AgentSnapshotPayload, CreationSnapshot } from "@getpaseo/protocol/messages";
import { encodeImages } from "@/utils/encode-images";
import { getHostRuntimeStore } from "@/runtime/host-runtime";
@@ -126,7 +123,6 @@ import {
} from "./workspace/terminals/state";
import { captureWorkspaceDraftCleanup } from "./new-workspace/background-handoff";
import { useNewWorkspaceScreenPresence } from "./new-workspace/screen-presence";
import { retriedAgentRequest } from "./new-workspace/retry-agent-request";
function useIsNewWorkspaceDraftHandoffActive(input: {
draftId: string | undefined;
@@ -243,7 +239,6 @@ interface SubmitDraftInput {
clearConsumedDraft: () => void;
agentCreation?: {
result: Promise<AgentSnapshotPayload>;
retry: (input: CreateAgentRequestOptions) => Promise<AgentSnapshotPayload>;
};
serverId: string;
draftKey: string;
@@ -471,13 +466,13 @@ async function createWorkspaceChatAgent(input: CreateChatAgentInput): Promise<Su
images: images?.length ? images : undefined,
attachments: wirePayload.attachments?.length ? wirePayload.attachments : undefined,
};
const execute = async (requestedAgent = initialAgent): Promise<AgentSnapshotPayload> => {
const execute = async (): Promise<AgentSnapshotPayload> => {
const { agent } = await ensureWorkspace({
cwd,
prompt: text,
attachments: workspaceNamingAttachments,
withInitialAgent: true,
agent: requestedAgent,
agent: initialAgent,
onEvent: (snapshot) => {
if (!snapshot.workspace || navigated) return;
navigated = true;
@@ -516,11 +511,8 @@ async function createWorkspaceChatAgent(input: CreateChatAgentInput): Promise<Su
if (!agent) throw new Error("Workspace creation returned no agent");
return agent;
};
const agentCreation = {
result: Promise.resolve().then(() => execute()),
retry: (request: CreateAgentRequestOptions) =>
execute(retriedAgentRequest(initialAgent, request, cwd)),
};
// A retry after a failed agent start belongs to the ready workspace's draft, not to this creation.
const agentCreation = { result: Promise.resolve().then(() => execute()) };
await agentCreation.result;
if (outcome === "background") clearConsumedDraft();
return outcome;
@@ -1,47 +0,0 @@
import { describe, expect, it } from "vitest";
import { retriedAgentRequest, type WorkspaceAgentRequest } from "./retry-agent-request";
const initial: WorkspaceAgentRequest = {
config: { provider: "codex", cwd: "/repo", model: "gpt-6" },
metadata: { "issues.key": "PAS-1" },
initialPrompt: "first try",
clientMessageId: "draft_1:initial-message",
};
describe("retriedAgentRequest", () => {
it("creates with the metadata the draft resubmitted", () => {
const retried = retriedAgentRequest(
initial,
{
config: { provider: "codex", cwd: "/repo", model: "gpt-6" },
initialPrompt: "second try",
metadata: { "issues.key": "PAS-2" },
},
"/repo",
);
expect(retried.metadata).toEqual({ "issues.key": "PAS-2" });
expect(retried.initialPrompt).toBe("second try");
expect(retried.clientMessageId).toBe("draft_1:initial-message");
});
it("creates with no metadata once the draft cleared it", () => {
const retried = retriedAgentRequest(
initial,
{ config: { provider: "codex", cwd: "/repo", model: "gpt-6" }, initialPrompt: "second try" },
"/repo",
);
expect("metadata" in retried).toBe(false);
});
it("keeps the first attempt's directory over the resubmitted config", () => {
const retried = retriedAgentRequest(
initial,
{
config: { provider: "codex", cwd: "/elsewhere", model: "gpt-6-mini" },
initialPrompt: "second try",
},
"/repo",
);
expect(retried.config).toEqual({ provider: "codex", cwd: "/repo", model: "gpt-6-mini" });
});
});
@@ -1,28 +0,0 @@
import type {
CreateAgentRequestOptions,
CreateWorkspaceRequestOptions,
} from "@getpaseo/client/internal/daemon-client";
export type WorkspaceAgentRequest = NonNullable<CreateWorkspaceRequestOptions["agent"]>;
/**
* The agent a retried creation asks for: everything the draft resubmitted (config, prompt,
* images, attachments and metadata, cleared included) on the first attempt's identity, so a
* retry creates what the composer shows now, not what it showed when creation first failed.
*/
export function retriedAgentRequest(
initial: WorkspaceAgentRequest,
request: CreateAgentRequestOptions,
cwd: string,
): WorkspaceAgentRequest {
const { metadata: _replaced, ...identity } = initial;
return {
...identity,
config: { ...request.config!, cwd },
initialPrompt: request.initialPrompt ?? "",
clientMessageId: initial.clientMessageId,
images: request.images,
attachments: request.attachments,
...(request.metadata ? { metadata: request.metadata } : {}),
};
}
@@ -45,27 +45,3 @@ test("the production app consumes a draft once and leaves other drafts pending",
expect(store.getState().consumePending(submission)).toBeNull();
expect(Object.keys(store.getState().pendingByDraftId)).toEqual(["draft-2"]);
});
test("consuming draft presentation retains its running creation through remount and retry", () => {
const store = loadProductionStore();
const result = new Promise<never>(() => {});
const creation = { result, retry: () => result };
const submission = {
serverId: "server",
workspaceId: "workspace",
draftId: "draft-one",
text: "hello",
attachments: [],
cwd: "/repo",
provider: "codex",
clientMessageId: "message",
timestamp: 1,
agentCreation: creation,
};
store.getState().setPending(submission);
store.getState().consumePending(submission);
expect(store.getState().pendingByDraftId[submission.draftId]).toBeUndefined();
expect(store.getState().creationByDraftId[submission.draftId]).toBe(creation);
store.getState().clearDraftSetup({ draftId: submission.draftId });
expect(store.getState().creationByDraftId[submission.draftId]).toBeUndefined();
});
@@ -1,4 +1,3 @@
import type { CreateAgentRequestOptions } from "@getpaseo/client/internal/daemon-client";
import type { AgentSnapshotPayload } from "@getpaseo/protocol/messages";
import { create } from "zustand";
import type { ComposerAttachment } from "@/attachments/types";
@@ -9,7 +8,6 @@ export interface PendingWorkspaceDraftSubmission {
/** Already-running creation. Mounting the draft only observes its result. */
agentCreation?: {
result: Promise<AgentSnapshotPayload>;
retry: (input: CreateAgentRequestOptions) => Promise<AgentSnapshotPayload>;
};
serverId: string;
workspaceId: string;
@@ -35,7 +33,6 @@ export interface PendingWorkspaceDraftSetup {
interface WorkspaceDraftSubmissionState {
pendingByDraftId: Record<string, PendingWorkspaceDraftSubmission>;
setupByDraftId: Record<string, PendingWorkspaceDraftSetup>;
creationByDraftId: Record<string, NonNullable<PendingWorkspaceDraftSubmission["agentCreation"]>>;
setPending: (submission: PendingWorkspaceDraftSubmission) => void;
setDraftSetup: (input: {
draftId: string;
@@ -69,12 +66,8 @@ export const useWorkspaceDraftSubmissionStore = create<WorkspaceDraftSubmissionS
(set, get) => ({
pendingByDraftId: {},
setupByDraftId: {},
creationByDraftId: {},
setPending: (submission) =>
set((state) => ({
creationByDraftId: submission.agentCreation
? { ...state.creationByDraftId, [submission.draftId]: submission.agentCreation }
: state.creationByDraftId,
pendingByDraftId: {
...state.pendingByDraftId,
[submission.draftId]: submission,
@@ -94,10 +87,8 @@ export const useWorkspaceDraftSubmissionStore = create<WorkspaceDraftSubmissionS
const normalizedDraftId = normalizeDraftId(draftId);
if (!normalizedDraftId) return;
set((state) => {
const creationByDraftId = { ...state.creationByDraftId };
delete creationByDraftId[normalizedDraftId];
const { [normalizedDraftId]: _removed, ...setupByDraftId } = state.setupByDraftId;
return { setupByDraftId, creationByDraftId };
return { setupByDraftId };
});
},
consumePending: (input) => {
@@ -115,6 +115,26 @@ interface CreateTerminalHostInput {
isMac?: boolean;
}
/** Text typed into xterm's textarea, as the browser reports a typed character. */
function typeText(mounted: MountedTerminal, text: string): void {
mounted.host
.querySelector(".xterm-helper-textarea")
?.dispatchEvent(
new InputEvent("input", { data: text, inputType: "insertText", bubbles: true }),
);
}
/** A native paste of plain text into xterm's textarea. */
function pasteText(mounted: MountedTerminal, text: string): void {
const clipboardData = new DataTransfer();
clipboardData.setData("text/plain", text);
mounted.host
.querySelector(".xterm-helper-textarea")
?.dispatchEvent(
new ClipboardEvent("paste", { clipboardData, bubbles: true, cancelable: true }),
);
}
function createTerminalHost(input: CreateTerminalHostInput): MountedTerminal {
const root = document.createElement("div");
root.style.width = `${input.width}px`;
@@ -824,6 +844,23 @@ describe("incremental replay lifecycle", () => {
expect(mounted.inputs[0]?.startsWith("\x1b]4;1;rgb:")).toBe(true);
});
it("keeps what the user types, but not pastes, while a replay is still parsing", async () => {
const mounted = createTerminalHost({ width: 720, height: 360 });
await waitFor({ predicate: () => mounted.sizes.length > 0 });
mounted.runtime.replay({ type: "begin" });
mounted.runtime.replay({ type: "data", data: terminalOutput("\x1b]4;1;?\x07restored") });
mounted.runtime.replay({ type: "end" });
typeText(mounted, "a");
pasteText(mounted, "echo one\necho two\n");
typeText(mounted, "f");
await new Promise<void>((resolve) =>
mounted.runtime.write({ data: terminalOutput("live"), onCommitted: resolve }),
);
expect(mounted.inputs).toEqual(["a", "f"]);
});
it("measures emoji clusters with the daemon's grapheme widths and copies them intact", async () => {
const mounted = createTerminalHost({ width: 720, height: 360 });
const text = "A😀B❤️C👋🏽D👨‍👩‍👧E";
@@ -124,6 +124,7 @@ interface TerminalEmulatorRuntimeDisposables {
clearFitTimeouts: () => void;
removeFontListeners: () => void;
removeFilePasteListener: () => void;
removeUserInputListeners: () => void;
restoreDocumentStyles: () => void;
restoreViewportStyles: () => void;
disposeFitAddon: () => void;
@@ -157,6 +158,10 @@ const isAppleHandheld =
const FIT_TIMEOUT_DELAYS_MS = [0, 16, 48, 120, 250, 500, 1_000, 2_000];
const OUTPUT_OPERATION_TIMEOUT_MS = 5_000;
const EMPTY_TERMINAL_OUTPUT = new Uint8Array(0);
// DOM events xterm turns into typed data on its textarea. Pastes stay suppressed during a
// replay: until the snapshot restores bracketed paste mode, a multi-line paste would run
// line by line in the shell.
const USER_INPUT_EVENT_TYPES = ["keydown", "keypress", "input"] as const;
const RESET_TERMINAL_OUTPUT = new Uint8Array([0x1b, 0x63]);
const terminalOutputEncoder = new TextEncoder();
@@ -313,6 +318,8 @@ export class TerminalEmulatorRuntime {
private hasUngatedWrites = false;
private readonly inputModeDecoder = new TextDecoder();
private suppressInput = false;
// The latest user input event the host saw; its eventPhase is NONE once dispatch ends.
private userInputEvent: Event | null = null;
private readonly inputModeTracker = new TerminalInputModeTracker();
private lastInputModeState: TerminalInputModeState = this.inputModeTracker.getState();
private themeBackgroundElements: HTMLElement[] = [];
@@ -739,8 +746,17 @@ export class TerminalEmulatorRuntime {
fitAndEmitResize({ forceRefresh: true, shouldClaim: false });
// xterm reports what the user types and the replies it generates while parsing
// through the same onData. Replies only come out of parsing a write, never while a
// user input event is dispatching, so typing survives a replay that suppresses them.
const recordUserInputEvent = (event: Event): void => {
this.userInputEvent = event;
};
for (const type of USER_INPUT_EVENT_TYPES) {
input.host.addEventListener(type, recordUserInputEvent, true);
}
const inputDisposable = terminal.onData((data) => {
if (this.suppressInput) {
if (this.suppressInput && !this.isDispatchingUserInput()) {
return;
}
this.callbacks.onInput?.(data);
@@ -844,6 +860,12 @@ export class TerminalEmulatorRuntime {
removeFilePasteListener: () => {
input.host.removeEventListener("paste", filePasteHandler, true);
},
removeUserInputListeners: () => {
for (const type of USER_INPUT_EVENT_TYPES) {
input.host.removeEventListener(type, recordUserInputEvent, true);
}
this.userInputEvent = null;
},
restoreDocumentStyles,
restoreViewportStyles,
disposeFitAddon: () => {
@@ -873,6 +895,7 @@ export class TerminalEmulatorRuntime {
disposables.clearFitTimeouts();
disposables.removeFontListeners();
disposables.removeFilePasteListener();
disposables.removeUserInputListeners();
disposables.disposeFitAddon();
disposables.disposeWebglAddon();
disposables.disposeTerminal();
@@ -913,6 +936,10 @@ export class TerminalEmulatorRuntime {
this.terminal?.paste(text);
}
private isDispatchingUserInput(): boolean {
return (this.userInputEvent?.eventPhase ?? Event.NONE) !== Event.NONE;
}
private pasteClipboard(terminal: Pick<Terminal, "paste">): void {
const text = this.clipboard.readText().catch(() => "");
const onFilePaste = this.callbacks.onFilePaste;
+5
View File
@@ -306,6 +306,11 @@ export interface PaseoAgentTimelineRefetchOptions {
cursor?: FetchAgentTimelineCursor;
limit?: number;
projection?: FetchAgentTimelineProjection;
/**
* Extends a `tail` or `before` page back to the start of the oldest turn it touches, so
* `{ direction: "tail", limit: 1, wholeTurns: true }` returns the latest turn.
*/
wholeTurns?: boolean;
requestId?: string;
}
@@ -0,0 +1,68 @@
import type { Page } from "@playwright/test";
import { test, expect } from "../../app/e2e/support/fixtures";
import { clickNewTerminal, gotoWorkspace } from "../../app/e2e/support/helpers/launcher";
import { seedWorkspace } from "../../app/e2e/support/helpers/seed-client";
import { waitForWorkspaceTabsVisible } from "../../app/e2e/support/helpers/workspace-tabs";
// macOS desktop switches tabs with Cmd+Shift+brackets, like other Mac apps.
// Web keeps Alt+Shift (the browser owns Cmd+Shift+brackets) and is covered in
// the app browser suite. Windows/Linux desktop can't be emulated here: shortcut
// OS detection falls back to the host user agent for non-darwin bridges.
async function installMacDesktopBridge(page: Page): Promise<void> {
await page.addInitScript(() => {
window.paseoDesktop = {
platform: "darwin",
events: { on: () => () => {} },
invoke: async (command: string) => {
if (command === "get_desktop_settings") {
return {
releaseChannel: "stable",
daemon: { manageBuiltInDaemon: false, keepRunningAfterQuit: true },
};
}
return null;
},
};
});
}
const terminalTabs = (page: Page) =>
page.locator('[data-testid^="workspace-tab-terminal_"]').filter({ visible: true });
async function openTwoTerminalTabs(page: Page) {
await waitForWorkspaceTabsVisible(page);
await clickNewTerminal(page);
await expect(terminalTabs(page)).toHaveCount(1, { timeout: 30_000 });
await clickNewTerminal(page);
await expect(terminalTabs(page)).toHaveCount(2, { timeout: 30_000 });
const first = terminalTabs(page).nth(0);
const second = terminalTabs(page).nth(1);
await expect(second).toHaveAttribute("aria-selected", "true");
return { first, second };
}
test.describe("Previous/next tab shortcuts on desktop", () => {
test("macOS desktop switches tabs with Cmd+Shift+[ and ], not Alt+Shift", async ({ page }) => {
const workspace = await seedWorkspace({ repoPrefix: "tab-relative-mac-desktop-" });
try {
await installMacDesktopBridge(page);
await gotoWorkspace(page, workspace.workspaceId);
const { first, second } = await openTwoTerminalTabs(page);
await page.keyboard.press("Meta+Shift+BracketLeft");
await expect(first).toHaveAttribute("aria-selected", "true");
await page.keyboard.press("Meta+Shift+BracketRight");
await expect(second).toHaveAttribute("aria-selected", "true");
// Alt+Shift is the web and Windows/Linux binding and must not fire here.
// Had it moved to the first tab, Cmd+Shift+[ would wrap back to the second.
await page.keyboard.press("Alt+Shift+BracketLeft");
await page.keyboard.press("Meta+Shift+BracketLeft");
await expect(first).toHaveAttribute("aria-selected", "true");
} finally {
await workspace.cleanup();
}
});
});
+11 -1
View File
@@ -393,6 +393,7 @@ describe("lifecycle hook SDK types", () => {
expect(
typeErrors(`
import type { PluginServerContext, PluginHooks, PluginHookPayload, PluginHookResult } from "@getpaseo/plugin/server";
import type { AgentTimelineItem } from "@getpaseo/protocol/agent-types";
export default function contribute(server: PluginServerContext) {
server.addHook("worktree.create.before", async ({ request }) => {
if (request.kind === "checkout-branch") {
@@ -426,8 +427,17 @@ describe("lifecycle hook SDK types", () => {
const { metadata, ...withoutMetadata } = request;
return withoutMetadata;
});
server.addHook("agent.turn.after", async ({ agent, outcome, timeline }, { paseo, signal }) => {
server.addHook("agent.turn.after", async (event, { paseo, signal }) => {
const { agent, outcome } = event;
const kind: "completed" | "failed" | "canceled" = outcome.kind;
// @ts-expect-error Turn payloads carry no history; read it from the agent's timeline.
event.timeline;
const turn = await paseo.agents
.ref(agent.id)
.timeline.refetch({ direction: "tail", limit: 1, wholeTurns: true });
const items: readonly AgentTimelineItem[] = turn.entries
.filter((entry) => entry.turnId === event.turnId)
.map(({ item }) => item);
// @ts-expect-error Notification payloads have no creation request.
outcome.request;
});
+5 -5
View File
@@ -2,7 +2,6 @@ import type { AgentToolDefinition } from "./agent-tools.js";
import type {
AgentPermissionRequest,
AgentPermissionResponse,
AgentTimelineItem,
AgentSessionConfig,
JsonValue,
} from "@getpaseo/protocol/agent-types";
@@ -104,12 +103,13 @@ export interface PluginHookEvents {
"agent.turn.after": {
agent: PluginHookAgent;
turnId: string | null;
outcome: PluginTurnOutcome;
/**
* @deprecated Always empty. Fetch history on demand with
* context.paseo.agents.ref(agent.id).timeline.refetch({ direction: "tail", limit: 20 }).
* The payload carries no history. Read the turn with
* `paseo.agents.ref(agent.id).timeline.refetch({ direction: "tail", limit: 1, wholeTurns: true })`
* and keep the entries whose `turnId` equals this `turnId`. The result is empty when a newer turn
* started before the read, or when `turnId` is `null`.
*/
timeline: readonly AgentTimelineItem[];
outcome: PluginTurnOutcome;
};
"agent.permission.before": { agent: PluginHookAgent; request: AgentPermissionRequest };
"agent.permission.after": {
@@ -1529,6 +1529,110 @@ test("does not replace a newer foreground turn after unavailable steer fallback
}
});
test("concurrent interrupting sends each start their own turn in order", async () => {
const session = new SteeringTestSession({ provider: "codex", cwd: process.cwd() });
const { manager, agentId, workdir } = await startAndSteerThroughManager(session, "interrupt");
try {
const prompts = ["first interrupt", "second interrupt", "third interrupt"];
await Promise.all(
prompts.map((prompt) =>
startAgentRun(manager, agentId, prompt, logger, {
replaceRunning: true,
activeTurnBehavior: "interrupt",
runOptions: { clientMessageId: prompt },
}),
),
);
await manager.waitForAgentRunStart(agentId);
expect(session.startPrompts.slice(-3)).toEqual(prompts);
expect(manager.getAgent(agentId)?.activeForegroundTurnId).toBe(
`active-turn-${session.startCount}`,
);
} finally {
await manager.closeAgent(agentId);
rmSync(workdir, { recursive: true, force: true });
}
});
test("Stop can cancel startup while an interrupting send waits behind it", async () => {
const entered = deferred<void>();
const release = deferred<void>();
class SlowStartSession extends SteeringTestSession {
starting = false;
override async startTurn(prompt: AgentPromptInput): Promise<{ turnId: string }> {
if (prompt === "slow startup") {
this.starting = true;
entered.resolve();
await release.promise;
this.starting = false;
}
return super.startTurn(prompt);
}
override async interrupt(): Promise<void> {
if (this.starting) release.resolve();
await super.interrupt();
}
}
const session = new SlowStartSession({ provider: "codex", cwd: process.cwd() });
const { manager, agentId, workdir } = await startAndSteerThroughManager(session, "interrupt");
try {
await startAgentRun(manager, agentId, "slow startup", logger, { replaceRunning: true });
await entered.promise;
const replacement = startAgentRun(manager, agentId, "queued replacement", logger, {
replaceRunning: true,
});
void replacement.catch(() => undefined);
const stop = manager.cancelAgentRun(agentId);
await vi.waitFor(() => expect(session.interruptCount).toBe(3), { timeout: 1000 });
await stop;
await replacement;
await manager.waitForAgentRunStart(agentId);
expect(session.startPrompts.slice(-2)).toEqual(["slow startup", "queued replacement"]);
} finally {
release.resolve();
await manager.closeAgent(agentId);
rmSync(workdir, { recursive: true, force: true });
}
});
test("an interrupting send replaces a turn whose start never finishes", async () => {
class StalledStartSession extends SteeringTestSession {
override async startTurn(prompt: AgentPromptInput): Promise<{ turnId: string }> {
if (prompt === "stalled start") {
this.startPrompts.push(prompt);
return new Promise(() => {});
}
return super.startTurn(prompt);
}
}
const session = new StalledStartSession({ provider: "codex", cwd: process.cwd() });
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-stalled-start-"));
const client = new (class extends TestAgentClient {
override async createSession(): Promise<AgentSession> {
return session;
}
})();
const manager = new AgentManager({
clients: { codex: client },
logger,
rescueTimeouts: { interruptSessionMs: 10 },
});
const agent = await manager.createAgent({ provider: "codex", cwd: workdir }, undefined, {
workspaceId: undefined,
});
try {
await startAgentRun(manager, agent.id, "stalled start", logger, { replaceRunning: true });
await startAgentRun(manager, agent.id, "recovery", logger, {
replaceRunning: true,
activeTurnBehavior: "interrupt",
});
expect(session.startPrompts).toEqual(["stalled start", "recovery"]);
} finally {
await manager.closeAgent(agent.id);
rmSync(workdir, { recursive: true, force: true });
}
});
test("steers a tracked autonomous turn without creating a replacement run", async () => {
const session = new SteeringTestSession({ provider: "claude", cwd: process.cwd() });
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-autonomous-steer-"));
@@ -13230,7 +13334,6 @@ test.each([
},
turnId: "hook-turn",
outcome,
timeline: [],
},
},
]);
@@ -844,6 +844,7 @@ export class AgentManager {
private readonly sessionEventTails = new Map<string, Promise<void>>();
private readonly steerEventBarriers = new Map<string, SteerEventBarrier>();
private readonly foregroundMutationTails = new Map<string, Promise<void>>();
private readonly replacementMutationTails = new Map<string, Promise<void>>();
private readonly runs = new AgentRunState();
private readonly subscribers = new Set<SubscriptionRecord>();
private readonly idFactory: () => string;
@@ -2112,6 +2113,7 @@ export class AgentManager {
agent.inFlightPermissionResponses.size === 0 &&
(this.collectingRuntimes.has(agent.id) || !this.lifecycleMutationTails.has(agent.id)) &&
!this.foregroundMutationTails.has(agent.id) &&
!this.replacementMutationTails.has(agent.id) &&
!this.sessionEventTails.has(agent.id) &&
!this.providerSubagents.list(agent.id).some((child) => child.status === "running")
);
@@ -3414,31 +3416,8 @@ export class AgentManager {
prompt: AgentPromptInput,
options?: AgentRunOptions,
): Promise<AsyncGenerator<AgentStreamEvent>> {
const snapshot = this.requireAgent(agentId);
if (
snapshot.lifecycle !== "running" &&
!snapshot.activeForegroundTurnId &&
!this.runs.hasRun(agentId)
) {
return this.streamAgent(agentId, prompt, options);
}
const agent = this.requireSessionAgent(agentId);
agent.pendingReplacement = true;
agent.lifecycle = "running";
this.touchUpdatedAt(agent);
this.emitState(agent);
try {
await this.cancelAgentRunBefore(agentId, "replace");
return this.streamAgent(agentId, prompt, options);
} catch (error) {
const latest = this.agents.get(agentId);
if (latest) {
latest.pendingReplacement = false;
}
throw error;
}
this.requireSessionAgent(agentId);
return this.replaceRunInForegroundLane(agentId, prompt, options);
}
async steerAgentRun(
@@ -3562,18 +3541,26 @@ export class AgentManager {
}
private async runForegroundMutation<T>(agentId: string, operation: () => Promise<T>): Promise<T> {
const previous = this.foregroundMutationTails.get(agentId) ?? Promise.resolve();
return this.runQueuedMutation(this.foregroundMutationTails, agentId, operation);
}
private async runQueuedMutation<T>(
tails: Map<string, Promise<void>>,
agentId: string,
operation: () => Promise<T>,
): Promise<T> {
const previous = tails.get(agentId) ?? Promise.resolve();
const run = previous.catch(() => undefined).then(operation);
const tail = run.then(
() => undefined,
() => undefined,
);
this.foregroundMutationTails.set(agentId, tail);
tails.set(agentId, tail);
try {
return await run;
} finally {
if (this.foregroundMutationTails.get(agentId) === tail) {
this.foregroundMutationTails.delete(agentId);
if (tails.get(agentId) === tail) {
tails.delete(agentId);
}
}
}
@@ -3585,20 +3572,81 @@ export class AgentManager {
options?: AgentRunOptions,
): Promise<AsyncGenerator<AgentStreamEvent>> {
this.assertSteerAdmissionOwnsTurn(agent, expectedTurnId);
agent.pendingReplacement = true;
agent.lifecycle = "running";
this.touchUpdatedAt(agent);
this.emitState(agent);
return this.replaceRunInForegroundLane(agent.id, prompt, options, (live) =>
this.assertSteerAdmissionOwnsTurn(live, expectedTurnId),
);
}
try {
await this.cancelAgentRunBefore(agent.id, "replace");
return this.streamAgent(agent.id, prompt, options);
} catch (error) {
const latest = this.agents.get(agent.id);
if (latest) {
latest.pendingReplacement = false;
/**
* Cancels the current run and reserves its replacement in one foreground mutation, so a
* concurrent replacement or Stop cannot land between the cancel and the new run's start.
*/
private async replaceRunInForegroundLane(
agentId: string,
prompt: AgentPromptInput,
options: AgentRunOptions | undefined,
assertAdmission?: (agent: ActiveManagedAgent) => void,
): Promise<AsyncGenerator<AgentStreamEvent>> {
// Replacements queue in arrival order; each one holds the foreground lane only while
// it cancels and reserves, so Stop is never stuck behind a replacement that is waiting.
return this.runQueuedMutation(this.replacementMutationTails, agentId, () =>
this.replaceRunWhenAdmitted(agentId, prompt, options, assertAdmission),
);
}
private async replaceRunWhenAdmitted(
agentId: string,
prompt: AgentPromptInput,
options: AgentRunOptions | undefined,
assertAdmission?: (agent: ActiveManagedAgent) => void,
): Promise<AsyncGenerator<AgentStreamEvent>> {
let stalledStart: PendingForegroundRun | null = null;
for (;;) {
const admission = await this.runForegroundMutation(agentId, async () => {
// Resolve the agent inside the lane: a reload while this send waited replaces it.
const agent = this.requireSessionAgent(agentId);
// A run that is still starting has no turn to interrupt yet. Canceling it would drop
// its prompt, so wait for it outside the lane, where Stop can still reach it. A start
// that outlasts the rescue timeout is canceled like any other run.
const starting = this.runs.getPendingRun(agent.id);
if (starting && starting.start.status !== "started" && starting !== stalledStart) {
return { status: "starting" as const, run: starting };
}
assertAdmission?.(agent);
if (!this.hasInFlightRun(agent.id)) {
return {
status: "reserved" as const,
iterator: this.streamAgent(agent.id, prompt, options),
};
}
agent.pendingReplacement = true;
agent.lifecycle = "running";
this.touchUpdatedAt(agent);
this.emitState(agent);
try {
const result = await this.cancelAgentRunNow(agent.id);
if (result.status === "refused") {
throw new AgentRunCancellationError(agent.id, "replace");
}
return {
status: "reserved" as const,
iterator: this.streamAgent(agent.id, prompt, options),
};
} catch (error) {
agent.pendingReplacement = false;
throw error;
}
});
if (admission.status === "reserved") {
return admission.iterator;
}
const started = await this.waitWithTimeout({
operation: Promise.race([admission.run.turnStartedPromise, admission.run.settledPromise]),
timeoutMs: this.rescueTimeouts.interruptSessionMs,
});
if (started === "timed_out") {
stalledStart = admission.run;
}
throw error;
}
}
@@ -107,11 +107,17 @@ async function olderHost() {
return { client, socket };
}
function resolverFor(client: DaemonClient, callerBackground: boolean) {
interface Caller {
background: boolean;
provider: string;
currentModeId: string | null;
}
function resolverFor(client: DaemonClient, caller: Caller | null) {
return createHostResolver({
serverId: "origin",
callerAgentId: "caller",
caller: () => ({ background: callerBackground }),
callerAgentId: caller ? "caller" : undefined,
caller: () => caller,
local: {} as HostOperations,
hosts: () => ({
directory: async () => ({ self: { serverId: "origin", label: "Origin" }, hosts: [] }),
@@ -120,6 +126,12 @@ function resolverFor(client: DaemonClient, callerBackground: boolean) {
});
}
const visibleCodexCaller: Caller = {
background: false,
provider: "codex",
currentModeId: "full-access",
};
const createArgs = {
workspaceId: workspace.id,
provider: "codex/gpt-5.4",
@@ -129,7 +141,7 @@ const createArgs = {
test("a visible caller still creates agents on a host without background agents", async () => {
const { client, socket } = await olderHost();
const creating = resolverFor(client, false).use("srv_older", (host) =>
const creating = resolverFor(client, visibleCodexCaller).use("srv_older", (host) =>
host.createAgent(createArgs, {} as never),
);
creating.catch(() => {});
@@ -143,7 +155,37 @@ test("a background caller's remote child asks the host to update instead of turn
const { client, socket } = await olderHost();
await expect(
resolverFor(client, true).use("srv_older", (host) => host.createAgent(createArgs, {} as never)),
resolverFor(client, { ...visibleCodexCaller, background: true }).use("srv_older", (host) =>
host.createAgent(createArgs, {} as never),
),
).rejects.toThrow("Update the host to use background agents.");
expect(socket.requests.map((request) => request.type)).not.toContain("create_agent_request");
});
async function forwardedMode(caller: Caller | null, args: Record<string, unknown> = createArgs) {
const { client, socket } = await olderHost();
const creating = resolverFor(client, caller).use("srv_older", (host) =>
host.createAgent(args, {} as never),
);
creating.catch(() => {});
const request = (await socket.createRequest()) as { config: { modeId?: string } };
return request.config.modeId;
}
test("a remote child of the same provider starts in its caller's mode", async () => {
expect(await forwardedMode(visibleCodexCaller)).toBe("full-access");
});
test("an explicit mode wins over the caller's mode on a remote host", async () => {
expect(
await forwardedMode(visibleCodexCaller, { ...createArgs, settings: { modeId: "auto" } }),
).toBe("auto");
});
test("a remote child of another provider leaves its mode to the host", async () => {
expect(await forwardedMode({ ...visibleCodexCaller, provider: "claude" })).toBeUndefined();
});
test("a remote create without a caller agent leaves its mode to the host", async () => {
expect(await forwardedMode(null)).toBeUndefined();
});
@@ -203,8 +203,8 @@ export function createHostResolver(input: {
local: HostOperations;
hosts: () => ToolHosts;
callerAgentId?: string;
/** The destination can't see the caller, so its background defaults resolve before routing. */
caller: () => BackgroundCaller | null;
/** The destination can't see the caller, so its background and mode defaults resolve before routing. */
caller: () => (BackgroundCaller & { provider: string; currentModeId: string | null }) | null;
lockedCwd?: string;
}) {
return {
@@ -222,6 +222,11 @@ export function createHostResolver(input: {
async createAgent(args) {
const parsed = remoteCreate.parse(args);
const { provider, model } = resolveRequiredProviderModel(parsed.provider);
const caller = input.caller();
// Same-provider children inherit the caller's mode, as they would on the same host.
const modeId =
parsed.settings?.modeId ??
(caller?.provider === provider ? (caller.currentModeId ?? undefined) : undefined);
const workspace = await client.findWorkspace(parsed.workspaceId);
if (!workspace) throw new Error(`Workspace ${parsed.workspaceId} not found`);
const labels = {
@@ -236,15 +241,13 @@ export function createHostResolver(input: {
model,
cwd: workspace.workspaceDirectory,
title: parsed.title,
modeId: parsed.settings?.modeId,
modeId,
thinkingOptionId: parsed.settings?.thinkingOptionId,
featureValues: parsed.settings?.features,
},
workspaceId: parsed.workspaceId,
// Older hosts reject any background with a workspaceId, so a visible create omits it.
...(inheritAgentBackground(parsed.background, input.caller())
? { background: true }
: {}),
...(inheritAgentBackground(parsed.background, caller) ? { background: true } : {}),
labels,
initialPrompt: parsed.initialPrompt,
idempotencyKey: parsed.idempotencyKey,
@@ -0,0 +1,67 @@
import { describe, expect, it } from "vitest";
import type {
AgentPersistenceHandle,
AgentSession,
AgentSessionConfig,
ImportProviderSessionContext,
} from "./agent-sdk-types.js";
import { importSessionFromPersistence } from "./provider-session-import.js";
const SESSION_FILE = "/Users/dev/.omp/agent/sessions/project/2026-01-01_session-id.jsonl";
const SESSION_ID = "session-id";
function config(cwd: string): AgentSessionConfig {
return { provider: "omp", cwd };
}
function context(cwd: string): ImportProviderSessionContext {
return { config: config(cwd), storedConfig: config(cwd) };
}
function fakeSession(described: AgentPersistenceHandle | null): AgentSession {
return {
async *streamHistory() {},
describePersistence: () => described,
} as unknown as AgentSession;
}
describe("importSessionFromPersistence", () => {
it("persists the resumed session identity instead of the listed file handle", async () => {
const cwd = "/Users/dev/code/project";
const imported = await importSessionFromPersistence({
provider: "omp",
request: { providerHandleId: SESSION_FILE, cwd },
context: context(cwd),
resumeSession: async () =>
fakeSession({
provider: "omp",
sessionId: SESSION_ID,
nativeHandle: SESSION_FILE,
metadata: { cwd, model: "resumed-model" },
}),
});
expect(imported.persistence).toMatchObject({
provider: "omp",
sessionId: SESSION_ID,
nativeHandle: SESSION_FILE,
metadata: { cwd, provider: "omp", model: "resumed-model" },
});
});
it("keeps the requested handle when the provider describes no identity", async () => {
const cwd = "/Users/dev/code/project";
const imported = await importSessionFromPersistence({
provider: "omp",
request: { providerHandleId: SESSION_FILE, cwd },
context: context(cwd),
resumeSession: async () => fakeSession(null),
});
expect(imported.persistence).toMatchObject({
provider: "omp",
sessionId: SESSION_FILE,
nativeHandle: SESSION_FILE,
});
});
});
@@ -38,12 +38,32 @@ export async function importSessionFromPersistence(input: {
return {
session,
config: storedConfig,
persistence,
persistence: canonicalImportPersistence(persistence, session),
timeline: history.timeline,
providerSubagentEvents: history.providerSubagentEvents,
};
}
/**
* Import handles identify a session by whatever the provider lists, which is not
* always the session id: OMP lists session files. Persist the resumed session's own
* identity instead, so records, dedupe and resume all agree, while the provider's
* native handle still resolves the session.
*/
function canonicalImportPersistence(
requested: AgentPersistenceHandle,
session: ImportedProviderSession["session"],
): AgentPersistenceHandle {
const described = session.describePersistence();
if (!described?.sessionId) return requested;
return {
...described,
provider: requested.provider,
nativeHandle: described.nativeHandle ?? requested.nativeHandle,
metadata: { ...requested.metadata, ...described.metadata },
};
}
function buildImportPersistenceHandle(
provider: AgentProvider,
input: ImportProviderSessionInput,
@@ -147,7 +147,7 @@ describe("Codex thread history", () => {
appServer.assertNoErrors();
});
test("a page too big to send shows a warning row and stops loading", async () => {
test("a page too big to send retries smaller pages, then warns at the item that cannot fit", async () => {
const answers = Array.from({ length: 120 }, (_, index) =>
agentItem(`agent-${index}`, `Answer ${index}`),
);
@@ -163,11 +163,12 @@ describe("Codex thread history", () => {
);
const loaded = [
{ type: "user_message", text: "One", messageId: "user-1" },
...answers.slice(0, 99).map((answer) => ({
...answers.map((answer) => ({
type: "assistant_message",
text: answer.text,
messageId: answer.id,
})),
{ type: "user_message", text: "Two", messageId: "user-2" },
{
type: "notification",
level: "warning",
@@ -176,6 +177,12 @@ describe("Codex thread history", () => {
];
try {
expect(timelineItems(await readHistory(session))).toEqual(loaded);
// Each oversized page retries the same cursor at half the size until it fits.
expect(
historyRequests(appServer)
.filter((request) => request.method === "thread/items/list")
.map((request) => (request.params as { limit: number }).limit),
).toEqual([100, 100, 50, 25, 12, 12, 6, 6, 3, 3, 1, 1]);
// The oversized reply failed only its own request; the session still answers.
expect(timelineItems(await readHistory(session))).toEqual(loaded);
} finally {
@@ -124,15 +124,28 @@ async function* readPaginatedHistory(
): AsyncGenerator<CodexHistoryRow> {
const seenCursors = new Set<string>();
let cursor: string | undefined;
let limit = PAGE_SIZE;
for (;;) {
const page = CodexItemsPageSchema.parse(
await requestHistory(
client,
"thread/items/list",
{ threadId, limit: PAGE_SIZE, sortDirection: "asc", cursor },
let response: unknown;
try {
response = await client.request("thread/items/list", {
threadId,
limit,
sortDirection: "asc",
cursor,
});
} catch (error) {
if (!(error instanceof CodexAppServerResponseTooLargeError)) throw error;
// Image-heavy items can push a full page past the line cap; retry the same cursor smaller.
if (limit > 1) {
limit = Math.max(1, Math.floor(limit / 2));
continue;
}
throw new CodexThreadHistoryTooLargeError(
"The rest of this Codex thread's history is too large to load.",
),
);
);
}
const page = CodexItemsPageSchema.parse(response);
for (const row of page.data) {
yield {
item: row.item,
@@ -1,6 +1,7 @@
import { z } from "zod";
import type { ToolCallDetail } from "../../agent-sdk-types.js";
import { omitNullArgs } from "../tool-call-args.js";
interface BashToolInput {
command: string;
@@ -324,7 +325,7 @@ export function parseToolArgs(toolName: string, rawArgs: unknown): OmpTrackedToo
}
const schema = SIMPLE_TOOL_SCHEMAS[toolName as SimpleToolKind];
if (schema) {
const parsed = schema.safeParse(rawArgs);
const parsed = schema.safeParse(omitNullArgs(rawArgs));
if (parsed.success) {
return {
kind: toolName as SimpleToolKind,
@@ -2,6 +2,7 @@ import { z } from "zod";
import type { ToolCallDetail } from "../../agent-sdk-types.js";
import type { ProviderImageOutput } from "../provider-image-output.js";
import { omitNullArgs } from "../tool-call-args.js";
interface BashToolInput {
command: string;
@@ -331,7 +332,7 @@ export function parseToolArgs(toolName: string, rawArgs: unknown): PiTrackedTool
}
const schema = SIMPLE_TOOL_SCHEMAS[toolName as SimpleToolKind];
if (schema) {
const parsed = schema.safeParse(rawArgs);
const parsed = schema.safeParse(omitNullArgs(rawArgs));
if (parsed.success) {
return { kind: toolName as SimpleToolKind, toolName, args: parsed.data } as PiTrackedToolCall;
}
@@ -0,0 +1,18 @@
import { describe, expect, test } from "vitest";
import { parseToolArgs as parseOmpToolArgs } from "./omp/tool-call-detail.js";
import { parseToolArgs as parsePiToolArgs } from "./pi/tool-call-mapper.js";
// Models send `null` for optional arguments (captured in #7011: read offset/limit), which the
// runtimes treat as omitted. Rejecting them turned every such call into an unknown card.
describe.each([
["Pi", parsePiToolArgs],
["OMP", parseOmpToolArgs],
])("%s tool args with null optional values", (_provider, parseToolArgs) => {
test.each([
["read", { path: "./preview.png", offset: null, limit: null }, { path: "./preview.png" }],
["bash", { command: "ls", timeout: null }, { command: "ls" }],
])("parses %s as its own tool kind", (toolName, rawArgs, args) => {
expect(parseToolArgs(toolName, rawArgs)).toEqual({ kind: toolName, toolName, args });
});
});
@@ -0,0 +1,9 @@
// Models routinely send `null` for optional tool arguments (e.g. read offset/limit); the
// runtimes treat that as omitted, so the mappers must too or the call degrades to an
// unknown card that carries the raw result.
export function omitNullArgs(rawArgs: unknown): unknown {
if (!rawArgs || typeof rawArgs !== "object" || Array.isArray(rawArgs)) {
return rawArgs;
}
return Object.fromEntries(Object.entries(rawArgs).filter(([, value]) => value !== null));
}
@@ -4,6 +4,7 @@ import { join } from "node:path";
import { tmpdir } from "node:os";
import pino from "pino";
import { expect, test } from "vitest";
import { DiffOpcode } from "@getpaseo/protocol/binary-frames/index";
import { CheckoutDiffManager, type CheckoutDiffSnapshotPayload } from "./checkout-diff-manager.js";
import { createWorkspaceRuntimeStore } from "./workspace-runtime-store/index.js";
import { createFileObserver } from "./file-observer/index.js";
@@ -137,6 +138,84 @@ test("cancelling a legacy subscriber during a shared revision does not revive it
});
}, 30_000);
test("legacy subscribers are not re-sent a snapshot that did not change", async () => {
await withCheckout(async ({ cwd, manager }) => {
await writeFile(join(cwd, ".gitignore"), "*.log\n");
await writeFile(join(cwd, "x.ts"), "const value = 1;\n");
let revisions = 0;
const modern = await manager.streams.subscribe(
{ cwd, base: { kind: "rev", rev: "HEAD" }, head: { kind: "worktree" } },
async (frame) => {
if (frame.opcode === DiffOpcode.End) revisions++;
},
);
await modern.start();
const updates: CheckoutDiffSnapshotPayload[] = [];
const legacy = await manager.subscribe({ cwd, compare: { mode: "uncommitted" } }, (snapshot) =>
updates.push(snapshot),
);
try {
expect(legacy.initial.files.map((file) => file.path)).toEqual([".gitignore", "x.ts"]);
await waitForSubscriberCount(manager, 2);
const before = revisions;
await writeFile(join(cwd, "app.log"), "ignored\n");
await writeFile(join(cwd, "README"), "base\n");
await writeFile(join(cwd, "x.ts"), "const value = 1;\n");
await manager.scheduleRefreshForCwd(cwd);
await expect.poll(() => revisions, { timeout: 10_000 }).toBeGreaterThan(before);
expect(updates).toHaveLength(0);
await writeFile(join(cwd, "x.ts"), "const value = 2;\n");
await expect.poll(() => updates.length, { timeout: 10_000 }).toBe(1);
expect(updates[0].files[1].hunks[0].lines).toContainEqual(
expect.objectContaining({ type: "add", content: "const value = 2;" }),
);
} finally {
await legacy.unsubscribe();
await modern.release();
}
});
}, 30_000);
test("legacy All subscribers are not re-sent the snapshot when a commit only moves HEAD", async () => {
await withCheckout(async ({ cwd, manager, git }) => {
git("checkout", "-b", "feature");
await writeFile(join(cwd, "x.ts"), "const value = 1;\n");
const endedHeads: string[] = [];
let beganHead = "";
const modern = await manager.streams.subscribe(
{
cwd,
base: { kind: "rev", rev: "main" },
checkoutBase: { compare: { mode: "all", baseRef: "main" }, paseoHome: cwd },
head: { kind: "worktree" },
mergeBase: true,
},
async (frame) => {
if (frame.opcode === DiffOpcode.Begin) beganHead = frame.headSha;
if (frame.opcode === DiffOpcode.End) endedHeads.push(beganHead);
},
);
await modern.start();
const updates: CheckoutDiffSnapshotPayload[] = [];
const legacy = await manager.subscribe(
{ cwd, compare: { mode: "all", baseRef: "main" } },
(snapshot) => updates.push(snapshot),
);
try {
expect(legacy.initial.files.map((file) => file.path)).toEqual(["x.ts"]);
await waitForSubscriberCount(manager, 2);
git("add", ".");
git("-c", "commit.gpgsign=false", "commit", "-m", "feature");
const head = git("rev-parse", "HEAD");
await expect.poll(() => endedHeads.at(-1), { timeout: 10_000 }).toBe(head);
expect(updates).toHaveLength(0);
} finally {
await legacy.unsubscribe();
await modern.release();
}
});
}, 30_000);
async function waitForSubscriberCount(manager: CheckoutDiffManager, count: number): Promise<void> {
await expect
.poll(() => manager.getMetrics().checkoutDiffSubscriptionCount, { timeout: 10_000 })
@@ -73,6 +73,7 @@ export class CheckoutDiffManager {
resolveInitial = resolve;
});
let delivered = false;
let deliveredFingerprint = "";
let pending: DiffBegin | null = null;
let files = new Map<string, ParsedDiffFile>();
let bodies = new Map<string, DiffBody>();
@@ -114,6 +115,11 @@ export class CheckoutDiffManager {
payload = legacySnapshot(cwd, [...next.values()]);
}
pending = null;
// The engine commits a revision per observed input change, including ones that only move the
// resolved SHAs this snapshot omits. Old clients get a snapshot only when it differs.
const fingerprint = JSON.stringify(payload);
if (fingerprint === deliveredFingerprint) return;
deliveredFingerprint = fingerprint;
if (delivered) listener(payload);
else {
delivered = true;
@@ -2127,6 +2127,57 @@ test(
},
);
test(
"a remote child without a mode starts in its same-provider caller's mode",
{ timeout: 30000 },
async () => {
const a = await daemon({ mcpEnabled: true });
const b = await daemon();
const origin = await api(a);
const destination = await api(b);
await origin.paseo.hosts.add({ connection: `127.0.0.1:${b.port}` });
const directory = await online(origin.paseo);
const workspace = await origin.paseo.workspaces.create({
source: { kind: "directory", path: a.staticDir },
});
const remoteWorkspace = await destination.paseo.workspaces.create({
source: { kind: "directory", path: b.staticDir },
});
const parent = await workspace.agents.create({
config: { provider: "claude/claude-test", modeId: "bypassPermissions" },
title: "Mode parent",
});
const mcp = new Client({ name: "mode-parent", version: "1" });
cleanup.push(() => mcp.close());
await mcp.connect(
new StreamableHTTPClientTransport(
new URL(`http://127.0.0.1:${a.port}/mcp/agents?callerAgentId=${parent.id}`),
),
);
const remoteMode = async (provider: string, settings?: { modeId: string }) => {
const response = await mcp.callTool({
name: "create_agent",
arguments: {
serverId: directory.hosts[0]!.serverId,
workspaceId: remoteWorkspace.id,
provider,
title: "Mode child",
initialPrompt: "Report your mode",
settings,
},
});
expect(response.isError, JSON.stringify(response)).not.toBe(true);
const childId = (response.structuredContent as { agentId: string }).agentId;
return (await destination.client.fetchAgent(childId))?.agent.currentModeId;
};
expect(await remoteMode("claude/claude-test")).toBe("bypassPermissions");
expect(await remoteMode("claude/claude-test", { modeId: "default" })).toBe("default");
// Another provider's modes are unknown here, so the destination applies its own default.
expect(await remoteMode("codex/gpt-5.4")).toBeNull();
},
);
test(
"remote finish observation survives a permission checkpoint until resumed completion",
{ timeout: 30000 },
+113 -34
View File
@@ -12,7 +12,6 @@ import {
type CheckoutDiffCompare,
} from "../../utils/checkout-git.js";
import { lstat, readFile, readlink } from "node:fs/promises";
import type { BigIntStats } from "node:fs";
import { resolve } from "node:path";
import type { DiffEndpoint, DiffSubscribeRequest } from "@getpaseo/protocol/messages";
import type { DiffEntry, DiffBody } from "@getpaseo/protocol/binary-frames/index";
@@ -27,6 +26,16 @@ export interface ListedFile {
identity: string;
source: { kind: "tracked" } | { kind: "untracked"; size: number };
}
interface WorktreeInput {
/** Comparison-side mode, or null when the file is added/untracked on this side. */
oldMode: string | null;
/** Comparison-side blob, or null when the file is added/untracked on this side. */
oldBlob: string | null;
/** A regular file whose bytes readGitInputs blob-hashes. */
hashed: boolean;
/** Content Git cannot read as a regular file, such as a symlink target or submodule state. */
literal: string | null;
}
interface ResolvedComparison {
baseSha: string;
headSha: string;
@@ -195,20 +204,47 @@ async function readComparisonList(
...(privateIndex ? { GIT_INDEX_FILE: privateIndex.indexFile } : {}),
},
};
const { files, worktreeFiles, interpretationPaths } = await readTrackedFiles(
compare,
args,
options,
);
if (compare.head.kind === "worktree") files.push(...(await listUntrackedFiles(options)));
const tracked = await readTrackedFiles(compare, args, options);
const files = tracked.files;
const worktreeInputs = tracked.worktreeInputs;
if (compare.head.kind === "worktree") {
const untracked = await listUntrackedFiles(options);
files.push(...untracked.files);
for (const [path, input] of untracked.worktreeInputs) worktreeInputs.set(path, input);
}
files.sort((a, b) => a.entry.path.localeCompare(b.entry.path));
for (const file of files) interpretationPaths.add(file.entry.path);
const gitInputs = await readGitInputs([...interpretationPaths].sort(), worktreeFiles, options);
for (const file of files)
for (const file of files) tracked.interpretationPaths.add(file.entry.path);
const hashedPaths = new Set(
[...worktreeInputs].filter(([, input]) => input.hashed).map(([path]) => path),
);
const literalContent = new Map(
[...worktreeInputs].flatMap(([path, input]) =>
input.literal === null ? [] : [[path, input.literal] as const],
),
);
const gitInputs = await readGitInputs({
paths: [...tracked.interpretationPaths].sort(),
hashedPaths,
options,
literalContent,
});
for (const file of files) {
if (compare.head.kind === "worktree") {
const input = worktreeInputs.get(file.entry.path);
if (!input) throw new Error(`Missing worktree identity for ${file.entry.path}`);
file.identity = JSON.stringify([
input.oldMode,
input.oldBlob,
gitInputs.identities.get(file.entry.path) ?? null,
gitInputs.identities.get(file.entry.oldPath ?? file.entry.path) ?? null,
]);
continue;
}
file.identity += JSON.stringify([
gitInputs.identities.get(file.entry.path),
gitInputs.identities.get(file.entry.oldPath ?? file.entry.path),
]);
}
const metrics = { highlightCalls: 0 };
const patches = new Patches(args, options, files);
const sources = new HighlightSources({
@@ -237,7 +273,15 @@ async function readComparisonList(
const result =
file.source.kind === "untracked"
? await runGitCommand(
["diff", "--no-index", ...GIT_PATCH_FORMAT_ARGS, "--", "/dev/null", file.entry.path],
[
"diff",
"--no-index",
"--no-textconv",
...GIT_PATCH_FORMAT_ARGS,
"--",
"/dev/null",
file.entry.path,
],
{
...options,
acceptExitCodes: [0, 1],
@@ -293,7 +337,11 @@ async function readTrackedFiles(
compare: Compare,
args: string[],
options: GitCommandOptions,
): Promise<{ files: ListedFile[]; worktreeFiles: Set<string>; interpretationPaths: Set<string> }> {
): Promise<{
files: ListedFile[];
worktreeInputs: Map<string, WorktreeInput>;
interpretationPaths: Set<string>;
}> {
const [raw, stat] = await Promise.all([
runGitCommand([...args.filter((arg) => arg !== "-w"), "--raw", "--no-abbrev", "-z"], options),
runGitCommand([...args, "--numstat", "-z"], options),
@@ -303,7 +351,7 @@ async function readTrackedFiles(
const stats = parseStats(stat.stdout);
const fields = raw.stdout.split("\0");
const files: ListedFile[] = [];
const worktreeFiles = new Set<string>();
const worktreeInputs = new Map<string, WorktreeInput>();
const interpretationPaths = new Set<string>();
for (let index = 0; index < fields.length - 1; index++) {
const header = fields[index].split(" ");
@@ -325,10 +373,25 @@ async function readTrackedFiles(
else if (code === "D") change = "deleted";
else if (oldPath !== null) change = "renamed";
let identity = header.join(" ");
if (compare.head.kind === "worktree" && code !== "D") {
const worktree = await readWorktreeIdentity(path, header, options);
identity += `:${worktree.identity}`;
if (worktree.regular && !counts.binary) worktreeFiles.add(path);
if (compare.head.kind === "worktree") {
const input: WorktreeInput = {
oldMode: code === "A" ? null : header[0],
oldBlob: code === "A" ? null : absentBlob(header[2]),
hashed: false,
literal: null,
};
if (code !== "D") {
const worktree = await readWorktreeContent({
path,
header,
text: !counts.binary,
options,
});
input.hashed = worktree.hashed;
input.literal = worktree.literal;
}
worktreeInputs.set(path, input);
identity = "";
}
files.push({
@@ -345,24 +408,32 @@ async function readTrackedFiles(
source: { kind: "tracked" },
});
}
return { files, worktreeFiles, interpretationPaths };
return { files, worktreeInputs, interpretationPaths };
}
async function readWorktreeIdentity(
path: string,
header: string[],
options: GitCommandOptions,
): Promise<{ identity: string; regular: boolean }> {
const value = await lstat(resolve(options.cwd, path), { bigint: true });
let identity = fileStatIdentity(value);
async function readWorktreeContent({
path,
header,
text,
options,
}: {
path: string;
header: string[];
text: boolean;
options: GitCommandOptions;
}): Promise<{ hashed: boolean; literal: string | null }> {
const absolutePath = resolve(options.cwd, path);
const value = await lstat(absolutePath, { bigint: true });
if (value.isSymbolicLink())
return { hashed: false, literal: `symlink:${await readlink(absolutePath)}` };
if (value.isDirectory() && (header[0] === ":160000" || header[1] === "160000")) {
const submodule = await runGitCommand(
["status", "--porcelain=v2", "--branch", "--untracked-files=normal"],
{ cwd: resolve(options.cwd, path), envOverlay: { GIT_OPTIONAL_LOCKS: "0" } },
{ cwd: absolutePath, envOverlay: { GIT_OPTIONAL_LOCKS: "0" } },
);
identity += `:${submodule.stdout}`;
return { hashed: false, literal: `submodule:${submodule.stdout}` };
}
return { identity, regular: value.isFile() };
return { hashed: value.isFile() && text, literal: null };
}
function diffArguments(compare: Compare, baseSha: string, headSha: string): string[] {
@@ -385,9 +456,12 @@ async function readUntrackedText(
return readFile(path);
}
async function listUntrackedFiles(options: GitCommandOptions): Promise<ListedFile[]> {
async function listUntrackedFiles(
options: GitCommandOptions,
): Promise<{ files: ListedFile[]; worktreeInputs: Map<string, WorktreeInput> }> {
const cwd = options.cwd;
const files: ListedFile[] = [];
const worktreeInputs = new Map<string, WorktreeInput>();
const untracked = await runGitCommand(
["ls-files", "--others", "--exclude-standard", "-z"],
options,
@@ -408,6 +482,12 @@ async function listUntrackedFiles(options: GitCommandOptions): Promise<ListedFil
binary || content.length === 0
? 0
: content.toString("utf8").split("\n").length - Number(content.at(-1) === 10);
worktreeInputs.set(path, {
oldMode: null,
oldBlob: null,
hashed: !symlink && !binary && !omitted,
literal: symlink ? `symlink:${content.toString("utf8")}` : null,
});
files.push({
entry: {
path,
@@ -418,14 +498,13 @@ async function listUntrackedFiles(options: GitCommandOptions): Promise<ListedFil
deletions: 0,
body: !binary,
},
identity: fileStatIdentity(value),
identity: "",
source: { kind: "untracked", size: Number(value.size) },
});
}
return files;
return { files, worktreeInputs };
}
function fileStatIdentity(value: BigIntStats): string {
// Timestamp-preserving copies still change file status or replace the inode.
return `${value.dev}:${value.ino}:${value.size}:${value.mtimeNs}:${value.ctimeNs}`;
function absentBlob(value: string): string | null {
return /^0+$/.test(value) ? null : value;
}
+15 -7
View File
@@ -9,12 +9,20 @@ interface Interpretation {
// Reuse depends on Git's interpretation, not just filesystem metadata. Ask Git
// for effective attributes/config and filtered blob identities without writing objects.
export async function readGitInputs(
paths: string[],
worktreeFiles: Set<string>,
options: GitCommandOptions,
) {
const regular = paths.filter((path) => worktreeFiles.has(path));
interface ReadGitInputsOptions {
paths: string[];
hashedPaths: ReadonlySet<string>;
options: GitCommandOptions;
literalContent?: ReadonlyMap<string, string>;
}
export async function readGitInputs({
paths,
hashedPaths,
options,
literalContent = new Map(),
}: ReadGitInputsOptions) {
const regular = paths.filter((path) => hashedPaths.has(path));
const [interpretation, blobs] = await Promise.all([
readInterpretation(paths, options),
regular.length
@@ -34,7 +42,7 @@ export async function readGitInputs(
fingerprint([
interpretation.config,
interpretation.attributes.get(path) ?? [],
blobByPath.get(path) ?? null,
blobByPath.get(path) ?? literalContent.get(path) ?? null,
]),
]),
);
@@ -530,6 +530,222 @@ test("submodule commits invalidate cached worktree gitlink bodies", async () =>
}
});
test("stat-only changes reuse cached bodies and send no body frames", async () => {
const { utimes } = await import("node:fs/promises");
const cwd = await mkdtemp(join(tmpdir(), "diff-stat-only-"));
const git = (...args: string[]) => execFileSync("git", args, { cwd, encoding: "utf8" }).trim();
const streams = new DiffSubscriptions({
async requestWorkingTreeWatch(root) {
return { repoRoot: root, unsubscribe: async () => {} };
},
});
const frames: DiffFrame[] = [];
const path = join(cwd, "file.txt");
try {
git("init", "-b", "main");
git("config", "user.name", "Test");
git("config", "user.email", "test@example.com");
await writeFile(join(cwd, "README"), "base\n");
await writeFile(path, "base\n");
git("add", ".");
git("-c", "commit.gpgsign=false", "commit", "-m", "base");
await writeFile(path, "changed\n");
const subscription = await streams.subscribe(
{ cwd, base: { kind: "rev", rev: "HEAD" }, head: { kind: "worktree" } },
async (frame) => {
frames.push(frame);
},
);
await subscription.start();
expect(
frames.flatMap((frame) => (frame.opcode === DiffOpcode.Bodies ? frame.files : [])),
).toContainEqual(expect.objectContaining({ path: "file.txt" }));
frames.length = 0;
for (let index = 0; index < 3; index++) {
const ended = frames.filter((frame) => frame.opcode === DiffOpcode.End).length;
const timestamp = new Date(Date.now() + 1000 * (index + 1));
await utimes(path, timestamp, timestamp);
await streams.invalidateForCwd(cwd);
await expect
.poll(() => frames.filter((frame) => frame.opcode === DiffOpcode.End).length, {
timeout: 5000,
})
.toBeGreaterThanOrEqual(ended + 1);
await new Promise((resolve) => setTimeout(resolve, 300));
}
expect(frames.filter((frame) => frame.opcode === DiffOpcode.Bodies)).toHaveLength(0);
const beginnings = frames.filter((frame) => frame.opcode === DiffOpcode.Begin);
expect(beginnings.length).toBeGreaterThanOrEqual(3);
for (const begin of beginnings) {
expect(begin.entries.find((entry) => entry.path === "file.txt")?.body).toBe(false);
}
await subscription.release();
} finally {
await streams.close();
await rm(cwd, { recursive: true, force: true });
}
});
test("staging unchanged worktree content reuses its cached body", async () => {
const cwd = await mkdtemp(join(tmpdir(), "diff-staging-only-"));
const git = (...args: string[]) => execFileSync("git", args, { cwd, encoding: "utf8" }).trim();
const streams = new DiffSubscriptions({
async requestWorkingTreeWatch(root) {
return { repoRoot: root, unsubscribe: async () => {} };
},
});
const frames: DiffFrame[] = [];
try {
git("init", "-b", "main");
git("config", "user.name", "Test");
git("config", "user.email", "test@example.com");
await writeFile(join(cwd, "README"), "base\n");
git("add", ".");
git("-c", "commit.gpgsign=false", "commit", "-m", "base");
await writeFile(join(cwd, "staged.ts"), "export const staged = true;\n");
const subscription = await streams.subscribe(
{ cwd, base: { kind: "rev", rev: "HEAD" }, head: { kind: "worktree" } },
async (frame) => {
frames.push(frame);
},
);
await subscription.start();
expect(
frames.flatMap((frame) => (frame.opcode === DiffOpcode.Bodies ? frame.files : [])),
).toContainEqual(expect.objectContaining({ path: "staged.ts" }));
frames.length = 0;
git("add", "-A");
await streams.invalidateForCwd(cwd);
await expect
.poll(() => frames.some((frame) => frame.opcode === DiffOpcode.End), { timeout: 5000 })
.toBe(true);
expect(frames.filter((frame) => frame.opcode === DiffOpcode.Bodies)).toHaveLength(0);
expect(frames.find((frame) => frame.opcode === DiffOpcode.Begin)).toMatchObject({
entries: [{ path: "staged.ts", change: "added", body: false }],
});
await subscription.release();
} finally {
await streams.close();
await rm(cwd, { recursive: true, force: true });
}
});
test.skipIf(process.platform === "win32")(
"untracked bodies use the same patch conversion as tracked files",
async () => {
const cwd = await mkdtemp(join(tmpdir(), "diff-textconv-"));
const git = (...args: string[]) => execFileSync("git", args, { cwd, encoding: "utf8" }).trim();
const streams = new DiffSubscriptions({
async requestWorkingTreeWatch(root) {
return { repoRoot: root, unsubscribe: async () => {} };
},
});
const frames: DiffFrame[] = [];
try {
git("init", "-b", "main");
git("config", "user.name", "Test");
git("config", "user.email", "test@example.com");
await writeFile(join(cwd, ".gitattributes"), "*.dat diff=upper\n");
git("config", "diff.upper.textconv", "sed s/hello/HELLO/");
git("add", ".");
git("-c", "commit.gpgsign=false", "commit", "-m", "base");
await writeFile(join(cwd, "x.dat"), "hello\n");
const subscription = await streams.subscribe(
{ cwd, base: { kind: "rev", rev: "HEAD" }, head: { kind: "worktree" } },
async (frame) => {
frames.push(frame);
},
);
await subscription.start();
const initial = frames
.flatMap((frame) => (frame.opcode === DiffOpcode.Bodies ? frame.files : []))
.find((file) => file.path === "x.dat");
expect(initial?.hunks.flatMap((hunk) => hunk.lines)).toContainEqual({
type: "add",
content: "hello",
});
frames.length = 0;
git("add", "x.dat");
await streams.invalidateForCwd(cwd);
await expect
.poll(() => frames.some((frame) => frame.opcode === DiffOpcode.End), { timeout: 5000 })
.toBe(true);
expect(frames.filter((frame) => frame.opcode === DiffOpcode.Bodies)).toHaveLength(0);
await subscription.release();
} finally {
await streams.close();
await rm(cwd, { recursive: true, force: true });
}
},
);
test.skipIf(process.platform === "win32")(
"comparison-side file type changes refresh cached bodies",
async () => {
const { symlink } = await import("node:fs/promises");
const cwd = await mkdtemp(join(tmpdir(), "diff-base-mode-"));
const git = (...args: string[]) => execFileSync("git", args, { cwd, encoding: "utf8" }).trim();
const streams = new DiffSubscriptions({
async requestWorkingTreeWatch(root) {
return { repoRoot: root, unsubscribe: async () => {} };
},
});
const frames: DiffFrame[] = [];
const path = join(cwd, "x");
try {
git("init", "-b", "main");
git("config", "user.name", "Test");
git("config", "user.email", "test@example.com");
await writeFile(path, "target");
git("add", ".");
git("-c", "commit.gpgsign=false", "commit", "-m", "regular");
git("branch", "compare");
await rm(path);
await symlink("target", path);
git("add", ".");
git("-c", "commit.gpgsign=false", "commit", "-m", "symlink");
const moved = git("rev-parse", "HEAD");
await rm(path);
await writeFile(path, "target\n");
const compare = {
cwd,
base: { kind: "rev" as const, rev: "refs/heads/compare" },
head: { kind: "worktree" as const },
};
const subscription = await streams.subscribe(compare, async (frame) => {
frames.push(frame);
});
await subscription.start();
expect(
frames.flatMap((frame) => (frame.opcode === DiffOpcode.Bodies ? frame.files : [])),
).toContainEqual(expect.objectContaining({ path: "x" }));
frames.length = 0;
git("update-ref", "refs/heads/compare", moved);
await streams.invalidateForCwd(cwd);
await expect
.poll(() => frames.some((frame) => frame.opcode === DiffOpcode.End), { timeout: 5000 })
.toBe(true);
const bodies = frames.flatMap((frame) =>
frame.opcode === DiffOpcode.Bodies ? frame.files : [],
);
expect(bodies).toContainEqual(expect.objectContaining({ path: "x" }));
const { listComparison } = await import("./git.js");
const fresh = await listComparison(compare);
try {
const record = await fresh.read(fresh.files[0]);
if (record.kind !== "ready") throw new Error("Expected readable type change");
expect(bodies).toContainEqual(record.body);
} finally {
await fresh.close();
}
await subscription.release();
} finally {
await streams.close();
await rm(cwd, { recursive: true, force: true });
}
},
);
test.each([true, false])(
"tracked=%s timestamp-preserving replacements refresh cached bodies",
async (tracked) => {
@@ -163,8 +163,14 @@ export default function contribute(server) {
server.addHook("agent.turn.before", async (event) => {
console.log(JSON.stringify({ hook: "agent.turn.before", event }));
});
server.addHook("agent.turn.after", async (event) => {
console.log(JSON.stringify({ hook: "agent.turn.after", event }));
server.addHook("agent.turn.after", async (event, context) => {
const page = await context.paseo.agents
.ref(event.agent.id)
.timeline.refetch({ direction: "tail", limit: 1, wholeTurns: true });
const turn = page.entries
.filter((entry) => entry.turnId === event.turnId)
.map(({ item }) => item.type);
console.log(JSON.stringify({ hook: "agent.turn.after", event, turn }));
});
server.addHook("agent.permission.before", async (event, context) => {
console.log(JSON.stringify({ hook: "agent.permission.before", event }));
@@ -220,6 +226,7 @@ export default function contribute(server) {
{
hook: "agent.turn.after",
event: { outcome: { kind: "completed" }, timeline: [] },
turn: ["user_message", "tool_call"],
},
{ hook: "agent.turn.before", event: { agent: { id: agent.id } } },
]);
@@ -157,8 +157,7 @@ test.skipIf(process.platform !== "linux")(
"This is a hook integration test. Reply exactly: out of credits. If the next user message is Try again., reply exactly RETRY_OK instead. Do not use tools.",
);
await waitForTurns(client, agent.id, 2);
const retryTurns = await endedTurns(client, agent.id);
expect(assistantText(retryTurns[1].data.timeline)).toBe("RETRY_OK");
expect(await latestReplyText(client, agent.id)).toBe("RETRY_OK");
proof.followup = "RETRY_OK";
await client.sendMessage(
@@ -207,8 +206,7 @@ test.skipIf(process.platform !== "linux")(
const envPermission = await waitForPermission(client, agent.id, envCommand);
await client.respondToPermission(agent.id, envPermission, { behavior: "allow" });
await waitForTurns(client, agent.id, 5);
const envTurns = await endedTurns(client, agent.id);
expect(assistantText(envTurns[4].data.timeline)).toBe("created|opened");
expect(await latestReplyText(client, agent.id)).toBe("created|opened");
proof.environmentOutput = "created|opened";
await client.refreshAgent(agent.id);
@@ -332,6 +330,16 @@ test.skipIf(process.platform !== "linux")(
600_000,
);
// Turn hooks carry no history; read the reply the way plugins do.
async function latestReplyText(client: DaemonClient, agentId: string): Promise<string> {
const page = await client.fetchAgentTimeline(agentId, {
direction: "tail",
limit: 1,
wholeTurns: true,
});
return assistantText(page.entries.map((entry) => entry.item));
}
function assistantText(timeline: readonly AgentTimelineItem[]): string {
let text = "";
for (const item of timeline) {
@@ -195,21 +195,18 @@ export function publishAgentStream(
lifecycle.emit("agent.turn.after", {
agent,
turnId: event.turnId ?? null,
timeline: [],
outcome: { kind: "completed" },
});
} else if (event.type === "turn_failed") {
lifecycle.emit("agent.turn.after", {
agent,
turnId: event.turnId ?? null,
timeline: [],
outcome: { kind: "failed", error: { message: event.error, code: event.code } },
});
} else if (event.type === "turn_canceled") {
lifecycle.emit("agent.turn.after", {
agent,
turnId: event.turnId ?? null,
timeline: [],
outcome: { kind: "canceled", reason: event.reason },
});
} else if (event.type === "permission_requested") {
@@ -564,7 +564,7 @@ export class PluginRuntime {
type: "hook",
requestId: randomUUID(),
name,
input: event,
input: wireEvent(name, event),
})
.then(() => undefined)
.catch((error) => {
@@ -1482,3 +1482,11 @@ function readConnectionId(value: unknown): string | null {
const connectionId = Reflect.get(value, "connectionId");
return typeof connectionId === "string" ? connectionId : null;
}
// COMPAT(pluginTurnTimeline): added in v0.16.0-beta.3; plugins built before the field left the SDK type read `timeline`; remove after 2027-05-31
function wireEvent<Name extends keyof PluginHookEvents>(
name: Name,
event: PluginHookEvents[Name],
): PluginHookEvents[Name] {
return name === "agent.turn.after" ? { ...event, timeline: [] } : event;
}
+1
View File
@@ -50,6 +50,7 @@ export function thanksPageHref(fileUrl: string, locale: Locale): string {
}
export const appStoreUrl = "https://apps.apple.com/app/paseo-pocket-engineer/id6758887924";
export const testFlightBetaUrl = "https://testflight.apple.com/join/Y5HpSQjJ";
export const playStoreUrl = "https://play.google.com/store/apps/details?id=sh.paseo";
export const webAppUrl = "https://app.paseo.sh";
@@ -10,6 +10,7 @@ import {
downloadUrls,
appStoreUrl,
playStoreUrl,
testFlightBetaUrl,
thanksPageHref,
webAppUrl,
AppleIcon,
@@ -159,13 +160,15 @@ function Download() {
</PillGroup>
</PlatformRow>
{!onBeta && (
<PlatformRow icon={AppleIcon} label="iOS">
<PillGroup>
<PlatformRow icon={AppleIcon} label="iOS">
<PillGroup>
{onBeta ? (
<DownloadPill href={testFlightBetaUrl} label="TestFlight" external />
) : (
<DownloadPill href={appStoreUrl} label="App Store" external />
</PillGroup>
</PlatformRow>
)}
)}
</PillGroup>
</PlatformRow>
</div>
</section>
@@ -23,8 +23,9 @@ export default function contribute(server: PluginServerContext) {
return;
}
const agent = context.paseo.agents.ref(event.agent.id);
const page = await agent.timeline.refetch({ direction: "tail", limit: 20 });
const text = latestOutputText(page.entries.map(({ item }) => item));
const page = await agent.timeline.refetch({ direction: "tail", limit: 1, wholeTurns: true });
const turn = page.entries.filter((entry) => entry.turnId === event.turnId);
const text = latestOutputText(turn.map(({ item }) => item));
if (/out of credits/i.test(text)) {
console.log("Sending a follow-up after out of credits", event.agent.id);
await agent.send("Try again.");
@@ -171,3 +171,90 @@ it.each([401, 403])("reports an existing login rejected with HTTP %i", async (st
else process.env["COPILOT_TOKEN"] = previous;
}
});
it.each([
{ remaining: 1500, percentRemaining: 100, usedPct: 0 },
{ remaining: 1125, percentRemaining: 75, usedPct: 25 },
{ remaining: 0, percentRemaining: 0, usedPct: 100 },
])(
"reports premium quota with $remaining requests remaining",
async ({ remaining, percentRemaining, usedPct }) => {
const report = await fetchPremiumQuota({
entitlement: 1500,
remaining,
percent_remaining: percentRemaining,
unlimited: false,
});
expect(report).toMatchObject({
status: "available",
planLabel: "individual",
windows: [
{
id: "premium_interactions",
label: "Premium requests",
usedPct,
remainingPct: percentRemaining,
resetsAt: "2026-12-01T00:00:00.000Z",
},
],
details: expect.arrayContaining([
{
id: "premium_remaining",
label: "Premium requests remaining",
value: `${remaining} / 1500`,
},
]),
});
},
);
it.each([
undefined,
null,
{ unlimited: true, percent_remaining: 100, remaining: 1500, entitlement: 1500 },
])(
"does not invent a finite quota for absent or unlimited premium snapshots (%j)",
async (premium) => {
const report = await fetchPremiumQuota(premium);
expect(report).toMatchObject({
status: "available",
windows: [],
details: [{ id: "reset", label: "Quota reset", value: "2026-12-01" }],
});
},
);
it("reports a percentage without inventing missing counts and falls back to the legacy reset", async () => {
const report = await fetchPremiumQuota({ percent_remaining: 60, unlimited: false }, null);
expect(report).toMatchObject({
status: "available",
windows: [{ usedPct: 40, remainingPct: 60, resetsAt: "2026-12-01" }],
details: [{ id: "reset", label: "Quota reset", value: "2026-12-01" }],
});
});
async function fetchPremiumQuota(
premium: unknown,
utcReset: string | null = "2026-12-01T00:00:00.000Z",
) {
const { writeFile } = await import("node:fs/promises");
const directory = mkdtempSync(join(tmpdir(), "copilot-premium-"));
const locator = join(directory, "hosts.yml");
try {
await writeFile(locator, "github.com:\n oauth_token: fixture-copilot\n");
return await fetchUsage({ store: "file", locator }, async () =>
jsonResponse({
copilot_plan: "individual",
quota_reset_date: "2026-12-01",
quota_reset_date_utc: utcReset,
quota_snapshots: {
premium_interactions: premium,
chat: { unlimited: true, percent_remaining: 100 },
completions: { unlimited: true, percent_remaining: 100 },
},
}),
);
} finally {
rmSync(directory, { recursive: true, force: true });
}
}
+38 -1
View File
@@ -5,6 +5,8 @@ import { join } from "node:path";
import { z } from "zod";
import {
unavailable,
windowFromUsedPct,
type UsageWindow,
type UsageAccount,
type UsageReport,
type UsageDetail,
@@ -18,6 +20,19 @@ const ApiOptionalStringSchema = z.preprocess(
const CopilotUsageResponseSchema = z.object({
copilot_plan: ApiOptionalStringSchema,
quota_reset_date: ApiOptionalStringSchema,
quota_reset_date_utc: ApiOptionalStringSchema,
quota_snapshots: z
.object({
premium_interactions: z
.object({
entitlement: z.number().nonnegative().nullish(),
remaining: z.number().nonnegative().nullish(),
percent_remaining: z.number().min(0).max(100).nullish(),
unlimited: z.boolean().optional(),
})
.nullish(),
})
.nullish(),
});
async function readToken(input: UsageInput): Promise<string | undefined> {
@@ -72,10 +87,32 @@ export async function fetchUsage(
? [{ id: "reset", label: "Quota reset", value: resp.quota_reset_date }]
: [];
const windows: UsageWindow[] = [];
const premium = resp.quota_snapshots?.premium_interactions;
if (premium && !premium.unlimited) {
if (premium.percent_remaining != null) {
windows.push(
windowFromUsedPct({
id: "premium_interactions",
label: "Premium requests",
utilizationPct: 100 - premium.percent_remaining,
resetsAt: resp.quota_reset_date_utc || resp.quota_reset_date,
}),
);
}
if (premium.remaining != null && premium.entitlement != null) {
details.push({
id: "premium_remaining",
label: "Premium requests remaining",
value: `${premium.remaining} / ${premium.entitlement}`,
});
}
}
return {
status: "available",
planLabel: resp.copilot_plan || undefined,
windows: [],
windows,
balances: [],
details,
};
+5 -7
View File
@@ -62,7 +62,7 @@ Paseo only sees the returned promise, so detached work gets no error reporting a
| `agent.close.after` | no | `{ agent }` | |
| `agent.archive.after` | no | `{ agent, archivedAt }` | |
| `agent.turn.before` | no | `{ agent, turnId }` | |
| `agent.turn.after` | no | `{ agent, turnId, outcome, timeline }` | |
| `agent.turn.after` | no | `{ agent, turnId, outcome }` | |
| `agent.permission.before` | no | `{ agent, request }` | |
| `agent.permission.after` | no | `{ agent, requestId, resolution }` | |
@@ -525,7 +525,7 @@ server.addHook("agent.turn.before", async ({ agent, turnId }) => {
## agent.turn.after
A live turn completes, fails, or is canceled. Receives `{ agent, turnId, outcome, timeline }`. `outcome.kind` is `completed`, `failed`, or `canceled`. `timeline` is deprecated and always empty; fetch history through the SDK as below.
A live turn completes, fails, or is canceled. Receives `{ agent, turnId, outcome }`. `outcome.kind` is `completed`, `failed`, or `canceled`. The payload carries no history. Read the turn from the agent's timeline: `refetch({ direction: "tail", limit: 1, wholeTurns: true })` returns the latest turn whole, as below. Keep the entries whose `turnId` equals `event.turnId`, because a turn that starts before your read, such as a follow-up another plugin sent, replaces this one as the latest. The filtered list is empty in that case, and when `event.turnId` is `null`. Plugins built against an older SDK that read `event.timeline` get an empty array.
### Send a follow-up when a turn ends
@@ -545,8 +545,9 @@ export default function contribute(server: PluginServerContext) {
}
const agent = context.paseo.agents.ref(event.agent.id);
const page = await agent.timeline.refetch({ direction: "tail", limit: 20 });
const text = latestOutputText(page.entries.map(({ item }) => item));
const page = await agent.timeline.refetch({ direction: "tail", limit: 1, wholeTurns: true });
const turn = page.entries.filter((entry) => entry.turnId === event.turnId);
const text = latestOutputText(turn.map(({ item }) => item));
if (/out of credits/i.test(text)) {
await agent.send("Try again.");
}
@@ -562,9 +563,6 @@ Turn ends: "out of credits"
→ a new turn starts
```
The handler fetches only the last 20 timeline items. Output before that page is not inspected.
Use `cursor` and `direction` to page further when your plugin needs more history.
This sends a new message with the existing SDK. Persistent matches keep sending follow-ups;
add limits or delays in your plugin when needed. Attachments and tool effects are not replayed.
+5 -6
View File
@@ -60,7 +60,7 @@ Paseo 只观察返回的 Promise,因此独立任务没有错误报告,也没
| `agent.close.after` | 否 | `{ agent }` | |
| `agent.archive.after` | 否 | `{ agent, archivedAt }` | |
| `agent.turn.before` | 否 | `{ agent, turnId }` | |
| `agent.turn.after` | 否 | `{ agent, turnId, outcome, timeline }` | |
| `agent.turn.after` | 否 | `{ agent, turnId, outcome }` | |
| `agent.permission.before` | 否 | `{ agent, request }` | |
| `agent.permission.after` | 否 | `{ agent, requestId, resolution }` | |
@@ -514,7 +514,7 @@ server.addHook("agent.turn.before", async ({ agent, turnId }) => {
## agent.turn.after {#agentturnafter}
实时轮次完成、失败或取消后触发,收到 `{ agent, turnId, outcome, timeline }`。`outcome.kind` 为 `completed`、`failed` 或 `canceled`。`timeline` 已弃用且始终为空;请按下面的示例通过 SDK 获取历史。
实时轮次完成、失败或取消后触发,收到 `{ agent, turnId, outcome }`。`outcome.kind` 为 `completed`、`failed` 或 `canceled`。载荷不含历史。请从 Agent 的时间线读取本轮:`refetch({ direction: "tail", limit: 1, wholeTurns: true })` 会完整返回最近一轮,见下面的示例。只保留 `turnId` 等于 `event.turnId` 的条目:如果在读取前已有新一轮开始(例如另一个插件发送的后续消息),最近一轮就不再是本轮。此时过滤后的列表为空;`event.turnId` 为 `null` 时也为空。基于旧版 SDK 构建、仍读取 `event.timeline` 的插件只会拿到空数组。
### 在轮次结束时发送后续消息 {#send-a-follow-up-when-a-turn-ends}
@@ -531,8 +531,9 @@ export default function contribute(server: PluginServerContext) {
}
const agent = context.paseo.agents.ref(event.agent.id);
const page = await agent.timeline.refetch({ direction: "tail", limit: 20 });
const text = latestOutputText(page.entries.map(({ item }) => item));
const page = await agent.timeline.refetch({ direction: "tail", limit: 1, wholeTurns: true });
const turn = page.entries.filter((entry) => entry.turnId === event.turnId);
const text = latestOutputText(turn.map(({ item }) => item));
if (/out of credits/i.test(text)) {
await agent.send("Try again.");
}
@@ -548,8 +549,6 @@ Turn ends: "out of credits"
→ a new turn starts
```
该处理函数只获取最近 20 条时间线条目,不检查这一页之前的输出。插件需要更多历史时,请用 `cursor` 和 `direction` 继续翻页。
这会用现有的 SDK 发送一条新消息。持续匹配会不断发送后续消息;需要时请在插件中添加次数限制或延迟。附件和工具的效果不会被重放。
## agent.permission.before {#agentpermissionbefore}
+16
View File
@@ -207,6 +207,22 @@ setting `id` as an option key and each `value` as that option, and sends changes
| `ProviderSetting` type | `ProviderOptionControl` |
| `agent.create.before` `config.featureValues` | `config.providerOptions`; a returned `featureValues` edit is still applied |
### Turn history in v0.16
`agent.turn.after` has sent an empty `timeline` since v0.13, and the SDK type no longer declares it.
A plugin that reads `event.timeline` now fails typecheck. Read the turn from the agent instead:
```ts
const turn = await context.paseo.agents
.ref(event.agent.id)
.timeline.refetch({ direction: "tail", limit: 1, wholeTurns: true });
const items = turn.entries.filter((entry) => entry.turnId === event.turnId).map(({ item }) => item);
```
The filter drops entries from a turn that started after this one ended, such as a follow-up another plugin sent. The list is empty when such a turn has already started, or when `event.turnId` is `null`.
Plugins built against an older SDK keep receiving `timeline: []` until removal after 2027-05-31.
## 5. Recognize half-migration errors
| Compiler or load error | Meaning and fix |
+15
View File
@@ -170,6 +170,21 @@ Provider 插件只有一个配置记录 `providerOptions`,并用控件描述
| `ProviderSetting` 类型 | `ProviderOptionControl` |
| `agent.create.before` 的 `config.featureValues` | `config.providerOptions`;返回的 `featureValues` 修改仍会被应用 |
### v0.16 中的轮次历史 {#turn-history-in-v016}
自 v0.13 起,`agent.turn.after` 发送的 `timeline` 始终为空,SDK 类型也不再声明它。读取 `event.timeline` 的插件现在无法通过类型检查。请改为从 Agent 读取本轮:
```ts
const turn = await context.paseo.agents
.ref(event.agent.id)
.timeline.refetch({ direction: "tail", limit: 1, wholeTurns: true });
const items = turn.entries.filter((entry) => entry.turnId === event.turnId).map(({ item }) => item);
```
过滤会去掉本轮结束后才开始的新一轮的条目,例如另一个插件发送的后续消息。如果这样的新一轮已经开始,或 `event.turnId` 为 `null`,列表为空。
基于旧版 SDK 构建的插件在 2027-05-31 之后移除兼容层之前仍会收到 `timeline: []`。
## 5. 识别只迁移了一半的错误 {#5-recognize-half-migration-errors}
| 编译器或加载错误 | 含义与修复方法 |
+1 -1
View File
@@ -118,7 +118,7 @@ workspace the call creates is background too. With a `parent` and no value, the
### Timeline handle
`agent.timeline.refetch(options?)` fetches a page. Options are `direction`, `cursor`, `limit`, `projection`, and `requestId`.
`agent.timeline.refetch(options?)` fetches a page. Options are `direction`, `cursor`, `limit`, `projection`, `wholeTurns`, and `requestId`. `wholeTurns: true` extends a `tail` or `before` page back to the start of the oldest turn it reaches, so `{ direction: "tail", limit: 1, wholeTurns: true }` returns the latest turn. It requires `features.wholeTurnTimeline`; older hosts ignore it and return a page of `limit` items.
`agent.timeline.subscribe(handler)` establishes network demand for this agent and restores it after reconnect. Its unsubscribe function releases that demand; await `unsubscribe.ready` for initial daemon acknowledgement before starting work. Delivery is live-only. Reconnect emits a local `subscription_restored` event; request missed history explicitly with `refetch()`. A live replacement invalidates the previous epoch. See the callback shapes, paging and failure behavior in [timeline events](./events.md#follow-timeline-events).
+1 -1
View File
@@ -114,7 +114,7 @@ nav: API 参考
### 时间线句柄 {#timeline-handle}
`agent.timeline.refetch(options?)` 获取一页时间线。选项有 `direction`、`cursor`、`limit`、`projection` 和 `requestId`。
`agent.timeline.refetch(options?)` 获取一页时间线。选项有 `direction`、`cursor`、`limit`、`projection`、`wholeTurns` 和 `requestId`。`wholeTurns: true` 会把 `tail` 或 `before` 页向前扩展到它所触及的最早一轮的开头,因此 `{ direction: "tail", limit: 1, wholeTurns: true }` 返回最近一轮。它需要 `features.wholeTurnTimeline`;旧版主机会忽略它,返回 `limit` 条记录。
`agent.timeline.subscribe(handler)` 为该 Agent 建立网络订阅需求,并在重连后恢复它。它返回的取消订阅函数会释放这项需求;开始工作之前,请 await `unsubscribe.ready` 以等待 daemon 的初始确认。投递只包含实时内容。重连时会发出一个本地的 `subscription_restored` 事件;错过的历史请用 `refetch()` 显式请求。实时的替换会使之前的 epoch 失效。回调的形状、分页和失败行为见[时间线事件](./events.md#follow-timeline-events)。
+8 -7
View File
@@ -52,19 +52,20 @@ Betas are the best way to get fixes and features early. If you hit a bug, report
### How to join the beta channel
In the desktop app:
| Platform | How to get betas |
| -------------- | ------------------------------------------------------------------------------------------------------------- |
| iOS and iPadOS | Join the [Paseo TestFlight beta](https://testflight.apple.com/join/Y5HpSQjJ). |
| Android | Download the APK from a prerelease on the [GitHub releases page](https://github.com/getpaseo/paseo/releases). |
| Desktop | Open **Settings → About** and select **Beta** under **Release channel**. |
| CLI | Run `npm install -g @getpaseo/cli@beta`. Plain `npm install -g @getpaseo/cli` stays on stable. |
1. Open **Settings**
2. Go to **About**
3. Under **Release channel**, select **Beta**
The app will check for beta updates from then on.
The desktop app checks for beta updates from the moment you select **Beta**.
## App stores
Desktop releases and the CLI are usually available first. App Store and Play Store releases can lag behind because they go through review.
There is no beta channel in the app stores right now — only stable builds are submitted. If you want early Android builds, download the APK from the [GitHub releases page](https://github.com/getpaseo/paseo/releases).
The App Store and Play Store listings carry stable builds only. On iOS, the [TestFlight beta](https://testflight.apple.com/join/Y5HpSQjJ) is the beta channel. Every beta is uploaded to it after Apple's beta review, and stable releases are uploaded to it as well, so TestFlight testers stay on the latest build. On Android there is no Play Store beta, so install beta APKs from GitHub.
## What to do if something breaks
+8 -7
View File
@@ -50,19 +50,20 @@ v0.1.92-beta.3
### 如何加入测试版渠道 {#how-to-join-the-beta-channel}
在桌面应用中:
| 平台 | 获取测试版的方式 |
| ------------- | ------------------------------------------------------------------------------------------------ |
| iOS 和 iPadOS | 加入 [Paseo TestFlight 测试版](https://testflight.apple.com/join/Y5HpSQjJ)。 |
| Android | 从 [GitHub releases 页面](https://github.com/getpaseo/paseo/releases)的预发布版本下载 APK。 |
| 桌面端 | 打开 **设置 → 关于**,在 **发布通道** 下选择 **Beta**。 |
| CLI | 运行 `npm install -g @getpaseo/cli@beta`。直接运行 `npm install -g @getpaseo/cli` 仍使用稳定版。 |
1. 打开 **设置**
2. 进入 **关于**
3. 在 **发布通道** 下选择 **Beta**
之后应用就会检查测试版更新。
在桌面应用中选择 **Beta** 后,应用就会检查测试版更新。
## 应用商店 {#app-stores}
桌面端版本和 CLI 通常最先提供。App Store 和 Play Store 的版本需要经过审核,可能会滞后。
应用商店目前没有测试版渠道,只提交稳定版构建。如果你想提前使用 Android 构建,请从 [GitHub releases 页面](https://github.com/getpaseo/paseo/releases)下载 APK。
App Store 和 Play Store 的商店页面只提供稳定版构建。在 iOS 上,[TestFlight 测试版](https://testflight.apple.com/join/Y5HpSQjJ)就是测试版渠道。每个测试版都会在通过 Apple 的测试版审核后上传到 TestFlight,稳定版也会上传到那里,所以 TestFlight 测试者始终使用最新构建。Android 没有 Play Store 测试版,请从 GitHub 安装测试版 APK。
## 出现问题怎么办 {#what-to-do-if-something-breaks}