Compare commits

...
Author SHA1 Message Date
paseo-bot[bot]andhadan8977 2bd9e9d87f fix(server): preserve plugin child outcomes after close (#8563)
* fix(server): keep a plugin child's canceled or failed turn on close

A plugin child that ended its turn as canceled or failed and then sent a
plain session.closed was upserted as completed. Remember the outcome of
the child's latest finished turn and use it when it closes without an
error.

* fix(server): clear plugin child outcomes when replaying a branch

Keep the close-status fix compatible with reused-child running updates and discard remembered outcomes when rewind replaces a binding. Cover terminal close states through the agent API and replay.

Co-authored-by: hadan8977 <zc-huang23@mails.tsinghua.edu.cn>

---------

Co-authored-by: hadan8977 <zc-huang23@mails.tsinghua.edu.cn>
Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-11-14 19:59:39 +00:00
Okan YILDIRIM 6e2fee462f fix(server): fork from a response that background work outlives (#9627)
Forking from a reply failed with "This checkpoint changed after it was
created" whenever a tool call started before that reply and finished
after it, the shape of background Bash, Monitor, Task and Codex
terminals. The projection keeps one row per tool call from its start to
its latest update, so the row spans the reply, and the fork refused any
copied row that spans the checkpoint.

Copy such a tool call into the fork marked running: its state at the
checkpoint can't be read back. Plugin cards that span the checkpoint
stay out, and a cursor from another epoch or a reply that is gone is
still refused.
2026-11-14 19:04:00 +00:00
ese111 cec92d0be6 fix(cli): treat full agent UUIDs as exact identifiers (#9680) 2026-11-14 16:56:10 +00:00
Okan YILDIRIM b407990699 fix(archive): warn from fresh git state before removing a worktree (#9625)
* fix(archive): warn from fresh git state before removing a worktree

Archiving a worktree decided whether to warn from the sidebar row's cached git status. Until the
row received that status it was empty, so the archive removed uncommitted work and unpushed commits
without asking. Every archive that confirms now reads the worktree's checkout status from its host
first, and a failed read shows its error and keeps the worktree.

The host's dirty check ran bare `git status --porcelain`, which drops untracked files when the
repository sets `status.showUntrackedFiles=no`, so a worktree holding only new files read clean. It
now passes `--untracked-files=normal`.

* fix(archive): redirect from the current selection after the host answers

A worktree archive now waits for the host's checkout status before it starts. If you opened another
workspace meanwhile, the archive redirected from the selection captured at the click and replaced
the workspace you had just opened with /new. The redirect now runs the latest onArchiveStarted and
receives the workspace being archived, so the git actions controller compares that workspace with
the current selection instead of redirecting from whatever is open.
2026-11-14 16:16:52 +00:00
Mohamed Boudra fc36438f03 fix(app): keep the Android chat still when a tap or long-press lands on its text (#9693)
* fix(app): keep the Android chat still when a tap or long-press lands on its text

Chat text is selectable, and Android focuses selectable text when it is
touched. React Native's ReactScrollView.requestChildFocus scrolls a newly
focused child fully into view, so tapping a link or a word in a reply that
was partly off screen, or long-pressing it, scrolled the timeline by up to
a screen. The inverted timeline had a guard for this, but the guard
depended on the inversion (#4090, then #7131), and #8717 removed it with
the inversion.

PaseoScrollView now skips that reveal when the focused view is selectable,
non-editable text in touch mode. Inputs, and focus moved by a keyboard,
still scroll into view.

* test(app): assert the long press selects the word under the finger

* fix(app): keep the Android chat still when a tapped reply keeps streaming

Touched chat text keeps focus. When a streaming reply's text is replaced,
ArrowKeyMovementMethod collapses the selection to 0, and the focused text
view asks its scroll parent to reveal that line, which scrolled the
timeline back to the reply's first line. PaseoScrollView now drops
rectangle requests while touched read-only text holds focus with nothing
selected. A drag on a selection handle still reveals.

The input control test now makes its EditText selectable, as React
Native's TextInput does, so it covers the editor check.

* fix(app): keep the Android chat still when the timeline resizes after a tap

ScrollView.onSizeChanged scrolls the focused view into view when the
scroll view changes height. Touched chat text keeps focus, so a composer
change below the timeline (a queued message being sent, a send error
appearing) realigned the tapped reply. PaseoScrollView now skips that
reveal while touched read-only text holds focus.
2026-11-14 22:56:18 +07:00
paseo-bot[bot]andClaude Opus 5.5 0fb6473a4b Keep the slash-menu arrow-choice test from erasing its search before it applies (#9677)
* test(app): wait for the slash search to change before erasing it

"forgets an arrow-key choice once the search changes" waited for /help
after typing "h", but /help is already in the unfiltered list, so the
wait passed at once. On web the cursor reaches the slash search after
paint; a Backspace sent before that frame left the search at "" the
whole time, the /exit choice stood, and Enter archived the agent. The
test now waits for /tdd to leave the list, which only happens once the
search is "h".

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

* test(app): wait for the slash search to return before Enter

The input's value runs ahead of the slash list on web, so Enter could
still land on the list for "h". Wait for /tdd to come back first.

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>
2026-11-14 15:36:40 +00:00
FDKevin a730ad1924 fix(server): give restored agent runtimes a fresh idle cleanup window (#8857)
When an archived or persisted agent is restored via resumeAgentFromPersistence, its managed record intentionally preserves the historical updatedAt timestamp so sidebar ordering and activity timestamps are not artificially bumped just by viewing or reopening the agent.

However, getRuntimeCleanupCandidates() previously used updatedAt.getTime() directly as lastActivityAt for idle runtime garbage collection. For any restored agent whose last conversation turn was older than the idle runtime threshold (default 60 minutes), the next periodic idle cleanup sweep (every 60s) immediately saw the newly spawned runtime as idle for >60m and terminated its live process.

Record openedAt on the per-session runtimeCleanupIdentities entry when a session is registered, and compute lastActivityAt in getRuntimeCleanupCandidates() as Math.max(updatedAt.getTime(), identity.openedAt) so a newly restored runtime gets a full idle window without mutating the agent's persisted updatedAt.
2026-11-14 15:29:42 +00:00
25 changed files with 1321 additions and 172 deletions
+13 -1
View File
@@ -186,7 +186,8 @@ Drop a hunk when the installed LegendList ships its fix.
`modules/paseo-scroll` replaces React Native's scroll view on both platforms with a subclass:
Android's `PaseoScrollView` and iOS's `PaseoScrollViewComponentView`.
`VisibleContentPositionClampTest` covers Android; iOS has no native test harness.
`VisibleContentPositionClampTest` and `SelectableTextTouchTest` cover Android; iOS has no native
test harness.
**Content clamp.** A mount that shrinks the content while `maintainVisibleContentPosition` is set
applies the shrink twice on both platforms: the scroll view clamps its offset to the new end during
@@ -206,5 +207,16 @@ React Native 0.88 skips the adjustment for a removed anchor
and skip its correction while the user drags ([user scroll](#user-scroll)). This one has no React
Native counterpart; it stays while the timeline uses the pin.
**Touched text (Android).** Chat text is selectable, and Android focuses selectable text when it is
touched. React Native scrolls a newly focused view fully into view, so tapping a link or a word, or
long-pressing to select, in a message partly off screen moved the timeline by up to a screen.
`PaseoScrollView` skips that scroll when the focused view is selectable, non-editable text in touch
mode. The text keeps focus, and a streaming reply's new text collapses its selection to the start,
which asks to reveal the reply's first line; that request is skipped too while nothing is selected.
A change in the timeline's height, such as the composer below it growing, also reveals the focused
view, and is skipped the same way. Inputs, focus moved by a keyboard, and a selection the user drags
still scroll into view. The inverted timeline guarded this with a rule tied to the inversion, which
went with it; keep this one while chat text is selectable.
Remove the content clamp and removed-anchor overrides once the installed React Native includes
their fixes.
+4 -4
View File
@@ -186,10 +186,10 @@ This is the Android counterpart of the iOS local-simulator flow in [development.
## Scroll view corrections
`modules/paseo-scroll` replaces React Native's vertical scroll view with `PaseoScrollView`, which
keeps React Native's scroll manager interface and corrects `maintainVisibleContentPosition` for the
chat timeline. [agent-stream-disclosure.md](agent-stream-disclosure.md#scroll-view-corrections)
lists the corrections and the React Native issues they work around. Its instrumentation test,
`VisibleContentPositionClampTest`, mounts through React Native's own mounting manager:
keeps React Native's scroll manager interface and corrects how the chat timeline holds its position.
[agent-stream-disclosure.md](agent-stream-disclosure.md#scroll-view-corrections) lists the
corrections and the React Native issues they work around. Its instrumentation tests run the scroll
view in a real window, mounting through React Native's own mounting manager and injecting touches:
```bash
cd packages/app/android
+5 -4
View File
@@ -95,10 +95,11 @@ Provider message IDs are not guaranteed for every displayed item. Paseo-generate
Actions that address a point in chat history, such as Fork, use the daemon timeline `epoch` plus the projected item's `seqEnd`. The app carries that position on the rendered assistant item for both live and fetched history. When adjacent projected chunks merge, the merged item retains the newer chunk's position.
The daemon validates the epoch and locates the projected item at the selected position. A fork
includes projected items through that checkpoint. If an item the fork copies spans the checkpoint
and changed afterward, the daemon refuses that fork with an actionable error: discarded historical
payloads cannot be reconstructed from sequence metadata. Plugin cards are not copied, so a card
updated after the checkpoint does not block it. Forking the current context remains available.
includes projected items through that checkpoint. A tool call that started before the checkpoint
and changed after it, such as background work that outlives a reply, is copied as still running:
the projection keeps only its latest payload, so its state at the checkpoint can't be read back.
Plugin cards are not copied. A position from another epoch, or one that is gone, is refused;
forking the current context remains available.
## Resume behavior
@@ -787,9 +787,13 @@ test.describe("Composer autocomplete", () => {
await input.press("ArrowDown");
await input.press("ArrowDown");
await input.press("h");
await expect(popover.getByText("/help", { exact: true }).first()).toBeVisible();
// On web the search reads the cursor after paint, so the input's value runs ahead of
// the list. Wait on the list itself: /tdd is hidden only while the search is "h", and
// back only once the search is "" again.
await expect(popover.getByText("/tdd", { exact: true })).toHaveCount(0);
await input.press("Backspace");
await expect(input).toHaveValue("/");
await expect(popover.getByText("/tdd", { exact: true }).first()).toBeVisible();
await input.press("Enter");
await expect(input).toHaveValue("/tdd ");
@@ -5,27 +5,34 @@ import path from "node:path";
import type { Dialog, Page } from "@playwright/test";
import { expect, test } from "../support/fixtures";
import { gotoAppShell } from "../support/helpers/app";
import { daemonWsRoutePattern } from "../support/helpers/daemon-port";
import {
archiveWorkspaceFromDaemon,
connectNewWorkspaceDaemonClient,
createWorktreeViaDaemon,
openProjectViaDaemon,
} from "../support/helpers/new-workspace";
import { expectAppRoute } from "../support/helpers/route-assertions";
import { getServerId } from "../support/helpers/server-id";
import {
clickArchiveWorkspaceMenuItem,
expectWorkspaceAbsentFromSidebar,
} from "../support/helpers/sidebar";
import { createTempGitRepo } from "../support/helpers/workspace";
import {
rowArchiveButton,
undoButton,
workspaceRow,
} from "../support/helpers/workspace-archive-undo";
import {
waitForSidebarHydration,
waitForWorkspaceInSidebar,
} from "../support/helpers/workspace-ui";
import { buildHostWorkspaceRoute } from "@/utils/host-routes";
async function seedRiskyWorktree(
client: Awaited<ReturnType<typeof connectNewWorkspaceDaemonClient>>,
worktreeDirectory: string,
): Promise<void> {
type DaemonClient = Awaited<ReturnType<typeof connectNewWorkspaceDaemonClient>>;
async function seedRiskyWorktree(client: DaemonClient, worktreeDirectory: string): Promise<void> {
// The daemon only reports unpushed commits when the branch has a configured
// upstream (aheadOfOrigin is computed against `branch.<name>.merge`). Push the
// worktree branch at its current head first so it tracks origin with 0 ahead,
@@ -55,35 +62,189 @@ async function seedRiskyWorktree(
const dirtyFile = path.join(worktreeDirectory, "DIRTY.md");
await writeFile(dirtyFile, "# dirty\n");
const refreshed = await client.checkoutRefresh(worktreeDirectory);
await refreshCheckout(client, worktreeDirectory);
}
async function refreshCheckout(client: DaemonClient, directory: string): Promise<void> {
const refreshed = await client.checkoutRefresh(directory);
if (!refreshed.success) {
throw new Error(`Failed to refresh checkout for ${worktreeDirectory}`);
throw new Error(`Failed to refresh checkout for ${directory}`);
}
}
// The archive confirmation is a synchronous web `window.confirm()`. The click that
// opens it does not resolve until the dialog is answered, so the handler must
// accept/dismiss inline — awaiting the dialog only *after* the click deadlocks, as
// the click waits for an answer that is gated behind that same click.
async function clickArchiveAndAnswerWarning(
// The archive confirmation is a synchronous web `window.confirm()` that opens once the app has
// read the worktree's git state from the host. Wait for the dialog itself: on web the click does
// not resolve until it is answered, so answer it from the event.
async function answerArchiveWarning(
page: Page,
workspaceId: string,
answer: "accept" | "dismiss",
openArchive: () => Promise<void>,
): Promise<Dialog> {
let warning: Dialog | undefined;
page.once("dialog", (dialog) => {
warning = dialog;
void (answer === "accept" ? dialog.accept() : dialog.dismiss());
const warning = page.waitForEvent("dialog", { timeout: 10_000 }).then(async (dialog) => {
await (answer === "accept" ? dialog.accept() : dialog.dismiss());
return dialog;
});
await clickArchiveWorkspaceMenuItem(page, workspaceId);
if (!warning) {
throw new Error("Expected an archive confirmation dialog, but none was shown.");
await openArchive();
return await warning;
}
// A clean Paseo worktree's primary git action in the workspace header is Archive.
function gitActionsArchiveButton(page: Page) {
return page.getByTestId("changes-primary-cta").and(page.getByLabel("Archive workspace"));
}
async function clickRowArchiveButton(page: Page, workspaceId: string): Promise<void> {
await workspaceRow(page, workspaceId).hover();
await rowArchiveButton(page, workspaceId).click();
}
interface SessionEnvelope {
type?: unknown;
message?: {
type?: unknown;
workspaceId?: unknown;
cwd?: unknown;
payload?: Record<string, unknown>;
};
}
function parseSessionEnvelope(message: string): SessionEnvelope | null {
try {
return JSON.parse(message) as SessionEnvelope;
} catch {
return null;
}
return warning;
}
function removeGitStatusFromDescriptors(value: unknown, workspaceId: string): number {
if (Array.isArray(value)) {
return value.reduce<number>(
(count, item) => count + removeGitStatusFromDescriptors(item, workspaceId),
0,
);
}
if (!value || typeof value !== "object") return 0;
const record = value as Record<string, unknown>;
let count = 0;
if (record.id === workspaceId && "workspaceDirectory" in record) {
delete record.gitRuntime;
record.diffStat = null;
count += 1;
}
for (const child of Object.values(record)) {
count += removeGitStatusFromDescriptors(child, workspaceId);
}
return count;
}
/**
* Relays the app's connection to the daemon. Every workspace descriptor for `workspaceId` reaches
* the app without git status, as the host sends it before its first checkout snapshot of the
* worktree, so the sidebar row has nothing cached to warn from. `failCheckoutStatusFor` turns the
* host's checkout status answer for that directory into a git error.
*/
async function relayDaemon(
page: Page,
input: { workspaceId: string; failCheckoutStatusFor?: string },
) {
let descriptorRewrites = 0;
let archiveRequests = 0;
await page.routeWebSocket(daemonWsRoutePattern(), (browser) => {
const server = browser.connectToServer();
browser.onMessage((message) => {
if (typeof message === "string") {
const request = parseSessionEnvelope(message)?.message;
if (
request?.type === "archive_workspace_request" &&
request.workspaceId === input.workspaceId
) {
archiveRequests += 1;
}
}
server.send(message);
});
server.onMessage((message) => {
if (typeof message !== "string") {
browser.send(message);
return;
}
const envelope = parseSessionEnvelope(message);
const response = envelope?.message;
if (
input.failCheckoutStatusFor &&
response?.type === "checkout_status_response" &&
response.payload?.cwd === input.failCheckoutStatusFor
) {
response.payload = {
...response.payload,
isGit: false,
isPaseoOwnedWorktree: false,
repoRoot: null,
currentBranch: null,
isDirty: null,
baseRef: null,
aheadBehind: null,
aheadOfOrigin: null,
behindOfOrigin: null,
hasRemote: false,
remoteUrl: null,
error: { code: "UNKNOWN", message: "git status failed" },
};
browser.send(JSON.stringify(envelope));
return;
}
if (!envelope || !message.includes(input.workspaceId)) {
browser.send(message);
return;
}
descriptorRewrites += removeGitStatusFromDescriptors(envelope, input.workspaceId);
browser.send(JSON.stringify(envelope));
});
});
return {
descriptorRewrites: () => descriptorRewrites,
archiveRequests: () => archiveRequests,
};
}
/**
* Holds the app's next checkout status request for `directory` once armed, so a test can act
* while the archive waits for the host's answer.
*/
async function holdCheckoutStatusRequest(page: Page, directory: string) {
let armed = false;
let held: (() => void) | null = null;
await page.routeWebSocket(daemonWsRoutePattern(), (browser) => {
const server = browser.connectToServer();
browser.onMessage((message) => {
const request = typeof message === "string" ? parseSessionEnvelope(message)?.message : null;
if (
armed &&
!held &&
request?.type === "checkout_status_request" &&
request.cwd === directory
) {
held = () => server.send(message);
return;
}
server.send(message);
});
server.onMessage((message) => browser.send(message));
});
return {
arm: () => {
armed = true;
},
waitForHeld: () => expect.poll(() => held !== null).toBe(true),
release: () => {
if (!held) throw new Error("No checkout status request is held");
held();
},
};
}
test.describe("Workspace archive risk warning for worktree backing", () => {
let client: Awaited<ReturnType<typeof connectNewWorkspaceDaemonClient>>;
let client: DaemonClient;
let tempRepo: { path: string; cleanup: () => Promise<void> };
const createdWorktreeDirectories = new Set<string>();
@@ -103,36 +264,53 @@ test.describe("Workspace archive risk warning for worktree backing", () => {
await tempRepo?.cleanup().catch(() => undefined);
});
test("a risky workspace archive is gated by confirmation and removes its worktree after acceptance", async ({
page,
}) => {
const serverId = getServerId();
await openProjectViaDaemon(client, tempRepo.path);
async function createWorktree(slug: string) {
const project = await openProjectViaDaemon(client, tempRepo.path);
const worktree = await createWorktreeViaDaemon(client, {
cwd: tempRepo.path,
slug: `archive-risk-${Date.now()}`,
slug: `${slug}-${Date.now()}`,
});
createdWorktreeDirectories.add(worktree.workspaceDirectory);
expect(existsSync(worktree.workspaceDirectory)).toBe(true);
return { ...worktree, project };
}
await seedRiskyWorktree(client, worktree.workspaceDirectory);
async function openSidebarRow(page: Page, workspaceId: string): Promise<void> {
await gotoAppShell(page);
await waitForSidebarHydration(page);
await waitForWorkspaceInSidebar(page, { serverId, workspaceId: worktree.workspaceId });
await waitForWorkspaceInSidebar(page, { serverId: getServerId(), workspaceId });
}
const firstWarning = await clickArchiveAndAnswerWarning(page, worktree.workspaceId, "dismiss");
async function expectWorktreeKept(page: Page, worktree: { workspaceId: string }) {
await expect(workspaceRow(page, worktree.workspaceId)).toBeVisible({ timeout: 10_000 });
}
async function hostListsWorkspace(workspaceId: string): Promise<boolean> {
const { entries } = await client.fetchWorkspaces();
return entries.some((entry) => entry.id === workspaceId);
}
test("a risky workspace archive is gated by confirmation and removes its worktree after acceptance", async ({
page,
}) => {
const worktree = await createWorktree("archive-risk");
await seedRiskyWorktree(client, worktree.workspaceDirectory);
await openSidebarRow(page, worktree.workspaceId);
const firstWarning = await answerArchiveWarning(page, "dismiss", () =>
clickArchiveWorkspaceMenuItem(page, worktree.workspaceId),
);
expect(firstWarning.type()).toBe("confirm");
expect(firstWarning.message()).toContain(`Archive "${worktree.workspaceName}"?`);
expect(firstWarning.message()).toContain("Uncommitted changes");
expect(firstWarning.message()).toContain("1 unpushed commit");
await expect(
page.getByTestId(`sidebar-workspace-row-${serverId}:${worktree.workspaceId}`),
).toBeVisible({ timeout: 10_000 });
await expectWorktreeKept(page, worktree);
expect(existsSync(worktree.workspaceDirectory)).toBe(true);
const secondWarning = await clickArchiveAndAnswerWarning(page, worktree.workspaceId, "accept");
const secondWarning = await answerArchiveWarning(page, "accept", () =>
clickArchiveWorkspaceMenuItem(page, worktree.workspaceId),
);
expect(secondWarning.message()).toContain("Uncommitted changes");
expect(secondWarning.message()).toContain("1 unpushed commit");
@@ -143,4 +321,128 @@ test.describe("Workspace archive risk warning for worktree backing", () => {
createdWorktreeDirectories.delete(worktree.workspaceDirectory);
});
test("the row's archive button warns from the host's git state while the row has none, and Cancel keeps the worktree", async ({
page,
}) => {
const worktree = await createWorktree("archive-risk-uncached");
await seedRiskyWorktree(client, worktree.workspaceDirectory);
const relay = await relayDaemon(page, { workspaceId: worktree.workspaceId });
await openSidebarRow(page, worktree.workspaceId);
expect(relay.descriptorRewrites()).toBeGreaterThan(0);
const warning = await answerArchiveWarning(page, "dismiss", () =>
clickRowArchiveButton(page, worktree.workspaceId),
);
expect(warning.message()).toContain(`Archive "${worktree.workspaceName}"?`);
expect(warning.message()).toContain("Uncommitted changes");
expect(warning.message()).toContain("1 unpushed commit");
await expectWorktreeKept(page, worktree);
expect(relay.archiveRequests()).toBe(0);
expect(existsSync(path.join(worktree.workspaceDirectory, "DIRTY.md"))).toBe(true);
});
test("a worktree holding only new files warns when the repository hides untracked files", async ({
page,
}) => {
execSync("git config status.showUntrackedFiles no", { cwd: tempRepo.path, stdio: "ignore" });
const worktree = await createWorktree("archive-risk-untracked");
await writeFile(path.join(worktree.workspaceDirectory, "NEW.md"), "# not yet added\n");
await refreshCheckout(client, worktree.workspaceDirectory);
await openSidebarRow(page, worktree.workspaceId);
const warning = await answerArchiveWarning(page, "dismiss", () =>
clickRowArchiveButton(page, worktree.workspaceId),
);
expect(warning.message()).toContain(`Archive "${worktree.workspaceName}"?`);
expect(warning.message()).toContain("Uncommitted changes");
await expectWorktreeKept(page, worktree);
expect(existsSync(path.join(worktree.workspaceDirectory, "NEW.md"))).toBe(true);
});
test("a failed git read shows its error and keeps the worktree", async ({ page }) => {
const worktree = await createWorktree("archive-risk-read-failure");
await seedRiskyWorktree(client, worktree.workspaceDirectory);
const relay = await relayDaemon(page, {
workspaceId: worktree.workspaceId,
failCheckoutStatusFor: worktree.workspaceDirectory,
});
await openSidebarRow(page, worktree.workspaceId);
await clickRowArchiveButton(page, worktree.workspaceId);
await expect(page.getByTestId("app-toast-message")).toHaveText("git status failed");
await expectWorktreeKept(page, worktree);
expect(relay.archiveRequests()).toBe(0);
expect(existsSync(worktree.workspaceDirectory)).toBe(true);
});
// Archives the open worktree with `startArchive`, opens the project's main workspace while the
// host's checkout status answer is held, then lets the answer through.
async function archiveWhileOpeningAnotherWorkspace(
page: Page,
slug: string,
startArchive: (worktree: { workspaceId: string }) => Promise<void>,
): Promise<string> {
const serverId = getServerId();
const worktree = await createWorktree(slug);
const statusRequest = await holdCheckoutStatusRequest(page, worktree.workspaceDirectory);
await openSidebarRow(page, worktree.workspaceId);
await workspaceRow(page, worktree.workspaceId).click();
await expectAppRoute(page, buildHostWorkspaceRoute(serverId, worktree.workspaceId));
statusRequest.arm();
await startArchive(worktree);
await statusRequest.waitForHeld();
await workspaceRow(page, worktree.project.workspaceId).click();
const openedRoute = buildHostWorkspaceRoute(serverId, worktree.project.workspaceId);
await expectAppRoute(page, openedRoute);
statusRequest.release();
await expect(undoButton(page)).toBeVisible();
// The redirect runs before the archive request, so once the host has archived the worktree
// any redirect has already happened.
await expect
.poll(() => hostListsWorkspace(worktree.workspaceId), { timeout: 30_000 })
.toBe(false);
return openedRoute;
}
test("opening another workspace while the host answers keeps it open after a row archive", async ({
page,
}) => {
const openedRoute = await archiveWhileOpeningAnotherWorkspace(
page,
"archive-risk-switch-row",
(worktree) => clickRowArchiveButton(page, worktree.workspaceId),
);
await expectAppRoute(page, openedRoute);
});
test("opening another workspace while the host answers keeps it open after a git actions archive", async ({
page,
}) => {
const openedRoute = await archiveWhileOpeningAnotherWorkspace(
page,
"archive-risk-switch-git",
() => gitActionsArchiveButton(page).click(),
);
await expectAppRoute(page, openedRoute);
});
test("archiving the open worktree from its git actions leaves it for a new workspace", async ({
page,
}) => {
const worktree = await createWorktree("archive-risk-git-redirect");
await openSidebarRow(page, worktree.workspaceId);
await workspaceRow(page, worktree.workspaceId).click();
await expectAppRoute(page, buildHostWorkspaceRoute(getServerId(), worktree.workspaceId));
await gitActionsArchiveButton(page).click();
await expect.poll(() => new URL(page.url()).pathname).toBe("/new");
await expectWorkspaceAbsentFromSidebar(page, worktree.workspaceId);
});
});
@@ -0,0 +1,282 @@
package sh.paseo.scroll
import android.app.Activity
import android.content.Intent
import android.os.SystemClock
import android.text.InputType
import android.view.MotionEvent
import android.view.View
import android.view.ViewConfiguration
import android.view.inputmethod.EditorInfo
import android.view.inputmethod.InputConnection
import android.widget.EditText
import android.widget.FrameLayout
import android.widget.TextView
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import com.facebook.react.soloader.OpenSourceMergedSoMapping
import com.facebook.react.uimanager.DisplayMetricsHolder
import com.facebook.react.uimanager.ThemedReactContext
import com.facebook.react.views.scroll.ReactScrollView
import com.facebook.react.views.scroll.ReactScrollViewManager
import com.facebook.react.views.view.ReactViewGroup
import com.facebook.soloader.SoLoader
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotEquals
import org.junit.Assert.assertTrue
import org.junit.Test
import org.junit.runner.RunWith
/**
* Touches on chat text in the timeline's scroll view. Chat text is selectable, so a tap or a long
* press focuses it, and the focus may not scroll the timeline. The touches are injected into a real
* window, so touch mode, focus and the selection controller run as on device.
*/
@RunWith(AndroidJUnit4::class)
class SelectableTextTouchTest {
private val instrumentation = InstrumentationRegistry.getInstrumentation()
private var activity: Activity? = null
@After fun finish() {
activity?.finish()
}
// A reply whose top is on screen and whose bottom is below the viewport.
@Test fun tappingTextThatRunsBelowTheViewportKeepsTheOffset() {
val timeline = openTimeline(edge = Edge.TOP)
timeline.tap(visibleY = EDGE_Y + 100)
assertTrue("The tap focuses the text", timeline.textHasFocus())
assertEquals("Offset after the tap", OFFSET, timeline.settledScrollY())
}
// A reply whose bottom is on screen and whose top is above the viewport, like a link at its end.
@Test fun tappingTextThatStartsAboveTheViewportKeepsTheOffset() {
val timeline = openTimeline(edge = Edge.BOTTOM)
timeline.tap(visibleY = EDGE_Y - 100)
assertTrue("The tap focuses the text", timeline.textHasFocus())
assertEquals("Offset after the tap", OFFSET, timeline.settledScrollY())
}
// A reply that is still streaming gets new text while the user's tap holds its focus.
@Test fun aStreamingUpdateToTappedTextKeepsTheOffset() {
val timeline = openTimeline(edge = Edge.BOTTOM)
timeline.tap(visibleY = EDGE_Y - 100)
assertTrue("The tap focuses the text", timeline.textHasFocus())
timeline.updateText("$CHAT_TEXT\nOne more line of the reply.")
assertEquals("Offset after the update", OFFSET, timeline.settledScrollY())
}
// The timeline changes height while tapped text holds focus, as when the composer above the
// keyboard loses its queued message.
@Test fun aTimelineResizeAfterATapKeepsTheOffset() {
val timeline = openTimeline(edge = Edge.TOP)
timeline.tap(visibleY = EDGE_Y + 100)
assertTrue("The tap focuses the text", timeline.textHasFocus())
timeline.resize(height = VIEWPORT + 200)
assertEquals("Offset after the resize", OFFSET, timeline.settledScrollY())
}
@Test fun longPressSelectsAWordAndKeepsTheOffset() {
val timeline = openTimeline(edge = Edge.TOP)
val word = timeline.wordAt(visibleY = EDGE_Y + 100)
timeline.longPress(visibleY = EDGE_Y + 100)
assertEquals("Selected text", word, timeline.selectedText())
assertEquals("Offset after the long press", OFFSET, timeline.settledScrollY())
}
// Inputs in the timeline, such as an agent's question form, still scroll into view to be typed in.
@Test fun tappingAnInputThatRunsBelowTheViewportRevealsIt() {
val timeline = openTimeline(edge = Edge.TOP, editable = true)
timeline.tap(visibleY = EDGE_Y + 100)
assertTrue("The tap focuses the input", timeline.textHasFocus())
assertNotEquals("Offset after the tap", OFFSET, timeline.settledScrollY())
}
private inner class Timeline(private val scroll: ReactScrollView, private val text: TextView) {
fun scrollTo(y: Int) {
instrumentation.runOnMainSync { scroll.scrollTo(0, y) }
awaitFrames(2)
}
fun tap(visibleY: Int) {
val downTime = SystemClock.uptimeMillis()
touch(downTime, MotionEvent.ACTION_DOWN, visibleY)
touch(downTime, MotionEvent.ACTION_UP, visibleY)
}
fun longPress(visibleY: Int) {
val downTime = SystemClock.uptimeMillis()
touch(downTime, MotionEvent.ACTION_DOWN, visibleY)
SystemClock.sleep(ViewConfiguration.getLongPressTimeout() * 2L)
touch(downTime, MotionEvent.ACTION_UP, visibleY)
}
fun resize(height: Int) {
instrumentation.runOnMainSync {
scroll.layoutParams = scroll.layoutParams.apply { this.height = height }
}
}
// Replaces the text and lays it out again at its top, the way a streaming reply mounts.
fun updateText(value: String) {
instrumentation.runOnMainSync {
text.setText(value)
text.measure(exact(WIDTH), unspecified())
text.layout(0, text.top, WIDTH, text.top + text.measuredHeight)
}
}
fun textHasFocus(): Boolean {
awaitFrames(2)
var focused = false
instrumentation.runOnMainSync { focused = text.isFocused }
return focused
}
fun selectedText(): String {
awaitFrames(2)
var selected = ""
instrumentation.runOnMainSync {
selected = text.text.substring(text.selectionStart, text.selectionEnd)
}
return selected
}
/** The word under [visibleY] at the current offset. */
fun wordAt(visibleY: Int): String {
var word = ""
instrumentation.runOnMainSync {
val chars = text.text
val y = scroll.scrollY + visibleY - text.top
val offset = text.getOffsetForPosition(TOUCH_X - text.left, y.toFloat())
var start = offset
var end = offset
while (start > 0 && chars[start - 1].isLetterOrDigit()) start--
while (end < chars.length && chars[end].isLetterOrDigit()) end++
word = chars.substring(start, end)
}
assertTrue("The touch lands on a word", word.isNotEmpty())
return word
}
fun settledScrollY(): Int {
var previous = scrollY()
repeat(60) {
awaitFrames(3)
val current = scrollY()
if (current == previous) return current
previous = current
}
throw AssertionError("Offset never settled")
}
private fun scrollY(): Int {
var y = 0
instrumentation.runOnMainSync { y = scroll.scrollY }
return y
}
// Injected through the window like a finger, which puts the window in touch mode.
private fun touch(downTime: Long, action: Int, visibleY: Int) {
val location = IntArray(2)
instrumentation.runOnMainSync { scroll.getLocationOnScreen(location) }
val x = location[0] + TOUCH_X
val y = location[1] + visibleY.toFloat()
val event = MotionEvent.obtain(downTime, SystemClock.uptimeMillis(), action, x, y, 0)
instrumentation.sendPointerSync(event)
event.recycle()
}
fun awaitFrames(count: Int) {
repeat(count) {
instrumentation.waitForIdleSync()
SystemClock.sleep(16)
}
}
}
/** Which edge of the text is on screen, at [EDGE_Y]; the other is past the viewport. */
private enum class Edge { TOP, BOTTOM }
/** A timeline scrolled to [OFFSET] with one block of chat text taller than the viewport. */
private fun openTimeline(edge: Edge, editable: Boolean = false): Timeline {
val started =
instrumentation.startActivitySync(
Intent(instrumentation.targetContext, Activity::class.java).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
)
activity = started
lateinit var timeline: Timeline
instrumentation.runOnMainSync {
DisplayMetricsHolder.initDisplayMetricsIfNotInitialized(started)
SoLoader.init(started, OpenSourceMergedSoMapping)
val reactContext = MountingReactContext(started.applicationContext)
val context = ThemedReactContext(reactContext, started)
val manager =
PaseoScrollPackage().createViewManager(reactContext, ReactScrollViewManager.REACT_CLASS)
as ReactScrollViewManager
val scroll = manager.createViewInstance(context)
val content = ReactViewGroup(context)
val text =
if (editable) {
// Selectable, as React Native's TextInput makes itself when attached, so only being a text
// editor tells it apart from chat text. No input connection: building one runs React
// Native's focus search through Fabric, which this context does not run.
object : EditText(context) {
override fun onCreateInputConnection(outAttrs: EditorInfo): InputConnection? = null
}.apply {
inputType = InputType.TYPE_CLASS_TEXT or InputType.TYPE_TEXT_FLAG_MULTI_LINE
setTextIsSelectable(true)
}
} else {
// How enriched-markdown and React Native's <Text selectable> set up chat text.
TextView(context).apply { setTextIsSelectable(true) }
}
text.setText(CHAT_TEXT)
content.addView(text)
scroll.addView(content)
started.setContentView(
FrameLayout(started).apply { addView(scroll, FrameLayout.LayoutParams(WIDTH, VIEWPORT)) }
)
// Fabric lays out React content; nothing in a React view group lays out its children itself.
content.layout(0, 0, WIDTH, CONTENT_HEIGHT)
text.measure(exact(WIDTH), unspecified())
assertTrue("The text is taller than the viewport", text.measuredHeight > VIEWPORT)
val top = if (edge == Edge.TOP) OFFSET + EDGE_Y else OFFSET + EDGE_Y - text.measuredHeight
text.layout(0, top, WIDTH, top + text.measuredHeight)
timeline = Timeline(scroll, text)
}
timeline.awaitFrames(2)
timeline.scrollTo(OFFSET)
assertEquals("Starts at the offset", OFFSET, timeline.settledScrollY())
return timeline
}
private fun exact(size: Int) = View.MeasureSpec.makeMeasureSpec(size, View.MeasureSpec.EXACTLY)
private fun unspecified() = View.MeasureSpec.makeMeasureSpec(0, View.MeasureSpec.UNSPECIFIED)
private companion object {
const val WIDTH = 600
const val TOUCH_X = WIDTH / 2f
const val VIEWPORT = 800
const val CONTENT_HEIGHT = 6000
const val OFFSET = 2400
// Below the plain activity's action bar, which overlays the top of the window.
const val EDGE_Y = 500
val CHAT_TEXT = List(30) { "Line $it of a reply in the chat timeline." }.joinToString("\n")
}
}
@@ -1,10 +1,12 @@
package sh.paseo.scroll;
import android.content.Context;
import android.graphics.Rect;
import android.view.MotionEvent;
import android.view.View;
import android.view.ViewGroup;
import android.view.ViewTreeObserver;
import android.widget.TextView;
import androidx.annotation.Nullable;
import com.facebook.react.R;
import com.facebook.react.views.scroll.MaintainVisibleScrollPositionHelper;
@@ -12,7 +14,7 @@ import com.facebook.react.views.scroll.ReactScrollView;
import com.facebook.react.views.scroll.ReactScrollViewHelper;
/**
* React Native's vertical scroll view with two corrections. Java, because the content-position
* React Native's vertical scroll view with five corrections. Java, because the content-position
* config type is internal to React Native's Kotlin module.
*/
final class PaseoScrollView extends ReactScrollView {
@@ -27,6 +29,7 @@ final class PaseoScrollView extends ReactScrollView {
private @Nullable MaintainVisibleScrollPositionHelper.Config visibleContentPosition = null;
private boolean isClampPending = false;
private boolean isUserDragging = false;
private boolean isFocusRevealSkipped = false;
PaseoScrollView(Context context) {
super(context);
@@ -51,6 +54,56 @@ final class PaseoScrollView extends ReactScrollView {
super.scrollToPreservingMomentum(x, y);
}
// Chat text is selectable (enriched-markdown's text views and <Text selectable>), and selectable
// text takes focus when touched: a tap on a link, a word, or a long-press to select. React Native
// scrolls a newly focused child fully into view, so touching a reply that is partly off screen
// scrolled the timeline by up to a screen under the user's finger. Text the user touched is
// already where they are looking, so its focus does not scroll. Inputs, and focus moved by a
// keyboard, still scroll into view.
@Override
public void requestChildFocus(View child, View focused) {
if (isTouchedReadOnlyText(focused)) {
requestChildFocusWithoutScroll(child, focused);
return;
}
super.requestChildFocus(child, focused);
}
// Focused text asks to reveal its selection whenever the selection changes. A streaming reply
// replaces its text, which collapses the selection to the start, so a reply that kept focus from
// a touch scrolled the timeline back to its first line on the next update. Read-only text has no
// caret, so a collapsed selection has nothing to reveal. A selection the user drags still does.
@Override
public boolean requestChildRectangleOnScreen(View child, Rect rectangle, boolean immediate) {
View focused = findFocus();
if (isTouchedReadOnlyText(focused) && !((TextView) focused).hasSelection()) return false;
return super.requestChildRectangleOnScreen(child, rectangle, immediate);
}
// When the scroll view changes height, Android scrolls the focused view into view again. Touched
// chat text keeps focus, so the composer growing or shrinking below the timeline realigned it.
@Override
protected void onSizeChanged(int w, int h, int oldw, int oldh) {
isFocusRevealSkipped = isTouchedReadOnlyText(findFocus());
try {
super.onSizeChanged(w, h, oldw, oldh);
} finally {
isFocusRevealSkipped = false;
}
}
@Override
protected int computeScrollDeltaToGetChildRectOnScreen(Rect rect) {
if (isFocusRevealSkipped) return 0;
return super.computeScrollDeltaToGetChildRectOnScreen(rect);
}
private static boolean isTouchedReadOnlyText(@Nullable View view) {
if (!(view instanceof TextView) || !view.isInTouchMode()) return false;
TextView text = (TextView) view;
return text.isTextSelectable() && !text.onCheckIsTextEditor();
}
// The drag begins where React Native emits onScrollBeginDrag.
@Override
protected void handleInterceptedTouchEvent(MotionEvent ev) {
@@ -87,7 +87,6 @@ import {
import { ProjectLeadingVisual } from "@/components/sidebar/project-leading-visual";
import { useToast } from "@/contexts/toast-context";
import { getForgePresentation, normalizeForge } from "@/git/forge";
import { toWorktreeArchiveRisk } from "@/git/worktree-archive-warning";
import { hasVisibleOrderChanged, mergeWithRemainder } from "@/utils/sidebar-reorder";
import { confirmDialog } from "@/utils/confirm-dialog";
import type { SidebarStateBucket } from "@/utils/sidebar-agent-state";
@@ -1252,8 +1251,9 @@ function WorkspaceRowWithMenu({
serverId: workspace.serverId,
workspaceId: workspace.workspaceId,
workspaceKind: workspace.workspaceKind,
workspaceDirectory: workspace.workspaceDirectory,
name: workspace.name,
...toWorktreeArchiveRisk(workspace),
diffStat: workspace.diffStat,
onArchiveStarted: redirectAfterArchive,
onSetHiding: setIsHidingWorkspace,
});
@@ -38,7 +38,6 @@ import { useSidebarWorkspaceRename } from "@/workspace-rename";
import { useWorkspaceClipboardActions } from "@/hooks/use-workspace-clipboard-actions";
import { redirectIfArchivingActiveWorkspace } from "@/utils/sidebar-workspace-archive-redirect";
import { useWorkspaceArchive } from "@/workspace/use-workspace-archive";
import { toWorktreeArchiveRisk } from "@/git/worktree-archive-warning";
import type { ShortcutKey } from "@/utils/format-shortcut";
import { useShortcutKeys } from "@/hooks/use-shortcut-keys";
import { useKeyboardActionHandler } from "@/hooks/use-keyboard-action-handler";
@@ -656,8 +655,9 @@ function StatusWorkspaceRowWithMenu({
serverId: workspace.serverId,
workspaceId: workspace.workspaceId,
workspaceKind: workspace.workspaceKind,
workspaceDirectory: workspace.workspaceDirectory,
name: workspace.name,
...toWorktreeArchiveRisk(workspace),
diffStat: workspace.diffStat,
onArchiveStarted: redirectAfterArchive,
onSetHiding: setIsHidingWorkspace,
});
+3 -11
View File
@@ -291,21 +291,13 @@ function useWorkspaceScreenArchiveController({
serverId,
workspaceId: workspaceDescriptor?.id ?? "",
workspaceKind: workspaceDescriptor?.workspaceKind ?? "directory",
workspaceDirectory: workspaceDescriptor?.workspaceDirectory ?? "",
name: workspaceDescriptor?.name ?? branchLabel,
isDirty: archiveRisk.isDirty,
aheadOfOrigin: archiveRisk.aheadOfOrigin,
diffStat: workspaceDescriptor?.diffStat ?? null,
warningLabels: getWorktreeArchiveWarningLabels(t),
onSetHiding: setIsHidingWorkspace,
onArchiveStarted: () => {
if (!activeWorkspaceSelection) {
return;
}
redirectIfArchivingActiveWorkspace({
serverId,
workspaceId: activeWorkspaceSelection.workspaceId,
activeWorkspaceSelection,
});
onArchiveStarted: (archived) => {
redirectIfArchivingActiveWorkspace({ ...archived, activeWorkspaceSelection });
},
});
@@ -1,11 +1,87 @@
import { describe, expect, it } from "vitest";
import type { CheckoutStatusClient, CheckoutStatusPayload } from "@/git/checkout-status-cache";
import {
buildWorktreeArchiveConfirmationMessage,
buildWorktreeArchiveRiskReasons,
readWorktreeArchiveRisk,
toWorktreeArchiveRisk,
} from "@/git/worktree-archive-warning";
const WORKTREE = "/home/me/.paseo/worktrees/repo/feature";
function hostAnswering(status: CheckoutStatusPayload): CheckoutStatusClient & { asked: string[] } {
const asked: string[] = [];
return {
asked,
getCheckoutStatus: async (cwd) => {
asked.push(cwd);
return status;
},
};
}
const NOT_GIT = {
cwd: WORKTREE,
requestId: "status",
isGit: false,
isPaseoOwnedWorktree: false,
repoRoot: null,
currentBranch: null,
isDirty: null,
baseRef: null,
aheadBehind: null,
aheadOfOrigin: null,
behindOfOrigin: null,
hasRemote: false,
remoteUrl: null,
error: null,
} satisfies CheckoutStatusPayload;
describe("reading a worktree's archive risk from its host", () => {
it("reads uncommitted changes and unpushed commits from the worktree's checkout status", async () => {
const host = hostAnswering({
cwd: WORKTREE,
requestId: "status",
isGit: true,
isPaseoOwnedWorktree: true,
repoRoot: WORKTREE,
mainRepoRoot: "/home/me/repo",
currentBranch: "feature",
isDirty: true,
baseRef: "main",
aheadBehind: { ahead: 2, behind: 0 },
aheadOfOrigin: 2,
behindOfOrigin: 0,
hasRemote: true,
remoteUrl: "git@example.com:me/repo.git",
error: null,
});
expect(await readWorktreeArchiveRisk(host, WORKTREE)).toEqual({
isDirty: true,
aheadOfOrigin: 2,
});
expect(host.asked).toEqual([WORKTREE]);
});
it("finds nothing to lose in a directory that is no longer a git checkout", async () => {
expect(await readWorktreeArchiveRisk(hostAnswering(NOT_GIT), WORKTREE)).toEqual({
isDirty: false,
aheadOfOrigin: null,
});
});
it("rejects with the host's error when it can't read the worktree", async () => {
const host = hostAnswering({
...NOT_GIT,
error: { code: "UNKNOWN", message: "git status failed" },
});
await expect(readWorktreeArchiveRisk(host, WORKTREE)).rejects.toThrow("git status failed");
});
});
describe("workspace archive warning for worktree backing", () => {
it("does not require a confirmation for clean and pushed worktrees", () => {
expect(
@@ -1,3 +1,4 @@
import type { CheckoutStatusClient } from "@/git/checkout-status-cache";
import { confirmDialog } from "@/utils/confirm-dialog";
import { i18n } from "@/i18n/i18next";
@@ -49,6 +50,21 @@ export const DEFAULT_WORKTREE_ARCHIVE_WARNING_LABELS: WorktreeArchiveWarningLabe
: i18n.t("workspace.git.actions.archiveWarning.unpushedCommits", { count }),
};
/**
* Reads the worktree's uncommitted changes and unpushed commits from its host. A directory the
* host no longer reads as a git checkout has neither. Rejects when the host can't read it.
*/
export async function readWorktreeArchiveRisk(
client: CheckoutStatusClient,
workspaceDirectory: string,
): Promise<Pick<WorktreeArchiveRisk, "isDirty" | "aheadOfOrigin">> {
const status = await client.getCheckoutStatus(workspaceDirectory);
if (status.error) {
throw new Error(status.error.message);
}
return { isDirty: status.isDirty ?? false, aheadOfOrigin: status.aheadOfOrigin };
}
export function toWorktreeArchiveRisk(input: WorktreeArchiveRiskInput): WorktreeArchiveRisk {
return {
isDirty: input.archiveHasUncommittedChanges,
@@ -1,17 +1,21 @@
import { useCallback } from "react";
import { useCallback, useRef } from "react";
import { useTranslation } from "react-i18next";
import { getHostRuntimeStore } from "@/runtime/host-runtime";
import { useToast } from "@/contexts/toast-context";
import {
confirmRiskyWorktreeArchive,
DEFAULT_WORKTREE_ARCHIVE_WARNING_LABELS,
readWorktreeArchiveRisk,
type WorktreeArchiveWarningLabels,
} from "@/git/worktree-archive-warning";
import type { WorkspaceDescriptor } from "@/stores/session-store";
import { useWorkspaceLayoutStore } from "@/stores/workspace-layout-store";
import { buildWorkspaceTabPersistenceKey } from "@/workspace-tabs/model";
import { archiveWorkspaceWithUndo, supportsWorkspaceArchiveUndo } from "@/workspace/archive-undo";
import { archiveWorkspaceOptimistically } from "@/workspace/workspace-archive";
import {
archiveWorkspaceOptimistically,
type WorkspaceArchiveTarget,
} from "@/workspace/workspace-archive";
function purgeArchivedWorkspaceState(input: { serverId: string; workspaceId: string }): void {
const workspaceKey = buildWorkspaceTabPersistenceKey(input);
@@ -24,12 +28,13 @@ export interface ArchiveWorkspaceInput {
serverId: string;
workspaceId: string;
workspaceKind: WorkspaceDescriptor["workspaceKind"];
workspaceDirectory: string;
name: string;
isDirty?: boolean | null;
aheadOfOrigin?: number | null;
/** Labels the warning's line counts. Whether to warn comes from the host's git state. */
diffStat?: { additions: number; deletions: number } | null;
warningLabels?: WorktreeArchiveWarningLabels;
onArchiveStarted: () => void;
/** Runs once the archive starts, with the workspace being archived. */
onArchiveStarted: (workspace: WorkspaceArchiveTarget) => void;
onSetHiding?: (hiding: boolean) => void;
}
@@ -42,9 +47,8 @@ export function useWorkspaceArchive(input: ArchiveWorkspaceInput): WorkspaceArch
serverId,
workspaceId,
workspaceKind,
workspaceDirectory,
name,
isDirty,
aheadOfOrigin,
diffStat,
warningLabels = DEFAULT_WORKTREE_ARCHIVE_WARNING_LABELS,
onArchiveStarted,
@@ -52,6 +56,11 @@ export function useWorkspaceArchive(input: ArchiveWorkspaceInput): WorkspaceArch
} = input;
const { t } = useTranslation();
const toast = useToast();
const confirmingRef = useRef(false);
// The archive starts after the host answers and you confirm. By then you may have opened another
// workspace, so the redirect reads the selection from the latest render.
const onArchiveStartedRef = useRef(onArchiveStarted);
onArchiveStartedRef.current = onArchiveStarted;
const archiveWorkspaceRecord = useCallback(async () => {
const client = getHostRuntimeStore().getClient(serverId);
@@ -62,7 +71,7 @@ export function useWorkspaceArchive(input: ArchiveWorkspaceInput): WorkspaceArch
const workspace = { serverId, workspaceId };
onSetHiding?.(true);
try {
onArchiveStarted();
onArchiveStartedRef.current(workspace);
if (supportsWorkspaceArchiveUndo(serverId)) {
await archiveWorkspaceWithUndo({
client,
@@ -82,35 +91,54 @@ export function useWorkspaceArchive(input: ArchiveWorkspaceInput): WorkspaceArch
} finally {
onSetHiding?.(false);
}
}, [onArchiveStarted, onSetHiding, serverId, t, toast, workspaceId]);
}, [onSetHiding, serverId, t, toast, workspaceId]);
// The host removes a worktree it archives, uncommitted work included. Ask it what that would
// lose: the sidebar row's git status is empty until the row receives it.
const confirmWorktreeArchive = useCallback(async (): Promise<boolean> => {
const client = getHostRuntimeStore().getClient(serverId);
if (!client) {
toast.error(t("sidebar.workspace.toasts.hostDisconnected"));
return false;
}
const risk = await readWorktreeArchiveRisk(client, workspaceDirectory).catch(
(error: unknown) => {
toast.error(
error instanceof Error ? error.message : t("sidebar.workspace.toasts.archiveFailed"),
);
return null;
},
);
if (!risk) {
return false;
}
return await confirmRiskyWorktreeArchive(
{ workspaceName: name, diffStat, ...risk },
warningLabels,
);
}, [diffStat, name, serverId, t, toast, warningLabels, workspaceDirectory]);
const archive = useCallback(() => {
// A second press while the host answers or the warning is open would ask twice.
if (confirmingRef.current) {
return;
}
void (async () => {
if (workspaceKind === "worktree") {
const confirmed = await confirmRiskyWorktreeArchive(
{
workspaceName: name,
isDirty,
aheadOfOrigin,
diffStat,
},
warningLabels,
);
confirmingRef.current = true;
let confirmed: boolean;
try {
confirmed = await confirmWorktreeArchive();
} finally {
confirmingRef.current = false;
}
if (!confirmed) {
return;
}
}
await archiveWorkspaceRecord();
})();
}, [
aheadOfOrigin,
archiveWorkspaceRecord,
diffStat,
isDirty,
name,
warningLabels,
workspaceKind,
]);
}, [archiveWorkspaceRecord, confirmWorktreeArchive, workspaceKind]);
return {
archive,
+7
View File
@@ -236,6 +236,8 @@ interface AgentLike {
title?: string | null;
}
const FULL_AGENT_UUID_PATTERN = /^[0-9a-f]{8}(?:-[0-9a-f]{4}){3}-[0-9a-f]{12}$/i;
/**
* Resolve an agent ID from a partial ID or name.
* Supports:
@@ -252,6 +254,11 @@ export function resolveAgentId(idOrName: string, agents: AgentLike[]): string |
const query = idOrName.toLowerCase();
// A complete agent UUID is an exact identity, never a name search fallback.
if (FULL_AGENT_UUID_PATTERN.test(idOrName)) {
return agents.find((a) => a.id.toLowerCase() === query)?.id ?? null;
}
// Try exact ID match first
const exactMatch = agents.find((a) => a.id === idOrName);
if (exactMatch) {
@@ -18,25 +18,52 @@ const agents = [
}
{
console.log("Test 2: ID prefix resolves");
console.log("Test 2: full UUID resolves exactly, case-insensitively");
const exactId = "9eac296c-4d60-4189-a0b5-ae71dd9c6ad9";
const agentsWithNameCollision = [
{ id: exactId, title: "target agent" },
{
id: "c9a97e5d-4a92-4646-a016-53032146d0a1",
title: `worker ${exactId}`,
},
];
assert.strictEqual(resolveAgentId(exactId.toUpperCase(), agentsWithNameCollision), exactId);
console.log("✓ full UUID resolves exactly, case-insensitively\n");
}
{
console.log("Test 3: missing full UUID does not match another agent name");
const missingId = "9eac296c-4d60-4189-a0b5-ae71dd9c6ad9";
const nameCollision = [
{
id: "c9a97e5d-4a92-4646-a016-53032146d0a1",
title: `worker ${missingId}`,
},
];
assert.strictEqual(resolveAgentId(missingId, nameCollision), null);
console.log("✓ missing full UUID does not match another agent name\n");
}
{
console.log("Test 4: ID prefix resolves");
assert.strictEqual(resolveAgentId("def5678", agents), "def5678-other-agent-id");
console.log("✓ ID prefix resolves\n");
}
{
console.log("Test 3: exact name match resolves case-insensitively");
console.log("Test 5: exact name match resolves case-insensitively");
assert.strictEqual(resolveAgentId("fix archive flow", agents), "def5678-other-agent-id");
console.log("✓ exact name match resolves case-insensitively\n");
}
{
console.log("Test 4: partial name match resolves when unique");
console.log("Test 6: partial name match resolves when unique");
assert.strictEqual(resolveAgentId("review", agents), "987zyxw-third-agent-id");
console.log("✓ partial name match resolves when unique\n");
}
{
console.log("Test 5: missing agent returns null");
console.log("Test 7: missing agent returns null");
assert.strictEqual(resolveAgentId("does not exist", agents), null);
console.log("✓ missing agent returns null\n");
}
@@ -3,6 +3,7 @@ import { buildAgentForkContextAttachment, curateAgentActivity } from "./activity
import type { AgentTimelineItem } from "./agent-sdk-types.js";
import type { AgentTimelineRow } from "./agent-timeline-store-types.js";
import { formatAgentMessage, projectAgentMessages } from "./agent-messages/index.js";
import { InMemoryAgentTimelineStore } from "./agent-timeline-store.js";
function toolCallItem(params: {
callId: string;
@@ -39,6 +40,29 @@ function row(seq: number, item: AgentTimelineItem): AgentTimelineRow {
};
}
/** Rows as the daemon hands them to a fork: projected by the timeline store. */
function storeTimeline(items: readonly AgentTimelineItem[]) {
const store = new InMemoryAgentTimelineStore();
store.initialize("agent-1");
for (const item of items) store.append("agent-1", item);
const { epoch, rows } = store.fetch("agent-1", { limit: 0 });
return { epoch, rows };
}
function backgroundTaskItem(input: { status: "running" | "completed"; log: string }) {
return toolCallItem({
callId: "background-task",
name: "Task",
status: input.status,
detail: {
type: "sub_agent",
subAgentType: "Explore",
description: "Audit the migrations",
log: input.log,
},
});
}
describe("curateAgentActivity", () => {
it("keeps reasoning before the assistant text that followed it", () => {
const timeline: AgentTimelineItem[] = [
@@ -414,44 +438,48 @@ second line'`,
expect(result.attachment.text).toContain("[Assistant] Done.");
});
it("rejects a checkpoint whose projected tool state changed later", () => {
expect(() =>
buildAgentForkContextAttachment({
boundaryMessageId: "assistant-1",
rows: [
row(1, { type: "user_message", text: "Run it", messageId: "user-1" }),
row(
2,
toolCallItem({
callId: "terminal-1",
name: "terminal",
status: "running",
detail: {
type: "plain_text",
label: "before boundary",
},
}),
),
row(3, {
type: "assistant_message",
text: "Partial result.",
messageId: "assistant-1",
}),
row(
4,
toolCallItem({
callId: "terminal-1",
name: "terminal",
status: "completed",
detail: {
type: "plain_text",
label: "after boundary",
},
}),
),
],
}),
).toThrow("Fork from a later completed response");
it("forks at a response that background work outlives, by message id", () => {
const { rows } = storeTimeline([
{ type: "user_message", text: "Audit in the background", messageId: "user-1" },
backgroundTaskItem({ status: "running", log: "scanning" }),
{ type: "assistant_message", text: "Audit started.", messageId: "assistant-1" },
backgroundTaskItem({ status: "completed", log: "FINAL REPORT after the response" }),
{ type: "user_message", text: "Any findings?", messageId: "user-2" },
]);
expect(rows[1]).toMatchObject({ seqStart: 2, seqEnd: 4 });
const result = buildAgentForkContextAttachment({ boundaryMessageId: "assistant-1", rows });
expect(result.itemCount).toBe(3);
expect(result.boundaryMessageId).toBe("assistant-1");
expect(result.attachment.text).toContain(
"[User] Audit in the background\n[Explore] Audit the migrations\n[Assistant] Audit started.\n",
);
expect(result.attachment.text).not.toContain("FINAL REPORT");
expect(result.attachment.text).not.toContain("Any findings?");
});
it("forks at a response that background work outlives, by timeline cursor", () => {
const { epoch, rows } = storeTimeline([
{ type: "user_message", text: "Audit in the background", messageId: "user-1" },
backgroundTaskItem({ status: "running", log: "scanning" }),
{ type: "assistant_message", text: "Audit started." },
backgroundTaskItem({ status: "completed", log: "FINAL REPORT after the response" }),
{ type: "assistant_message", text: "The audit finished." },
]);
const result = buildAgentForkContextAttachment({
cursorBoundary: { timelineEpoch: epoch, cursor: { epoch, seq: 3 } },
rows,
});
expect(result.boundaryCursor).toEqual({ epoch, seq: 3 });
expect(result.itemCount).toBe(3);
expect(result.attachment.text).toContain(
"[User] Audit in the background\n[Explore] Audit the migrations\n[Assistant] Audit started.\n",
);
expect(result.attachment.text).not.toContain("FINAL REPORT");
expect(result.attachment.text).not.toContain("The audit finished.");
});
it("forks from a checkpoint when a plugin card was updated after it", () => {
@@ -1,4 +1,4 @@
import type { AgentTimelineItem } from "./agent-sdk-types.js";
import type { AgentTimelineItem, ToolCallTimelineItem } from "./agent-sdk-types.js";
import type { AgentMessage } from "@getpaseo/protocol/agent-message";
import type { AgentAttachment } from "@getpaseo/protocol/messages";
import type { AgentTimelineRow } from "./agent-timeline-store-types.js";
@@ -13,7 +13,7 @@ const MAX_TOOL_SUMMARY_CHARS = 200;
const MAX_FORK_CONTEXT_CHARS = 100_000;
const MAX_FORK_METADATA_CHARS = 1_000;
const FORK_HISTORY_OMITTED = "[Earlier chat history omitted]\n";
// A fork copies only these kinds, so only they can make a checkpoint stale.
// A fork copies only these kinds.
const FORK_CONTEXT_KINDS: readonly AgentTimelineItem["type"][] = [
"user_message",
"assistant_message",
@@ -275,25 +275,25 @@ function selectForkContextRows(input: {
);
}
const boundarySeq = projectedRows[boundaryIndex].seqEnd;
if (
projectedRows.some(
(row) =>
FORK_CONTEXT_KINDS.includes(row.item.type) &&
row.seqStart <= boundarySeq &&
row.seqEnd > boundarySeq,
)
) {
throw new Error(
"This checkpoint changed after it was created. Fork from a later completed response instead.",
);
}
const projected = projectedRows.filter((row) => row.seqEnd <= boundarySeq);
// Only tool calls and plugin cards fold later updates into an earlier row, and a fork doesn't
// copy plugin cards. So the only row that spans the checkpoint and still reaches the fork is a
// tool call.
const items = projectedRows.flatMap((row) => {
if (row.seqEnd <= boundarySeq) return [row.item];
if (row.seqStart > boundarySeq || row.item.type !== "tool_call") return [];
return [toolCallAtCheckpoint(row.item)];
});
return {
items: projected.map((entry) => entry.item),
boundaryCursor,
boundaryMessageId,
};
return { items, boundaryCursor, boundaryMessageId };
}
/**
* A tool call that started before the checkpoint and changed after it, such as background work
* that outlives a reply. The projection keeps only its latest payload, so the fork copies it as
* still running.
*/
function toolCallAtCheckpoint(item: ToolCallTimelineItem): ToolCallTimelineItem {
return { ...item, status: "running", error: null };
}
function trimContextMetadata(value: string | null | undefined): string | null {
@@ -13457,7 +13457,20 @@ function idleCleanupHarness() {
const storage = new AgentStorage(join(workdir, "agents"), logger);
const commands: AgentPromptInput[] = [];
const prompts: AgentPromptInput[] = [];
const sessions: AgentSession[] = [];
const probes: Array<(() => Promise<boolean>) | undefined> = [];
const closeEntered = deferred<void>();
let closeGate: Promise<void> | undefined;
let closes = 0;
const recordPrompts = (session: AgentSession) => {
const index = sessions.length;
session.canStopRuntime = async () => probes[index]?.() ?? false;
session.close = async () => {
closes++;
closeEntered.resolve();
await closeGate;
};
sessions.push(session);
session.tryHandleOutOfBand = (prompt) =>
prompt === "/maintenance"
? {
@@ -13473,11 +13486,6 @@ function idleCleanupHarness() {
};
return session;
};
const sessions: AgentSession[] = [];
const probes: Array<(() => Promise<boolean>) | undefined> = [];
const closeEntered = deferred<void>();
let closeGate: Promise<void> | undefined;
let closes = 0;
const client = new (class extends TestAgentClient {
override async resumeSession(
handle: AgentPersistenceHandle,
@@ -13488,15 +13496,7 @@ function idleCleanupHarness() {
return recordPrompts(session);
}
override async createSession(config: AgentSessionConfig): Promise<AgentSession> {
const index = sessions.length;
const session: AgentSession = await super.createSession(config);
session.canStopRuntime = async () => probes[index]?.() ?? false;
session.close = async () => {
closes++;
closeEntered.resolve();
await closeGate;
};
sessions.push(session);
return recordPrompts(session);
}
})();
@@ -14419,3 +14419,39 @@ test("workspace archive preserves a loaded history agent stamp and stamps cascad
rmSync(workdir, { recursive: true, force: true });
}
});
test("idle cleanup gives a newly restored runtime a fresh idle window while keeping historical updatedAt", async () => {
const h = idleCleanupHarness();
const created = await h.create();
h.probes[0] = async () => true;
h.probes[1] = async () => true;
await h.manager.closeAgent(created.id);
await h.storage.flush();
const historicalUpdatedAt = new Date(Date.now() - 180 * 60_000);
const existingRecord = await h.storage.get(created.id);
expect(existingRecord).not.toBeNull();
const staleRecord: StoredAgentRecord = {
...existingRecord!,
updatedAt: historicalUpdatedAt.toISOString(),
lastActivityAt: historicalUpdatedAt.toISOString(),
};
await h.storage.upsert(staleRecord);
await h.storage.flush();
const closesBeforeRestore = h.closes;
const restored = await h.manager.resumeAgentFromPersistence(
created.persistence!,
{ cwd: created.cwd },
created.id,
{ updatedAt: historicalUpdatedAt },
);
expect(restored.updatedAt.getTime()).toBe(historicalUpdatedAt.getTime());
// Sweeping 1 minute after restoring an old agent must NOT immediately kill the newly opened runtime.
expect((await h.sweep({ now: h.later(1) })).collected).toEqual([]);
expect(h.closes).toBe(closesBeforeRestore);
// Sweeping 61 minutes after restoration collects the runtime as expected.
expect((await h.sweep({ now: h.later(61) })).collected).toHaveLength(1);
expect(h.closes).toBe(closesBeforeRestore + 1);
});
@@ -856,7 +856,10 @@ export class AgentManager {
private readonly inFlightAgentCloses = new Map<string, Promise<void>>();
private readonly historySessions = new WeakSet<AgentSession>();
private readonly reloadedSessionCloses = new WeakMap<AgentSession, Promise<void>>();
private readonly runtimeCleanupIdentities = new WeakMap<AgentSession, object>();
private readonly runtimeCleanupIdentities = new WeakMap<
AgentSession,
{ readonly openedAt: number }
>();
private readonly collectingRuntimes = new Set<string>();
private readonly lifecycleMutationTails = new Map<string, Promise<void>>();
private readonly agentStreamCoalescer: AgentStreamCoalescer;
@@ -2068,7 +2071,7 @@ export class AgentManager {
const updatedAt = agent.updatedAt;
let identity = this.runtimeCleanupIdentities.get(agent.session);
if (!identity) {
identity = Object.freeze({});
identity = Object.freeze({ openedAt: Date.now() });
this.runtimeCleanupIdentities.set(agent.session, identity);
}
const isEligible = () =>
@@ -2081,7 +2084,7 @@ export class AgentManager {
parentAgentId: getParentAgentIdFromLabels(agent.labels) ?? undefined,
provider: agent.provider,
sessionId: agent.persistence?.sessionId ?? null,
lastActivityAt: updatedAt.getTime(),
lastActivityAt: Math.max(updatedAt.getTime(), identity.openedAt),
isEligible,
canStopRuntime: agent.session.canStopRuntime?.bind(agent.session),
closeIfEligible: async (revalidate) => {
@@ -4342,6 +4345,7 @@ export class AgentManager {
}
this.assertAcceptingAgentRegistrations();
this.runtimeCleanupIdentities.set(session, Object.freeze({ openedAt: Date.now() }));
this.agents.set(resolvedAgentId, managed);
registered = true;
// Initialize previousStatus to track transitions
@@ -496,6 +496,58 @@ async function pluginMessages(session: Awaited<ReturnType<AgentClient["createSes
);
}
test("plugin rewind discards child outcomes from the withdrawn branch", async () => {
await withPluginRewindSession(
{
async handleInput(input, emit) {
if (input.type !== "session.open" || input.history !== "replay") return false;
emit({
type: "session.opened",
requestId: input.requestId,
sessionId: input.sessionId,
capabilities: CAPABILITIES,
restoration: "core",
persistence: input.persistence,
cwd: input.config.cwd,
});
emit({
type: "session.opened",
sessionId: "rewound-child",
parentSessionId: input.sessionId,
capabilities: [],
restoration: "parent",
cwd: input.config.cwd,
});
emit({ type: "session.closed", sessionId: "rewound-child" });
emit({ type: "session.ready", requestId: input.requestId, sessionId: input.sessionId });
return true;
},
},
async (session, harness) => {
const open = harness.inputs.find((input) => input.type === "session.open")!;
harness.emit({
type: "session.opened",
sessionId: "rewound-child",
parentSessionId: open.sessionId,
capabilities: [],
restoration: "parent",
cwd: tmpdir(),
});
harness.emit({
type: "session.turn",
sessionId: "rewound-child",
turnId: "withdrawn-turn",
state: "failed",
});
await session.revertConversation!({ messageId: "user-B" });
const history = await Array.fromAsync(session.streamHistory());
expect(history.findLast((event) => event.type === "provider_subagent")).toMatchObject({
event: { type: "upsert", id: "rewound-child", status: "completed" },
});
},
);
});
test("combined plugin rewind replays the retained native branch", async () => {
await withPluginRewindSession({}, async (session) => {
await session.revertBoth!({ messageId: "user-B" });
@@ -1690,6 +1742,11 @@ describe("PluginAgentClientRegistry", () => {
expect(manager.listProviderSubagents(agent.id)).toMatchObject([
{ id: child.id, title: child.title, status: outcome },
]);
harness.emit({ type: "session.closed", sessionId: "reusable-child" });
await nextTurn();
expect(manager.listProviderSubagents(agent.id)).toMatchObject([
{ id: child.id, title: child.title, status: outcome },
]);
} finally {
await Promise.all(manager.listAgents().map((agent) => manager.closeAgent(agent.id)));
await plugins.shutdown();
@@ -1724,6 +1781,7 @@ describe("PluginAgentClientRegistry", () => {
turnId: "second-turn",
state,
});
emit({ type: "session.closed", sessionId: "reusable-child" });
}
},
});
@@ -1750,6 +1808,100 @@ describe("PluginAgentClientRegistry", () => {
}
},
);
test("keeps a child's canceled or failed turn when the child closes normally", async () => {
const harness = createProviderHarness();
const registry = new PluginAgentClientRegistry(createTestLogger());
registry.replace([harness.registration]);
const session = await registry.clients()[harness.registration.id]!.createSession({
provider: harness.registration.id,
cwd: "/workspace",
});
try {
const events: AgentStreamEvent[] = [];
session.subscribe((event) => events.push(event));
const open = harness.inputs.find((input) => input.type === "session.open")!;
const outcomes = [
["child-canceled", "canceled"],
["child-failed", "failed"],
["child-completed", "completed"],
] as const;
for (const [sessionId, state] of outcomes) {
harness.emit({
type: "session.opened",
sessionId,
parentSessionId: open.sessionId,
capabilities: [],
restoration: "parent",
cwd: "/workspace",
});
harness.emit({ type: "session.turn", sessionId, turnId: "turn", state: "started" });
harness.emit({ type: "session.turn", sessionId, turnId: "turn", state });
harness.emit({ type: "session.closed", sessionId });
}
const lastStatus = (id: string) =>
events
.flatMap((event) =>
event.type === "provider_subagent" &&
event.event.type === "upsert" &&
event.event.id === id
? [event.event.status]
: [],
)
.at(-1);
expect(lastStatus("child-canceled")).toBe("canceled");
expect(lastStatus("child-failed")).toBe("failed");
expect(lastStatus("child-completed")).toBe("completed");
} finally {
await session.close();
registry.replace([]);
}
});
test.each([
{ turns: [], error: false, expected: "completed" },
{ turns: ["canceled", "started"], error: false, expected: "completed" },
{ turns: ["failed", "started", "completed"], error: false, expected: "completed" },
{ turns: ["canceled"], error: true, expected: "failed" },
] as const)(
"closes a plugin child using its latest turn: $turns, error=$error",
async ({ turns, error, expected }) => {
const harness = createProviderHarness();
const registry = new PluginAgentClientRegistry(createTestLogger());
registry.replace([harness.registration]);
try {
const session = await registry.clients()[harness.registration.id]!.createSession({
provider: harness.registration.id,
cwd: tmpdir(),
});
const events: AgentStreamEvent[] = [];
session.subscribe((event) => events.push(event));
const open = harness.inputs.find((input) => input.type === "session.open")!;
harness.emit({
type: "session.opened",
sessionId: "close-control",
parentSessionId: open.sessionId,
capabilities: [],
restoration: "parent",
cwd: tmpdir(),
});
for (const state of turns) {
harness.emit({ type: "session.turn", sessionId: "close-control", turnId: "turn", state });
}
harness.emit({
type: "session.closed",
sessionId: "close-control",
...(error ? { error: { message: "Provider failed to close" } } : {}),
});
expect(events.at(-1)).toMatchObject({
type: "provider_subagent",
event: { type: "upsert", id: "close-control", status: expected },
});
} finally {
await registry.shutdown();
}
},
);
test("gates persistence operations on negotiated provider capabilities", async () => {
const harness = createProviderHarness({ capabilities: ["session.persistence"] });
@@ -1202,6 +1202,8 @@ class PluginAgentSession implements AgentSession {
{ session: ProviderRuntimeSession; unsubscribe: () => void }
>();
private readonly childSnapshots = new Map<string, Map<string, TimelineTextSnapshot>>();
// Outcome of each child's latest finished turn, so a plain close does not report it as completed.
private readonly childTurnOutcomes = new Map<string, "completed" | "canceled" | "failed">();
private unsubscribe: (() => void) | null = null;
/** How the opened session differs from the options it was given. */
readonly openOptionsPatch: Record<string, JsonValue>;
@@ -1766,6 +1768,11 @@ class PluginAgentSession implements AgentSession {
return;
}
if (event.type === "session.turn") {
if (event.state === "started") {
this.childTurnOutcomes.delete(childId);
} else {
this.childTurnOutcomes.set(childId, event.state);
}
this.publish({
type: "provider_subagent",
provider: this.provider,
@@ -1784,7 +1791,10 @@ class PluginAgentSession implements AgentSession {
event: {
type: "upsert",
id: childId,
status: event.type === "session.runtime_failed" || event.error ? "failed" : "completed",
status:
event.type === "session.runtime_failed" || event.error
? "failed"
: (this.childTurnOutcomes.get(childId) ?? "completed"),
},
});
}
@@ -1802,6 +1812,7 @@ class PluginAgentSession implements AgentSession {
this.history.clear();
this.timelineSnapshots.clear();
this.childSnapshots.clear();
this.childTurnOutcomes.clear();
this.revertTokens.clear();
this.pendingPermissions.clear();
this.permissionResponses.clear();
@@ -0,0 +1,56 @@
import { mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import path from "node:path";
import { afterEach, beforeEach, expect, test } from "vitest";
import { createDaemonTestContext, type DaemonTestContext } from "../test-utils/index.js";
let ctx: DaemonTestContext;
let cwd: string;
beforeEach(async () => {
ctx = await createDaemonTestContext();
cwd = mkdtempSync(path.join(tmpdir(), "fork-background-work-"));
});
afterEach(async () => {
await ctx.cleanup();
rmSync(cwd, { recursive: true, force: true });
}, 30_000);
test("forks from a reply that background work outlived", async () => {
const agent = await ctx.client.createAgent({ provider: "claude", cwd });
await ctx.client.sendMessage(agent.id, "Start background work");
expect((await ctx.client.waitForFinish(agent.id, 15_000)).status).toBe("idle");
await ctx.client.sendMessage(agent.id, "Any findings?");
expect((await ctx.client.waitForFinish(agent.id, 15_000)).status).toBe("idle");
const page = await ctx.client.fetchAgentTimeline(agent.id, { direction: "tail", limit: 0 });
const reply = page.entries.find(
(entry) => entry.item.type === "assistant_message" && entry.item.text === "Audit started.",
);
if (reply?.item.type !== "assistant_message" || !reply.item.messageId) {
throw new Error("Expected the reply to carry a provider message id");
}
const task = page.entries.find((entry) => entry.item.type === "tool_call");
// The Task row started before the reply and completed after it.
expect(task?.item).toMatchObject({ name: "Task", status: "completed" });
expect(task?.seqStart).toBeLessThan(reply.seqEnd);
expect(task?.seqEnd).toBeGreaterThan(reply.seqEnd);
const byMessage = await ctx.client.buildAgentForkContext(agent.id, {
boundaryMessageId: reply.item.messageId,
});
const byCursor = await ctx.client.buildAgentForkContext(agent.id, {
boundaryCursor: { epoch: page.epoch, seq: reply.seqEnd },
});
for (const fork of [byMessage, byCursor]) {
expect(fork.itemCount).toBe(3);
expect(fork.attachment?.text).toContain(
"[User] Start background work\n[Explore] Audit the migrations\n[Assistant] Audit started.\n",
);
expect(fork.attachment?.text).not.toContain("Any findings?");
}
expect(byMessage.boundaryMessageId).toBe(reply.item.messageId);
expect(byCursor.boundaryCursor).toEqual({ epoch: page.epoch, seq: reply.seqEnd });
}, 60_000);
@@ -547,6 +547,48 @@ class FakeAgentSession implements AgentSession {
this.notifySubscribers(completed);
}
private async emitEvent(event: AgentStreamEvent): Promise<void> {
await this.appendHistoryEvent(event);
this.notifySubscribers(event);
}
/**
* A background Task that outlives the turn's reply: the Task starts, the assistant replies,
* then the Task completes.
*/
private async emitBackgroundWorkTurn(): Promise<void> {
const callId = randomUUID();
const task = (status: "running" | "completed", log: string): AgentStreamEvent => ({
type: "timeline",
provider: this.providerName,
item: {
type: "tool_call",
name: "Task",
callId,
status,
error: null,
detail: {
type: "sub_agent",
subAgentType: "Explore",
description: "Audit the migrations",
log,
},
},
});
await this.emitEvent(task("running", ""));
await this.emitEvent({
type: "timeline",
provider: this.providerName,
item: { type: "assistant_message", text: "Audit started.", messageId: randomUUID() },
});
await this.emitEvent(task("completed", "Found 2 unsafe migrations."));
await this.emitEvent({
type: "turn_completed",
provider: this.providerName,
usage: { inputTokens: 1, outputTokens: 1 },
});
}
private async emitStressTurn(stress: { count: number; coalesced: boolean }): Promise<void> {
for (let index = 0; index < stress.count; index += 1) {
const stressUpdate: AgentStreamEvent = {
@@ -790,6 +832,11 @@ class FakeAgentSession implements AgentSession {
this.notifySubscribers(child);
}
if (textPrompt === "Start background work") {
await this.emitBackgroundWorkTurn();
return;
}
if (textPrompt.toLowerCase().includes("emit a turn failure")) {
const failed: AgentStreamEvent = {
type: "turn_failed",
@@ -30,6 +30,7 @@ import {
getCheckoutShortstat,
getPullRequestStatus,
getCheckoutStatus,
getCheckoutWorktreeState,
checkoutResolvedBranch,
listBranchSuggestions,
setCheckoutBaseBranch,
@@ -572,6 +573,15 @@ describe("checkout git utilities", () => {
expect(message).toBe("update file");
});
it("counts untracked files as dirty when the repository hides them from status", async () => {
execFileSync("git", ["config", "status.showUntrackedFiles", "no"], { cwd: repoDir });
writeFileSync(join(repoDir, "new-file.txt"), "not yet added\n");
expect(execFileSync("git", ["status", "--porcelain"], { cwd: repoDir }).toString()).toBe("");
expect((await getCheckoutStatus(repoDir)).isDirty).toBe(true);
expect((await getCheckoutWorktreeState(repoDir, {})).isDirty).toBe(true);
});
it("honors commit signing configuration", async () => {
execFileSync("git", ["config", "commit.gpgsign", "true"], { cwd: repoDir });
execFileSync("git", ["config", "gpg.format", "openpgp"], { cwd: repoDir });
+10 -5
View File
@@ -1413,12 +1413,17 @@ function resolveOperationBaseRef(input: {
return input.storedBaseRef ?? input.requestedBaseRef ?? input.resolvedBaseRef;
}
// Untracked files count whatever `status.showUntrackedFiles` says: archive warns from this before
// it removes a worktree, and a file nobody added yet is still work to lose.
async function isWorkingTreeDirty(cwd: string, context?: CheckoutContext): Promise<boolean> {
const { stdout } = await getRunGitCommand(context)(["status", "--porcelain"], {
cwd,
envOverlay: READ_ONLY_GIT_ENV,
logger: context?.logger,
});
const { stdout } = await getRunGitCommand(context)(
["status", "--porcelain", "--untracked-files=normal"],
{
cwd,
envOverlay: READ_ONLY_GIT_ENV,
logger: context?.logger,
},
);
return stdout.trim().length > 0;
}