mirror of
https://github.com/getpaseo/paseo.git
synced 2026-07-29 12:01:31 +00:00
Compare commits
8 Commits
desktop-ex
...
integratio
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
aed3eb490e | ||
|
|
58f8737a01 | ||
|
|
ceb06116f2 | ||
|
|
44236ea474 | ||
|
|
24934b4bbe | ||
|
|
acb8506d1f | ||
|
|
ab24070075 | ||
|
|
fab975a059 |
@@ -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.
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
274
packages/app/e2e/agent-consecutive-turns.spec.ts
Normal file
274
packages/app/e2e/agent-consecutive-turns.spec.ts
Normal 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();
|
||||
}
|
||||
});
|
||||
@@ -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: ",
|
||||
});
|
||||
} 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 ({
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
));
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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"]);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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],
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -296,7 +296,7 @@ export interface ExplorerFile {
|
||||
modifiedAt: string;
|
||||
}
|
||||
|
||||
export interface ExplorerDirectory {
|
||||
interface ExplorerDirectory {
|
||||
path: string;
|
||||
entries: ExplorerEntry[];
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
|
||||
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
Reference in New Issue
Block a user