mirror of
https://github.com/getpaseo/paseo.git
synced 2026-11-13 03:45:11 +00:00
Compare commits
9
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1aba2a5f23 | ||
|
|
aead84bf0f | ||
|
|
7980ad008b | ||
|
|
7c35ad90b9 | ||
|
|
9eccb4722b | ||
|
|
536846eb60 | ||
|
|
cc16ac6ac4 | ||
|
|
fd9aebd622 | ||
|
|
21709812bb |
@@ -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),
|
||||
|
||||
@@ -26,7 +26,6 @@ import {
|
||||
reloadAgent,
|
||||
scriptAgentUsage,
|
||||
seedAgentWithContextWindow,
|
||||
tapContextWindowMeter,
|
||||
usageCard,
|
||||
} from "../support/helpers/context-window";
|
||||
import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent";
|
||||
@@ -510,24 +509,6 @@ test("context details stay open while the chat streams", async ({ page }) => {
|
||||
}
|
||||
});
|
||||
|
||||
test.describe("on a wide touch screen", () => {
|
||||
test.use({ hasTouch: true });
|
||||
|
||||
test("tapping the meter opens context details that stay open", async ({ page, agent }) => {
|
||||
const usage = await scriptAgentUsage(page);
|
||||
await page.setViewportSize(DESKTOP);
|
||||
await openAgent(page, agent);
|
||||
const [claude] = claudeAndCodexReports();
|
||||
// A tap can request the agent's usage more than once.
|
||||
usage.answerNext([onPersonalLogin(claude!)]);
|
||||
usage.answerNext([onPersonalLogin(claude!)]);
|
||||
const details = await tapContextWindowMeter(page);
|
||||
// Past the card's close grace. A touch sends no pointer movement after the tap.
|
||||
await page.waitForTimeout(500);
|
||||
await expect(details).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
test("pointer hovering context details preserves composer focus", async ({ page, agent }) => {
|
||||
const usage = await scriptAgentUsage(page);
|
||||
await page.setViewportSize(DESKTOP);
|
||||
|
||||
@@ -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.");
|
||||
|
||||
@@ -16,7 +16,6 @@ import {
|
||||
pinWorkspaceFromSidebar,
|
||||
scrollWorkspaceList,
|
||||
sweepPointerAcrossWorkspaceRows,
|
||||
watchWorkspaceHoverCard,
|
||||
} from "../support/helpers/sidebar";
|
||||
import { seedWorkspace } from "../support/helpers/seed-client";
|
||||
import { expectWorkspaceHeader } from "../support/helpers/workspace-ui";
|
||||
@@ -544,29 +543,6 @@ test.describe("Sidebar workspace list", () => {
|
||||
});
|
||||
});
|
||||
|
||||
// On a wide touch screen (iPad) a tap is not a hover. Opening the card on the tap changes the page
|
||||
// under the finger, and iPad Safari then drops the tap instead of opening the workspace.
|
||||
test.describe("Sidebar workspace rows on a touch screen", () => {
|
||||
test.use({ hasTouch: true, viewport: { width: 1180, height: 820 } });
|
||||
|
||||
test("tapping a workspace row opens it without the hover card", async ({ page }) => {
|
||||
const workspace = await seedWorkspace({ repoPrefix: "sidebar-touch-tap-" });
|
||||
try {
|
||||
await gotoAppShell(page);
|
||||
await waitForSidebarProject(page, path.basename(workspace.repoPath));
|
||||
const row = await waitForSidebarWorkspace(page, workspace.workspaceId);
|
||||
const hoverCard = await watchWorkspaceHoverCard(page);
|
||||
await row.tap();
|
||||
await expect(page).toHaveURL(/\/workspace\//, { timeout: 30_000 });
|
||||
await expectWorkspaceHoverCardClosed(page);
|
||||
// A card that opens during the tap is what makes iPad Safari drop the tap.
|
||||
expect(await hoverCard.hasOpened()).toBe(false);
|
||||
} finally {
|
||||
await workspace.cleanup();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("Mobile sidebar panelState transition", () => {
|
||||
test.use({ viewport: { width: 390, height: 844 } });
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -41,14 +41,6 @@ export async function hoverContextWindowMeter(page: Page, name = METER_NAME): Pr
|
||||
return card;
|
||||
}
|
||||
|
||||
/** Taps the meter on a touch screen and returns its details once they are on screen. */
|
||||
export async function tapContextWindowMeter(page: Page, name = METER_NAME): Promise<Locator> {
|
||||
await page.getByRole("img", { name, exact: true }).tap({ timeout: 30_000 });
|
||||
const card = contextWindowDetails(page);
|
||||
await expect(card).toBeVisible();
|
||||
return card;
|
||||
}
|
||||
|
||||
/** Walks the pointer from the meter up onto its hover card, across the gap between them. */
|
||||
export async function moveOntoContextWindowDetails(page: Page, card: Locator): Promise<void> {
|
||||
const meter = await page.getByRole("img", { name: METER_NAME }).boundingBox();
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -132,31 +132,6 @@ export async function expectWorkspaceHoverCardClosed(page: Page): Promise<void>
|
||||
await expect(page.getByTestId("workspace-hover-card")).toHaveCount(0);
|
||||
}
|
||||
|
||||
/**
|
||||
* Records whether a workspace hover card mounts at any point from now on, including one that
|
||||
* closes again before an assertion could see it.
|
||||
*/
|
||||
export async function watchWorkspaceHoverCard(
|
||||
page: Page,
|
||||
): Promise<{ hasOpened: () => Promise<boolean> }> {
|
||||
await page.evaluate(() => {
|
||||
const target = window as unknown as { __workspaceHoverCardOpened?: boolean };
|
||||
const isOpen = () => document.querySelector('[data-testid="workspace-hover-card"]') !== null;
|
||||
target.__workspaceHoverCardOpened = isOpen();
|
||||
new MutationObserver(() => {
|
||||
if (isOpen()) target.__workspaceHoverCardOpened = true;
|
||||
}).observe(document.body, { childList: true, subtree: true });
|
||||
});
|
||||
return {
|
||||
hasOpened: () =>
|
||||
page.evaluate(
|
||||
() =>
|
||||
(window as unknown as { __workspaceHoverCardOpened?: boolean })
|
||||
.__workspaceHoverCardOpened === true,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
export async function closeWorkspaceContextMenu(page: Page, workspaceId: string): Promise<void> {
|
||||
const workspaceKey = `${getServerId()}:${workspaceId}`;
|
||||
await page.getByTestId(`sidebar-workspace-context-menu-${workspaceKey}-backdrop`).click();
|
||||
|
||||
@@ -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 &&
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -12,14 +12,7 @@ import {
|
||||
type ReactNode,
|
||||
type RefObject,
|
||||
} from "react";
|
||||
import {
|
||||
Dimensions,
|
||||
View,
|
||||
type PointerEvent as ViewPointerEvent,
|
||||
type Role,
|
||||
type StyleProp,
|
||||
type ViewStyle,
|
||||
} from "react-native";
|
||||
import { Dimensions, View, type Role, type StyleProp, type ViewStyle } from "react-native";
|
||||
import { FadeIn, FadeOut } from "react-native-reanimated";
|
||||
import { StyleSheet } from "react-native-unistyles";
|
||||
import { Portal } from "@gorhom/portal";
|
||||
@@ -42,7 +35,7 @@ interface HoverCardContextValue {
|
||||
setTriggerRef: (node: View | null) => void;
|
||||
triggerRef: RefObject<View | null>;
|
||||
contentRef: RefObject<View | null>;
|
||||
pointerEntered: (event: ViewPointerEvent) => void;
|
||||
openNow: () => void;
|
||||
scheduleClose: () => void;
|
||||
}
|
||||
|
||||
@@ -62,34 +55,21 @@ const HoverCardContext = createContext<HoverCardContextValue | null>(null);
|
||||
*/
|
||||
export function HoverCard({
|
||||
disabled = false,
|
||||
ignoreTouch = false,
|
||||
children,
|
||||
}: {
|
||||
/** Closes the card and keeps it closed, e.g. while its trigger is dragged. */
|
||||
disabled?: boolean;
|
||||
/**
|
||||
* The trigger handles a tap itself, so a touch does not open the card: neither the touch's
|
||||
* pointerenter nor the focus the tap leaves on the trigger. Opening during a tap changes the
|
||||
* page under the finger, and iPad Safari then drops the tap.
|
||||
*/
|
||||
ignoreTouch?: boolean;
|
||||
children: ReactNode;
|
||||
}): ReactNode {
|
||||
if (!isWeb) return children;
|
||||
return (
|
||||
<WebHoverCard disabled={disabled} ignoreTouch={ignoreTouch}>
|
||||
{children}
|
||||
</WebHoverCard>
|
||||
);
|
||||
return <WebHoverCard disabled={disabled}>{children}</WebHoverCard>;
|
||||
}
|
||||
|
||||
function WebHoverCard({
|
||||
disabled,
|
||||
ignoreTouch,
|
||||
children,
|
||||
}: {
|
||||
disabled: boolean;
|
||||
ignoreTouch: boolean;
|
||||
children: ReactNode;
|
||||
}): ReactElement {
|
||||
const layer = useOverlayLayer("floating");
|
||||
@@ -99,8 +79,6 @@ function WebHoverCard({
|
||||
const graceTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
// Read when focus arrives: by focusout the element losing focus no longer matches :focus-visible.
|
||||
const keyboardFocusedRef = useRef(false);
|
||||
// Whether the latest press was a touch, so with ignoreTouch the focus that tap leaves is ignored.
|
||||
const touchPressedRef = useRef(false);
|
||||
|
||||
const clearGraceTimer = useCallback(() => {
|
||||
if (graceTimerRef.current) {
|
||||
@@ -147,13 +125,6 @@ function WebHoverCard({
|
||||
setOpen(true);
|
||||
}, [clearGraceTimer, closeNow, disabled, layer]);
|
||||
|
||||
const pointerEntered = useCallback(
|
||||
(event: ViewPointerEvent) => {
|
||||
if (!(ignoreTouch && event.nativeEvent.pointerType === "touch")) openNow();
|
||||
},
|
||||
[ignoreTouch, openNow],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
return () => {
|
||||
@@ -220,7 +191,7 @@ function WebHoverCard({
|
||||
const target = event.target as Element;
|
||||
keyboardFocusedRef.current = target.matches(":focus-visible");
|
||||
if (trigger.contains(target)) {
|
||||
if (!(ignoreTouch && touchPressedRef.current)) openNow();
|
||||
openNow();
|
||||
} else if ((contentRef.current as unknown as HTMLElement | null)?.contains(target)) {
|
||||
clearGraceTimer();
|
||||
}
|
||||
@@ -237,12 +208,6 @@ function WebHoverCard({
|
||||
scheduleClose();
|
||||
}
|
||||
};
|
||||
const pointerPressed = (event: PointerEvent) => {
|
||||
touchPressedRef.current = event.pointerType === "touch";
|
||||
};
|
||||
const keyPressedAnywhere = () => {
|
||||
touchPressedRef.current = false;
|
||||
};
|
||||
const activateClosedTrigger = (event: KeyboardEvent) => {
|
||||
if (
|
||||
!open &&
|
||||
@@ -256,23 +221,19 @@ function WebHoverCard({
|
||||
const scrolled = (event: Event) => {
|
||||
if ((event.target as Node).contains(trigger)) close();
|
||||
};
|
||||
document.addEventListener("pointerdown", pointerPressed, true);
|
||||
document.addEventListener("keydown", keyPressedAnywhere, true);
|
||||
document.addEventListener("focusin", focusEntered);
|
||||
document.addEventListener("focusout", focusLeft);
|
||||
trigger.addEventListener("keydown", activateClosedTrigger);
|
||||
document.addEventListener("scroll", scrolled, true);
|
||||
window.addEventListener("resize", close);
|
||||
return () => {
|
||||
document.removeEventListener("pointerdown", pointerPressed, true);
|
||||
document.removeEventListener("keydown", keyPressedAnywhere, true);
|
||||
document.removeEventListener("focusin", focusEntered);
|
||||
document.removeEventListener("focusout", focusLeft);
|
||||
trigger.removeEventListener("keydown", activateClosedTrigger);
|
||||
document.removeEventListener("scroll", scrolled, true);
|
||||
window.removeEventListener("resize", close);
|
||||
};
|
||||
}, [clearGraceTimer, ignoreTouch, keyPressed, open, openNow, scheduleClose]);
|
||||
}, [clearGraceTimer, keyPressed, open, openNow, scheduleClose]);
|
||||
|
||||
// While open, the safe zone covers trigger + content + the bridge between
|
||||
// them. Close only fires when the pointer leaves the safe zone; re-entering
|
||||
@@ -295,16 +256,8 @@ function WebHoverCard({
|
||||
useEffect(() => clearGraceTimer, [clearGraceTimer]);
|
||||
|
||||
const value = useMemo<HoverCardContextValue>(
|
||||
() => ({
|
||||
open,
|
||||
layer,
|
||||
setTriggerRef,
|
||||
triggerRef,
|
||||
contentRef,
|
||||
pointerEntered,
|
||||
scheduleClose,
|
||||
}),
|
||||
[open, layer, setTriggerRef, pointerEntered, scheduleClose],
|
||||
() => ({ open, layer, setTriggerRef, triggerRef, contentRef, openNow, scheduleClose }),
|
||||
[open, layer, setTriggerRef, openNow, scheduleClose],
|
||||
);
|
||||
|
||||
return (
|
||||
@@ -332,7 +285,7 @@ export function HoverCardTrigger({
|
||||
accessibilityRole={focusable ? "button" : undefined}
|
||||
accessibilityLabel={accessibilityLabel}
|
||||
collapsable={false}
|
||||
onPointerEnter={ctx.pointerEntered}
|
||||
onPointerEnter={ctx.openNow}
|
||||
onPointerLeave={ctx.scheduleClose}
|
||||
>
|
||||
{children}
|
||||
|
||||
@@ -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") {
|
||||
|
||||
@@ -64,7 +64,7 @@ export function WorkspaceHoverCard({
|
||||
}
|
||||
|
||||
return (
|
||||
<HoverCard disabled={isDragging || disabled} ignoreTouch>
|
||||
<HoverCard disabled={isDragging || disabled}>
|
||||
<HoverCardTrigger>{children}</HoverCardTrigger>
|
||||
<HoverCardContent
|
||||
placement="right"
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user