Compare commits

...
Author SHA1 Message Date
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
29 changed files with 723 additions and 218 deletions
+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.
@@ -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.");
@@ -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(),
@@ -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,
@@ -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;
@@ -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-"));
@@ -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;
}
}
@@ -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,