Compare commits

..

8 Commits

Author SHA1 Message Date
Mohamed Boudra
aed3eb490e Rename the shadowed prompt row binding in the frame recorder 2026-07-29 13:35:55 +02:00
Mohamed Boudra
58f8737a01 Measure the turn footer blink from when the footer appears
Bounding the window at the prompt row conflated two things: the footer arriving a
frame after the row, and the footer leaving the layout and coming back. Bounded from
the footer's first frame, the blink still reproduces, so the footer-ownership race is
real rather than a sampling artifact.
2026-07-29 13:35:55 +02:00
Mohamed Boudra
ceb06116f2 Lock timeline layout stability with user-driven specs
Two reds reproduce the reported jank:
- new agent, first prompt: the row paints at top=100 then jumps to 144 through
  authoritative hydration, because the draft tab and the agent panel each render
  their own AgentStreamView for the same logical row.
- long loaded timeline, submit: scrollHeight collapses 10793 -> 9111 mid-turn,
  because splitOrderedTail reclassifies measured rows into the virtualized segment
  where the virtualizer falls back to estimateStreamItemHeight.

Also restores centered timeline rows: the pagination anchor wrapper introduced in
#2490 was a plain block div, which broke alignSelf: center.
2026-07-29 13:35:55 +02:00
Mohamed Boudra
44236ea474 Reapply: Keep older chat history and image previews stable (#2490) 2026-07-29 13:35:55 +02:00
Mohamed Boudra
24934b4bbe Reapply: Stop completed turns from appearing stuck (#2484) 2026-07-29 13:35:55 +02:00
Mohamed Boudra
acb8506d1f Revert the timeline optimistic and pagination rework (#2596)
* Add npm release track invariant and beta dist-tag procedure

* Revert "Keep older chat history and image previews stable (#2490)"

The timeline optimistic/pagination rework needs more work than main releases can wait for. Reverted together with #2484; both are reapplied on integration/timeline-optimistic-rework.

* Revert "Stop completed turns from appearing stuck (#2484)"

See the #2490 revert. #2490 built on this submission-authority change, so the two revert and reapply as a pair.
2026-07-29 13:35:43 +02:00
Matt Cowger
ab24070075 fix(server): suppress Pi interruption stream error (#2311)
* Handle Pi aborted responses after interruption

* fix(server): suppress late Pi interruption terminal response
2026-07-29 18:41:09 +08:00
Mohamed Boudra
fab975a059 Keep idle agents and their background work alive (#2590)
* fix(server): keep idle agents resident

Remove time-based runtime collection so background work and subsequent prompts are not destroyed during idle periods. Runtimes now close only through explicit lifecycle actions.

* fix(server): preserve explicit close coordination

* fix(server): support partial agent manager adapters
2026-07-29 18:36:44 +08:00
26 changed files with 804 additions and 1107 deletions

View File

@@ -21,16 +21,8 @@ the agent runs through `ensureAgentLoaded()`, which resumes the durable provider
same Paseo agent ID. Provider history is not appended again when the canonical timeline is already
primed.
The daemon collects an eligible idle runtime after 30 minutes and sweeps every minute. Only
unarchived, non-internal agents that are exactly `idle`, have no active or pending run, replacement,
or permission, and have not been activated during the idle window are eligible. `running`,
`initializing`, and `error` agents stay resident. An idle parent also stays resident while current
in-memory state shows a running managed child or provider subagent. Otherwise agents are evaluated
independently; collection does not cascade or change parentage.
Active schedules targeting an existing agent protect that agent from collection. Paused, completed,
and new-agent schedules do not. A pane may remain open after collection; its next prompt resumes the
runtime.
Idle agents remain resident indefinitely. Runtime closure happens only through an explicit lifecycle
action such as archive, replacement, reload, workspace teardown, or daemon shutdown.
### Cancellation
@@ -59,9 +51,8 @@ The provider still owns the underlying runtime. Paseo keeps an agent record so t
Archive is a **soft delete**: the agent record stays on disk with `archivedAt` set, the runtime is closed, and the agent disappears from active lists. Archive is **global** — it lives on the server and propagates to every connected client.
Archive is distinct from runtime collection. Archive sets `archivedAt`, invokes the provider's native
archive hook, and cascades to managed children. Runtime collection does none of those things; it only
releases the live runtime and writes `lastStatus: closed` on the still-active record.
Archive sets `archivedAt`, invokes the provider's native archive hook, and cascades to managed
children.
`create_agent_request` can opt an agent into `autoArchive`. In that mode the daemon archives the agent after the first terminal turn event (`turn_completed`, `turn_failed`, or `turn_canceled`). When the agent owns an isolated workspace, auto-archive archives that workspace too; the managed worktree is removed when its final workspace reference is gone.

View File

@@ -38,6 +38,14 @@ There are two supported ways to ship from `main`:
1. **Direct stable release**: you are ready to ship the current `main` commit to everyone immediately.
2. **Beta flow**: release candidates on the `beta` channel. Betas carry an in-place changelog entry (beta users check it), publish npm only on the explicit `beta` dist-tag, and never move the website download target off the latest stable.
Paseo has one linear release track even though npm dist-tags are independent
pointers. The npm invariant is:
- A beta release moves only `beta`; `latest` remains on the newest stable.
- A stable release moves both `latest` and `beta` to that stable version. This
keeps users who install `@getpaseo/cli@beta` on the newest Paseo release after
a beta is promoted or superseded by a direct stable release.
## Release version decision
Every fresh release starts by classifying the full previous-stable-to-`HEAD`
@@ -76,6 +84,20 @@ npm run release:minor
This bumps the version across all workspaces, runs checks, publishes to npm, and pushes the branch + tag. The tag push triggers `Desktop Release`, `Android APK Release`, `Docker`, and `Release Notes Sync` on GitHub Actions. EAS picks up the same tag via the EAS GitHub app and starts the iOS + Android store builds in parallel (see "Mobile builds (EAS)" below) — there is no `release-mobile.yml` in this repo.
After the stable release succeeds, move npm's `beta` pointer to the new stable
version for every published package. This changes dist-tags only; do not
republish the packages:
```bash
PASEO_VERSION=$(node -p "require('./package.json').version")
for package in highlight relay protocol client server cli; do
npm dist-tag add "@getpaseo/$package@$PASEO_VERSION" beta
done
```
Verify both npm tags now resolve to `PASEO_VERSION` before considering the
stable release complete.
The Docker workflow builds images from the checked-out source tree on pull requests and on `main` as non-publishing checks. Stable `vX.Y.Z` tag pushes publish `ghcr.io/getpaseo/paseo:X.Y.Z` and `ghcr.io/getpaseo/paseo:latest`; beta `vX.Y.Z-beta.N` tag pushes publish only `ghcr.io/getpaseo/paseo:X.Y.Z-beta.N` and never move `latest`.
The production relay is the Elixir service in [getpaseo/paseo-relay](https://github.com/getpaseo/paseo-relay), with its own deployment process. Paseo releases and pushes to this repository do not deploy it. The Cloudflare relay code and workflow in this repository are legacy and are not used in production.
@@ -92,6 +114,7 @@ npm run version:all:patch
npm run version:all:minor
npm run release:publish # Publish to npm
npm run release:push # Push HEAD + tag (triggers CI workflows)
# Then move npm's beta dist-tag to this stable version using the command above.
```
## Beta flow
@@ -506,6 +529,7 @@ Betas are checkpoints along the way; the entry is the single record for the jump
- [ ] Update `CHANGELOG.md` with user-facing release notes (features, fixes — not refactors). When promoting from beta, overwrite the existing `## X.Y.Z-beta.N` heading in place (heading → `X.Y.Z`, date → promotion day) — do not add a new entry on top of the beta one
- [ ] Verify the changelog heading follows strict `## X.Y.Z - YYYY-MM-DD` format
- [ ] `npm run release:patch`, `npm run release:minor`, or `npm run release:promote` completes successfully
- [ ] Move npm's `beta` dist-tag to the new stable version for every published package and verify both `latest` and `beta` resolve to it
- [ ] GitHub `Desktop Release` workflow for the `v*` tag is green
- [ ] GitHub `Android APK Release` workflow for the same tag is green
- [ ] EAS `Release Mobile` workflow for the same tag is green

View File

@@ -0,0 +1,274 @@
import type { Page } from "@playwright/test";
import { expect, test as baseTest } from "./fixtures";
import { expectAgentIdle } from "./helpers/agent-stream";
import { expectComposerVisible, submitMessage } from "./helpers/composer";
import { clickNewChat, gotoWorkspace } from "./helpers/launcher";
import { seedWorkspace } from "./helpers/seed-client";
import {
openAgentTimeline,
scrollTimelineToNewestLoadedEdge,
scrollTimelineUntilOlderHistoryIsReachable,
seedLongMockAgentTimeline,
} from "./helpers/timeline-pagination";
import {
openAgentRoute,
seedMockAgentWorkspace,
type MockAgentWorkspace,
} from "./helpers/mock-agent";
interface TurnFrame {
scrollHeight: number;
workingVisible: boolean;
promptVisible: boolean;
promptTop: number | null;
assistantVisible: boolean;
}
declare global {
interface Window {
__consecutiveTurnFrames?: { active: boolean; frames: TurnFrame[] };
}
}
const test = baseTest.extend<{ tenSecondAgent: MockAgentWorkspace }>({
tenSecondAgent: async ({ page: _page }, provide) => {
const agent = await seedMockAgentWorkspace({
repoPrefix: "consecutive-ten-second-turns-",
title: "Consecutive ten-second turns",
model: "ten-second-stream",
});
await provide(agent);
await agent.cleanup();
},
});
async function waitForTurnToComplete(page: Page, completedTurnCount: number): Promise<void> {
await expect(page.getByText("(end of synthetic stream)", { exact: true })).toHaveCount(
completedTurnCount + 1,
{ timeout: 30_000 },
);
await expectAgentIdle(page);
}
async function recordTurnFrames(page: Page, prompt: string): Promise<void> {
await page.evaluate((promptText) => {
const isVisible = (candidate: Element) => candidate.getBoundingClientRect().height > 0;
// The timeline does not exist yet when a new agent is being created, so it is
// resolved per frame rather than captured up front.
const findScroll = () =>
Array.from(document.querySelectorAll('[data-testid="agent-chat-scroll"]')).find(isVisible);
const state = { active: true, frames: [] as TurnFrame[] };
const sample = () => {
const scroll = findScroll();
// Re-find the row every frame: reconciliation replaces the node, and a stale
// reference would keep reporting the position of something already detached.
const promptRow = Array.from(document.querySelectorAll('[data-testid="user-message"]')).find(
(candidate) => isVisible(candidate) && candidate.textContent?.includes(promptText),
);
state.frames.push({
scrollHeight: scroll ? scroll.scrollHeight : 0,
workingVisible: Array.from(
document.querySelectorAll('[data-testid="turn-working-indicator"]'),
).some(isVisible),
promptVisible: Boolean(promptRow),
promptTop: promptRow ? promptRow.getBoundingClientRect().top : null,
assistantVisible: Array.from(
document.querySelectorAll('[data-testid="assistant-message"]'),
).some(isVisible),
});
if (state.active) requestAnimationFrame(sample);
};
window.__consecutiveTurnFrames = state;
sample();
}, prompt);
}
async function expectUninterruptedTurn(page: Page): Promise<void> {
const result = await page.evaluate(() => {
const state = window.__consecutiveTurnFrames;
if (!state) throw new Error("Turn frames were never recorded");
state.active = false;
const start = state.frames.findIndex((frame) => frame.promptVisible);
// The turn runs from the footer first appearing to it going away for good. Bounding
// it that way keeps this measuring one thing: the footer leaving the layout and
// coming back. How long the footer takes to appear after the row is a separate
// question and does not belong in this assertion.
const firstWorking = state.frames.findIndex((frame) => frame.workingVisible);
const lastWorking = state.frames.reduce(
(latest, frame, index) => (frame.workingVisible ? index : latest),
-1,
);
const turn = state.frames.slice(Math.max(start, firstWorking), lastWorking + 1);
const blinkAt = turn.findIndex((frame) => !frame.workingVisible);
let largestHeightDrop = 0;
let largestHeightDropAt = -1;
for (let index = 1; index < turn.length; index += 1) {
const drop = turn[index - 1].scrollHeight - turn[index].scrollHeight;
if (drop > largestHeightDrop) {
largestHeightDrop = drop;
largestHeightDropAt = index;
}
}
return {
start,
blinkAt,
largestHeightDrop,
turnFrameCount: turn.length,
largestHeightDropAt,
heightsAroundDrop: turn
.slice(Math.max(0, largestHeightDropAt - 6), largestHeightDropAt + 6)
.map((frame) => frame.scrollHeight)
.join(","),
};
});
expect(result.start, "the submitted prompt never appeared").toBeGreaterThan(-1);
expect(
result.blinkAt,
`the working indicator blinked off at frame ${result.blinkAt} of ${result.turnFrameCount} and came back`,
).toBe(-1);
expect(
result.largestHeightDrop,
`the timeline lost ${result.largestHeightDrop}px of content mid-turn (first heights: ${result.heightsAroundDrop})`,
).toBe(0);
}
async function expectSubmittedPromptNeverMovedDown(page: Page): Promise<void> {
const result = await page.evaluate(() => {
const state = window.__consecutiveTurnFrames;
if (!state) throw new Error("Turn frames were never recorded");
state.active = false;
const start = state.frames.findIndex((frame) => frame.promptVisible);
const tops = state.frames
.slice(start)
.map((frame) => frame.promptTop)
.filter((top): top is number => top !== null);
let largestDownwardShift = 0;
let largestDownwardShiftAt = -1;
for (let index = 1; index < tops.length; index += 1) {
const shift = tops[index] - tops[index - 1];
if (shift > largestDownwardShift) {
largestDownwardShift = shift;
largestDownwardShiftAt = index;
}
}
return {
start,
largestDownwardShift,
topsAroundShift: tops
.slice(Math.max(0, largestDownwardShiftAt - 4), largestDownwardShiftAt + 4)
.map((top) => Math.round(top))
.join(","),
};
});
expect(result.start, "the submitted prompt never appeared").toBeGreaterThan(-1);
expect(
Math.round(result.largestDownwardShift),
`the submitted prompt was pushed down ${Math.round(result.largestDownwardShift)}px (tops: ${result.topsAroundShift})`,
).toBeLessThanOrEqual(1);
}
test("keeps the first submitted prompt in place once the assistant starts streaming", async ({
page,
tenSecondAgent,
}) => {
await openAgentRoute(page, {
workspaceId: tenSecondAgent.workspaceId,
agentId: tenSecondAgent.agentId,
});
const prompt = "First prompt.";
const composer = page.getByRole("textbox", { name: "Message agent..." }).first();
await composer.fill(prompt);
await recordTurnFrames(page, prompt);
await composer.press("Enter");
await expect(page.getByText(prompt, { exact: true })).toBeVisible();
await expect(page.getByTestId("assistant-message").first()).toBeVisible();
await waitForTurnToComplete(page, 0);
await expectSubmittedPromptNeverMovedDown(page);
});
// The first prompt of a brand new agent is submitted before the agent exists, so the
// authoritative timeline arrives after the row is already on screen. That is the only
// path where hydration can move an already-visible message.
test("keeps the first prompt of a new agent in place through authoritative hydration", async ({
page,
}) => {
const workspace = await seedWorkspace({ repoPrefix: "new-agent-first-prompt-" });
try {
await page.addInitScript(() => {
localStorage.setItem(
"@paseo:create-agent-preferences",
JSON.stringify({ provider: "mock", providerPreferences: { mock: { mode: "load-test" } } }),
);
});
await gotoWorkspace(page, workspace.workspaceId);
await clickNewChat(page);
await expectComposerVisible(page);
const prompt = "First prompt of a new agent.";
const composer = page.getByRole("textbox", { name: "Message agent..." }).first();
await composer.fill(prompt);
await recordTurnFrames(page, prompt);
await composer.press("Enter");
await expect(
page.getByTestId("user-message").filter({ hasText: prompt }).first(),
).toBeVisible();
await expect(page.getByTestId("assistant-message").first()).toBeVisible({ timeout: 30_000 });
await expect(page.getByTestId("turn-working-elapsed")).toHaveCount(1);
await expectSubmittedPromptNeverMovedDown(page);
} finally {
await workspace.cleanup();
}
});
test("keeps the turn footer and timeline height stable while a second turn runs", async ({
page,
tenSecondAgent,
}) => {
await openAgentRoute(page, {
workspaceId: tenSecondAgent.workspaceId,
agentId: tenSecondAgent.agentId,
});
await submitMessage(page, "First prompt.");
await waitForTurnToComplete(page, 0);
const secondPrompt = "Second prompt.";
const composer = page.getByRole("textbox", { name: "Message agent..." }).first();
await composer.fill(secondPrompt);
await recordTurnFrames(page, secondPrompt);
await composer.press("Enter");
await expect(page.getByText(secondPrompt, { exact: true })).toBeVisible();
await waitForTurnToComplete(page, 1);
await expectUninterruptedTurn(page);
});
test("keeps the timeline height stable when submitting into a long timeline", async ({ page }) => {
const agent = await seedLongMockAgentTimeline({ turns: 40 });
try {
await openAgentTimeline(page, agent);
await expect(page.getByText(agent.newestPrompt, { exact: true })).toBeVisible();
// A real long chat is fully loaded and virtualized before the user types again.
await scrollTimelineUntilOlderHistoryIsReachable(page, agent.oldestPrompt);
await scrollTimelineToNewestLoadedEdge(page);
const prompt = "Stability probe into a long timeline.";
const composer = page.getByRole("textbox", { name: "Message agent..." }).first();
await composer.fill(prompt);
await recordTurnFrames(page, prompt);
await composer.press("Enter");
await expect(page.getByText(prompt, { exact: true }).last()).toBeVisible();
// Observe the whole turn: wait for it to start before waiting for it to end.
await expect(page.getByRole("button", { name: /stop|cancel/i }).first()).toBeVisible();
await expectAgentIdle(page, 60_000);
await expectUninterruptedTurn(page);
} finally {
await agent.cleanup();
}
});

View File

@@ -13,7 +13,6 @@ import {
startRunningMockAgent,
} from "./helpers/composer";
import { openAgentRoute, seedMockAgentWorkspace } from "./helpers/mock-agent";
import { readScrollMetrics } from "./helpers/agent-bottom-anchor";
import { seedWorkspace } from "./helpers/seed-client";
import { waitForWorkspaceTabsVisible } from "./helpers/workspace-tabs";
import { getServerId } from "./helpers/server-id";
@@ -21,6 +20,17 @@ import { buildHostWorkspaceRoute } from "@/utils/host-routes";
import { delayBrowserAgentCreatedStatus } from "./helpers/new-workspace";
import { installDaemonWebSocketGate } from "./helpers/daemon-websocket-gate";
import { selectModel } from "./helpers/app";
import {
scrollTimelineToNewestLoadedEdge,
scrollTimelineUntilOlderHistoryIsReachable,
} from "./helpers/timeline-pagination";
import {
appendSettledTimelineTurns,
createSettledMockAgent,
createSmallAssistantPng,
emitSettledAssistantImage,
expectAssistantImageRendered,
} from "./helpers/assistant-images";
const IMAGE = {
name: "message-submission.png",
@@ -31,15 +41,8 @@ const IMAGE = {
),
};
interface MessageGeometry {
x: number;
y: number;
width: number;
height: number;
}
interface SubmissionScenario {
gate: Awaited<ReturnType<typeof gateNextAgentMessage>>;
existingPrompt: string;
}
interface DraftCreateScenario {
@@ -63,17 +66,32 @@ const test = baseTest.extend<{
unrelatedRunningScenario: UnrelatedRunningScenario;
}>({
submissionScenario: async ({ page }, provide, testInfo) => {
const gate = await gateNextAgentMessage(page);
const agent = await seedMockAgentWorkspace({
repoPrefix: `message-submission-${testInfo.workerIndex}-`,
title: "Message submission regression",
model: "ten-second-stream",
await page.setViewportSize({ width: 1280, height: 960 });
const workspace = await seedWorkspace({
repoPrefix: `message-submission-layout-${testInfo.workerIndex}-`,
});
await openAgentRoute(page, { workspaceId: agent.workspaceId, agentId: agent.agentId });
await expectComposerVisible(page);
await expectAgentIdle(page);
await provide({ gate });
await agent.cleanup();
try {
const agent = await createSettledMockAgent(workspace, "Message submission layout regression");
await appendSettledTimelineTurns(workspace.client, agent, 80);
const image = await createSmallAssistantPng(workspace, {
alt: "Existing timeline image",
fileName: "existing-timeline.png",
});
await emitSettledAssistantImage(workspace.client, agent, image);
await openAgentRoute(page, { workspaceId: agent.workspaceId, agentId: agent.id });
await expectComposerVisible(page);
await expectAgentIdle(page);
const oldestPrompt = "image-history-turn-0: emit 1 coalesced agent stream updates";
await scrollTimelineUntilOlderHistoryIsReachable(page, oldestPrompt);
await scrollTimelineToNewestLoadedEdge(page);
await expectAssistantImageRendered(page, image);
await provide({
existingPrompt:
"Emit settled assistant image Markdown: ![Existing timeline image](existing-timeline.png)",
});
} finally {
await workspace.cleanup();
}
},
draftCreateScenario: async ({ page }, provide, testInfo) => {
const agentCreatedDelay = await delayBrowserAgentCreatedStatus(page);
@@ -174,55 +192,51 @@ async function expectPendingSubmission(page: Page, userMessage: Locator): Promis
await expect(userMessage.getByRole("button", { name: "Open image attachment" })).toBeVisible();
}
async function readMessageGeometry(page: Page, userMessage: Locator): Promise<MessageGeometry> {
const box = await userMessage.boundingBox();
if (!box) throw new Error("Submitted user message has no browser geometry");
const { offsetY } = await readScrollMetrics(page);
return { x: box.x, y: box.y + offsetY, width: box.width, height: box.height };
}
async function beginWorkingFooterContinuityCheck(page: Page): Promise<() => Promise<void>> {
await expect(page.getByTestId("turn-working-indicator")).toBeVisible();
await page.evaluate(() => {
const state = { active: true, sawMissing: false };
async function beginTimelineRowStabilityCheck(
page: Page,
timelineRow: Locator,
): Promise<() => Promise<void>> {
await expect(timelineRow).toBeVisible();
await timelineRow.evaluate((initialRow) => {
const state = {
active: true,
lastTop: initialRow.getBoundingClientRect().top,
largestDownwardShift: 0,
sawMissing: false,
};
const windowState = window as unknown as Record<string, unknown>;
windowState.__messageSubmissionFooterContinuity = state;
windowState.__messageSubmissionTimelineRowStability = state;
const checkFrame = () => {
if (!state.active) return;
if (!document.querySelector('[data-testid="turn-working-indicator"]')) {
if (!initialRow.isConnected) {
state.sawMissing = true;
requestAnimationFrame(checkFrame);
return;
}
const top = initialRow.getBoundingClientRect().top;
state.largestDownwardShift = Math.max(state.largestDownwardShift, top - state.lastTop);
state.lastTop = top;
requestAnimationFrame(checkFrame);
};
requestAnimationFrame(checkFrame);
});
return async () => {
const sawMissing = await page.evaluate(() => {
const result = await page.evaluate(() => {
const windowState = window as unknown as Record<string, unknown>;
const state = windowState.__messageSubmissionFooterContinuity as
| { active: boolean; sawMissing: boolean }
const state = windowState.__messageSubmissionTimelineRowStability as
| { active: boolean; largestDownwardShift: number; sawMissing: boolean }
| undefined;
if (!state) throw new Error("Working-footer continuity check was not started");
if (!state) throw new Error("Timeline-row stability check was not started");
state.active = false;
delete windowState.__messageSubmissionFooterContinuity;
return state.sawMissing;
delete windowState.__messageSubmissionTimelineRowStability;
return { largestDownwardShift: state.largestDownwardShift, sawMissing: state.sawMissing };
});
expect(sawMissing).toBe(false);
expect(result.sawMissing).toBe(false);
expect(result.largestDownwardShift).toBeLessThanOrEqual(2);
};
}
async function expectAcceptedSubmission(
page: Page,
userMessage: Locator,
submittedGeometry: MessageGeometry,
): Promise<void> {
await expect(page.getByTestId("turn-working-indicator")).toBeVisible();
await expect(userMessage).toHaveAttribute("aria-busy", "false", { timeout: 30_000 });
await expect(page.getByTestId("turn-working-indicator")).toBeVisible();
expect(await readMessageGeometry(page, userMessage)).toEqual(submittedGeometry);
}
async function submitMessageThatWillBeRejected(page: Page, prompt: string): Promise<void> {
await attachImageFromMenu(page, IMAGE);
await expectAttachmentPill(page, "composer-image-attachment-pill");
@@ -566,18 +580,33 @@ async function completeDraftCreateSubmission(
}
test.describe("Agent message submission", () => {
test("keeps the submitted row stable when the host accepts", async ({
test("keeps layout stable when submitting to an agent with existing history", async ({
page,
submissionScenario,
}) => {
const userMessage = await submitMessageWithImage(page, "Hold this submission.");
await expectPendingSubmission(page, userMessage);
await submissionScenario.gate.waitForRequest();
const submittedGeometry = await readMessageGeometry(page, userMessage);
const finishFooterContinuityCheck = await beginWorkingFooterContinuityCheck(page);
submissionScenario.gate.accept();
await expectAcceptedSubmission(page, userMessage, submittedGeometry);
await finishFooterContinuityCheck();
const prompt = "Hold this submission.";
const composer = page.getByRole("textbox", { name: "Message agent..." }).first();
await composer.fill(prompt);
await expect(composer).toHaveValue(prompt);
await expect(
page.getByTestId("user-message").filter({ hasText: submissionScenario.existingPrompt }),
).toBeVisible();
const finishTimelineRowStabilityCheck = await beginTimelineRowStabilityCheck(
page,
page.getByTestId("assistant-message").last(),
);
const assistantMessageCount = await page.getByTestId("assistant-message").count();
const toolCallCount = await page.getByTestId("tool-call-badge").count();
await composer.press("Enter");
const userMessage = page.getByTestId("user-message").filter({ hasText: prompt }).last();
await expect(userMessage).toBeVisible();
await expect
.poll(async () => page.getByTestId("assistant-message").count())
.toBeGreaterThan(assistantMessageCount);
await expect
.poll(async () => page.getByTestId("tool-call-badge").count())
.toBeGreaterThan(toolCallCount);
await finishTimelineRowStabilityCheck();
});
test("keeps the submitted row stable through draft create handoff", async ({

View File

@@ -2,6 +2,7 @@ import { expect, test } from "./fixtures";
import {
expectSameOlderHistoryLoadingOperation,
expectTimelineAtHistoryStart,
expectTimelinePromptCentered,
expectTimelinePromptNotMounted,
expectTimelinePromptPositionPreserved,
expectTimelinePromptVisible,
@@ -30,12 +31,14 @@ test.describe("Agent timeline pagination", () => {
const history = await holdOlderHistoryPages(page, agent);
await openAgentTimeline(page, agent);
await expectTimelinePromptVisible(page, agent.newestPrompt);
await expectTimelinePromptCentered(page, agent.newestPrompt);
await expectTimelinePromptNotMounted(page, agent.oldestPrompt);
await userScrollsTimelineToHistoryStart(page);
await history.expectRequestedPages(1);
history.releasePage(1);
await history.expectSettledWithRequestedPages(1);
await expectTimelinePromptCentered(page, agent.firstOlderPagePrompt);
await userScrollsTimelineToHistoryStart(page);
await history.expectRequestedPages(2);

View File

@@ -17,6 +17,7 @@ interface LongTimelineAgentOptions {
}
interface LongTimelineAgent extends MockAgentWorkspace {
firstOlderPagePrompt: string;
initialTailOldestPrompt: string;
oldestPrompt: string;
newestPrompt: string;
@@ -67,6 +68,7 @@ export async function seedLongMockAgentTimeline(
return {
...agent,
firstOlderPagePrompt: promptForTurn(Math.max(0, options.turns - 40)),
initialTailOldestPrompt: promptForTurn(Math.max(0, options.turns - 20)),
oldestPrompt: promptForTurn(0),
newestPrompt: promptForTurn(options.turns - 1),
@@ -115,6 +117,26 @@ export async function expectTimelinePromptVisible(page: Page, prompt: string): P
await expect(timeline.getByText(prompt, { exact: true })).toBeVisible({ timeout: 30_000 });
}
export async function expectTimelinePromptCentered(page: Page, prompt: string): Promise<void> {
const timeline = page.locator('[data-testid="agent-chat-scroll"]:visible').first();
const message = timeline.getByTestId("user-message").filter({ hasText: prompt });
await expect(message).toBeVisible();
await expect
.poll(async () => {
const [timelineBox, messageBox] = await Promise.all([
timeline.boundingBox(),
message.boundingBox(),
]);
if (!timelineBox || !messageBox) {
return Number.POSITIVE_INFINITY;
}
const timelineCenter = timelineBox.x + timelineBox.width / 2;
const messageCenter = messageBox.x + messageBox.width / 2;
return Math.abs(timelineCenter - messageCenter);
})
.toBeLessThanOrEqual(2);
}
export async function expectTimelinePromptNotMounted(page: Page, prompt: string): Promise<void> {
const timeline = page.locator('[data-testid="agent-chat-scroll"]:visible').first();
await expect(timeline.getByText(prompt, { exact: true })).toHaveCount(0);

View File

@@ -73,6 +73,12 @@ const historyStartSlotStyle: CSSProperties = {
paddingBottom: 8,
};
const mountedHistoryRowStyle: CSSProperties = {
display: "flex",
flexDirection: "column",
width: "100%",
};
function isScrollContainerNearBottom(
scrollContainer: Pick<HTMLElement, "scrollTop" | "clientHeight" | "scrollHeight">,
thresholdPx = AUTO_SCROLL_BOTTOM_THRESHOLD_PX,
@@ -557,25 +563,24 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
scheduleStickToBottom,
]);
useEffect(() => {
// Following output is a layout invariant: rows, footer, and bottom offset must
// reach the browser in the same paint.
useLayoutEffect(() => {
if (!followOutputRef.current) {
return;
}
scheduleStickToBottom();
cancelPendingStickToBottom();
scrollMessagesToBottom("auto");
}, [
scheduleStickToBottom,
cancelPendingStickToBottom,
renderLiveAuxiliary,
scrollMessagesToBottom,
segments.historyMounted,
segments.historyVirtualized,
segments.liveHead,
virtualTotalSize,
]);
useEffect(() => {
if (!followOutputRef.current || !shouldUseVirtualizer) {
return;
}
scheduleStickToBottom();
}, [scheduleStickToBottom, shouldUseVirtualizer, virtualTotalSize]);
useEffect(() => {
updateScrollMetrics();
evaluateHistoryStart();
@@ -764,7 +769,7 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
);
const mountedHistoryRows = useMemo(() => {
return segments.historyMounted.map((item, index) => (
<div key={item.id} data-history-row-id={item.id}>
<div key={item.id} data-history-row-id={item.id} style={mountedHistoryRowStyle}>
{renderHistoryMountedRow(item, index, segments.historyMounted)}
</div>
));

View File

@@ -24,28 +24,16 @@ import {
} from "@/components/tree-primitives";
import { LoadingSpinner } from "@/components/ui/loading-spinner";
import type { Theme } from "@/styles/theme";
import type {
AgentFileExplorerState,
ExplorerDirectory,
ExplorerEntry,
} from "@/stores/session-store";
import type { AgentFileExplorerState, ExplorerEntry } from "@/stores/session-store";
import { useSessionStore } from "@/stores/session-store";
import { FileActionsMenu } from "@/components/file-actions-menu";
import { useFileDownload } from "@/hooks/use-file-download";
import { useFileExplorerActions } from "@/hooks/use-file-explorer-actions";
import { buildWorkspaceExplorerStateKey } from "@/hooks/use-file-explorer-actions";
import { usePanelStore, type ExpandedPathsUpdate, type SortOption } from "@/stores/panel-store";
import { usePanelStore, type SortOption } from "@/stores/panel-store";
import { formatTimeAgo } from "@/utils/time";
import { buildAbsoluteExplorerPath } from "@/utils/explorer-paths";
import { isHiddenExplorerPath } from "@/file-explorer/visibility";
import {
flattenExplorerTree,
reconcileRestoredExpandedPaths,
restoreExpandedDirectories,
setExpandedDirectoryPath,
showHiddenFilesAndRestoreExpandedDirectories,
type ExplorerTreeRow,
} from "@/file-explorer/tree";
import { filterVisibleExplorerEntries, isHiddenExplorerPath } from "@/file-explorer/visibility";
import { useWorkspaceFileDragSource } from "@/attachments/use-workspace-file-drag-source";
const SORT_OPTIONS: { value: SortOption }[] = [
@@ -95,7 +83,7 @@ function iconButtonStyle({ hovered, pressed }: PressableStateCallbackType & { ho
return [styles.iconButton, (Boolean(hovered) || pressed) && styles.iconButtonHovered];
}
function treeRowKeyExtractor(row: ExplorerTreeRow) {
function treeRowKeyExtractor(row: TreeRow) {
return row.entry.path;
}
@@ -211,6 +199,11 @@ interface FileExplorerPaneProps {
onAddToChat?: (path: string) => void;
}
interface TreeRow {
entry: ExplorerEntry;
depth: number;
}
export function FileExplorerPane({
serverId,
workspaceId,
@@ -270,7 +263,7 @@ export function FileExplorerPane({
[isExplorerLoading, pendingRequest],
);
const treeListRef = useRef<FlatList<ExplorerTreeRow>>(null);
const treeListRef = useRef<FlatList<TreeRow>>(null);
const hasInitializedRef = useRef(false);
@@ -283,19 +276,9 @@ export function FileExplorerPane({
hasWorkspaceScope,
hasInitializedRef,
workspaceStateKey,
persistedExpandedPaths: expandedPaths,
showHiddenFiles,
requestDirectoryListing,
setExpandedPathsForWorkspace,
});
}, [
expandedPaths,
hasWorkspaceScope,
requestDirectoryListing,
setExpandedPathsForWorkspace,
showHiddenFiles,
workspaceStateKey,
]);
}, [hasWorkspaceScope, requestDirectoryListing, workspaceStateKey]);
const handleToggleDirectory = useCallback(
(entry: ExplorerEntry) =>
@@ -368,42 +351,11 @@ export function FileExplorerPane({
const handleToggleHiddenFiles = useCallback(() => {
const willShow = !usePanelStore.getState().explorerShowHiddenFiles;
if (!willShow) {
toggleExplorerShowHiddenFiles();
return;
toggleExplorerShowHiddenFiles();
if (willShow) {
requestPersistedExpandedPaths({ workspaceStateKey, requestDirectoryListing });
}
const rootDirectory = directories.get(".");
if (!rootDirectory || !workspaceStateKey) {
toggleExplorerShowHiddenFiles();
return;
}
void showHiddenFilesAndRestoreExpandedDirectories({
rootDirectory,
persistedExpandedPaths: expandedPaths,
showHiddenFiles: toggleExplorerShowHiddenFiles,
requestDirectoryListing: (path) =>
requestDirectoryListing(path, {
recordHistory: false,
setCurrentPath: false,
}),
}).then((restoredPaths) => {
setExpandedPathsForWorkspace(workspaceStateKey, (currentPaths) =>
reconcileRestoredExpandedPaths({
persistedExpandedPaths: expandedPaths,
currentExpandedPaths: new Set(currentPaths),
restoredExpandedPaths: restoredPaths,
}),
);
return null;
});
}, [
directories,
expandedPaths,
requestDirectoryListing,
setExpandedPathsForWorkspace,
toggleExplorerShowHiddenFiles,
workspaceStateKey,
]);
}, [requestDirectoryListing, toggleExplorerShowHiddenFiles, workspaceStateKey]);
const refreshExplorer = useCallback(
() =>
@@ -435,7 +387,7 @@ export function FileExplorerPane({
const currentSortLabel = resolveCurrentSortLabel(sortOption, sortLabels);
const treeRows = useMemo(
() => flattenExplorerTree({ directories, expandedPaths, sortOption, showHiddenFiles }),
() => resolveTreeRows({ directories, expandedPaths, sortOption, showHiddenFiles }),
[directories, expandedPaths, showHiddenFiles, sortOption],
);
@@ -448,7 +400,7 @@ export function FileExplorerPane({
const errorRecoveryPath = useMemo(() => getErrorRecoveryPath(explorerState), [explorerState]);
const renderTreeRow = useCallback(
(info: ListRenderItemInfo<ExplorerTreeRow>) => (
(info: ListRenderItemInfo<TreeRow>) => (
<TreeRowDispatcher
serverId={serverId}
workspaceId={workspaceId}
@@ -528,11 +480,11 @@ interface FileExplorerPaneContentProps {
error: string | null;
showInitialLoading: boolean;
showBackFromError: boolean;
treeRows: ExplorerTreeRow[];
treeRows: TreeRow[];
currentSortLabel: string;
isRefreshFetching: boolean;
treeListRef: RefObject<FlatList<ExplorerTreeRow> | null>;
renderTreeRow: (info: ListRenderItemInfo<ExplorerTreeRow>) => ReactElement;
treeListRef: RefObject<FlatList<TreeRow> | null>;
renderTreeRow: (info: ListRenderItemInfo<TreeRow>) => ReactElement;
handleSortCycle: () => void;
handleToggleHiddenFiles: () => void;
handleRefresh: () => void;
@@ -685,6 +637,71 @@ function FileExplorerPaneContent(props: FileExplorerPaneContentProps) {
);
}
function sortEntries(entries: ExplorerEntry[], sortOption: SortOption): ExplorerEntry[] {
const sorted = [...entries];
sorted.sort((a, b) => {
if (a.kind !== b.kind) {
return a.kind === "directory" ? -1 : 1;
}
switch (sortOption) {
case "name":
return a.name.localeCompare(b.name);
case "modified":
return new Date(b.modifiedAt).getTime() - new Date(a.modifiedAt).getTime();
case "size":
return b.size - a.size;
default:
return 0;
}
});
return sorted;
}
function buildTreeRows({
directories,
expandedPaths,
sortOption,
showHiddenFiles,
path,
depth,
}: {
directories: Map<string, { path: string; entries: ExplorerEntry[] }>;
expandedPaths: Set<string>;
sortOption: SortOption;
showHiddenFiles: boolean;
path: string;
depth: number;
}): TreeRow[] {
const directory = directories.get(path);
if (!directory) {
return [];
}
const rows: TreeRow[] = [];
const entries = sortEntries(
filterVisibleExplorerEntries(directory.entries, showHiddenFiles),
sortOption,
);
for (const entry of entries) {
rows.push({ entry, depth });
if (entry.kind === "directory" && expandedPaths.has(entry.path)) {
rows.push(
...buildTreeRows({
directories,
expandedPaths,
sortOption,
showHiddenFiles,
path: entry.path,
depth: depth + 1,
}),
);
}
}
return rows;
}
function deriveExplorerFields(state: AgentFileExplorerState | undefined) {
return {
directories:
@@ -734,6 +751,30 @@ function resolveCurrentSortLabel(
return labels[sortOption] ?? labels.name;
}
function resolveTreeRows({
directories,
expandedPaths,
sortOption,
showHiddenFiles,
}: {
directories: Map<string, { path: string; entries: ExplorerEntry[] }>;
expandedPaths: Set<string>;
sortOption: SortOption;
showHiddenFiles: boolean;
}): TreeRow[] {
if (!directories.get(".")) {
return [];
}
return buildTreeRows({
directories,
expandedPaths,
sortOption,
showHiddenFiles,
path: ".",
depth: 0,
});
}
function toggleDirectory({
entry,
workspaceStateKey,
@@ -745,25 +786,26 @@ function toggleDirectory({
entry: ExplorerEntry;
workspaceStateKey: string | null;
expandedPaths: Set<string>;
directories: Map<string, ExplorerDirectory>;
directories: Map<string, { path: string; entries: ExplorerEntry[] }>;
requestDirectoryListing: (
path: string,
opts?: { recordHistory?: boolean; setCurrentPath?: boolean },
) => Promise<ExplorerDirectory | null>;
setExpandedPathsForWorkspace: (workspaceStateKey: string, paths: ExpandedPathsUpdate) => void;
) => Promise<boolean>;
setExpandedPathsForWorkspace: (workspaceStateKey: string, paths: string[]) => void;
}): void {
if (!workspaceStateKey) {
return;
}
const isExpanded = expandedPaths.has(entry.path);
setExpandedPathsForWorkspace(workspaceStateKey, (currentPaths) =>
setExpandedDirectoryPath({
currentExpandedPaths: currentPaths,
directoryPath: entry.path,
expanded: !isExpanded,
}),
);
if (!isExpanded && !directories.has(entry.path)) {
if (isExpanded) {
setExpandedPathsForWorkspace(
workspaceStateKey,
Array.from(expandedPaths).filter((path) => path !== entry.path),
);
return;
}
setExpandedPathsForWorkspace(workspaceStateKey, [...Array.from(expandedPaths), entry.path]);
if (!directories.has(entry.path)) {
void requestDirectoryListing(entry.path, {
recordHistory: false,
setCurrentPath: false,
@@ -785,7 +827,7 @@ function TreeRowDispatcher({
}: {
serverId: string;
workspaceId?: string | null;
info: ListRenderItemInfo<ExplorerTreeRow>;
info: ListRenderItemInfo<TreeRow>;
expandedPaths: Set<string>;
selectedEntryPath: string | null;
isDirectoryLoading: (path: string) => boolean;
@@ -823,59 +865,54 @@ async function initializeExplorer({
hasWorkspaceScope,
hasInitializedRef,
workspaceStateKey,
persistedExpandedPaths,
showHiddenFiles,
requestDirectoryListing,
setExpandedPathsForWorkspace,
}: {
hasWorkspaceScope: boolean;
hasInitializedRef: RefObject<boolean>;
workspaceStateKey: string | null;
persistedExpandedPaths: ReadonlySet<string>;
showHiddenFiles: boolean;
requestDirectoryListing: (
path: string,
opts?: { recordHistory?: boolean; setCurrentPath?: boolean },
) => Promise<ExplorerDirectory | null>;
setExpandedPathsForWorkspace: (workspaceStateKey: string, paths: ExpandedPathsUpdate) => void;
) => Promise<boolean>;
}): Promise<void> {
if (!hasWorkspaceScope || hasInitializedRef.current) {
return;
}
hasInitializedRef.current = true;
const rootDirectory = await requestDirectoryListing(".", {
const succeeded = await requestDirectoryListing(".", {
recordHistory: false,
setCurrentPath: false,
});
if (!rootDirectory) {
if (!succeeded) {
hasInitializedRef.current = false;
return;
}
if (!workspaceStateKey) {
requestPersistedExpandedPaths({ workspaceStateKey, requestDirectoryListing });
}
function requestPersistedExpandedPaths({
workspaceStateKey,
requestDirectoryListing,
}: {
workspaceStateKey: string | null;
requestDirectoryListing: (
path: string,
opts?: { recordHistory?: boolean; setCurrentPath?: boolean },
) => Promise<boolean>;
}): void {
const showHiddenFiles = usePanelStore.getState().explorerShowHiddenFiles;
const persistedPaths = usePanelStore.getState().expandedPathsByWorkspace[workspaceStateKey ?? ""];
if (!persistedPaths) {
return;
}
const restoredPaths = await restoreExpandedDirectories({
rootDirectory,
persistedExpandedPaths,
showHiddenFiles,
requestDirectoryListing: (path) =>
requestDirectoryListing(path, {
for (const path of persistedPaths) {
if (path !== "." && (showHiddenFiles || !isHiddenExplorerPath(path))) {
void requestDirectoryListing(path, {
recordHistory: false,
setCurrentPath: false,
}),
});
const hiddenPersistedPaths = showHiddenFiles
? []
: Array.from(persistedExpandedPaths).filter(isHiddenExplorerPath);
const restoredPathsWithHidden = [...restoredPaths, ...hiddenPersistedPaths];
setExpandedPathsForWorkspace(workspaceStateKey, (currentPaths) =>
reconcileRestoredExpandedPaths({
persistedExpandedPaths,
currentExpandedPaths: new Set(currentPaths),
restoredExpandedPaths: restoredPathsWithHidden,
}),
);
});
}
}
}
async function refreshExplorerDirectories({
@@ -888,7 +925,7 @@ async function refreshExplorerDirectories({
requestDirectoryListing: (
path: string,
opts?: { recordHistory?: boolean; setCurrentPath?: boolean },
) => Promise<ExplorerDirectory | null>;
) => Promise<boolean>;
}): Promise<null> {
if (!hasWorkspaceScope) {
return null;

View File

@@ -1,197 +0,0 @@
import { describe, expect, it } from "vitest";
import type { ExplorerEntry } from "@/stores/session-store";
import {
MAX_AUTO_EXPANDED_DIRECTORY_DEPTH,
flattenExplorerTree,
reconcileRestoredExpandedPaths,
restoreExpandedDirectories,
setExpandedDirectoryPath,
showHiddenFilesAndRestoreExpandedDirectories,
} from "./tree";
function makeDirectoryEntry(name: string, path: string): ExplorerEntry {
return {
name,
path,
kind: "directory",
size: 0,
modifiedAt: "2026-01-01T00:00:00.000Z",
};
}
describe("file explorer tree", () => {
it("flattens a deeply expanded tree without consuming the call stack", () => {
const depth = 10_000;
const directories = new Map<string, { path: string; entries: ExplorerEntry[] }>();
const expandedPaths = new Set<string>(["."]);
let parentPath = ".";
for (let index = 1; index <= depth; index += 1) {
const childPath = `directory-${index}`;
directories.set(parentPath, {
path: parentPath,
entries: [makeDirectoryEntry(childPath, childPath)],
});
expandedPaths.add(childPath);
parentPath = childPath;
}
directories.set(parentPath, { path: parentPath, entries: [] });
const rows = flattenExplorerTree({
directories,
expandedPaths,
sortOption: "name",
showHiddenFiles: true,
});
expect(rows).toHaveLength(depth);
expect(rows[0]).toEqual({
entry: makeDirectoryEntry("directory-1", "directory-1"),
depth: 0,
});
expect(rows.at(-1)).toEqual({
entry: makeDirectoryEntry(`directory-${depth}`, `directory-${depth}`),
depth: depth - 1,
});
});
it("flattens a large expanded directory without spreading its rows into the parent", () => {
const fileCount = 150_000;
const files = Array.from(
{ length: fileCount },
(_, index): ExplorerEntry => ({
name: `file-${index.toString().padStart(6, "0")}`,
path: `generated/file-${index}`,
kind: "file",
size: index,
modifiedAt: "2026-01-01T00:00:00.000Z",
}),
);
const child = makeDirectoryEntry("generated", "generated");
const directories = new Map([
[".", { path: ".", entries: [child] }],
["generated", { path: "generated", entries: files }],
]);
const rows = flattenExplorerTree({
directories,
expandedPaths: new Set([".", "generated"]),
sortOption: "name",
showHiddenFiles: true,
});
expect(rows).toHaveLength(fileCount + 1);
expect(rows[0]).toEqual({ entry: child, depth: 0 });
expect(rows.at(-1)).toEqual({ entry: files[fileCount - 1], depth: 1 });
});
it("restores five rendered directory levels rather than counting path segments", async () => {
const paths = [
"generated/cache/level-1",
"generated/cache/level-1/level-2",
"generated/cache/level-1/level-2/level-3",
"generated/cache/level-1/level-2/level-3/level-4",
"generated/cache/level-1/level-2/level-3/level-4/level-5",
"generated/cache/level-1/level-2/level-3/level-4/level-5/level-6",
];
const directories = new Map<string, { path: string; entries: ExplorerEntry[] }>();
const rootDirectory = {
path: ".",
entries: [makeDirectoryEntry("level-1", paths[0])],
};
directories.set(".", rootDirectory);
for (let index = 0; index < paths.length - 1; index += 1) {
directories.set(paths[index], {
path: paths[index],
entries: [makeDirectoryEntry(`level-${index + 2}`, paths[index + 1])],
});
}
const requestedPaths: string[] = [];
const expandedPaths = await restoreExpandedDirectories({
rootDirectory,
persistedExpandedPaths: new Set(paths),
showHiddenFiles: true,
requestDirectoryListing: async (path) => {
requestedPaths.push(path);
return directories.get(path) ?? null;
},
});
expect(MAX_AUTO_EXPANDED_DIRECTORY_DEPTH).toBe(5);
expect(requestedPaths).toEqual(paths.slice(0, 5));
expect(expandedPaths).toEqual([".", ...paths.slice(0, 5)]);
});
it("does not restore persisted descendants beneath a collapsed rendered directory", async () => {
const rootDirectory = {
path: ".",
entries: [makeDirectoryEntry("parent", "parent")],
};
const requestedPaths: string[] = [];
const expandedPaths = await restoreExpandedDirectories({
rootDirectory,
persistedExpandedPaths: new Set(["parent/child", "parent/child/grandchild"]),
showHiddenFiles: true,
requestDirectoryListing: async (path) => {
requestedPaths.push(path);
return null;
},
});
expect(requestedPaths).toEqual([]);
expect(expandedPaths).toEqual(["."]);
});
it("preserves expansion changes made while persisted directories are restoring", () => {
const paths = reconcileRestoredExpandedPaths({
persistedExpandedPaths: new Set([".", "parent", "parent/child"]),
currentExpandedPaths: new Set([".", "parent/child", "manual"]),
restoredExpandedPaths: [".", "parent"],
});
expect(paths).toEqual([".", "manual"]);
});
it("applies a directory click to the latest restored expansion paths", () => {
const expanded = setExpandedDirectoryPath({
currentExpandedPaths: [".", "restored"],
directoryPath: "manual",
expanded: true,
});
const collapsed = setExpandedDirectoryPath({
currentExpandedPaths: expanded,
directoryPath: "manual",
expanded: false,
});
expect(expanded).toEqual([".", "restored", "manual"]);
expect(collapsed).toEqual([".", "restored"]);
});
it("shows hidden files before waiting for expanded directories to restore", async () => {
const rootDirectory = {
path: ".",
entries: [makeDirectoryEntry(".hidden", ".hidden")],
};
let resolveDirectory!: (directory: { path: string; entries: ExplorerEntry[] }) => void;
const directoryListing = new Promise<{ path: string; entries: ExplorerEntry[] }>((resolve) => {
resolveDirectory = resolve;
});
let hiddenFilesAreShown = false;
const restoration = showHiddenFilesAndRestoreExpandedDirectories({
rootDirectory,
persistedExpandedPaths: new Set([".hidden"]),
showHiddenFiles: () => {
hiddenFilesAreShown = true;
},
requestDirectoryListing: () => directoryListing,
});
expect(hiddenFilesAreShown).toBe(true);
resolveDirectory({ path: ".hidden", entries: [] });
await expect(restoration).resolves.toEqual([".", ".hidden"]);
});
});

View File

@@ -1,203 +0,0 @@
import type { ExplorerDirectory, ExplorerEntry } from "@/stores/session-store";
import type { SortOption } from "@/stores/panel-store/state";
import { filterVisibleExplorerEntries } from "./visibility";
export const MAX_AUTO_EXPANDED_DIRECTORY_DEPTH = 5;
export interface ExplorerTreeRow {
entry: ExplorerEntry;
depth: number;
}
interface FlattenExplorerTreeInput {
directories: ReadonlyMap<string, ExplorerDirectory>;
expandedPaths: ReadonlySet<string>;
sortOption: SortOption;
showHiddenFiles: boolean;
}
interface RestoreExpandedDirectoriesInput {
rootDirectory: ExplorerDirectory;
persistedExpandedPaths: ReadonlySet<string>;
showHiddenFiles: boolean;
requestDirectoryListing: (path: string) => Promise<ExplorerDirectory | null>;
}
interface ShowHiddenFilesAndRestoreExpandedDirectoriesInput extends Omit<
RestoreExpandedDirectoriesInput,
"showHiddenFiles"
> {
showHiddenFiles: () => void;
}
interface ReconcileRestoredExpandedPathsInput {
persistedExpandedPaths: ReadonlySet<string>;
currentExpandedPaths: ReadonlySet<string>;
restoredExpandedPaths: string[];
}
interface SetExpandedDirectoryPathInput {
currentExpandedPaths: readonly string[];
directoryPath: string;
expanded: boolean;
}
export function flattenExplorerTree({
directories,
expandedPaths,
sortOption,
showHiddenFiles,
}: FlattenExplorerTreeInput): ExplorerTreeRow[] {
const root = directories.get(".");
if (!root) {
return [];
}
const rows: ExplorerTreeRow[] = [];
const pending = rowsForDirectory(root, 0, sortOption, showHiddenFiles).toReversed();
while (pending.length > 0) {
const row = pending.pop();
if (!row) {
break;
}
rows.push(row);
const entry = row.entry;
if (entry.kind !== "directory" || !expandedPaths.has(entry.path)) {
continue;
}
const childDirectory = directories.get(entry.path);
if (!childDirectory) {
continue;
}
const childRows = rowsForDirectory(childDirectory, row.depth + 1, sortOption, showHiddenFiles);
for (let index = childRows.length - 1; index >= 0; index -= 1) {
pending.push(childRows[index]);
}
}
return rows;
}
export async function restoreExpandedDirectories({
rootDirectory,
persistedExpandedPaths,
showHiddenFiles,
requestDirectoryListing,
}: RestoreExpandedDirectoriesInput): Promise<string[]> {
const restoredPaths = ["."];
const restoredPathSet = new Set(restoredPaths);
let parentDirectories = [rootDirectory];
for (let depth = 1; depth <= MAX_AUTO_EXPANDED_DIRECTORY_DEPTH; depth += 1) {
const pathsToRequest: string[] = [];
for (const directory of parentDirectories) {
const entries = filterVisibleExplorerEntries(directory.entries, showHiddenFiles);
for (const entry of entries) {
const isPersistedExpandedDirectory =
entry.kind === "directory" && persistedExpandedPaths.has(entry.path);
if (isPersistedExpandedDirectory && !restoredPathSet.has(entry.path)) {
pathsToRequest.push(entry.path);
restoredPathSet.add(entry.path);
}
}
}
if (pathsToRequest.length === 0) {
break;
}
const requestedDirectories = await Promise.all(
pathsToRequest.map((path) => requestDirectoryListing(path)),
);
parentDirectories = [];
for (const directory of requestedDirectories) {
if (!directory) {
continue;
}
restoredPaths.push(directory.path);
parentDirectories.push(directory);
}
}
return restoredPaths;
}
export function showHiddenFilesAndRestoreExpandedDirectories({
rootDirectory,
persistedExpandedPaths,
showHiddenFiles,
requestDirectoryListing,
}: ShowHiddenFilesAndRestoreExpandedDirectoriesInput): Promise<string[]> {
showHiddenFiles();
return restoreExpandedDirectories({
rootDirectory,
persistedExpandedPaths,
showHiddenFiles: true,
requestDirectoryListing,
});
}
export function reconcileRestoredExpandedPaths({
persistedExpandedPaths,
currentExpandedPaths,
restoredExpandedPaths,
}: ReconcileRestoredExpandedPathsInput): string[] {
const reconciledPaths = new Set(restoredExpandedPaths);
for (const path of persistedExpandedPaths) {
if (!currentExpandedPaths.has(path)) {
reconciledPaths.delete(path);
}
}
for (const path of currentExpandedPaths) {
if (!persistedExpandedPaths.has(path)) {
reconciledPaths.add(path);
}
}
return Array.from(reconciledPaths);
}
export function setExpandedDirectoryPath({
currentExpandedPaths,
directoryPath,
expanded,
}: SetExpandedDirectoryPathInput): string[] {
const nextPaths = new Set(currentExpandedPaths);
if (expanded) {
nextPaths.add(directoryPath);
} else {
nextPaths.delete(directoryPath);
}
return Array.from(nextPaths);
}
function rowsForDirectory(
directory: ExplorerDirectory,
depth: number,
sortOption: SortOption,
showHiddenFiles: boolean,
): ExplorerTreeRow[] {
const visibleEntries = filterVisibleExplorerEntries(directory.entries, showHiddenFiles);
const sortedEntries = sortExplorerEntries(visibleEntries, sortOption);
return sortedEntries.map((entry) => ({ entry, depth }));
}
function sortExplorerEntries(entries: ExplorerEntry[], sortOption: SortOption): ExplorerEntry[] {
const sorted = [...entries];
sorted.sort((a, b) => {
if (a.kind !== b.kind) {
return a.kind === "directory" ? -1 : 1;
}
switch (sortOption) {
case "name":
return a.name.localeCompare(b.name);
case "modified":
return new Date(b.modifiedAt).getTime() - new Date(a.modifiedAt).getTime();
case "size":
return b.size - a.size;
}
});
return sorted;
}

View File

@@ -1,10 +1,6 @@
import { useCallback, useMemo } from "react";
import { useTranslation } from "react-i18next";
import {
useSessionStore,
type AgentFileExplorerState,
type ExplorerDirectory,
} from "@/stores/session-store";
import { useSessionStore, type AgentFileExplorerState } from "@/stores/session-store";
import { explorerFileFromReadResult } from "@/file-explorer/read-result";
function createExplorerState(): AgentFileExplorerState {
@@ -92,9 +88,9 @@ export function useFileExplorerActions(params: { serverId: string } & FileExplor
async (
path: string,
options?: { recordHistory?: boolean; setCurrentPath?: boolean },
): Promise<ExplorerDirectory | null> => {
): Promise<boolean> => {
if (!workspaceStateKey) {
return null;
return false;
}
const normalizedPath = path && path.length > 0 ? path : ".";
const shouldSetCurrentPath = options?.setCurrentPath ?? true;
@@ -123,7 +119,7 @@ export function useFileExplorerActions(params: { serverId: string } & FileExplor
lastError: t("workspace.fileExplorer.states.unavailable"),
pendingRequest: null,
}));
return null;
return false;
}
if (!client) {
@@ -133,7 +129,7 @@ export function useFileExplorerActions(params: { serverId: string } & FileExplor
lastError: t("workspace.terminal.hostDisconnected"),
pendingRequest: null,
}));
return null;
return false;
}
try {
@@ -154,7 +150,7 @@ export function useFileExplorerActions(params: { serverId: string } & FileExplor
return nextState;
});
return directory;
return true;
} catch (error) {
updateExplorerState((state) => ({
...state,
@@ -165,7 +161,7 @@ export function useFileExplorerActions(params: { serverId: string } & FileExplor
: t("workspace.fileExplorer.errors.failedToListDirectory"),
pendingRequest: null,
}));
return null;
return false;
}
},
[client, normalizedWorkspaceRoot, t, updateExplorerState, workspaceStateKey],

View File

@@ -63,8 +63,6 @@ export {
selectPanelVisibility,
};
export type ExpandedPathsUpdate = string[] | ((currentPaths: string[]) => string[]);
export interface PanelState {
// Mobile: React's durable target plus the generation that owns it.
mobilePanel: MobilePanelSelection;
@@ -106,7 +104,7 @@ export interface PanelState {
// File explorer settings actions
setExplorerTab: (tab: ExplorerTab) => void;
setExplorerTabForCheckout: (params: ExplorerCheckoutContext & { tab: ExplorerTab }) => void;
setExpandedPathsForWorkspace: (workspaceKey: string, paths: ExpandedPathsUpdate) => void;
setExpandedPathsForWorkspace: (workspaceKey: string, paths: string[]) => void;
setDiffExpandedPathsForWorkspace: (workspaceKey: string, paths: string[]) => void;
setDiffCollapsedFoldersForWorkspace: (workspaceKey: string, dirPaths: string[]) => void;
activateExplorerTabForCheckout: (checkout: ExplorerCheckoutContext) => void;
@@ -255,16 +253,9 @@ export const usePanelStore = create<PanelState>()(
return nextState;
}),
setExpandedPathsForWorkspace: (workspaceKey, paths) =>
set((state) => {
const currentPaths = state.expandedPathsByWorkspace[workspaceKey] ?? ["."];
const nextPaths = typeof paths === "function" ? paths(currentPaths) : paths;
return {
expandedPathsByWorkspace: {
...state.expandedPathsByWorkspace,
[workspaceKey]: nextPaths,
},
};
}),
set((state) => ({
expandedPathsByWorkspace: { ...state.expandedPathsByWorkspace, [workspaceKey]: paths },
})),
setDiffExpandedPathsForWorkspace: (workspaceKey, paths) =>
set((state) => ({
diffExpandedPathsByWorkspace: {

View File

@@ -296,7 +296,7 @@ export interface ExplorerFile {
modifiedAt: string;
}
export interface ExplorerDirectory {
interface ExplorerDirectory {
path: string;
entries: ExplorerEntry[];
}

View File

@@ -26,7 +26,7 @@ export type AgentLoaderManager = Pick<
| "hydrateTimelineFromProvider"
| "resumeAgentFromPersistence"
> &
Partial<Pick<AgentManager, "touchAgentActivity" | "waitForAgentClose">>;
Partial<Pick<AgentManager, "waitForAgentClose">>;
export interface EnsureAgentLoadedDeps {
agentManager: AgentLoaderManager;
@@ -71,8 +71,7 @@ export async function ensureAgentLoaded(
return inflight.promise;
}
const existing =
deps.agentManager.touchAgentActivity?.(agentId) ?? deps.agentManager.getAgent(agentId);
const existing = deps.agentManager.getAgent(agentId);
if (existing) {
return existing;
}

View File

@@ -7257,108 +7257,57 @@ test("closeAgent persists one final closed snapshot", async () => {
}
});
test("collectIdleAgents releases an idle runtime and resumes the same agent and timeline", async () => {
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-idle-collection-"));
const storage = new AgentStorage(join(workdir, "agents"), logger);
let activeSession: TestAgentSession | null = null;
const client = new (class extends NativeArchiveRecordingClient {
test("idle agents remain resident until an explicit lifecycle action closes them", async () => {
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-idle-residency-"));
let closeCount = 0;
let resumeCount = 0;
const client = new (class extends TestAgentClient {
override async createSession(config: AgentSessionConfig): Promise<AgentSession> {
activeSession = new TestAgentSession(config);
return activeSession;
const recordClose = () => {
closeCount += 1;
};
return new (class extends TestAgentSession {
override async close(): Promise<void> {
recordClose();
}
})(config);
}
override async resumeSession(
handle: AgentPersistenceHandle,
config?: Partial<AgentSessionConfig>,
launchContext?: AgentLaunchContext,
): Promise<AgentSession> {
resumeCount += 1;
return super.resumeSession(handle, config, launchContext);
}
})();
const manager = new AgentManager({
clients: { codex: client },
registry: storage,
logger,
idFactory: () => "00000000-0000-4000-8000-000000000210",
});
const manager = new AgentManager({ clients: { codex: client }, logger });
try {
const created = await manager.createAgent({ provider: "codex", cwd: workdir }, undefined, {
workspaceId: "workspace-idle-collection",
});
await manager.appendTimelineItem(created.id, {
type: "user_message",
text: "Keep this timeline",
});
activeSession?.pushEvent({
type: "provider_subagent",
provider: "codex",
event: {
type: "upsert",
id: "retained-provider-child",
title: "Retained provider child",
status: "completed",
},
});
await manager.flush();
const timelineBeforeCollection = manager.getTimeline(created.id);
const collection = await manager.collectIdleAgents({
cutoff: new Date(Date.now() + 1_000),
protectedAgentIds: new Set(),
const agent = await manager.createAgent({ provider: "codex", cwd: workdir }, undefined, {
workspaceId: undefined,
});
expect(collection).toEqual({
collected: [
{
agentId: created.id,
provider: "codex",
sessionId: created.persistence?.sessionId,
},
],
failures: [],
});
expect(manager.getAgent(created.id)).toBeNull();
expect(client.archivedHandles).toEqual([]);
const stored = await storage.get(created.id);
expect(stored).toMatchObject({
id: created.id,
lastStatus: "closed",
workspaceId: "workspace-idle-collection",
});
expect(stored?.archivedAt).toBeFalsy();
await new Promise((resolve) => setTimeout(resolve, 25));
const resumed = await ensureAgentLoaded(created.id, {
agentManager: manager,
agentStorage: storage,
logger,
});
expect(manager.getAgent(agent.id)?.lifecycle).toBe("idle");
expect(closeCount).toBe(0);
expect(resumed.id).toBe(created.id);
expect(resumed.persistence).toEqual(created.persistence);
expect(manager.getTimeline(created.id)).toEqual(timelineBeforeCollection);
expect(manager.listProviderSubagents(created.id)).toEqual([
expect.objectContaining({
id: "retained-provider-child",
title: "Retained provider child",
status: "completed",
}),
]);
const idleBeforeOpen = resumed.updatedAt;
await ensureAgentLoaded(created.id, {
agentManager: manager,
agentStorage: storage,
logger,
});
await expect(
manager.collectIdleAgents({ cutoff: idleBeforeOpen, protectedAgentIds: new Set() }),
).resolves.toMatchObject({ collected: [] });
await expect(manager.runAgent(created.id, "Continue the same agent")).resolves.toMatchObject({
finalText: "",
canceled: false,
});
expect(manager.getAgent(created.id)?.id).toBe(created.id);
await manager.runAgent(agent.id, "Continue on the resident runtime");
expect(manager.getAgent(agent.id)?.lifecycle).toBe("idle");
expect(resumeCount).toBe(0);
} finally {
await manager.flush().catch(() => undefined);
await storage.flush().catch(() => undefined);
await Promise.all(manager.listAgents().map((agent) => manager.closeAgent(agent.id))).catch(
() => undefined,
);
rmSync(workdir, { recursive: true, force: true });
}
});
test("archiving an idle-collected parent still cascades to its managed children", async () => {
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-collected-parent-archive-"));
test("archiving a closed parent still cascades to its managed children", async () => {
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-closed-parent-archive-"));
const storage = new AgentStorage(join(workdir, "agents"), logger);
const manager = new AgentManager({
clients: { codex: new TestAgentClient() },
@@ -7368,7 +7317,7 @@ test("archiving an idle-collected parent still cascades to its managed children"
try {
const parent = await manager.createAgent(
{ provider: "codex", cwd: workdir, title: "Collected parent" },
{ provider: "codex", cwd: workdir, title: "Closed parent" },
undefined,
{ workspaceId: undefined },
);
@@ -7381,10 +7330,7 @@ test("archiving an idle-collected parent still cascades to its managed children"
},
);
await manager.collectIdleAgents({
cutoff: new Date(Date.now() + 1_000),
protectedAgentIds: new Set([child.id]),
});
await manager.closeAgent(parent.id);
await manager.archiveSnapshot(parent.id, new Date().toISOString());
expect((await storage.get(parent.id))?.archivedAt).toEqual(expect.any(String));
@@ -7410,10 +7356,7 @@ test("ensureUnarchivedAgentLoaded does not resume an archived agent", async () =
const agent = await manager.createAgent({ provider: "codex", cwd: workdir }, undefined, {
workspaceId: undefined,
});
await manager.collectIdleAgents({
cutoff: new Date(Date.now() + 1_000),
protectedAgentIds: new Set(),
});
await manager.closeAgent(agent.id);
await manager.archiveSnapshot(agent.id, new Date().toISOString());
await expect(
@@ -7452,10 +7395,7 @@ test("ensureUnarchivedAgentLoaded closes a runtime archived while it resumes", a
const agent = await manager.createAgent({ provider: "codex", cwd: workdir }, undefined, {
workspaceId: undefined,
});
await manager.collectIdleAgents({
cutoff: new Date(Date.now() + 1_000),
protectedAgentIds: new Set(),
});
await manager.closeAgent(agent.id);
const load = ensureUnarchivedAgentLoaded(agent.id, {
agentManager: manager,
@@ -7498,10 +7438,7 @@ test("ensureUnarchivedAgentLoaded fences an archived agent after joining a share
const agent = await manager.createAgent({ provider: "codex", cwd: workdir }, undefined, {
workspaceId: undefined,
});
await manager.collectIdleAgents({
cutoff: new Date(Date.now() + 1_000),
protectedAgentIds: new Set(),
});
await manager.closeAgent(agent.id);
const sharedLoad = ensureAgentLoaded(agent.id, {
agentManager: manager,
@@ -7557,10 +7494,7 @@ test("a shared agent load upgrades provider history hydration to broadcast", asy
const agent = await manager.createAgent({ provider: "codex", cwd: workdir }, undefined, {
workspaceId: undefined,
});
await manager.collectIdleAgents({
cutoff: new Date(Date.now() + 1_000),
protectedAgentIds: new Set(),
});
await manager.closeAgent(agent.id);
await manager.deleteAgentState(agent.id);
const events: AgentManagerEvent[] = [];
manager.subscribe((event) => events.push(event), { agentId: agent.id, replayState: false });
@@ -7598,166 +7532,7 @@ test("a shared agent load upgrades provider history hydration to broadcast", asy
}
});
test("collectIdleAgents leaves recent, protected, internal, running, and error agents resident", async () => {
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-idle-eligibility-"));
const client = new SessionRecordingAgentClient();
const ids = [
"00000000-0000-4000-8000-000000000211",
"00000000-0000-4000-8000-000000000212",
"00000000-0000-4000-8000-000000000213",
"00000000-0000-4000-8000-000000000214",
"00000000-0000-4000-8000-000000000215",
];
const manager = new AgentManager({
clients: { codex: client },
logger,
idFactory: () => ids.shift()!,
});
try {
const recent = await manager.createAgent({ provider: "codex", cwd: workdir }, undefined, {
workspaceId: undefined,
});
const protectedAgent = await manager.createAgent(
{ provider: "codex", cwd: workdir },
undefined,
{ workspaceId: undefined },
);
const internal = await manager.createAgent(
{ provider: "codex", cwd: workdir, internal: true },
undefined,
{ workspaceId: undefined },
);
const running = await manager.createAgent({ provider: "codex", cwd: workdir }, undefined, {
workspaceId: undefined,
});
const failed = await manager.createAgent({ provider: "codex", cwd: workdir }, undefined, {
workspaceId: undefined,
});
client.sessions[3]!.pushEvent({
type: "turn_started",
provider: "codex",
turnId: "autonomous-running",
});
client.sessions[4]!.pushEvent({
type: "turn_failed",
provider: "codex",
turnId: "autonomous-failed",
error: "provider failed",
});
await manager.flush();
const recentSweep = await manager.collectIdleAgents({
cutoff: new Date(recent.updatedAt.getTime() - 1),
protectedAgentIds: new Set(),
});
const protectedSweep = await manager.collectIdleAgents({
cutoff: new Date(Date.now() + 1_000),
protectedAgentIds: new Set([protectedAgent.id, recent.id]),
});
expect(recentSweep.collected).toEqual([]);
expect(protectedSweep.collected).toEqual([]);
expect(manager.getAgent(recent.id)?.lifecycle).toBe("idle");
expect(manager.getAgent(protectedAgent.id)?.lifecycle).toBe("idle");
expect(manager.getAgent(internal.id)?.lifecycle).toBe("idle");
expect(manager.getAgent(running.id)?.lifecycle).toBe("running");
expect(manager.getAgent(failed.id)?.lifecycle).toBe("error");
} finally {
await Promise.all(manager.listAgents().map((agent) => manager.closeAgent(agent.id))).catch(
() => undefined,
);
rmSync(workdir, { recursive: true, force: true });
}
});
test("collectIdleAgents protects an idle parent with a running managed child", async () => {
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-running-child-"));
const client = new SessionRecordingAgentClient();
const manager = new AgentManager({ clients: { codex: client }, logger });
try {
const parent = await manager.createAgent({ provider: "codex", cwd: workdir }, undefined, {
workspaceId: undefined,
});
const child = await manager.createAgent({ provider: "codex", cwd: workdir }, undefined, {
labels: { [PARENT_AGENT_ID_LABEL]: parent.id },
workspaceId: undefined,
});
const independent = await manager.createAgent({ provider: "codex", cwd: workdir }, undefined, {
workspaceId: undefined,
});
client.sessions[1]!.pushEvent({
type: "turn_started",
provider: "codex",
turnId: "managed-child-running",
});
await manager.flush();
const collection = await manager.collectIdleAgents({
cutoff: new Date(Date.now() + 1_000),
protectedAgentIds: new Set(),
});
expect(collection).toEqual({
collected: [expect.objectContaining({ agentId: independent.id })],
failures: [],
});
expect(manager.getAgent(parent.id)?.lifecycle).toBe("idle");
expect(manager.getAgent(child.id)?.lifecycle).toBe("running");
expect(manager.getAgent(independent.id)).toBeNull();
} finally {
await Promise.all(manager.listAgents().map((agent) => manager.closeAgent(agent.id))).catch(
() => undefined,
);
rmSync(workdir, { recursive: true, force: true });
}
});
test("collectIdleAgents protects an idle parent with a running provider subagent", async () => {
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-running-provider-child-"));
const client = new SessionRecordingAgentClient();
const manager = new AgentManager({ clients: { codex: client }, logger });
try {
const parent = await manager.createAgent({ provider: "codex", cwd: workdir }, undefined, {
workspaceId: undefined,
});
const independent = await manager.createAgent({ provider: "codex", cwd: workdir }, undefined, {
workspaceId: undefined,
});
client.sessions[0]!.pushEvent({
type: "provider_subagent",
provider: "codex",
event: {
type: "upsert",
id: "provider-child-running",
title: "Provider child",
status: "running",
},
});
await manager.flush();
const collection = await manager.collectIdleAgents({
cutoff: new Date(Date.now() + 1_000),
protectedAgentIds: new Set(),
});
expect(collection).toEqual({
collected: [expect.objectContaining({ agentId: independent.id })],
failures: [],
});
expect(manager.getAgent(parent.id)?.lifecycle).toBe("idle");
expect(manager.getAgent(independent.id)).toBeNull();
} finally {
await Promise.all(manager.listAgents().map((agent) => manager.closeAgent(agent.id))).catch(
() => undefined,
);
rmSync(workdir, { recursive: true, force: true });
}
});
test("closed provider subagents do not block collection after resume", async () => {
test("explicit close cancels running provider subagents before resume", async () => {
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-closed-provider-child-"));
const storage = new AgentStorage(join(workdir, "agents"), logger);
const client = new SessionRecordingAgentClient();
@@ -7811,11 +7586,6 @@ test("closed provider subagents do not block collection after resume", async ()
expect(manager.getProviderSubagent(parent.id, "provider-child-finishing")?.status).toBe(
"completed",
);
const collection = await manager.collectIdleAgents({
cutoff: new Date(Date.now() + 1_000),
protectedAgentIds: new Set(),
});
expect(collection.collected).toEqual([expect.objectContaining({ agentId: parent.id })]);
} finally {
await Promise.all(manager.listAgents().map((agent) => manager.closeAgent(agent.id))).catch(
() => undefined,
@@ -7825,8 +7595,8 @@ test("closed provider subagents do not block collection after resume", async ()
}
});
test("load waits for an in-flight collection close and creates only one resumed runtime", async () => {
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-idle-close-race-"));
test("load waits for an in-flight explicit close and creates one resumed runtime", async () => {
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-explicit-close-race-"));
const storage = new AgentStorage(join(workdir, "agents"), logger);
const closeStarted = deferred<void>();
const closeAllowed = deferred<void>();
@@ -7858,10 +7628,7 @@ test("load waits for an in-flight collection close and creates only one resumed
"00000000-0000-4000-8000-000000000216",
{ workspaceId: undefined },
);
const collection = manager.collectIdleAgents({
cutoff: new Date(Date.now() + 1_000),
protectedAgentIds: new Set(),
});
const close = manager.closeAgent(created.id);
await closeStarted.promise;
const loads = Promise.all([
ensureAgentLoaded(created.id, { agentManager: manager, agentStorage: storage, logger }),
@@ -7871,18 +7638,58 @@ test("load waits for an in-flight collection close and creates only one resumed
expect(client.resumeCount).toBe(0);
closeAllowed.resolve();
const [first, second] = await loads;
await collection;
await close;
expect(first.id).toBe(created.id);
expect(second.id).toBe(created.id);
expect(client.resumeCount).toBe(1);
} finally {
closeAllowed.resolve();
await manager.closeAgent("00000000-0000-4000-8000-000000000216").catch(() => undefined);
await storage.flush().catch(() => undefined);
rmSync(workdir, { recursive: true, force: true });
}
});
test("concurrent explicit closes tear down the runtime once", async () => {
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-concurrent-close-"));
const closeStarted = deferred<void>();
const closeAllowed = deferred<void>();
let closeCount = 0;
const client = new (class extends TestAgentClient {
override async createSession(config: AgentSessionConfig): Promise<AgentSession> {
const recordClose = () => {
closeCount += 1;
};
return new (class extends TestAgentSession {
override async close(): Promise<void> {
recordClose();
closeStarted.resolve();
await closeAllowed.promise;
}
})(config);
}
})();
const manager = new AgentManager({ clients: { codex: client }, logger });
try {
const agent = await manager.createAgent({ provider: "codex", cwd: workdir }, undefined, {
workspaceId: undefined,
});
const firstClose = manager.closeAgent(agent.id);
await closeStarted.promise;
const secondClose = manager.closeAgent(agent.id);
closeAllowed.resolve();
await Promise.all([firstClose, secondClose]);
expect(closeCount).toBe(1);
} finally {
closeAllowed.resolve();
rmSync(workdir, { recursive: true, force: true });
}
});
test("provider close failure still persists and emits a resumable closed agent", async () => {
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-close-failure-"));
const storage = new AgentStorage(join(workdir, "agents"), logger);
@@ -7905,18 +7712,8 @@ test("provider close failure still persists and emits a resumable closed agent",
);
const closed = waitForAgentLifecycle(manager, created.id, "closed");
const collection = await manager.collectIdleAgents({
cutoff: new Date(Date.now() + 1_000),
protectedAgentIds: new Set(),
});
await expect(manager.closeAgent(created.id)).rejects.toThrow("provider cleanup failed");
await closed;
expect(collection.collected).toEqual([]);
expect(collection.failures).toHaveLength(1);
expect(collection.failures[0]).toMatchObject({
agentId: created.id,
provider: "codex",
error: expect.objectContaining({ message: "provider cleanup failed" }),
});
const stored = await storage.get(created.id);
expect(stored).toMatchObject({ lastStatus: "closed" });
expect(stored?.archivedAt).toBeFalsy();

View File

@@ -394,21 +394,6 @@ export interface AgentMetricsSnapshot {
};
}
export interface IdleAgentCollectionEntry {
agentId: string;
provider: AgentProvider;
sessionId?: string;
}
export interface IdleAgentCollectionFailure extends IdleAgentCollectionEntry {
error: unknown;
}
export interface IdleAgentCollectionResult {
collected: IdleAgentCollectionEntry[];
failures: IdleAgentCollectionFailure[];
}
type ActiveManagedAgent =
| ManagedAgentInitializing
| ManagedAgentIdle
@@ -969,15 +954,6 @@ export class AgentManager {
return agent ? { ...agent } : null;
}
touchAgentActivity(id: string): ManagedAgent | null {
const agent = this.agents?.get(id);
if (!agent) {
return null;
}
this.touchUpdatedAt(agent);
return { ...agent };
}
async waitForAgentClose(agentId: string): Promise<void> {
await this.inFlightAgentCloses?.get(agentId)?.catch(() => undefined);
}
@@ -1436,66 +1412,6 @@ export class AgentManager {
}
}
async collectIdleAgents(options: {
cutoff: Date;
protectedAgentIds: ReadonlySet<string>;
}): Promise<IdleAgentCollectionResult> {
const result: IdleAgentCollectionResult = { collected: [], failures: [] };
for (const agent of Array.from(this.agents.values())) {
const current = this.agents.get(agent.id);
if (!current || !this.isIdleAgentCollectable(current, options)) {
continue;
}
const entry: IdleAgentCollectionEntry = {
agentId: current.id,
provider: current.provider,
...(current.persistence?.sessionId ? { sessionId: current.persistence.sessionId } : {}),
};
try {
await this.closeAgent(current.id);
result.collected.push(entry);
} catch (error) {
result.failures.push({ ...entry, error });
}
}
return result;
}
private isIdleAgentCollectable(
agent: LiveManagedAgent,
options: { cutoff: Date; protectedAgentIds: ReadonlySet<string> },
): agent is ManagedAgentIdle {
return (
agent.lifecycle === "idle" &&
agent.updatedAt.getTime() <= options.cutoff.getTime() &&
!agent.internal &&
!options.protectedAgentIds.has(agent.id) &&
agent.activeForegroundTurnId === null &&
!this.runs.hasRun(agent.id) &&
!agent.pendingReplacement &&
agent.pendingPermissions.size === 0 &&
agent.inFlightPermissionResponses.size === 0 &&
!this.hasRunningChild(agent.id)
);
}
private hasRunningChild(parentAgentId: string): boolean {
for (const agent of this.agents.values()) {
if (
agent.lifecycle === "running" &&
getParentAgentIdFromLabels(agent.labels) === parentAgentId
) {
return true;
}
}
return this.providerSubagents
.list(parentAgentId)
.some((subagent) => subagent.status === "running");
}
async archiveAgent(agentId: string): Promise<{ archivedAt: string }> {
const agent = this.requireAgent(agentId);
if (!this.registry) {

View File

@@ -37,19 +37,14 @@ describe("opencode agent commands E2E", () => {
}
}, 60_000);
test("listing commands resumes an idle-collected agent", async () => {
test("listing commands resumes an explicitly closed agent", async () => {
const agent = await ctx.client.createAgent({
...getFullAccessConfig("opencode"),
cwd: "/tmp",
title: "Collected OpenCode Commands Test Agent",
title: "Closed OpenCode Commands Test Agent",
});
const collection = await ctx.daemon.daemon.agentManager.collectIdleAgents({
cutoff: new Date(Date.now() + 1_000),
protectedAgentIds: new Set(),
});
expect(collection.failures).toEqual([]);
expect(collection.collected.map((entry) => entry.agentId)).toContain(agent.id);
await ctx.daemon.daemon.agentManager.closeAgent(agent.id);
expect(ctx.daemon.daemon.agentManager.getAgent(agent.id)).toBeNull();
const result = await ctx.client.listCommands({ agentId: agent.id });

View File

@@ -707,6 +707,62 @@ describe("PiRpcAgentSession", () => {
);
});
test("treats Pi's aborted terminal response as cancellation after an interrupt", async () => {
const { pi, session, events } = await createSession();
const fakeSession = pi.latestSession();
fakeSession.abort = async () => {
fakeSession.finishTurn({
role: "assistant",
provider: "openai-responses",
model: "gpt-5.6-terra",
responseId: "resp-aborted",
stopReason: "aborted",
errorMessage: "OpenAI Responses stream ended before a terminal response event",
content: [],
});
};
const { turnId } = await session.startTurn("stop this turn");
await session.interrupt();
await expect(events.nextTurnCancellation()).resolves.toEqual({
type: "turn_canceled",
provider: "pi",
reason: "interrupted",
turnId,
});
});
test("suppresses late aborted terminal response arriving after interrupt resolves", async () => {
const { pi, session, events } = await createSession();
const fakeSession = pi.latestSession();
fakeSession.abort = async () => {};
const { turnId } = await session.startTurn("stop this turn");
await session.interrupt();
await expect(events.nextTurnCancellation()).resolves.toEqual({
type: "turn_canceled",
provider: "pi",
reason: "interrupted",
turnId,
});
fakeSession.finishTurn({
role: "assistant",
provider: "openai-responses",
model: "gpt-5.6-terra",
responseId: "resp-aborted",
stopReason: "aborted",
errorMessage: "OpenAI Responses stream ended before a terminal response event",
content: [],
});
expect(
(events as unknown as { events: AgentStreamEvent[] }).events.map((e) => e.type),
).not.toContain("turn_failed");
});
test("adds Pi assistant context to generic provider finish errors", async () => {
const { pi, session, events } = await createSession();

View File

@@ -849,6 +849,11 @@ function latestPiErrorMessage(messages: PiAgentMessage[]): string | null {
return formatPiErrorMessage(latestAssistant);
}
function isPiAbortedTerminalResponse(messages: PiAgentMessage[]): boolean {
const latestAssistant = messages.findLast((message) => message.role === "assistant");
return latestAssistant?.stopReason?.toLowerCase() === "aborted";
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
@@ -1241,6 +1246,11 @@ export class PiRpcAgentSession implements AgentSession {
private state: PiSessionState;
private readonly currentModeId: string | null;
private closed = false;
// Pi reports an aborted OpenAI Responses stream before the abort RPC resolves.
// Keep the turn active until that RPC acknowledges the user-requested cancellation.
private interruptingTurnId: string | null = null;
private lastInterruptedTurnId: string | null = null;
private interruptedTerminalError: { turnId: string; error: string } | null = null;
constructor(options: PiRpcAgentSessionOptions) {
this.runtimeSession = options.runtimeSession;
@@ -1290,6 +1300,7 @@ export class PiRpcAgentSession implements AgentSession {
const payload = convertPromptInput(prompt, { model: this.state.model });
const turnId = randomUUID();
this.activeTurnId = turnId;
this.lastInterruptedTurnId = null;
this.activeClientMessageId = options?.clientMessageId ?? null;
this.activeAssistantMessageId = null;
this.activeTurnStarted = false;
@@ -1434,7 +1445,33 @@ export class PiRpcAgentSession implements AgentSession {
async interrupt(): Promise<void> {
const turnId = this.activeTurnId;
await this.runtimeSession.abort();
if (turnId) {
this.interruptingTurnId = turnId;
this.lastInterruptedTurnId = turnId;
}
try {
await this.runtimeSession.abort();
} catch (error) {
if (this.interruptingTurnId === turnId) {
this.interruptingTurnId = null;
}
if (this.interruptedTerminalError?.turnId === turnId) {
const terminalError = this.interruptedTerminalError;
this.interruptedTerminalError = null;
this.activeTurnId = null;
this.activeClientMessageId = null;
this.activeTurnStarted = false;
this.activeAssistantMessageId = null;
this.clearNoTurnBuffers();
this.emit({
type: "turn_failed",
provider: this.provider,
turnId,
error: terminalError.error,
});
}
throw error;
}
if (turnId && this.activeTurnId === turnId) {
this.activeTurnId = null;
this.activeClientMessageId = null;
@@ -1448,6 +1485,12 @@ export class PiRpcAgentSession implements AgentSession {
turnId,
});
}
if (this.interruptingTurnId === turnId) {
this.interruptingTurnId = null;
}
if (this.interruptedTerminalError?.turnId === turnId) {
this.interruptedTerminalError = null;
}
}
async revertConversation(input: { messageId: string }): Promise<void> {
@@ -2246,6 +2289,20 @@ export class PiRpcAgentSession implements AgentSession {
}
private completeTurn(turnId: string | undefined, messages: PiAgentMessage[]): void {
if (turnId && this.interruptingTurnId === turnId && isPiAbortedTerminalResponse(messages)) {
this.interruptedTerminalError = {
turnId,
error: latestPiErrorMessage(messages) ?? "Pi turn failed",
};
return;
}
if (
isPiAbortedTerminalResponse(messages) &&
(turnId === this.lastInterruptedTurnId || (!turnId && this.lastInterruptedTurnId !== null))
) {
this.lastInterruptedTurnId = null;
return;
}
this.activeTurnId = null;
this.activeClientMessageId = null;
this.activeAssistantMessageId = null;

View File

@@ -215,8 +215,6 @@ import { DaemonExecutions } from "./hub/daemon-executions.js";
const MAX_MCP_DEBUG_BATCH_ITEMS = 10;
const REDACTED_LOG_VALUE = "[redacted]";
const IDLE_AGENT_RUNTIME_TTL_MS = 30 * 60 * 1000;
const IDLE_AGENT_RUNTIME_SWEEP_INTERVAL_MS = 60 * 1000;
const DOWNLOAD_OPEN_FLAGS =
process.platform === "win32" ? constants.O_RDONLY : constants.O_RDONLY | constants.O_NOFOLLOW;
@@ -1194,39 +1192,6 @@ export async function createPaseoDaemon(
archiveWorkspace: archiveScheduleWorkspaceExternal,
});
await scheduleService.start();
let inFlightIdleAgentCollection: Promise<void> | null = null;
const collectIdleAgentRuntimes = async () => {
const protectedAgentIds = await scheduleService.listActiveAgentTargetIds();
const cutoff = new Date(Date.now() - IDLE_AGENT_RUNTIME_TTL_MS);
const result = await agentManager.collectIdleAgents({ cutoff, protectedAgentIds });
for (const collected of result.collected) {
logger.info(collected, "Collected idle agent runtime");
}
for (const failure of result.failures) {
const { error, ...context } = failure;
logger.warn({ ...context, err: error }, "Failed to collect idle agent runtime");
}
};
const runIdleAgentCollection = () => {
if (inFlightIdleAgentCollection) {
return;
}
const collection = collectIdleAgentRuntimes()
.catch((error) => {
logger.warn({ err: error }, "Idle agent runtime sweep failed");
})
.finally(() => {
if (inFlightIdleAgentCollection === collection) {
inFlightIdleAgentCollection = null;
}
});
inFlightIdleAgentCollection = collection;
};
const idleAgentCollectionTimer = setInterval(
runIdleAgentCollection,
IDLE_AGENT_RUNTIME_SWEEP_INTERVAL_MS,
);
idleAgentCollectionTimer.unref();
agentManager.setAgentArchivedCallback(async (agentId) => {
try {
await scheduleService.completeForAgent(agentId);
@@ -1634,8 +1599,6 @@ export async function createPaseoDaemon(
await hubRelationships.stop();
workspaceReconciliation.dispose();
scriptHealthMonitor.stop();
clearInterval(idleAgentCollectionTimer);
await inFlightIdleAgentCollection;
// Freeze both ingress and registration before taking the agent closure snapshot.
wsServer?.prepareForShutdown();
agentManager.prepareForShutdown();

View File

@@ -646,7 +646,7 @@ test(
);
test(
"resumed Pi prompts retain their exact native entry ids after idle collection",
"resumed Pi prompts retain their exact native entry ids after explicit runtime close",
async () => {
const cwd = tmpCwd("pi-resumed-entry-id-");
const firstPrompt = "PASEO_PI_ENTRY_ID_FIRST. Reply exactly: first-ok";
@@ -665,12 +665,7 @@ test(
const firstFinish = await client.waitForFinish(agent.id, PI_TEST_TIMEOUT_MS);
expect(firstFinish.status).toBe("idle");
const collection = await daemon.daemon.agentManager.collectIdleAgents({
cutoff: new Date(Date.now() + 1_000),
protectedAgentIds: new Set(),
});
expect(collection.failures).toEqual([]);
expect(collection.collected.map((entry) => entry.agentId)).toContain(agent.id);
await daemon.daemon.agentManager.closeAgent(agent.id);
await client.sendMessage(agent.id, secondPrompt);
const secondFinish = await client.waitForFinish(agent.id, PI_TEST_TIMEOUT_MS);

View File

@@ -376,47 +376,6 @@ describe("ScheduleService", () => {
expect(resumed.nextRunAt).toBe("2026-01-01T00:04:00.000Z");
});
test("lists only active schedules that target existing agents", async () => {
const service = createScheduleService({
paseoHome: tempDir,
logger: createTestLogger(),
agentManager: new AgentManager({ logger: createTestLogger() }),
agentStorage,
providerSnapshotManager: NO_UNATTENDED_SCHEDULE_POLICY,
now: () => now,
runner: async () => ({ agentId: null, output: "ok" }),
});
const activeAgentId = "00000000-0000-4000-8000-000000000201";
const pausedAgentId = "00000000-0000-4000-8000-000000000202";
const completedAgentId = "00000000-0000-4000-8000-000000000203";
const cadence = { type: "every" as const, everyMs: 60_000 };
await service.create({
prompt: "Keep active agent resident",
cadence,
target: { type: "agent", agentId: activeAgentId },
});
const paused = await service.create({
prompt: "Paused heartbeat",
cadence,
target: { type: "agent", agentId: pausedAgentId },
});
await service.pause(paused.id);
await service.create({
prompt: "Completed heartbeat",
cadence,
target: { type: "agent", agentId: completedAgentId },
});
await service.completeForAgent(completedAgentId);
await service.create({
prompt: "Fresh agent each run",
cadence,
target: { type: "new-agent", config: { provider: "claude", cwd: tempDir } },
});
await expect(service.listActiveAgentTargetIds()).resolves.toEqual(new Set([activeAgentId]));
});
test("completes schedules when max runs is reached", async () => {
const service = createScheduleService({
paseoHome: tempDir,

View File

@@ -204,7 +204,6 @@ type ScheduleAgentManager = Pick<
| "hydrateTimelineFromProvider"
| "resumeAgentFromPersistence"
| "runAgent"
| "touchAgentActivity"
| "waitForAgentEvent"
| "waitForAgentClose"
>;
@@ -370,17 +369,6 @@ export class ScheduleService {
return this.store.list();
}
async listActiveAgentTargetIds(): Promise<Set<string>> {
const schedules = await this.store.list();
const agentIds = new Set<string>();
for (const schedule of schedules) {
if (schedule.status === "active" && schedule.target.type === "agent") {
agentIds.add(schedule.target.agentId);
}
}
return agentIds;
}
async inspect(id: string): Promise<StoredSchedule> {
const schedule = await this.store.get(id);
if (!schedule) {

View File

@@ -4906,7 +4906,7 @@ describe("agent config setters", () => {
} {
return {
waitForAgentClose: vi.fn().mockResolvedValue(undefined),
touchAgentActivity: vi.fn(() => ({ id: "agent-1" })),
getAgent: vi.fn(() => ({ id: "agent-1" })),
...overrides,
};
}

View File

@@ -117,7 +117,7 @@ describe("AgentConfigSession", () => {
});
});
test("set mode: a failed load rejects without mutating the collected agent", async () => {
test("set mode: a failed load rejects without mutating the closed agent", async () => {
const { subsystem, emitted, operations } = makeSubsystem();
operations.loadFailure = new Error("agent is archived");

View File

@@ -19,7 +19,7 @@ export interface AgentConfigSessionHost {
/**
* The per-agent config mutations this subsystem drives. The shell adapts these
* onto the AgentManager and loads a collected agent before mutation (mode still
* onto the AgentManager and loads a closed agent before mutation (mode still
* routes through setAgentModeCommand); tests wire an in-memory fake. Mode and
* thinking yield a provider notice; model and feature do not.
*/