mirror of
https://github.com/getpaseo/paseo.git
synced 2026-11-07 17:04:42 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
acc5838173 | ||
|
|
9131505810 | ||
|
|
c6f0c24bf6 | ||
|
|
ef7de3d066 | ||
|
|
b7b38412a0 | ||
|
|
a48bc3aa6f | ||
|
|
28c01ea1ce |
@@ -15,7 +15,7 @@ supervisor (paseo.pid) ──► daemon worker ──► launcher ──► term
|
||||
- The worker exits on its own when it has no controller and no terminals, on `shutdown`, on SIGTERM (killing its terminals), and when its record disappears (a deleted `$PASEO_HOME`: nobody can ever reach it again).
|
||||
- Its stderr goes to `$PASEO_HOME/terminal-worker.log`. No pipe to the daemon survives the daemon.
|
||||
|
||||
The worker retains a fixed 10,000 history rows plus the screen in its existing xterm. Replay uses `@xterm/addon-serialize` 0.14 against headless xterm 6; no second cell grid or lifetime byte log is retained. The serializer boundary owns cursor position, visibility and explicit program-selected presentation; the addon alone does not preserve all three. An unset program style leaves each client’s configured cursor defaults intact. Standard modes come from the serializer; the existing input-mode tracker retains protocol modes such as Kitty and Win32 input. Commit that tracker with parsed output so replay bytes and metadata describe the same cut.
|
||||
The worker retains a fixed 10,000 history rows plus the screen in its existing xterm. Replay uses `@xterm/addon-serialize` 0.14 against headless xterm 6; no second cell grid or lifetime byte log is retained. The serializer boundary owns cursor position, visibility and explicit program-selected presentation; the addon alone does not preserve all three. An unset program style leaves each client’s configured cursor defaults intact. It also restores SGR mouse encoding: the addon preserves mouse tracking but omits its encoding, so switching away and back otherwise sends legacy wheel reports that Copilot CLI cannot use. These missing modes require xterm's internal services because its public modes API omits them. Standard modes come from the serializer; the existing input-mode tracker retains protocol modes such as Kitty and Win32 input. Commit that tracker with parsed output so replay bytes and metadata describe the same cut.
|
||||
|
||||
Which triggers keep terminals and which close them is owned by the stop reason (`keepTerminals`) that flows from the request through the supervisor to `stop()`. The kill side always ends in the reaper (`terminal-worker-reaper.ts`), the only path that signals terminal processes outside a connected daemon.
|
||||
|
||||
|
||||
@@ -69,8 +69,8 @@ Source coverage still includes interleaved tool updates.
|
||||
|
||||
Canonical IDs define runs. With missing IDs, human prompts, heartbeats and typed received
|
||||
agent messages each start a fallback turn. This deterministic fallback is shared by paging
|
||||
and presentation; it cannot recover historical batching or steering. Older history remains
|
||||
user-driven by scrolling upward.
|
||||
and presentation; it cannot recover historical batching or steering. An underfilled viewport
|
||||
loads older history until it fills or history is exhausted. Further history is user-driven.
|
||||
|
||||
A failed catch-up or subscription reconcile retries on its own, doubling from 1s to a 30s ceiling.
|
||||
A fixed 1s retry turned a persistent daemon-side refusal — a Codex thread that already has an active
|
||||
|
||||
@@ -198,3 +198,81 @@ test.describe("Turn end", () => {
|
||||
expectSteadyTurnEnd(await recordTurnEnd(page, agent, input), input);
|
||||
});
|
||||
});
|
||||
|
||||
async function expectTimingInsideFooter(page: Page): Promise<void> {
|
||||
const label = page.getByRole("button", { name: /^Worked for/ }).last();
|
||||
const paintedLines = await label.getByTestId("turn-footer-timing-label").evaluate((element) => {
|
||||
const labelBounds = element.parentElement!.getBoundingClientRect();
|
||||
const rowBounds = element.closest('[data-testid="turn-footer"]')!.getBoundingClientRect();
|
||||
const walker = document.createTreeWalker(element, NodeFilter.SHOW_TEXT);
|
||||
const lines: boolean[] = [];
|
||||
while (walker.nextNode()) {
|
||||
const range = document.createRange();
|
||||
range.selectNodeContents(walker.currentNode);
|
||||
for (const rect of range.getClientRects()) {
|
||||
lines.push(
|
||||
rect.left >= Math.max(rowBounds.left, labelBounds.left) - 0.5 &&
|
||||
rect.right <= Math.min(rowBounds.right, labelBounds.right) + 0.5 &&
|
||||
rect.top >= Math.max(rowBounds.top, labelBounds.top) - 0.5 &&
|
||||
rect.bottom <= Math.min(rowBounds.bottom, labelBounds.bottom) + 0.5,
|
||||
);
|
||||
}
|
||||
}
|
||||
return lines;
|
||||
});
|
||||
expect(paintedLines.length).toBeGreaterThan(0);
|
||||
expect(paintedLines.every(Boolean)).toBe(true);
|
||||
}
|
||||
|
||||
async function expectFooterWithinWidth(page: Page, width: number): Promise<void> {
|
||||
const footer = page.getByTestId("turn-footer").last();
|
||||
await footer.evaluate((element, availableWidth) => {
|
||||
element.style.width = `${availableWidth}px`;
|
||||
}, width);
|
||||
const label = page.getByRole("button", { name: /^Worked for/ }).last();
|
||||
await expect(label).toBeVisible();
|
||||
await expectTimingInsideFooter(page);
|
||||
const controls = footer.getByRole("button", { name: /Copy turn|Fork chat/ });
|
||||
const before = await controls.evaluateAll((elements) =>
|
||||
elements.map((element) => {
|
||||
const rect = element.getBoundingClientRect();
|
||||
return { x: rect.x, y: rect.y };
|
||||
}),
|
||||
);
|
||||
const footerBefore = await footer.boundingBox();
|
||||
const labelBefore = await label.boundingBox();
|
||||
await label.hover({ position: { x: labelBefore!.width - 2, y: labelBefore!.height - 2 } });
|
||||
await expect(label.getByTestId("turn-footer-timing-label")).toContainText(/\d:\d\d/);
|
||||
expect(await label.boundingBox()).toMatchObject({
|
||||
x: labelBefore!.x,
|
||||
width: labelBefore!.width,
|
||||
height: labelBefore!.height,
|
||||
});
|
||||
expect(
|
||||
await controls.evaluateAll((elements) =>
|
||||
elements.map((element) => {
|
||||
const rect = element.getBoundingClientRect();
|
||||
return { x: rect.x, y: rect.y };
|
||||
}),
|
||||
),
|
||||
).toEqual(before);
|
||||
expect(await footer.boundingBox()).toMatchObject({
|
||||
x: footerBefore!.x,
|
||||
y: footerBefore!.y,
|
||||
width: footerBefore!.width,
|
||||
});
|
||||
await expectTimingInsideFooter(page);
|
||||
await page.mouse.move(0, 0);
|
||||
await expect(label.getByTestId("turn-footer-timing-label")).toContainText("Worked for");
|
||||
}
|
||||
|
||||
test("footer timing fits the chat column and swapping labels keeps controls in place", async ({
|
||||
page,
|
||||
agent,
|
||||
}) => {
|
||||
await openAgentRoute(page, agent);
|
||||
await expectFooterWithinWidth(page, 140);
|
||||
await expectFooterWithinWidth(page, 150);
|
||||
await expectFooterWithinWidth(page, 300);
|
||||
await expectFooterWithinWidth(page, 880);
|
||||
});
|
||||
|
||||
@@ -63,6 +63,26 @@ async function rowsBetweenLatestSummaryAndReply(
|
||||
}, replyTestId);
|
||||
}
|
||||
|
||||
/** The latest turn's summary line is a chat row of its own, sitting apart from its prompt. */
|
||||
async function expectSummaryLineSitsApartFromPrompt(page: Page) {
|
||||
const summaryRow = () =>
|
||||
page.evaluate(() => {
|
||||
const latest = (testId: string) =>
|
||||
[...document.querySelectorAll(`[data-testid="${testId}"]`)].at(-1)!;
|
||||
const summary = latest("turn-summary");
|
||||
const row = summary.closest("[data-message-id]")!;
|
||||
return {
|
||||
sharesRowWithPrompt: row.querySelector("[data-testid=user-message]") !== null,
|
||||
gapBelowPrompt: Math.round(
|
||||
summary.getBoundingClientRect().top -
|
||||
latest("user-message").getBoundingClientRect().bottom,
|
||||
),
|
||||
};
|
||||
});
|
||||
await expect.poll(summaryRow).toMatchObject({ sharesRowWithPrompt: false });
|
||||
expect((await summaryRow()).gapBelowPrompt).toBeGreaterThan(0);
|
||||
}
|
||||
|
||||
async function groupedToolsInsideLatestTurn(page: Page): Promise<string[]> {
|
||||
return page.evaluate(() => {
|
||||
const rows = [...document.querySelectorAll("[data-message-id]")];
|
||||
@@ -208,6 +228,7 @@ export default function contribute(client) {
|
||||
/\(end of synthetic stream\)$/,
|
||||
);
|
||||
await expect.poll(() => rowsBetweenLatestSummaryAndReply(page)).toBe(0);
|
||||
await expectSummaryLineSitsApartFromPrompt(page);
|
||||
|
||||
await summary.click();
|
||||
await expect.poll(() => rowsBetweenLatestSummaryAndReply(page)).toBeGreaterThan(0);
|
||||
@@ -470,34 +491,87 @@ async function seedWholeTurnHistory() {
|
||||
return agent;
|
||||
}
|
||||
|
||||
test("loads complete long turns and preserves expansion while revealing older history", async ({
|
||||
page,
|
||||
}) => {
|
||||
async function expectInitialFillIsConcealed(page: Page) {
|
||||
await expect(page.getByTestId("agent-initial-fill")).toBeVisible();
|
||||
await expect(page.getByTestId("agent-chat-scroll")).toHaveCSS("opacity", "0");
|
||||
}
|
||||
|
||||
async function expectFilledChatAtBottom(page: Page) {
|
||||
await expect(page.getByTestId("agent-initial-fill")).toBeHidden();
|
||||
const chat = page.getByTestId("agent-chat-scroll");
|
||||
await expect(chat).toHaveCSS("opacity", "1");
|
||||
await expect
|
||||
.poll(() =>
|
||||
chat.evaluate((element) => ({
|
||||
hasOverflow: element.scrollHeight > element.clientHeight,
|
||||
atBottom: Math.abs(element.scrollHeight - element.clientHeight - element.scrollTop) <= 2,
|
||||
})),
|
||||
)
|
||||
.toEqual({ hasOverflow: true, atBottom: true });
|
||||
}
|
||||
|
||||
test("fills collapsed history before revealing a fresh chat", async ({ page }) => {
|
||||
test.setTimeout(120_000);
|
||||
await page.setViewportSize({ width: 1280, height: 1000 });
|
||||
await useChatDisplay(page, "collapseTurns");
|
||||
const agent = await seedWholeTurnHistory();
|
||||
try {
|
||||
const history = await holdOlderHistoryPages(page, agent);
|
||||
await openAgentRoute(page, agent);
|
||||
await history.expectRequestedPages(1);
|
||||
await expectInitialFillIsConcealed(page);
|
||||
history.releasePage(1);
|
||||
await history.expectRequestedPages(2);
|
||||
await expectInitialFillIsConcealed(page);
|
||||
history.releasePage(2);
|
||||
await expectFilledChatAtBottom(page);
|
||||
await page.setViewportSize({ width: 1280, height: 8000 });
|
||||
await history.expectRequestedPages(3);
|
||||
await expect(page.getByTestId("agent-chat-scroll")).toHaveCSS("opacity", "1");
|
||||
await expect(page.getByTestId("agent-initial-fill")).toBeHidden();
|
||||
history.releasePage(3);
|
||||
await history.expectSettledWithRequestedPages(3);
|
||||
history.expectNoRepeatedEntries();
|
||||
} finally {
|
||||
await agent.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("loads complete long turns and preserves expansion while revealing older history", async ({
|
||||
page,
|
||||
}) => {
|
||||
test.setTimeout(120_000);
|
||||
await page.setViewportSize({ width: 1280, height: 1000 });
|
||||
await useChatDisplay(page, "collapseTurns");
|
||||
const agent = await seedWholeTurnHistory();
|
||||
try {
|
||||
const history = await holdOlderHistoryPages(page, agent);
|
||||
await openAgentRoute(page, agent);
|
||||
await history.expectRequestedPages(1);
|
||||
history.releasePage(1);
|
||||
await history.expectRequestedPages(2);
|
||||
history.releasePage(2);
|
||||
await expectFilledChatAtBottom(page);
|
||||
await expectComposerVisible(page);
|
||||
const latest = page
|
||||
.getByTestId("turn-summary")
|
||||
.getByRole("button", { name: "Ran 120 commands", exact: true });
|
||||
await expect(latest).toBeVisible();
|
||||
await expect.poll(() => rowsBetweenLatestSummaryAndReply(page)).toBe(0);
|
||||
await latest.click();
|
||||
await expect.poll(() => rowsBetweenLatestSummaryAndReply(page)).toBe(120);
|
||||
await userScrollsTimelineToHistoryStart(page);
|
||||
await history.expectRequestedPages(1);
|
||||
history.releasePage(1);
|
||||
await expect(
|
||||
page
|
||||
.getByTestId("turn-summary")
|
||||
.getByRole("button", { name: "Ran 60 commands", exact: true }),
|
||||
).toBeVisible();
|
||||
await expect.poll(() => rowsBetweenLatestSummaryAndReply(page)).toBe(0);
|
||||
await latest.click();
|
||||
await expect.poll(() => rowsBetweenLatestSummaryAndReply(page)).toBe(120);
|
||||
await userScrollsTimelineToHistoryStart(page);
|
||||
await history.expectRequestedPages(3);
|
||||
history.releasePage(3);
|
||||
await page.getByTestId("scroll-to-bottom-button").click();
|
||||
await expect.poll(() => rowsBetweenLatestSummaryAndReply(page)).toBe(120);
|
||||
history.expectNoRepeatedEntries();
|
||||
await latest.scrollIntoViewIfNeeded();
|
||||
await scrollTimelineRowIntoView(page, latest);
|
||||
await latest.click();
|
||||
await expect.poll(() => rowsBetweenLatestSummaryAndReply(page)).toBe(0);
|
||||
} finally {
|
||||
|
||||
@@ -575,8 +575,10 @@ export async function expectFoldedTurnAtReadingPosition(page: Page, prompt: stri
|
||||
const timeline = page.locator('[data-testid="agent-chat-scroll"]:visible').first();
|
||||
const promptRow = timeline.locator("[data-history-row-id]").filter({ hasText: prompt });
|
||||
await expect(promptRow).toBeVisible({ timeout: 15_000 });
|
||||
// The host row carries the folded turn's summary line.
|
||||
await expect(promptRow.getByTestId("turn-summary")).toBeVisible();
|
||||
// The folded turn's summary line is the row under its prompt.
|
||||
const hostId = await promptRow.getAttribute("data-history-row-id");
|
||||
const summaryRow = timeline.locator(`[data-history-row-id="turn-summary:${hostId}"]`);
|
||||
await expect(summaryRow.getByTestId("turn-summary")).toBeVisible();
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
|
||||
@@ -1,197 +1,310 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { nativeHistoryStartGeometry } from "./native-history-start-geometry";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
abandonHistoryStartPaginationRequest,
|
||||
createHistoryStartPaginationState,
|
||||
evaluateHistoryStartPagination,
|
||||
isHistoryStartLoadingOperation,
|
||||
rearmHistoryStartPagination,
|
||||
settleHistoryStartPagination,
|
||||
createHistoryStartPagination,
|
||||
type HistoryStartPaginationInput,
|
||||
} from "./history-start-pagination";
|
||||
|
||||
function createArmedHistoryStartPaginationState() {
|
||||
return rearmHistoryStartPagination(createHistoryStartPaginationState());
|
||||
}
|
||||
|
||||
const visibleHistoryStart: HistoryStartPaginationInput = {
|
||||
const initial: HistoryStartPaginationInput = {
|
||||
distanceFromHistoryStart: 0,
|
||||
isUnderfilled: true,
|
||||
hasOlderHistory: true,
|
||||
isLoadingOlderHistory: false,
|
||||
isReady: true,
|
||||
progressKey: "epoch-1:20",
|
||||
progressKey: "20",
|
||||
};
|
||||
|
||||
function harness(initiallyVisible = false) {
|
||||
let geometry = { ...initial };
|
||||
const requests: Array<(value: boolean) => void> = [];
|
||||
let settles = 0;
|
||||
const pagination = createHistoryStartPagination({
|
||||
initiallyVisible,
|
||||
read: () => geometry,
|
||||
load: () => new Promise<boolean>((resolve) => requests.push(resolve)),
|
||||
changed: () => {},
|
||||
scheduleSettle: () => {
|
||||
settles++;
|
||||
},
|
||||
});
|
||||
return {
|
||||
pagination,
|
||||
requests,
|
||||
get settles() {
|
||||
return settles;
|
||||
},
|
||||
set(patch: Partial<HistoryStartPaginationInput>) {
|
||||
geometry = { ...geometry, ...patch };
|
||||
pagination.update();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe("history start pagination", () => {
|
||||
it("treats locally revealed history as progress before remote pagination", () => {
|
||||
const first = evaluateHistoryStartPagination(
|
||||
createArmedHistoryStartPaginationState(),
|
||||
visibleHistoryStart,
|
||||
);
|
||||
const localReveal = evaluateHistoryStartPagination(first.state, {
|
||||
...visibleHistoryStart,
|
||||
progressKey: "epoch-1:20:local-60",
|
||||
});
|
||||
|
||||
expect(first.shouldLoad).toBe(true);
|
||||
expect(localReveal.state).toEqual({
|
||||
status: "settling",
|
||||
loadedProgressKey: "epoch-1:20:local-60",
|
||||
});
|
||||
it("waits for a delayed request and its committed page before settling", async () => {
|
||||
const h = harness();
|
||||
h.pagination.update();
|
||||
h.set({ progressKey: "10" });
|
||||
expect(h.settles).toBe(0);
|
||||
h.requests[0](true);
|
||||
await Promise.resolve();
|
||||
expect(h.settles).toBe(1);
|
||||
expect(h.pagination.state.visible).toBe(false);
|
||||
h.set({ isUnderfilled: false });
|
||||
h.pagination.settle();
|
||||
expect(h.pagination.state).toEqual({ status: "dormant", visible: true });
|
||||
h.pagination.dispose();
|
||||
});
|
||||
it("waits for user scroll intent before loading", () => {
|
||||
const dormant = createHistoryStartPaginationState();
|
||||
const evaluated = evaluateHistoryStartPagination(dormant, visibleHistoryStart);
|
||||
|
||||
expect(evaluated).toEqual({ state: { status: "dormant" }, shouldLoad: false });
|
||||
it("waits for the committed page after a request completes", async () => {
|
||||
const h = harness();
|
||||
h.pagination.update();
|
||||
h.requests[0](true);
|
||||
await Promise.resolve();
|
||||
expect(h.settles).toBe(0);
|
||||
h.set({ progressKey: "10" });
|
||||
expect(h.settles).toBe(1);
|
||||
h.pagination.dispose();
|
||||
});
|
||||
|
||||
it("waits for anchored page geometry before authorizing another page", () => {
|
||||
const first = evaluateHistoryStartPagination(
|
||||
createArmedHistoryStartPaginationState(),
|
||||
visibleHistoryStart,
|
||||
);
|
||||
const inFlight = evaluateHistoryStartPagination(first.state, {
|
||||
...visibleHistoryStart,
|
||||
isLoadingOlderHistory: true,
|
||||
});
|
||||
const pageApplied = evaluateHistoryStartPagination(inFlight.state, {
|
||||
...visibleHistoryStart,
|
||||
isLoadingOlderHistory: true,
|
||||
progressKey: "epoch-1:10",
|
||||
});
|
||||
|
||||
expect([first.shouldLoad, inFlight.shouldLoad, pageApplied.shouldLoad]).toEqual([
|
||||
true,
|
||||
false,
|
||||
false,
|
||||
]);
|
||||
expect(pageApplied.state).toEqual({ status: "settling", loadedProgressKey: "epoch-1:10" });
|
||||
it("continues short pages and reveals on exhaustion", async () => {
|
||||
const h = harness();
|
||||
h.pagination.update();
|
||||
h.set({ progressKey: "10" });
|
||||
h.requests[0](true);
|
||||
await Promise.resolve();
|
||||
h.pagination.settle();
|
||||
expect(h.requests).toHaveLength(2);
|
||||
h.set({ hasOlderHistory: false });
|
||||
h.requests[1](true);
|
||||
await Promise.resolve();
|
||||
expect(h.pagination.state).toEqual({ status: "dormant", visible: true });
|
||||
h.pagination.dispose();
|
||||
});
|
||||
|
||||
it("loads one page each time anchored geometry leaves and returns to history start", () => {
|
||||
const first = evaluateHistoryStartPagination(
|
||||
createArmedHistoryStartPaginationState(),
|
||||
visibleHistoryStart,
|
||||
);
|
||||
const pageApplied = evaluateHistoryStartPagination(first.state, {
|
||||
...visibleHistoryStart,
|
||||
progressKey: "epoch-1:10",
|
||||
});
|
||||
const settledAway = settleHistoryStartPagination(pageApplied.state, {
|
||||
...visibleHistoryStart,
|
||||
distanceFromHistoryStart: 300,
|
||||
progressKey: "epoch-1:10",
|
||||
});
|
||||
const returned = evaluateHistoryStartPagination(settledAway.state, {
|
||||
...visibleHistoryStart,
|
||||
progressKey: "epoch-1:10",
|
||||
});
|
||||
|
||||
expect([
|
||||
first.shouldLoad,
|
||||
pageApplied.shouldLoad,
|
||||
settledAway.shouldLoad,
|
||||
returned.shouldLoad,
|
||||
]).toEqual([true, false, false, true]);
|
||||
it("releases a failed or stalled request and allows retry", async () => {
|
||||
vi.useFakeTimers();
|
||||
const h = harness();
|
||||
h.pagination.update();
|
||||
vi.advanceTimersByTime(15_000);
|
||||
expect(h.pagination.state).toEqual({ status: "failed", visible: true });
|
||||
h.pagination.rearm();
|
||||
expect(h.requests).toHaveLength(2);
|
||||
h.requests[1](false);
|
||||
await Promise.resolve();
|
||||
expect(h.pagination.state.status).toBe("failed");
|
||||
h.pagination.dispose();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("continues the same loading operation when anchored geometry remains at history start", () => {
|
||||
const first = evaluateHistoryStartPagination(
|
||||
createArmedHistoryStartPaginationState(),
|
||||
visibleHistoryStart,
|
||||
);
|
||||
const pageApplied = evaluateHistoryStartPagination(first.state, {
|
||||
...visibleHistoryStart,
|
||||
progressKey: "epoch-1:10",
|
||||
});
|
||||
const continued = settleHistoryStartPagination(pageApplied.state, {
|
||||
...visibleHistoryStart,
|
||||
progressKey: "epoch-1:10",
|
||||
});
|
||||
|
||||
expect(continued.shouldLoad).toBe(true);
|
||||
expect(isHistoryStartLoadingOperation(first.state)).toBe(true);
|
||||
expect(isHistoryStartLoadingOperation(pageApplied.state)).toBe(true);
|
||||
expect(isHistoryStartLoadingOperation(continued.state)).toBe(true);
|
||||
it("keeps visible history visible while filling", () => {
|
||||
const h = harness(true);
|
||||
h.pagination.update();
|
||||
expect(h.pagination.state.visible).toBe(true);
|
||||
h.pagination.dispose();
|
||||
});
|
||||
|
||||
it("latches a request that finishes without cursor progress", () => {
|
||||
const first = evaluateHistoryStartPagination(
|
||||
createArmedHistoryStartPaginationState(),
|
||||
visibleHistoryStart,
|
||||
);
|
||||
const inFlight = evaluateHistoryStartPagination(first.state, {
|
||||
...visibleHistoryStart,
|
||||
isLoadingOlderHistory: true,
|
||||
});
|
||||
const finished = evaluateHistoryStartPagination(inFlight.state, visibleHistoryStart);
|
||||
const duplicate = evaluateHistoryStartPagination(finished.state, visibleHistoryStart);
|
||||
const away = evaluateHistoryStartPagination(duplicate.state, {
|
||||
...visibleHistoryStart,
|
||||
distanceFromHistoryStart: 300,
|
||||
});
|
||||
|
||||
expect(finished.state).toEqual({ status: "latched" });
|
||||
expect([finished.shouldLoad, duplicate.shouldLoad, away.shouldLoad]).toEqual([
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
]);
|
||||
expect(away.state).toEqual({ status: "ready" });
|
||||
it("does not load a full viewport until user intent and respects inactive views", () => {
|
||||
const h = harness();
|
||||
h.set({ isUnderfilled: false });
|
||||
expect(h.requests).toHaveLength(0);
|
||||
h.set({ isReady: false });
|
||||
h.pagination.rearm();
|
||||
expect(h.requests).toHaveLength(0);
|
||||
h.set({ isReady: true });
|
||||
expect(h.requests).toHaveLength(1);
|
||||
h.pagination.dispose();
|
||||
});
|
||||
|
||||
it("allows another user attempt after a request finishes without progress", () => {
|
||||
const first = evaluateHistoryStartPagination(
|
||||
createArmedHistoryStartPaginationState(),
|
||||
visibleHistoryStart,
|
||||
);
|
||||
const inFlight = evaluateHistoryStartPagination(first.state, {
|
||||
...visibleHistoryStart,
|
||||
isLoadingOlderHistory: true,
|
||||
});
|
||||
const failed = evaluateHistoryStartPagination(inFlight.state, visibleHistoryStart);
|
||||
const retried = evaluateHistoryStartPagination(
|
||||
rearmHistoryStartPagination(failed.state),
|
||||
visibleHistoryStart,
|
||||
);
|
||||
|
||||
expect(failed.state).toEqual({ status: "latched" });
|
||||
expect(retried.shouldLoad).toBe(true);
|
||||
});
|
||||
|
||||
it("latches an attempt that becomes invalid before entering flight", () => {
|
||||
const requested = evaluateHistoryStartPagination(
|
||||
createArmedHistoryStartPaginationState(),
|
||||
visibleHistoryStart,
|
||||
);
|
||||
|
||||
expect(abandonHistoryStartPaginationRequest(requested.state, "epoch-1:20")).toEqual({
|
||||
status: "latched",
|
||||
});
|
||||
});
|
||||
|
||||
it("does not mistake repeated edge observations for a finished request", () => {
|
||||
const first = evaluateHistoryStartPagination(
|
||||
createArmedHistoryStartPaginationState(),
|
||||
visibleHistoryStart,
|
||||
);
|
||||
const repeated = evaluateHistoryStartPagination(first.state, visibleHistoryStart);
|
||||
|
||||
expect(repeated.state).toBe(first.state);
|
||||
expect(repeated.shouldLoad).toBe(false);
|
||||
});
|
||||
|
||||
it("waits while history loading is unavailable or already active", () => {
|
||||
const state = createArmedHistoryStartPaginationState();
|
||||
|
||||
expect([
|
||||
evaluateHistoryStartPagination(state, { ...visibleHistoryStart, isReady: false }).shouldLoad,
|
||||
evaluateHistoryStartPagination(state, { ...visibleHistoryStart, hasOlderHistory: false })
|
||||
.shouldLoad,
|
||||
evaluateHistoryStartPagination(state, { ...visibleHistoryStart, isLoadingOlderHistory: true })
|
||||
.shouldLoad,
|
||||
evaluateHistoryStartPagination(state, { ...visibleHistoryStart, progressKey: null })
|
||||
.shouldLoad,
|
||||
]).toEqual([false, false, false, false]);
|
||||
it("preserves the reading position after an anchored page moves away from the start", async () => {
|
||||
const h = harness(true);
|
||||
h.set({ isUnderfilled: false });
|
||||
h.pagination.rearm();
|
||||
h.set({ progressKey: "10", distanceFromHistoryStart: 300 });
|
||||
h.requests[0](true);
|
||||
await Promise.resolve();
|
||||
h.pagination.settle();
|
||||
expect(h.requests).toHaveLength(1);
|
||||
h.set({ distanceFromHistoryStart: 0 });
|
||||
expect(h.requests).toHaveLength(2);
|
||||
h.pagination.dispose();
|
||||
});
|
||||
});
|
||||
|
||||
it("ignores a timed-out response after the reader retries", async () => {
|
||||
vi.useFakeTimers();
|
||||
const h = harness();
|
||||
h.pagination.update();
|
||||
vi.advanceTimersByTime(15_000);
|
||||
h.pagination.rearm();
|
||||
h.requests[0](true);
|
||||
await Promise.resolve();
|
||||
expect(h.pagination.state.status).toBe("loading");
|
||||
expect(h.settles).toBe(0);
|
||||
h.requests[1](false);
|
||||
await Promise.resolve();
|
||||
expect(h.pagination.state.status).toBe("failed");
|
||||
h.pagination.dispose();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("does not repeat a successful page that made no progress", async () => {
|
||||
vi.useFakeTimers();
|
||||
const h = harness();
|
||||
h.pagination.update();
|
||||
h.requests[0](true);
|
||||
await Promise.resolve();
|
||||
h.pagination.update();
|
||||
expect(h.requests).toHaveLength(1);
|
||||
vi.advanceTimersByTime(15_000);
|
||||
expect(h.pagination.state).toEqual({ status: "failed", visible: true });
|
||||
h.pagination.dispose();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("waits until the cursor and active history are available", () => {
|
||||
const h = harness();
|
||||
h.set({ isReady: false, progressKey: null });
|
||||
h.set({ isReady: true, isLoadingOlderHistory: true });
|
||||
h.set({ isLoadingOlderHistory: false });
|
||||
expect(h.requests).toHaveLength(0);
|
||||
h.set({ progressKey: "20" });
|
||||
expect(h.requests).toHaveLength(1);
|
||||
h.pagination.dispose();
|
||||
});
|
||||
|
||||
it("reveals immediately on exhausted history without offering retry", () => {
|
||||
const h = harness();
|
||||
h.set({ hasOlderHistory: false });
|
||||
expect(h.requests).toHaveLength(0);
|
||||
expect(h.pagination.state).toEqual({ status: "dormant", visible: true });
|
||||
h.pagination.dispose();
|
||||
});
|
||||
|
||||
it("keeps a settled page pending while its view is inactive", async () => {
|
||||
const h = harness();
|
||||
h.pagination.update();
|
||||
h.set({ progressKey: "10" });
|
||||
h.requests[0](true);
|
||||
await Promise.resolve();
|
||||
h.set({ isReady: false });
|
||||
h.pagination.settle();
|
||||
expect(h.pagination.state.status).toBe("settling");
|
||||
expect(h.requests).toHaveLength(1);
|
||||
h.set({ isReady: true, isUnderfilled: false });
|
||||
h.pagination.settle();
|
||||
expect(h.pagination.state).toEqual({ status: "dormant", visible: true });
|
||||
h.pagination.dispose();
|
||||
});
|
||||
|
||||
it("keeps cached history visible after synchronization and subsequent pagination", async () => {
|
||||
const h = harness();
|
||||
h.pagination.reveal();
|
||||
h.pagination.update();
|
||||
h.set({ progressKey: "10" });
|
||||
h.requests[0](true);
|
||||
await Promise.resolve();
|
||||
h.pagination.settle();
|
||||
expect(h.pagination.state.visible).toBe(true);
|
||||
expect(h.requests).toHaveLength(2);
|
||||
h.pagination.dispose();
|
||||
});
|
||||
|
||||
it.each([true, false])(
|
||||
"handles native padding when short history has older=%s",
|
||||
(hasOlderHistory) => {
|
||||
const geometry = nativeHistoryStartGeometry({
|
||||
contentHeight: 825,
|
||||
viewportHeight: 700,
|
||||
offsetY: 125,
|
||||
topOverlayInset: 123,
|
||||
});
|
||||
let requests = 0;
|
||||
const pagination = createHistoryStartPagination({
|
||||
initiallyVisible: false,
|
||||
read: () => ({ ...initial, ...geometry, hasOlderHistory }),
|
||||
load: () => {
|
||||
requests++;
|
||||
return new Promise<boolean>(() => {});
|
||||
},
|
||||
changed: () => {},
|
||||
scheduleSettle: () => {},
|
||||
});
|
||||
pagination.update();
|
||||
expect(requests).toBe(hasOlderHistory ? 1 : 0);
|
||||
expect(pagination.state.visible).toBe(!hasOlderHistory);
|
||||
pagination.dispose();
|
||||
},
|
||||
);
|
||||
|
||||
it("uses the forward native list's top edge to request older history", () => {
|
||||
let offsetY = 1300;
|
||||
let requests = 0;
|
||||
const pagination = createHistoryStartPagination({
|
||||
initiallyVisible: true,
|
||||
read: () => ({
|
||||
...initial,
|
||||
...nativeHistoryStartGeometry({
|
||||
contentHeight: 2000,
|
||||
viewportHeight: 700,
|
||||
offsetY,
|
||||
topOverlayInset: 123,
|
||||
}),
|
||||
}),
|
||||
load: () => {
|
||||
requests++;
|
||||
return new Promise<boolean>(() => {});
|
||||
},
|
||||
changed: () => {},
|
||||
scheduleSettle: () => {},
|
||||
});
|
||||
pagination.rearm();
|
||||
expect(requests).toBe(0);
|
||||
offsetY = 155;
|
||||
pagination.update();
|
||||
expect(requests).toBe(1);
|
||||
pagination.dispose();
|
||||
});
|
||||
|
||||
it.each([null, { ...initial, isReady: false }])(
|
||||
"resumes a completed page after its view stays inactive beyond the deadline (%j)",
|
||||
async (inactive) => {
|
||||
vi.useFakeTimers();
|
||||
let geometry: HistoryStartPaginationInput | null = { ...initial };
|
||||
let complete!: (applied: boolean) => void;
|
||||
let requests = 0;
|
||||
const pagination = createHistoryStartPagination({
|
||||
initiallyVisible: false,
|
||||
read: () => geometry,
|
||||
load: () => {
|
||||
requests++;
|
||||
return new Promise<boolean>((resolve) => {
|
||||
complete = resolve;
|
||||
});
|
||||
},
|
||||
changed: () => {},
|
||||
scheduleSettle: () => {},
|
||||
});
|
||||
try {
|
||||
pagination.update();
|
||||
geometry = inactive;
|
||||
pagination.update();
|
||||
complete(true);
|
||||
await Promise.resolve();
|
||||
vi.advanceTimersByTime(30_000);
|
||||
expect(pagination.state.status).toBe("loading");
|
||||
geometry = { ...initial, progressKey: "10" };
|
||||
pagination.update();
|
||||
expect(pagination.state.status).toBe("settling");
|
||||
geometry = inactive;
|
||||
pagination.update();
|
||||
pagination.settle();
|
||||
vi.advanceTimersByTime(30_000);
|
||||
expect(pagination.state.status).toBe("settling");
|
||||
geometry = { ...initial, progressKey: "10" };
|
||||
pagination.update();
|
||||
pagination.settle();
|
||||
expect(requests).toBe(2);
|
||||
expect(pagination.state.status).toBe("loading");
|
||||
vi.advanceTimersByTime(15_000);
|
||||
expect(pagination.state.status).toBe("failed");
|
||||
} finally {
|
||||
pagination.dispose();
|
||||
vi.useRealTimers();
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
@@ -1,138 +1,149 @@
|
||||
export const HISTORY_START_THRESHOLD_PX = 96;
|
||||
|
||||
export type HistoryStartPaginationState =
|
||||
| { status: "dormant" }
|
||||
| { status: "ready" }
|
||||
| { status: "loading"; requestedProgressKey: string; requestObserved: boolean }
|
||||
| { status: "settling"; loadedProgressKey: string }
|
||||
| { status: "latched" };
|
||||
|
||||
export interface HistoryStartPaginationInput {
|
||||
distanceFromHistoryStart: number;
|
||||
isUnderfilled: boolean;
|
||||
hasOlderHistory: boolean;
|
||||
isLoadingOlderHistory: boolean;
|
||||
isReady: boolean;
|
||||
progressKey: string | null;
|
||||
}
|
||||
|
||||
export interface HistoryStartPaginationTransition {
|
||||
state: HistoryStartPaginationState;
|
||||
shouldLoad: boolean;
|
||||
}
|
||||
|
||||
export function createHistoryStartPaginationState(): HistoryStartPaginationState {
|
||||
return { status: "dormant" };
|
||||
export interface HistoryStartPaginationState {
|
||||
status: "dormant" | "ready" | "loading" | "settling" | "failed";
|
||||
visible: boolean;
|
||||
}
|
||||
|
||||
export function isHistoryStartLoadingOperation(state: HistoryStartPaginationState): boolean {
|
||||
return state.status === "loading" || state.status === "settling";
|
||||
}
|
||||
|
||||
export function rearmHistoryStartPagination(
|
||||
state: HistoryStartPaginationState,
|
||||
): HistoryStartPaginationState {
|
||||
return state.status === "dormant" || state.status === "latched" ? { status: "ready" } : state;
|
||||
}
|
||||
/** Owns one pagination operation, including the initial viewport reveal. */
|
||||
export function createHistoryStartPagination(input: {
|
||||
initiallyVisible: boolean;
|
||||
read: () => HistoryStartPaginationInput | null;
|
||||
load: () => Promise<boolean>;
|
||||
changed: (state: HistoryStartPaginationState) => void;
|
||||
scheduleSettle: () => void;
|
||||
}) {
|
||||
let state: HistoryStartPaginationState = { status: "dormant", visible: input.initiallyVisible };
|
||||
let requestedKey: string | null = null;
|
||||
let completed = false;
|
||||
let fill = false;
|
||||
let generation = 0;
|
||||
let timeout: ReturnType<typeof setTimeout> | undefined;
|
||||
|
||||
export function abandonHistoryStartPaginationRequest(
|
||||
state: HistoryStartPaginationState,
|
||||
requestedProgressKey: string,
|
||||
): HistoryStartPaginationState {
|
||||
if (
|
||||
state.status !== "loading" ||
|
||||
state.requestObserved ||
|
||||
state.requestedProgressKey !== requestedProgressKey
|
||||
) {
|
||||
return state;
|
||||
}
|
||||
return { status: "latched" };
|
||||
}
|
||||
const publish = (status: HistoryStartPaginationState["status"], visible = state.visible) => {
|
||||
if (status === state.status && visible === state.visible) return;
|
||||
state = { status, visible };
|
||||
input.changed(state);
|
||||
};
|
||||
const clear = () => {
|
||||
generation++;
|
||||
clearTimeout(timeout);
|
||||
timeout = undefined;
|
||||
};
|
||||
const finish = (status: HistoryStartPaginationState["status"]) => {
|
||||
clear();
|
||||
publish(status, true);
|
||||
};
|
||||
const fail = () => finish("failed");
|
||||
|
||||
export function evaluateHistoryStartPagination(
|
||||
state: HistoryStartPaginationState,
|
||||
input: HistoryStartPaginationInput,
|
||||
): HistoryStartPaginationTransition {
|
||||
if (state.status === "dormant") {
|
||||
return { state, shouldLoad: false };
|
||||
}
|
||||
if (state.status === "loading") {
|
||||
if (input.progressKey !== null && input.progressKey !== state.requestedProgressKey) {
|
||||
return {
|
||||
state: { status: "settling", loadedProgressKey: input.progressKey },
|
||||
shouldLoad: false,
|
||||
};
|
||||
const watchProgress = (geometry: HistoryStartPaginationInput | null) => {
|
||||
if (completed && !geometry?.isReady) {
|
||||
clearTimeout(timeout);
|
||||
timeout = undefined;
|
||||
} else if (timeout === undefined) {
|
||||
const request = generation;
|
||||
timeout = setTimeout(() => {
|
||||
timeout = undefined;
|
||||
// A completed request waits for its retained view to become measurable again.
|
||||
// Unfinished requests still time out even while the view is inactive.
|
||||
if (request === generation && (!completed || input.read()?.isReady)) fail();
|
||||
}, 15_000);
|
||||
}
|
||||
if (input.isLoadingOlderHistory && !state.requestObserved) {
|
||||
return { state: { ...state, requestObserved: true }, shouldLoad: false };
|
||||
}
|
||||
if (!input.isLoadingOlderHistory && !input.hasOlderHistory) {
|
||||
return { state: { status: "latched" }, shouldLoad: false };
|
||||
}
|
||||
if (
|
||||
state.requestObserved &&
|
||||
!input.isLoadingOlderHistory &&
|
||||
input.progressKey === state.requestedProgressKey
|
||||
) {
|
||||
return { state: { status: "latched" }, shouldLoad: false };
|
||||
}
|
||||
return { state, shouldLoad: false };
|
||||
}
|
||||
};
|
||||
|
||||
if (state.status === "settling") {
|
||||
return { state, shouldLoad: false };
|
||||
}
|
||||
|
||||
const isAtHistoryStart = input.distanceFromHistoryStart <= HISTORY_START_THRESHOLD_PX;
|
||||
if (!isAtHistoryStart) {
|
||||
return state.status === "ready"
|
||||
? { state, shouldLoad: false }
|
||||
: { state: { status: "ready" }, shouldLoad: false };
|
||||
}
|
||||
if (
|
||||
state.status === "latched" ||
|
||||
!input.isReady ||
|
||||
!input.hasOlderHistory ||
|
||||
input.isLoadingOlderHistory ||
|
||||
input.progressKey === null
|
||||
) {
|
||||
return { state, shouldLoad: false };
|
||||
}
|
||||
const update = () => {
|
||||
const geometry = input.read();
|
||||
if (isHistoryStartLoadingOperation(state)) watchProgress(geometry);
|
||||
if (!geometry) return;
|
||||
if (state.status === "loading") {
|
||||
if (!completed) return;
|
||||
if (geometry.progressKey !== null && geometry.progressKey !== requestedKey) {
|
||||
publish("settling");
|
||||
input.scheduleSettle();
|
||||
} else if (!geometry.hasOlderHistory) {
|
||||
finish("dormant");
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (state.status === "settling" || state.status === "failed" || !geometry.isReady) return;
|
||||
const atStart = geometry.distanceFromHistoryStart <= HISTORY_START_THRESHOLD_PX;
|
||||
if (state.status === "dormant" && !geometry.isUnderfilled) {
|
||||
publish("dormant", true);
|
||||
return;
|
||||
}
|
||||
if (!atStart || (!geometry.isUnderfilled && state.status !== "ready")) return;
|
||||
if (!geometry.hasOlderHistory) {
|
||||
finish("dormant");
|
||||
return;
|
||||
}
|
||||
if (geometry.isLoadingOlderHistory || geometry.progressKey === null) return;
|
||||
fill = geometry.isUnderfilled;
|
||||
requestedKey = geometry.progressKey;
|
||||
completed = false;
|
||||
publish("loading");
|
||||
const request = ++generation;
|
||||
watchProgress(geometry);
|
||||
void input.load().then(
|
||||
(applied) => {
|
||||
if (request !== generation) return undefined;
|
||||
if (!applied) {
|
||||
fail();
|
||||
return undefined;
|
||||
}
|
||||
completed = true;
|
||||
update();
|
||||
return undefined;
|
||||
},
|
||||
() => {
|
||||
if (request === generation) fail();
|
||||
},
|
||||
);
|
||||
};
|
||||
return {
|
||||
state: {
|
||||
status: "loading",
|
||||
requestedProgressKey: input.progressKey,
|
||||
requestObserved: false,
|
||||
get state() {
|
||||
return state;
|
||||
},
|
||||
shouldLoad: true,
|
||||
};
|
||||
}
|
||||
|
||||
export function settleHistoryStartPagination(
|
||||
state: HistoryStartPaginationState,
|
||||
input: HistoryStartPaginationInput,
|
||||
): HistoryStartPaginationTransition {
|
||||
if (state.status !== "settling") {
|
||||
return { state, shouldLoad: false };
|
||||
}
|
||||
const isAtHistoryStart = input.distanceFromHistoryStart <= HISTORY_START_THRESHOLD_PX;
|
||||
if (
|
||||
!isAtHistoryStart ||
|
||||
!input.isReady ||
|
||||
!input.hasOlderHistory ||
|
||||
input.isLoadingOlderHistory ||
|
||||
input.progressKey === null
|
||||
) {
|
||||
return {
|
||||
state: isAtHistoryStart ? { status: "latched" } : { status: "ready" },
|
||||
shouldLoad: false,
|
||||
};
|
||||
}
|
||||
return {
|
||||
state: {
|
||||
status: "loading",
|
||||
requestedProgressKey: input.progressKey,
|
||||
requestObserved: false,
|
||||
update,
|
||||
settle() {
|
||||
if (state.status !== "settling") return;
|
||||
const geometry = input.read();
|
||||
if (!geometry?.isReady) return;
|
||||
clear();
|
||||
if (fill && !geometry.isUnderfilled) {
|
||||
publish("dormant", true);
|
||||
} else if (geometry.distanceFromHistoryStart > HISTORY_START_THRESHOLD_PX) {
|
||||
publish("ready", true);
|
||||
} else if (!geometry.hasOlderHistory) {
|
||||
publish("dormant", true);
|
||||
} else {
|
||||
publish("ready");
|
||||
update();
|
||||
}
|
||||
},
|
||||
shouldLoad: true,
|
||||
rearm() {
|
||||
if (state.status === "dormant" || state.status === "failed") publish("ready", true);
|
||||
update();
|
||||
},
|
||||
reveal() {
|
||||
publish(state.status, true);
|
||||
},
|
||||
reset(visible: boolean) {
|
||||
clear();
|
||||
publish("dormant", visible);
|
||||
},
|
||||
dispose: clear,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
export function nativeHistoryStartGeometry(input: {
|
||||
contentHeight: number;
|
||||
viewportHeight: number;
|
||||
offsetY: number;
|
||||
topOverlayInset: number;
|
||||
}) {
|
||||
const contentHeight = input.contentHeight - 32 - input.topOverlayInset;
|
||||
return {
|
||||
distanceFromHistoryStart: Math.max(0, input.offsetY - input.topOverlayInset - 32),
|
||||
isUnderfilled: contentHeight < input.viewportHeight,
|
||||
};
|
||||
}
|
||||
@@ -608,8 +608,11 @@ it("folds work before a plugin-rendered reply and leaves the reply visible", ()
|
||||
display: "collapseTurns",
|
||||
isTurnActive: false,
|
||||
});
|
||||
expect(result.tail.map((item) => item.id)).toEqual(["prompt", "probe-plugin/reply/0"]);
|
||||
expect([...result.turns.turnsByHostId.keys()]).toEqual(["prompt"]);
|
||||
expect(result.tail.map((item) => item.id)).toEqual([
|
||||
"prompt",
|
||||
"turn-summary:prompt",
|
||||
"probe-plugin/reply/0",
|
||||
]);
|
||||
expect(pluginData(result.tail)).toEqual([{ text: "$$x^2$$", phase: "complete" }]);
|
||||
});
|
||||
|
||||
@@ -636,12 +639,14 @@ it("keeps streaming replies native, then folds work without losing manual expans
|
||||
const completed = { ...input, tail: [...work, reply], head: [], isTurnActive: false };
|
||||
expect(present(completed).tail.map((item) => item.id)).toEqual([
|
||||
"prompt",
|
||||
"turn-summary:prompt",
|
||||
"probe-plugin/reply/0",
|
||||
]);
|
||||
const expandedTurnHostIds = new Set(["prompt"]);
|
||||
const opened = present({ ...completed, expandedTurnHostIds });
|
||||
expect(opened.tail.map((item) => item.id)).toEqual([
|
||||
"prompt",
|
||||
"turn-summary:prompt",
|
||||
"tool-one",
|
||||
"probe-plugin/reply/0",
|
||||
]);
|
||||
@@ -649,6 +654,7 @@ it("keeps streaming replies native, then folds work without losing manual expans
|
||||
expect(present({ ...completed, expandedTurnHostIds }).tail).toEqual(opened.tail);
|
||||
expect(present(completed).tail.map((item) => item.id)).toEqual([
|
||||
"prompt",
|
||||
"turn-summary:prompt",
|
||||
"probe-plugin/reply/0",
|
||||
]);
|
||||
});
|
||||
@@ -675,7 +681,7 @@ it.each(["full", "collapseToolCalls"] as const)(
|
||||
"tool-one",
|
||||
"probe-plugin/reply/0",
|
||||
]);
|
||||
expect(result.turns.turnsByHostId.size).toBe(0);
|
||||
expect(result.turns.summaryRowsByHostId.size).toBe(0);
|
||||
},
|
||||
);
|
||||
|
||||
@@ -697,7 +703,13 @@ it("opening a turn reveals individual calls rather than another tool group", ()
|
||||
isTurnActive: false,
|
||||
expandedTurnHostIds: new Set(["prompt"]),
|
||||
});
|
||||
expect(result.tail.map((item) => item.id)).toEqual(tail.map((item) => item.id));
|
||||
expect(result.tail.map((item) => item.id)).toEqual([
|
||||
"prompt",
|
||||
"turn-summary:prompt",
|
||||
"tool-one",
|
||||
"tool-two",
|
||||
"reply",
|
||||
]);
|
||||
expect(result.groupsByHostId.size).toBe(0);
|
||||
expect(result.readingRowReplacements.get("tool-two")).toBeUndefined();
|
||||
});
|
||||
@@ -739,7 +751,7 @@ it.each(["collapseToolCalls", "collapseTurns"] as const)(
|
||||
{ kind: "assistant_message", id: "reply", text: "Done", timestamp },
|
||||
],
|
||||
});
|
||||
// Both the original partial group and its replacement resolve directly to the turn.
|
||||
// Both the original partial group and its replacement resolve directly to the turn's host.
|
||||
expect(folded.readingRowReplacements.get(calls[1]!.id)).toBe("prompt");
|
||||
expect(anchor.getRowId(folded.readingRowReplacements)).toBe("prompt");
|
||||
const opened = present({ ...input, tail: calls, display: "full" });
|
||||
@@ -788,7 +800,7 @@ it("does not reopen the previous turn while a new run waits for its first row",
|
||||
isTurnActive: true,
|
||||
activeTurnId: "new",
|
||||
});
|
||||
expect(result.tail.map((item) => item.id)).toEqual(["prompt", "reply"]);
|
||||
expect(result.tail.map((item) => item.id)).toEqual(["prompt", "turn-summary:prompt", "reply"]);
|
||||
});
|
||||
|
||||
// A sent prompt counts as activity before the daemon opens its turn, so the only known turn is
|
||||
@@ -810,7 +822,7 @@ it("keeps the previous turn folded while a sent prompt waits for its turn to ope
|
||||
isTurnActive: true,
|
||||
activeTurnId: null,
|
||||
});
|
||||
expect(result.tail.map((item) => item.id)).toEqual(["prompt", "reply"]);
|
||||
expect(result.tail.map((item) => item.id)).toEqual(["prompt", "turn-summary:prompt", "reply"]);
|
||||
});
|
||||
|
||||
it("groups reasoning with tools unless Expand thinking is enabled, including setting changes", () => {
|
||||
@@ -867,7 +879,13 @@ it.each([false, true])(
|
||||
const closed = present(input);
|
||||
expect(closed.tail.some((item) => item.id === "thought")).toBe(false);
|
||||
const opened = present({ ...input, expandedTurnHostIds: new Set(["prompt"]) });
|
||||
expect(opened.tail).toEqual(tail);
|
||||
expect(opened.tail.map((item) => item.id)).toEqual([
|
||||
"prompt",
|
||||
"turn-summary:prompt",
|
||||
"thought",
|
||||
"tool-one",
|
||||
"reply",
|
||||
]);
|
||||
expect(opened.turns.nestedItemIds.has("thought")).toBe(true);
|
||||
expect(opened.groupsByHostId.size).toBe(0);
|
||||
},
|
||||
|
||||
@@ -50,7 +50,7 @@ const NO_COLLAPSED_TURNS: StreamPresentation["turns"] = collapseTurns({
|
||||
isLatestTurnOpen: false,
|
||||
expandedHostIds: new Set(),
|
||||
getGroupedCalls: () => undefined,
|
||||
previousTurnsByHostId: new Map(),
|
||||
previousSummaryRowsByHostId: new Map(),
|
||||
});
|
||||
|
||||
/** Source messages reach plugins before display projection. */
|
||||
@@ -135,7 +135,8 @@ export function createStreamPresentation() {
|
||||
isLatestTurnOpen,
|
||||
expandedHostIds: input.expandedTurnHostIds,
|
||||
getGroupedCalls: (itemId) => projection.groupsByHostId.get(itemId)?.run.calls,
|
||||
previousTurnsByHostId: collapsed?.turnsByHostId ?? NO_COLLAPSED_TURNS.turnsByHostId,
|
||||
previousSummaryRowsByHostId:
|
||||
collapsed?.summaryRowsByHostId ?? NO_COLLAPSED_TURNS.summaryRowsByHostId,
|
||||
});
|
||||
collapsedSource = projection.tail;
|
||||
collapsedIsLatestTurnOpen = isLatestTurnOpen;
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { getAssistantMessageSpacing, getGapBetweenStreamItems } from "./spacing";
|
||||
import { summarizeToolCalls } from "@/tool-calls/detail-level/overview/model";
|
||||
import type { StreamItem } from "@/types/stream";
|
||||
const message: StreamItem = {
|
||||
kind: "assistant_message",
|
||||
@@ -7,6 +8,27 @@ const message: StreamItem = {
|
||||
text: "First\n\nSecond",
|
||||
timestamp: new Date(0),
|
||||
};
|
||||
const prompt: StreamItem = {
|
||||
kind: "user_message",
|
||||
id: "p",
|
||||
text: "Fix it",
|
||||
timestamp: new Date(0),
|
||||
};
|
||||
const thought: StreamItem = {
|
||||
kind: "thought",
|
||||
id: "t",
|
||||
text: "Reading",
|
||||
timestamp: new Date(0),
|
||||
status: "ready",
|
||||
};
|
||||
const summary: StreamItem = {
|
||||
kind: "turn_summary",
|
||||
id: "turn-summary:p",
|
||||
hostId: "p",
|
||||
expanded: false,
|
||||
timestamp: new Date(0),
|
||||
tools: summarizeToolCalls([]),
|
||||
};
|
||||
describe("whole-message spacing", () => {
|
||||
it("compacts only the gap to its turn footer", () => {
|
||||
expect(getAssistantMessageSpacing({ item: message })).toBe("default");
|
||||
@@ -17,4 +39,18 @@ describe("whole-message spacing", () => {
|
||||
it("keeps separate messages separated", () => {
|
||||
expect(getGapBetweenStreamItems(message, { ...message, id: "next" })).toBeGreaterThan(0);
|
||||
});
|
||||
it("sets a prompt the same distance above whatever answers it", () => {
|
||||
const belowPrompt = getGapBetweenStreamItems(prompt, thought);
|
||||
expect(belowPrompt).toBeGreaterThan(0);
|
||||
expect(getGapBetweenStreamItems(prompt, message)).toBe(belowPrompt);
|
||||
expect(getGapBetweenStreamItems(prompt, summary)).toBe(belowPrompt);
|
||||
});
|
||||
it("spaces a turn's summary row like the work rows it stands for", () => {
|
||||
expect(getGapBetweenStreamItems(summary, message)).toBe(
|
||||
getGapBetweenStreamItems(thought, message),
|
||||
);
|
||||
expect(getGapBetweenStreamItems(thought, summary)).toBe(
|
||||
getGapBetweenStreamItems(thought, thought),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -11,8 +11,12 @@ export function getAssistantMessageSpacing(params: {
|
||||
}
|
||||
|
||||
const isUserMessageItem = (item?: StreamItem | null) => item?.kind === "user_message";
|
||||
const isToolSequenceItem = (item?: StreamItem | null) =>
|
||||
item?.kind === "tool_call" || item?.kind === "thought" || item?.kind === "todo_list";
|
||||
/** A row of the agent's work, including the summary row that stands in for a folded turn's. */
|
||||
const isWorkItem = (item?: StreamItem | null) =>
|
||||
item?.kind === "tool_call" ||
|
||||
item?.kind === "thought" ||
|
||||
item?.kind === "todo_list" ||
|
||||
item?.kind === "turn_summary";
|
||||
|
||||
export function getGapBetweenStreamItems(
|
||||
item: StreamItem | null,
|
||||
@@ -25,19 +29,16 @@ export function getGapBetweenStreamItems(
|
||||
if (isUserMessageItem(item) && isUserMessageItem(belowItem)) {
|
||||
return SPACING[1];
|
||||
}
|
||||
if (item.kind === "user_message" && belowItem.kind === "assistant_message") {
|
||||
return 0;
|
||||
}
|
||||
if (isToolSequenceItem(item) && isToolSequenceItem(belowItem)) {
|
||||
return 0;
|
||||
}
|
||||
if (item.kind === "user_message" && isToolSequenceItem(belowItem)) {
|
||||
if (isUserMessageItem(item)) {
|
||||
return SPACING[4];
|
||||
}
|
||||
if (item.kind === "assistant_message" && isToolSequenceItem(belowItem)) {
|
||||
if (isWorkItem(item) && isWorkItem(belowItem)) {
|
||||
return 0;
|
||||
}
|
||||
if (item.kind === "assistant_message" && isWorkItem(belowItem)) {
|
||||
return SPACING[1];
|
||||
}
|
||||
if (isToolSequenceItem(item) && belowItem.kind === "assistant_message") {
|
||||
if (isWorkItem(item) && belowItem.kind === "assistant_message") {
|
||||
return SPACING[1];
|
||||
}
|
||||
return SPACING[4];
|
||||
|
||||
@@ -25,6 +25,9 @@ import {
|
||||
} from "@legendapp/list/react-native";
|
||||
import { withUnistyles } from "react-native-unistyles";
|
||||
import { LoadingSpinner } from "@/components/ui/loading-spinner";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useRetainedPanelActive } from "@/components/retained-panel";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { StreamItem } from "@/types/stream";
|
||||
import type { Theme } from "@/styles/theme";
|
||||
import { useStableEvent } from "@/hooks/use-stable-event";
|
||||
@@ -38,15 +41,11 @@ import { createNewRowEstimates } from "./new-row-estimates";
|
||||
import type { StreamRenderInput, StreamStrategy, StreamViewportHandle } from "./strategy";
|
||||
import { createStreamStrategy, isNearBottomForStreamRenderStrategy } from "./strategy";
|
||||
import {
|
||||
abandonHistoryStartPaginationRequest,
|
||||
createHistoryStartPaginationState,
|
||||
evaluateHistoryStartPagination,
|
||||
isHistoryStartLoadingOperation,
|
||||
rearmHistoryStartPagination,
|
||||
settleHistoryStartPagination,
|
||||
type HistoryStartPaginationInput,
|
||||
type HistoryStartPaginationTransition,
|
||||
} from "./history-start-pagination";
|
||||
import { useHistoryStartPagination } from "./use-history-start-pagination";
|
||||
import { nativeHistoryStartGeometry } from "./native-history-start-geometry";
|
||||
import { useStreamTopOverlayInset } from "./top-overlay";
|
||||
import {
|
||||
createHistoryStartSettleScheduler,
|
||||
@@ -108,6 +107,16 @@ function distanceFromEnd(input: {
|
||||
}): number {
|
||||
return Math.max(0, input.contentHeight - input.viewportHeight - input.offsetY);
|
||||
}
|
||||
const viewportStyle: ViewStyle = { flex: 1 };
|
||||
const initialFillStyle: ViewStyle = {
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
};
|
||||
|
||||
function keyExtractor(item: { id: string }): string {
|
||||
return item.id;
|
||||
@@ -125,6 +134,8 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
|
||||
viewportRef,
|
||||
routeBottomAnchorRequest,
|
||||
isAuthoritativeHistoryReady,
|
||||
isInitialHistoryLoading,
|
||||
revealInitialHistory,
|
||||
onNearBottomChange,
|
||||
onNearHistoryStart,
|
||||
isLoadingOlderHistory,
|
||||
@@ -135,6 +146,8 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
|
||||
baseListContentContainerStyle,
|
||||
strategy,
|
||||
} = props;
|
||||
const isActive = useRetainedPanelActive();
|
||||
const { t } = useTranslation();
|
||||
const { renderHistoryMountedRow, renderLiveHeadRow, renderLiveAuxiliary } = renderers;
|
||||
const listRef = useRef<LegendListRef>(null);
|
||||
// LegendList places the rows once, behind a hidden reveal, when the list mounts.
|
||||
@@ -157,10 +170,6 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
|
||||
const isReleasedAtEndRef = useRef(false);
|
||||
const programmaticScrollEventBudgetRef = useRef(0);
|
||||
const historyStartReadyRef = useRef(false);
|
||||
const [historyStartPaginationState, setHistoryStartPaginationState] = useState(
|
||||
createHistoryStartPaginationState,
|
||||
);
|
||||
const historyStartPaginationStateRef = useRef(historyStartPaginationState);
|
||||
const historyStartSettleSchedulerRef = useRef<HistoryStartSettleScheduler | null>(null);
|
||||
|
||||
const historyItems = useMemo(() => {
|
||||
@@ -191,48 +200,36 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
|
||||
metrics.viewportMeasuredForKey === metrics.containerKey &&
|
||||
metrics.contentMeasuredForKey === metrics.containerKey;
|
||||
return {
|
||||
distanceFromHistoryStart: metrics.offsetY,
|
||||
...nativeHistoryStartGeometry({
|
||||
contentHeight: metrics.contentHeight,
|
||||
viewportHeight: metrics.viewportHeight,
|
||||
offsetY: metrics.offsetY,
|
||||
topOverlayInset,
|
||||
}),
|
||||
hasOlderHistory,
|
||||
isLoadingOlderHistory,
|
||||
isReady: historyStartReadyRef.current && hasMeasuredViewport,
|
||||
isReady:
|
||||
isActive &&
|
||||
isAuthoritativeHistoryReady &&
|
||||
historyStartReadyRef.current &&
|
||||
hasMeasuredViewport,
|
||||
progressKey: olderHistoryProgressKey,
|
||||
};
|
||||
});
|
||||
const applyHistoryStartPaginationTransition = useStableEvent(
|
||||
(transition: HistoryStartPaginationTransition) => {
|
||||
const previousState = historyStartPaginationStateRef.current;
|
||||
historyStartPaginationStateRef.current = transition.state;
|
||||
if (transition.state !== previousState) {
|
||||
setHistoryStartPaginationState(transition.state);
|
||||
}
|
||||
if (transition.shouldLoad) {
|
||||
const requestedProgressKey = olderHistoryProgressKey;
|
||||
if (requestedProgressKey === null) {
|
||||
return;
|
||||
}
|
||||
void (async () => {
|
||||
const started = await onNearHistoryStart();
|
||||
if (started === true) {
|
||||
return;
|
||||
}
|
||||
applyHistoryStartPaginationTransition({
|
||||
state: abandonHistoryStartPaginationRequest(
|
||||
historyStartPaginationStateRef.current,
|
||||
requestedProgressKey,
|
||||
),
|
||||
shouldLoad: false,
|
||||
});
|
||||
})();
|
||||
}
|
||||
},
|
||||
const { state: historyStartPaginationState, controller: historyStartPagination } =
|
||||
useHistoryStartPagination({
|
||||
initiallyVisible: !isInitialHistoryLoading,
|
||||
read: getHistoryStartPaginationInput,
|
||||
load: async () => onNearHistoryStart(),
|
||||
scheduleSettle: () => scheduleHistoryStartSettle(),
|
||||
});
|
||||
const evaluateHistoryStart = useStableEvent(() => historyStartPagination.update());
|
||||
const disposeHistoryStart = useStableEvent(() => historyStartPagination.dispose());
|
||||
const isHistoryStartSettling = useStableEvent(
|
||||
() => historyStartPagination.state.status === "settling",
|
||||
);
|
||||
const evaluateHistoryStart = useStableEvent(() => {
|
||||
const transition = evaluateHistoryStartPagination(
|
||||
historyStartPaginationStateRef.current,
|
||||
getHistoryStartPaginationInput(),
|
||||
);
|
||||
applyHistoryStartPaginationTransition(transition);
|
||||
});
|
||||
const finishInitialFill = useStableEvent(() => historyStartPagination.reveal());
|
||||
const retryHistoryStart = useStableEvent(() => historyStartPagination.rearm());
|
||||
const scheduleHistoryStartSettle = useStableEvent(() => {
|
||||
let scheduler = historyStartSettleSchedulerRef.current;
|
||||
if (!scheduler) {
|
||||
@@ -240,15 +237,9 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
|
||||
settleFrames: HISTORY_START_SETTLE_FRAMES,
|
||||
requestFrame: requestAnimationFrame,
|
||||
cancelFrame: cancelAnimationFrame,
|
||||
isSettling: () => historyStartPaginationStateRef.current.status === "settling",
|
||||
isSettling: () => isHistoryStartSettling(),
|
||||
isLoading: () => getHistoryStartPaginationInput().isLoadingOlderHistory,
|
||||
onSettle: () => {
|
||||
const transition = settleHistoryStartPagination(
|
||||
historyStartPaginationStateRef.current,
|
||||
getHistoryStartPaginationInput(),
|
||||
);
|
||||
applyHistoryStartPaginationTransition(transition);
|
||||
},
|
||||
onSettle: () => historyStartPagination.settle(),
|
||||
});
|
||||
historyStartSettleSchedulerRef.current = scheduler;
|
||||
}
|
||||
@@ -390,10 +381,11 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
|
||||
return () => {
|
||||
cancelAnimationFrame(frame);
|
||||
clearPendingUserScrollEnd();
|
||||
disposeHistoryStart();
|
||||
historyStartSettleSchedulerRef.current?.cancel();
|
||||
historyStartSettleSchedulerRef.current = null;
|
||||
};
|
||||
}, [clearPendingUserScrollEnd, evaluateHistoryStart]);
|
||||
}, [clearPendingUserScrollEnd, disposeHistoryStart, evaluateHistoryStart]);
|
||||
|
||||
useEffect(() => {
|
||||
const handle: StreamViewportHandle = {
|
||||
@@ -506,6 +498,7 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
|
||||
});
|
||||
|
||||
const handleScrollBeginDrag = useStableEvent((event: NativeSyntheticEvent<NativeScrollEvent>) => {
|
||||
finishInitialFill();
|
||||
clearPendingUserScrollEnd();
|
||||
isUserScrollActiveRef.current = true;
|
||||
scrollKeyboardDismiss.onScrollBeginDrag(event);
|
||||
@@ -514,14 +507,13 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
|
||||
// no row LegendList may anchor a history page on. The gesture's end decides following.
|
||||
disclosureHold.cancel();
|
||||
bottomAnchorController.beginUserScroll();
|
||||
const rearmed = rearmHistoryStartPagination(historyStartPaginationStateRef.current);
|
||||
if (rearmed !== historyStartPaginationStateRef.current) {
|
||||
historyStartPaginationStateRef.current = rearmed;
|
||||
setHistoryStartPaginationState(rearmed);
|
||||
evaluateHistoryStart();
|
||||
}
|
||||
historyStartPagination.rearm();
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (revealInitialHistory) finishInitialFill();
|
||||
}, [finishInitialFill, revealInitialHistory]);
|
||||
|
||||
// A gesture ends at the end when the finger lifts at or past it, or when its momentum stops there.
|
||||
// A release past the end bounces back to it on iOS, and lines that land during the bounce scroll
|
||||
// in as they do while following. Momentum stopping short of the end, or output that arrived below
|
||||
@@ -598,7 +590,7 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
|
||||
publishNearBottom();
|
||||
}
|
||||
evaluateHistoryStart();
|
||||
if (historyStartPaginationStateRef.current.status === "settling") {
|
||||
if (isHistoryStartSettling()) {
|
||||
scheduleHistoryStartSettle();
|
||||
}
|
||||
});
|
||||
@@ -611,19 +603,23 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
|
||||
contentMeasuredForKey: "native-virtualized",
|
||||
};
|
||||
bottomAnchorController.handleContentSizeChange();
|
||||
evaluateHistoryStart();
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
evaluateHistoryStart();
|
||||
if (historyStartPaginationStateRef.current.status === "settling") {
|
||||
if (isHistoryStartSettling()) {
|
||||
scheduleHistoryStartSettle();
|
||||
}
|
||||
}, [
|
||||
evaluateHistoryStart,
|
||||
hasOlderHistory,
|
||||
isActive,
|
||||
isAuthoritativeHistoryReady,
|
||||
isLoadingOlderHistory,
|
||||
olderHistoryProgressKey,
|
||||
scheduleHistoryStartSettle,
|
||||
isHistoryStartSettling,
|
||||
]);
|
||||
|
||||
// LegendList re-renders a mounted row only when its item or `extraData` changes, not when
|
||||
@@ -658,51 +654,65 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
|
||||
const isLoadingOperation = isHistoryStartLoadingOperation(historyStartPaginationState);
|
||||
return (
|
||||
<View style={historyStartSlotStyle} testID="older-history-slot">
|
||||
{isLoadingOperation ? (
|
||||
{isLoadingOperation && (
|
||||
<View testID="load-older-history-spinner">
|
||||
<ThemedLoadingSpinner size="small" uniProps={foregroundMutedColorMapping} />
|
||||
</View>
|
||||
) : null}
|
||||
)}
|
||||
{!isLoadingOperation &&
|
||||
historyStartPaginationState.status === "failed" &&
|
||||
hasOlderHistory && (
|
||||
<Button variant="ghost" size="sm" onPress={retryHistoryStart}>
|
||||
{t("paneFind.retry")}
|
||||
</Button>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
}, [historyStartPaginationState]);
|
||||
}, [historyStartPaginationState, hasOlderHistory, retryHistoryStart, t]);
|
||||
|
||||
return (
|
||||
<LegendList<StreamItem>
|
||||
ref={listRef}
|
||||
data={rows}
|
||||
renderItem={renderItem}
|
||||
extraData={renderHistoryMountedRow}
|
||||
keyExtractor={keyExtractor}
|
||||
testID="agent-chat-scroll"
|
||||
nativeID="agent-chat-scroll-native-virtualized"
|
||||
ListHeaderComponent={historyStartContent}
|
||||
ListFooterComponent={liveAuxiliaryContent ?? undefined}
|
||||
contentContainerStyle={listContentContainerStyle}
|
||||
automaticallyAdjustContentInsets={false}
|
||||
automaticallyAdjustsScrollIndicatorInsets={false}
|
||||
contentInsetAdjustmentBehavior="never"
|
||||
style={listStyle}
|
||||
onLayout={handleListLayout}
|
||||
onScroll={handleScroll}
|
||||
onScrollBeginDrag={handleScrollBeginDrag}
|
||||
onScrollEndDrag={handleScrollEndDrag}
|
||||
onMomentumScrollBegin={handleMomentumScrollBegin}
|
||||
onMomentumScrollEnd={handleMomentumScrollEnd}
|
||||
scrollEventThrottle={16}
|
||||
onContentSizeChange={handleContentSizeChange}
|
||||
onItemSizeChanged={handleItemSizeChanged}
|
||||
onReady={handleListPlaced}
|
||||
maintainVisibleContentPosition={maintainVisibleContentPosition}
|
||||
renderScrollComponent={renderScrollComponent}
|
||||
recycleItems={false}
|
||||
alignItemsAtEnd
|
||||
initialScrollAtEnd
|
||||
drawDistance={NATIVE_STREAM_DRAW_DISTANCE}
|
||||
getEstimatedItemSize={getEstimatedItemSize}
|
||||
scrollEnabled={scrollEnabled}
|
||||
showsVerticalScrollIndicator
|
||||
/>
|
||||
<View style={viewportStyle}>
|
||||
<LegendList<StreamItem>
|
||||
ref={listRef}
|
||||
data={rows}
|
||||
renderItem={renderItem}
|
||||
extraData={renderHistoryMountedRow}
|
||||
keyExtractor={keyExtractor}
|
||||
testID="agent-chat-scroll"
|
||||
nativeID="agent-chat-scroll-native-virtualized"
|
||||
ListHeaderComponent={historyStartContent}
|
||||
ListFooterComponent={liveAuxiliaryContent ?? undefined}
|
||||
contentContainerStyle={listContentContainerStyle}
|
||||
automaticallyAdjustContentInsets={false}
|
||||
automaticallyAdjustsScrollIndicatorInsets={false}
|
||||
contentInsetAdjustmentBehavior="never"
|
||||
style={[listStyle, { opacity: historyStartPaginationState.visible ? 1 : 0 }]}
|
||||
onLayout={handleListLayout}
|
||||
onScroll={handleScroll}
|
||||
onScrollBeginDrag={handleScrollBeginDrag}
|
||||
onScrollEndDrag={handleScrollEndDrag}
|
||||
onMomentumScrollBegin={handleMomentumScrollBegin}
|
||||
onMomentumScrollEnd={handleMomentumScrollEnd}
|
||||
scrollEventThrottle={16}
|
||||
onContentSizeChange={handleContentSizeChange}
|
||||
onItemSizeChanged={handleItemSizeChanged}
|
||||
onReady={handleListPlaced}
|
||||
maintainVisibleContentPosition={maintainVisibleContentPosition}
|
||||
renderScrollComponent={renderScrollComponent}
|
||||
recycleItems={false}
|
||||
alignItemsAtEnd
|
||||
initialScrollAtEnd
|
||||
drawDistance={NATIVE_STREAM_DRAW_DISTANCE}
|
||||
getEstimatedItemSize={getEstimatedItemSize}
|
||||
scrollEnabled={scrollEnabled}
|
||||
showsVerticalScrollIndicator
|
||||
/>
|
||||
{!historyStartPaginationState.visible ? (
|
||||
<View pointerEvents="none" testID="agent-initial-fill" style={initialFillStyle}>
|
||||
<ThemedLoadingSpinner size="large" uniProps={foregroundMutedColorMapping} />
|
||||
</View>
|
||||
) : null}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -67,7 +67,7 @@ function mountTimeline(historyMounted: StreamItem[]) {
|
||||
routeBottomAnchorRequest: null,
|
||||
isAuthoritativeHistoryReady: true,
|
||||
onNearBottomChange: () => {},
|
||||
onNearHistoryStart: () => true,
|
||||
onNearHistoryStart: async () => true,
|
||||
isLoadingOlderHistory: false,
|
||||
hasOlderHistory: false,
|
||||
olderHistoryProgressKey: null,
|
||||
|
||||
@@ -152,7 +152,7 @@ describe("createWebStreamStrategy", () => {
|
||||
routeBottomAnchorRequest: null,
|
||||
isAuthoritativeHistoryReady: true,
|
||||
onNearBottomChange: () => {},
|
||||
onNearHistoryStart: () => true,
|
||||
onNearHistoryStart: async () => true,
|
||||
isLoadingOlderHistory: false,
|
||||
hasOlderHistory: false,
|
||||
olderHistoryProgressKey: null,
|
||||
@@ -274,7 +274,7 @@ describe("createWebStreamStrategy", () => {
|
||||
routeBottomAnchorRequest: null,
|
||||
isAuthoritativeHistoryReady: true,
|
||||
onNearBottomChange: () => {},
|
||||
onNearHistoryStart: () => true,
|
||||
onNearHistoryStart: async () => true,
|
||||
isLoadingOlderHistory: false,
|
||||
hasOlderHistory: false,
|
||||
olderHistoryProgressKey: null,
|
||||
|
||||
@@ -20,6 +20,7 @@ import { withUnistyles } from "react-native-unistyles";
|
||||
import { useRetainedPanelActive } from "@/components/retained-panel";
|
||||
import { LoadingSpinner } from "@/components/ui/loading-spinner";
|
||||
import { useStableEvent } from "@/hooks/use-stable-event";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { getStreamItemMessageId, type StreamItem } from "@/types/stream";
|
||||
import type { Theme } from "@/styles/theme";
|
||||
import { WEB_SCROLLBAR_SIZE_PX } from "@/styles/web-scrollbar";
|
||||
@@ -30,15 +31,10 @@ import type { StreamRenderInput, StreamStrategy, StreamViewportHandle } from "./
|
||||
import { useRevisedHistoryRows } from "./history-row-revision";
|
||||
import { createStreamStrategy } from "./strategy";
|
||||
import {
|
||||
abandonHistoryStartPaginationRequest,
|
||||
createHistoryStartPaginationState,
|
||||
evaluateHistoryStartPagination,
|
||||
isHistoryStartLoadingOperation,
|
||||
rearmHistoryStartPagination,
|
||||
settleHistoryStartPagination,
|
||||
type HistoryStartPaginationInput,
|
||||
type HistoryStartPaginationTransition,
|
||||
} from "./history-start-pagination";
|
||||
import { useHistoryStartPagination } from "./use-history-start-pagination";
|
||||
import {
|
||||
createHistoryStartSettleScheduler,
|
||||
type HistoryStartSettleScheduler,
|
||||
@@ -77,6 +73,14 @@ const historyStartSlotStyle: CSSProperties = {
|
||||
};
|
||||
|
||||
const streamRowsHostStyle: CSSProperties = { position: "relative", width: "100%" };
|
||||
const initialFillStyle: CSSProperties = {
|
||||
position: "absolute",
|
||||
inset: 0,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
pointerEvents: "none",
|
||||
};
|
||||
|
||||
const streamRowStyle: CSSProperties = {
|
||||
display: "flex",
|
||||
@@ -298,6 +302,8 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
|
||||
viewportRef,
|
||||
routeBottomAnchorRequest,
|
||||
isAuthoritativeHistoryReady,
|
||||
isInitialHistoryLoading,
|
||||
revealInitialHistory,
|
||||
onNearBottomChange,
|
||||
onReadingPositionChange,
|
||||
onNearHistoryStart,
|
||||
@@ -318,6 +324,9 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
|
||||
[historyMounted, historyVirtualized, inputSegments],
|
||||
);
|
||||
const isActive = useRetainedPanelActive();
|
||||
const { t } = useTranslation();
|
||||
const rowsHostRef = useRef<HTMLDivElement | null>(null);
|
||||
const auxiliaryRef = useRef<HTMLDivElement | null>(null);
|
||||
const isActiveRef = useRef(isActive);
|
||||
const scrollContainerRef = useRef<HTMLElement | null>(null);
|
||||
const contentRef = useRef<HTMLElement | null>(null);
|
||||
@@ -351,10 +360,6 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
|
||||
const pendingAutoScrollFrameRef = useRef<number | null>(null);
|
||||
const pendingAutoScrollTimeoutRef = useRef<number | null>(null);
|
||||
const historyStartReadyRef = useRef(false);
|
||||
const [historyStartPaginationState, setHistoryStartPaginationState] = useState(
|
||||
createHistoryStartPaginationState,
|
||||
);
|
||||
const historyStartPaginationStateRef = useRef(historyStartPaginationState);
|
||||
const readingAnchor = useMemo(createReadingAnchor, []);
|
||||
const measuredRowHeights = useRef(new Map<string, { width: number; height: number }>());
|
||||
const historyStartSettleSchedulerRef = useRef<HistoryStartSettleScheduler | null>(null);
|
||||
@@ -481,60 +486,36 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
|
||||
!followOutputRef.current || isScrollContainerNearBottom(scrollContainer);
|
||||
return {
|
||||
distanceFromHistoryStart: scrollContainer.scrollTop,
|
||||
isUnderfilled:
|
||||
(rowsHostRef.current?.getBoundingClientRect().height ?? 0) +
|
||||
(auxiliaryRef.current?.getBoundingClientRect().height ?? 0) <
|
||||
scrollContainer.clientHeight,
|
||||
hasOlderHistory,
|
||||
isLoadingOlderHistory,
|
||||
// An explicit jump owns the landing position. Revealing another local
|
||||
// page here changes the virtual index before the target has mounted.
|
||||
isReady: historyStartReadyRef.current && bottomAnchorSettled && !isJumpSettling(),
|
||||
isReady:
|
||||
historyStartReadyRef.current &&
|
||||
isAuthoritativeHistoryReady &&
|
||||
bottomAnchorSettled &&
|
||||
!isJumpSettling(),
|
||||
progressKey: olderHistoryProgressKey,
|
||||
};
|
||||
});
|
||||
const applyHistoryStartPaginationTransition = useStableEvent(
|
||||
(transition: HistoryStartPaginationTransition) => {
|
||||
const previousState = historyStartPaginationStateRef.current;
|
||||
historyStartPaginationStateRef.current = transition.state;
|
||||
if (transition.state !== previousState) {
|
||||
setHistoryStartPaginationState(transition.state);
|
||||
}
|
||||
if (!transition.shouldLoad || olderHistoryProgressKey === null) {
|
||||
return;
|
||||
}
|
||||
const requestedProgressKey = olderHistoryProgressKey;
|
||||
void (async () => {
|
||||
const started = await onNearHistoryStart();
|
||||
if (started === true) {
|
||||
return;
|
||||
}
|
||||
applyHistoryStartPaginationTransition({
|
||||
state: abandonHistoryStartPaginationRequest(
|
||||
historyStartPaginationStateRef.current,
|
||||
requestedProgressKey,
|
||||
),
|
||||
shouldLoad: false,
|
||||
});
|
||||
})();
|
||||
},
|
||||
const { state: historyStartPaginationState, controller: historyStartPagination } =
|
||||
useHistoryStartPagination({
|
||||
initiallyVisible: !isInitialHistoryLoading,
|
||||
read: getHistoryStartPaginationInput,
|
||||
load: async () => onNearHistoryStart(),
|
||||
scheduleSettle: () => scheduleHistoryStartPrependSettle(),
|
||||
});
|
||||
const evaluateHistoryStart = useStableEvent(() => historyStartPagination.update());
|
||||
const disposeHistoryStart = useStableEvent(() => historyStartPagination.dispose());
|
||||
const isHistoryStartSettling = useStableEvent(
|
||||
() => historyStartPagination.state.status === "settling",
|
||||
);
|
||||
const evaluateHistoryStart = useStableEvent(() => {
|
||||
const input = getHistoryStartPaginationInput();
|
||||
if (!input) {
|
||||
return;
|
||||
}
|
||||
const transition = evaluateHistoryStartPagination(
|
||||
historyStartPaginationStateRef.current,
|
||||
input,
|
||||
);
|
||||
applyHistoryStartPaginationTransition(transition);
|
||||
});
|
||||
const rearmHistoryStartFromUserIntent = useStableEvent(() => {
|
||||
const rearmed = rearmHistoryStartPagination(historyStartPaginationStateRef.current);
|
||||
if (rearmed === historyStartPaginationStateRef.current) {
|
||||
return;
|
||||
}
|
||||
historyStartPaginationStateRef.current = rearmed;
|
||||
setHistoryStartPaginationState(rearmed);
|
||||
evaluateHistoryStart();
|
||||
});
|
||||
const finishInitialFill = useStableEvent(() => historyStartPagination.reveal());
|
||||
const rearmHistoryStartFromUserIntent = useStableEvent(() => historyStartPagination.rearm());
|
||||
const scheduleHistoryStartPrependSettle = useStableEvent(() => {
|
||||
let scheduler = historyStartSettleSchedulerRef.current;
|
||||
if (!scheduler) {
|
||||
@@ -542,22 +523,12 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
|
||||
settleFrames: HISTORY_START_SETTLE_FRAMES,
|
||||
requestFrame: (callback) => window.requestAnimationFrame(callback),
|
||||
cancelFrame: (frame) => window.cancelAnimationFrame(frame),
|
||||
isSettling: () => historyStartPaginationStateRef.current.status === "settling",
|
||||
isSettling: () => isHistoryStartSettling(),
|
||||
isLoading: () => {
|
||||
const input = getHistoryStartPaginationInput();
|
||||
return !input || input.isLoadingOlderHistory;
|
||||
},
|
||||
onSettle: () => {
|
||||
const input = getHistoryStartPaginationInput();
|
||||
if (!input) {
|
||||
return;
|
||||
}
|
||||
const transition = settleHistoryStartPagination(
|
||||
historyStartPaginationStateRef.current,
|
||||
input,
|
||||
);
|
||||
applyHistoryStartPaginationTransition(transition);
|
||||
},
|
||||
onSettle: () => historyStartPagination.settle(),
|
||||
});
|
||||
historyStartSettleSchedulerRef.current = scheduler;
|
||||
}
|
||||
@@ -794,6 +765,7 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
|
||||
]);
|
||||
|
||||
const stopFollowingOutputFromUserIntent = useStableEvent(() => {
|
||||
finishInitialFill();
|
||||
cancelPendingStickToBottom();
|
||||
if (followOutputRef.current) {
|
||||
setFollowOutput(false);
|
||||
@@ -801,6 +773,10 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
|
||||
rearmHistoryStartFromUserIntent();
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (revealInitialHistory) finishInitialFill();
|
||||
}, [finishInitialFill, revealInitialHistory]);
|
||||
|
||||
const handleDomScroll = useCallback(() => {
|
||||
const scrollContainer = scrollContainerRef.current;
|
||||
if (!isActiveRef.current || !scrollContainer || !isScrollContainerMeasurable(scrollContainer)) {
|
||||
@@ -846,10 +822,11 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
|
||||
return () => {
|
||||
window.cancelAnimationFrame(frame);
|
||||
historyStartReadyRef.current = false;
|
||||
disposeHistoryStart();
|
||||
historyStartSettleSchedulerRef.current?.cancel();
|
||||
historyStartSettleSchedulerRef.current = null;
|
||||
};
|
||||
}, [evaluateHistoryStart]);
|
||||
}, [disposeHistoryStart, evaluateHistoryStart]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (!isActiveRef.current || !isActivationReady) {
|
||||
@@ -938,16 +915,18 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
|
||||
updateScrollMetrics();
|
||||
}
|
||||
evaluateHistoryStart();
|
||||
if (historyStartPaginationStateRef.current.status === "settling") {
|
||||
if (isHistoryStartSettling()) {
|
||||
scheduleHistoryStartPrependSettle();
|
||||
}
|
||||
}, [
|
||||
evaluateHistoryStart,
|
||||
hasOlderHistory,
|
||||
isActive,
|
||||
isAuthoritativeHistoryReady,
|
||||
isLoadingOlderHistory,
|
||||
olderHistoryProgressKey,
|
||||
scheduleHistoryStartPrependSettle,
|
||||
isHistoryStartSettling,
|
||||
segments.historyMounted.length,
|
||||
segments.historyVirtualized.length,
|
||||
segments.liveHead.length,
|
||||
@@ -986,7 +965,7 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
|
||||
lastObservedViewportGeometryRef.current = nextGeometry;
|
||||
}
|
||||
reconcileReadingPosition();
|
||||
if (historyStartPaginationStateRef.current.status === "settling") {
|
||||
if (isHistoryStartSettling()) {
|
||||
scheduleHistoryStartPrependSettle();
|
||||
}
|
||||
updateScrollMetrics();
|
||||
@@ -1020,6 +999,7 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
|
||||
isActive,
|
||||
reportReadingPosition,
|
||||
scheduleHistoryStartPrependSettle,
|
||||
isHistoryStartSettling,
|
||||
forceStickToBottom,
|
||||
updateScrollMetrics,
|
||||
measureVirtualizedRows,
|
||||
@@ -1241,8 +1221,9 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
|
||||
// Detached reading has one owner: reconcileReadingPosition.
|
||||
overflowAnchor: followOutput ? "auto" : "none",
|
||||
scrollbarWidth: overlayScrollbarEnabled ? "none" : undefined,
|
||||
opacity: historyStartPaginationState.visible ? 1 : 0,
|
||||
};
|
||||
}, [followOutput, isMobileBreakpoint, scrollEnabled]);
|
||||
}, [followOutput, historyStartPaginationState.visible, isMobileBreakpoint, scrollEnabled]);
|
||||
const viewportStyle = useMemo(
|
||||
(): CSSProperties => ({
|
||||
position: "relative",
|
||||
@@ -1319,14 +1300,21 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
|
||||
const isLoadingOperation = isHistoryStartLoadingOperation(historyStartPaginationState);
|
||||
return (
|
||||
<div style={historyStartSlotStyle} data-testid="older-history-slot">
|
||||
{isLoadingOperation ? (
|
||||
{isLoadingOperation && (
|
||||
<div data-testid="load-older-history-spinner">
|
||||
<ThemedLoadingSpinner size="small" uniProps={foregroundMutedColorMapping} />
|
||||
</div>
|
||||
) : null}
|
||||
)}
|
||||
{!isLoadingOperation &&
|
||||
historyStartPaginationState.status === "failed" &&
|
||||
hasOlderHistory && (
|
||||
<button type="button" onClick={rearmHistoryStartFromUserIntent}>
|
||||
{t("paneFind.retry")}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}, [historyStartPaginationState]);
|
||||
}, [historyStartPaginationState, hasOlderHistory, rearmHistoryStartFromUserIntent, t]);
|
||||
const shouldRenderEmpty =
|
||||
!boundary.hasMountedHistory &&
|
||||
!boundary.hasVirtualizedHistory &&
|
||||
@@ -1346,7 +1334,7 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
|
||||
>
|
||||
<div ref={handleContentRef} style={contentContainerStyle}>
|
||||
{historyStartSlot}
|
||||
<div style={streamRowsHostStyle}>
|
||||
<div ref={rowsHostRef} style={streamRowsHostStyle}>
|
||||
<div style={virtualRowsContainerStyle} />
|
||||
{[
|
||||
...virtualRows.map((virtualRow) => {
|
||||
@@ -1374,10 +1362,15 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
|
||||
...mountedRows,
|
||||
]}
|
||||
</div>
|
||||
{liveAuxiliary}
|
||||
<div ref={auxiliaryRef}>{liveAuxiliary}</div>
|
||||
{shouldRenderEmpty ? listEmptyComponent : null}
|
||||
</div>
|
||||
</div>
|
||||
{!historyStartPaginationState.visible ? (
|
||||
<div data-testid="agent-initial-fill" style={initialFillStyle}>
|
||||
<ThemedLoadingSpinner size="large" uniProps={foregroundMutedColorMapping} />
|
||||
</div>
|
||||
) : null}
|
||||
{scrollEnabled && !isMobileBreakpoint ? (
|
||||
<DomOverlayScrollbar
|
||||
scrollContainerRef={scrollContainerRef}
|
||||
|
||||
@@ -85,11 +85,13 @@ export interface StreamRenderInput {
|
||||
viewportRef: RefObject<StreamViewportHandle | null>;
|
||||
routeBottomAnchorRequest: BottomAnchorRouteRequest | null;
|
||||
isAuthoritativeHistoryReady: boolean;
|
||||
isInitialHistoryLoading?: boolean;
|
||||
revealInitialHistory?: boolean;
|
||||
onNearBottomChange: (value: boolean) => void;
|
||||
// The history row under the top of the viewport, for surfaces that mark where the reader
|
||||
// is in the transcript. Only the web viewport measures it today.
|
||||
onReadingPositionChange?: (rowId: string | null) => void;
|
||||
onNearHistoryStart: () => boolean | Promise<boolean>;
|
||||
onNearHistoryStart: () => Promise<boolean>;
|
||||
isLoadingOlderHistory: boolean;
|
||||
hasOlderHistory: boolean;
|
||||
olderHistoryProgressKey: string | null;
|
||||
|
||||
@@ -56,7 +56,7 @@ function collapse(tail: StreamItem[], options: Partial<CollapseTurnsInput> = {})
|
||||
isLatestTurnOpen: false,
|
||||
expandedHostIds: new Set(),
|
||||
getGroupedCalls: () => undefined,
|
||||
previousTurnsByHostId: new Map(),
|
||||
previousSummaryRowsByHostId: new Map(),
|
||||
...options,
|
||||
});
|
||||
}
|
||||
@@ -73,17 +73,29 @@ describe("collapseTurns", () => {
|
||||
pluginReply("final", "right"),
|
||||
];
|
||||
const result = collapse(items);
|
||||
expect(ids(result.tail)).toEqual(["prompt", "math/final/left", "math/final/right"]);
|
||||
expect(ids(result.tail)).toEqual([
|
||||
"prompt",
|
||||
"turn-summary:prompt",
|
||||
"math/final/left",
|
||||
"math/final/right",
|
||||
]);
|
||||
expect(result.hostIdByFoldedMessageId.get("acknowledgment")).toBe("prompt");
|
||||
expect(result.hostIdByFoldedMessageId.has("final")).toBe(false);
|
||||
const expanded = collapse(items, { expandedHostIds: new Set(["prompt"]) });
|
||||
expect(expanded.tail).toEqual(items);
|
||||
expect(ids(expanded.tail)).toEqual([
|
||||
"prompt",
|
||||
"turn-summary:prompt",
|
||||
"acknowledgment",
|
||||
"call",
|
||||
"math/final/left",
|
||||
"math/final/right",
|
||||
]);
|
||||
expect([...expanded.nestedItemIds]).toEqual(["acknowledgment", "call"]);
|
||||
});
|
||||
|
||||
it("folds intermediate plugin replies even without tool calls", () => {
|
||||
const result = collapse([user("prompt"), pluginReply("intermediate"), pluginReply("final")]);
|
||||
expect(ids(result.tail)).toEqual(["prompt", "math/final/0"]);
|
||||
expect(ids(result.tail)).toEqual(["prompt", "turn-summary:prompt", "math/final/0"]);
|
||||
expect(result.hostIdByFoldedMessageId.get("intermediate")).toBe("prompt");
|
||||
});
|
||||
|
||||
@@ -93,7 +105,7 @@ describe("collapseTurns", () => {
|
||||
const card = { ...pluginReply("card"), sourceKind };
|
||||
const items = [user("prompt"), shell("call"), card];
|
||||
expect(collapse(items).tail).toEqual(items);
|
||||
expect(collapse(items).turnsByHostId.size).toBe(0);
|
||||
expect(collapse(items).summaryRowsByHostId.size).toBe(0);
|
||||
},
|
||||
);
|
||||
|
||||
@@ -102,38 +114,41 @@ describe("collapseTurns", () => {
|
||||
expect(collapse(items, { isLatestTurnOpen: true }).tail).toEqual(items);
|
||||
});
|
||||
|
||||
// A history row renders again only when its item changes, so a prompt that is already retained
|
||||
// when its turn closes needs its row revised to show the summary line.
|
||||
it("reports the hosts whose summary line appeared, changed or went away", () => {
|
||||
const open = collapse([user("u1"), shell("c1"), reply("r1"), user("u2"), shell("c2")], {
|
||||
// A mounted row renders again only when its item changes, so a turn whose work and open state
|
||||
// are unchanged keeps its summary row object across folds of the same history.
|
||||
it("keeps a summary row until its turn's work or open state changes", () => {
|
||||
const first = collapse([user("u1"), shell("c1"), reply("r1"), user("u2"), shell("c2")], {
|
||||
isLatestTurnOpen: true,
|
||||
});
|
||||
expect([...open.updatedHostIds]).toEqual(["u1"]);
|
||||
const closed = collapse(
|
||||
[user("u1"), shell("c1"), reply("r1"), user("u2"), shell("c2"), reply("r2")],
|
||||
{
|
||||
previousTurnsByHostId: open.turnsByHostId,
|
||||
},
|
||||
{ previousSummaryRowsByHostId: first.summaryRowsByHostId },
|
||||
);
|
||||
expect([...closed.updatedHostIds]).toEqual(["u2"]);
|
||||
const unchanged = collapse(
|
||||
[user("u1"), shell("c1"), reply("r1"), user("u2"), shell("c2"), reply("r2")],
|
||||
{
|
||||
previousTurnsByHostId: closed.turnsByHostId,
|
||||
},
|
||||
);
|
||||
expect(unchanged.updatedHostIds.size).toBe(0);
|
||||
expect(closed.summaryRowsByHostId.get("u1")).toBe(first.summaryRowsByHostId.get("u1"));
|
||||
const moreWork = collapse(
|
||||
[user("u1"), shell("c1"), reply("r1"), user("u2"), shell("c2"), shell("c3"), reply("r2")],
|
||||
{ previousTurnsByHostId: unchanged.turnsByHostId },
|
||||
{ previousSummaryRowsByHostId: closed.summaryRowsByHostId },
|
||||
);
|
||||
expect([...moreWork.updatedHostIds]).toEqual(["u2"]);
|
||||
const rewound = collapse([user("u1"), shell("c1"), reply("r1")], {
|
||||
previousTurnsByHostId: moreWork.turnsByHostId,
|
||||
expect(moreWork.summaryRowsByHostId.get("u1")).toBe(closed.summaryRowsByHostId.get("u1"));
|
||||
expect(moreWork.summaryRowsByHostId.get("u2")).not.toBe(closed.summaryRowsByHostId.get("u2"));
|
||||
const opened = collapse(moreWork.tail, {
|
||||
previousSummaryRowsByHostId: moreWork.summaryRowsByHostId,
|
||||
expandedHostIds: new Set(["u1"]),
|
||||
});
|
||||
expect([...rewound.updatedHostIds]).toEqual(["u2"]);
|
||||
expect(opened.summaryRowsByHostId.get("u1")).not.toBe(moreWork.summaryRowsByHostId.get("u1"));
|
||||
});
|
||||
it("maps hidden rows only while their turn is closed", () => {
|
||||
it("stands at its host's timeline position, taking a new row when that position lands", () => {
|
||||
const live = collapse([user("u1"), shell("c1"), reply("r1")]);
|
||||
expect(live.summaryRowsByHostId.get("u1")).not.toHaveProperty("timelineCursor");
|
||||
const cursor = { epoch: "epoch-1", seq: 7 };
|
||||
const placed = [{ ...user("u1"), timelineCursor: cursor }, shell("c1"), reply("r1")];
|
||||
const canonical = collapse(placed, { previousSummaryRowsByHostId: live.summaryRowsByHostId });
|
||||
expect(canonical.summaryRowsByHostId.get("u1")).not.toBe(live.summaryRowsByHostId.get("u1"));
|
||||
expect(canonical.summaryRowsByHostId.get("u1")?.timelineCursor).toEqual(cursor);
|
||||
const again = collapse(placed, { previousSummaryRowsByHostId: canonical.summaryRowsByHostId });
|
||||
expect(again.summaryRowsByHostId.get("u1")).toBe(canonical.summaryRowsByHostId.get("u1"));
|
||||
});
|
||||
it("maps hidden rows to their turn's host only while the turn is closed", () => {
|
||||
const result = collapse([user("u1"), shell("c1"), reply("final")]);
|
||||
expect(result.hostIdByHiddenRowId.get("c1")).toBe("u1");
|
||||
const expanded = collapse([user("u1"), shell("c1"), reply("final")], {
|
||||
@@ -168,15 +183,15 @@ describe("collapseTurns", () => {
|
||||
reply("done"),
|
||||
].map((item) => Object.assign(item, { turnId: "same-run" }));
|
||||
const result = collapse(items);
|
||||
expect(ids(result.tail)).toEqual(["u1", "finished", "compaction", "done"]);
|
||||
expect(result.turnsByHostId.get("u1")?.summary.tools).toMatchObject({
|
||||
expect(ids(result.tail)).toEqual(["u1", "turn-summary:u1", "finished", "compaction", "done"]);
|
||||
expect(result.summaryRowsByHostId.get("u1")?.tools).toMatchObject({
|
||||
commandCount: 1,
|
||||
otherToolCount: 0,
|
||||
paseoActions: { "Created agent": 1 },
|
||||
});
|
||||
});
|
||||
|
||||
it("folds a finished turn's work into its user message and keeps the reply", () => {
|
||||
it("folds a finished turn's work into a summary row under its user message and keeps the reply", () => {
|
||||
const result = collapse([
|
||||
user("u1"),
|
||||
thought("t1"),
|
||||
@@ -186,14 +201,16 @@ describe("collapseTurns", () => {
|
||||
reply("final"),
|
||||
]);
|
||||
|
||||
expect(ids(result.tail)).toEqual(["u1", "final"]);
|
||||
expect(result.turnsByHostId.get("u1")).toEqual({
|
||||
expect(ids(result.tail)).toEqual(["u1", "turn-summary:u1", "final"]);
|
||||
expect(result.tail[1]).toEqual({
|
||||
kind: "turn_summary",
|
||||
id: "turn-summary:u1",
|
||||
hostId: "u1",
|
||||
timestamp: at,
|
||||
expanded: false,
|
||||
summary: {
|
||||
tools: expect.objectContaining({ commandCount: 2 }),
|
||||
},
|
||||
tools: expect.objectContaining({ commandCount: 2 }),
|
||||
});
|
||||
expect(result.summaryRowsByHostId.get("u1")).toBe(result.tail[1]);
|
||||
expect(result.hostIdByFoldedMessageId.get("early")).toBe("u1");
|
||||
});
|
||||
|
||||
@@ -202,9 +219,9 @@ describe("collapseTurns", () => {
|
||||
expandedHostIds: new Set(["u1"]),
|
||||
});
|
||||
|
||||
expect(ids(result.tail)).toEqual(["u1", "t1", "c1", "final"]);
|
||||
expect(ids(result.tail)).toEqual(["u1", "turn-summary:u1", "t1", "c1", "final"]);
|
||||
expect([...result.nestedItemIds]).toEqual(["t1", "c1"]);
|
||||
expect(result.turnsByHostId.get("u1")?.expanded).toBe(true);
|
||||
expect(result.summaryRowsByHostId.get("u1")?.expanded).toBe(true);
|
||||
});
|
||||
|
||||
it("leaves the latest turn alone while it is still producing output", () => {
|
||||
@@ -212,8 +229,8 @@ describe("collapseTurns", () => {
|
||||
|
||||
const result = collapse(tail, { isLatestTurnOpen: true });
|
||||
|
||||
expect(ids(result.tail)).toEqual(["u1", "r1", "u2", "c2", "r2"]);
|
||||
expect([...result.turnsByHostId.keys()]).toEqual(["u1"]);
|
||||
expect(ids(result.tail)).toEqual(["u1", "turn-summary:u1", "r1", "u2", "c2", "r2"]);
|
||||
expect([...result.summaryRowsByHostId.keys()]).toEqual(["u1"]);
|
||||
});
|
||||
|
||||
it("keeps rows after the reply, and turns with no work before the reply, as they are", () => {
|
||||
@@ -226,8 +243,8 @@ describe("collapseTurns", () => {
|
||||
reply("r2"),
|
||||
]);
|
||||
|
||||
expect(ids(result.tail)).toEqual(["u1", "r1", "after", "u2", "r2"]);
|
||||
expect([...result.turnsByHostId.keys()]).toEqual(["u1"]);
|
||||
expect(ids(result.tail)).toEqual(["u1", "turn-summary:u1", "r1", "after", "u2", "r2"]);
|
||||
expect([...result.summaryRowsByHostId.keys()]).toEqual(["u1"]);
|
||||
});
|
||||
|
||||
it("does not fold a turn that only thought before its reply", () => {
|
||||
@@ -236,7 +253,7 @@ describe("collapseTurns", () => {
|
||||
const result = collapse(tail);
|
||||
|
||||
expect(result.tail).toBe(tail);
|
||||
expect(result.turnsByHostId.size).toBe(0);
|
||||
expect(result.summaryRowsByHostId.size).toBe(0);
|
||||
});
|
||||
|
||||
it("does not fold a turn without a reply or whose prompt is above the loaded history", () => {
|
||||
@@ -245,7 +262,7 @@ describe("collapseTurns", () => {
|
||||
const result = collapse(tail);
|
||||
|
||||
expect(result.tail).toBe(tail);
|
||||
expect(result.turnsByHostId.size).toBe(0);
|
||||
expect(result.summaryRowsByHostId.size).toBe(0);
|
||||
});
|
||||
|
||||
it("keeps a completed response folded when a promptless canonical turn starts", () => {
|
||||
@@ -254,15 +271,15 @@ describe("collapseTurns", () => {
|
||||
);
|
||||
const next = { ...shell("heartbeat"), turnId: "second" };
|
||||
const result = collapse([...first, next], { isLatestTurnOpen: true });
|
||||
expect(ids(result.tail)).toEqual(["u1", "r1", "heartbeat"]);
|
||||
expect(result.turnsByHostId.has("u1")).toBe(true);
|
||||
expect(ids(result.tail)).toEqual(["u1", "turn-summary:u1", "r1", "heartbeat"]);
|
||||
expect(result.tail[1]).toMatchObject({ kind: "turn_summary", turnId: "first" });
|
||||
});
|
||||
|
||||
it("keeps mid-turn messages visible when work is folded", () => {
|
||||
const tail = [user("prompt"), shell("work"), user("steer"), shell("more"), reply("done")];
|
||||
for (const item of tail) item.turnId = "same-turn";
|
||||
const result = collapse(tail);
|
||||
expect(ids(result.tail)).toEqual(["prompt", "steer", "done"]);
|
||||
expect(ids(result.tail)).toEqual(["prompt", "turn-summary:prompt", "steer", "done"]);
|
||||
expect(result.hostIdByFoldedMessageId.has("steer")).toBe(false);
|
||||
});
|
||||
|
||||
@@ -272,7 +289,7 @@ describe("collapseTurns", () => {
|
||||
expandedHostIds: new Set(["prompt"]),
|
||||
getGroupedCalls: (id) => (id === "one" ? calls : undefined),
|
||||
});
|
||||
expect(ids(result.tail)).toEqual(["prompt", "one", "two", "done"]);
|
||||
expect(ids(result.tail)).toEqual(["prompt", "turn-summary:prompt", "one", "two", "done"]);
|
||||
expect([...result.nestedItemIds]).toEqual(["one", "two"]);
|
||||
});
|
||||
|
||||
@@ -282,8 +299,6 @@ describe("collapseTurns", () => {
|
||||
getGroupedCalls: (itemId) => (itemId === "c1" ? group : undefined),
|
||||
});
|
||||
|
||||
expect(result.turnsByHostId.get("u1")?.summary).toMatchObject({
|
||||
tools: { commandCount: 3 },
|
||||
});
|
||||
expect(result.summaryRowsByHostId.get("u1")?.tools).toMatchObject({ commandCount: 3 });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,35 +3,25 @@ import {
|
||||
getStreamItemMessageId,
|
||||
type StreamItem,
|
||||
type ThoughtItem,
|
||||
type TimelinePosition,
|
||||
type ToolCallItem,
|
||||
type TurnSummaryItem,
|
||||
} from "@/types/stream";
|
||||
import { summarizeToolCalls, type OverviewSummary } from "@/tool-calls/detail-level/overview/model";
|
||||
|
||||
export interface TurnSummary {
|
||||
tools: OverviewSummary;
|
||||
}
|
||||
|
||||
export interface CollapsedTurn {
|
||||
/** The input or first loaded row that carries the turn's summary line. */
|
||||
hostId: string;
|
||||
expanded: boolean;
|
||||
summary: TurnSummary;
|
||||
}
|
||||
|
||||
export interface TurnCollapse {
|
||||
tail: StreamItem[];
|
||||
turnsByHostId: ReadonlyMap<string, CollapsedTurn>;
|
||||
/** Rows of expanded turns, which render inside the turn's indent rail. */
|
||||
nestedItemIds: ReadonlySet<string>;
|
||||
/** The turn a folded message belongs to, for surfaces that must reveal it. */
|
||||
/** The host of the turn a folded message belongs to, for surfaces that must reveal it. */
|
||||
hostIdByFoldedMessageId: ReadonlyMap<string, string>;
|
||||
/** Mounted rows replaced by a closed turn's host, for preserving the reading position. */
|
||||
hostIdByHiddenRowId: ReadonlyMap<string, string>;
|
||||
/**
|
||||
* Hosts whose summary line appeared, changed or went away since `previousTurnsByHostId`. A
|
||||
* history row renders again only when its item changes, so these rows are revised.
|
||||
* The summary rows by host. A mounted row renders again only when its item changes, so a fold
|
||||
* keeps the previous row object for a turn whose work and open state are unchanged.
|
||||
*/
|
||||
updatedHostIds: ReadonlySet<string>;
|
||||
summaryRowsByHostId: ReadonlyMap<string, TurnSummaryItem>;
|
||||
}
|
||||
|
||||
export interface CollapseTurnsInput {
|
||||
@@ -41,39 +31,39 @@ export interface CollapseTurnsInput {
|
||||
expandedHostIds: ReadonlySet<string>;
|
||||
/** The calls a tool-call group row stands for; undefined for a single call. */
|
||||
getGroupedCalls: (itemId: string) => readonly (ToolCallItem | ThoughtItem)[] | undefined;
|
||||
/** The previous fold of the same history, the baseline for `updatedHostIds`. */
|
||||
previousTurnsByHostId: ReadonlyMap<string, CollapsedTurn>;
|
||||
/** The previous fold of the same history, whose unchanged summary rows are reused. */
|
||||
previousSummaryRowsByHostId: ReadonlyMap<string, TurnSummaryItem>;
|
||||
}
|
||||
|
||||
const EMPTY_TURNS: ReadonlyMap<string, CollapsedTurn> = new Map();
|
||||
const EMPTY_IDS: ReadonlySet<string> = new Set();
|
||||
const EMPTY_ROWS: ReadonlyMap<string, TurnSummaryItem> = new Map();
|
||||
const EMPTY_HOSTS: ReadonlyMap<string, string> = new Map();
|
||||
|
||||
function summarizeTurn(
|
||||
items: readonly StreamItem[],
|
||||
getGroupedCalls: CollapseTurnsInput["getGroupedCalls"],
|
||||
): TurnSummary {
|
||||
): OverviewSummary {
|
||||
const calls: (ToolCallItem | ThoughtItem)[] = [];
|
||||
for (const item of items) {
|
||||
if ((item.kind === "tool_call" || item.kind === "thought") && !isTurnTrigger(item))
|
||||
calls.push(...(getGroupedCalls(item.id) ?? [item]));
|
||||
}
|
||||
return { tools: summarizeToolCalls(calls) };
|
||||
return summarizeToolCalls(calls);
|
||||
}
|
||||
|
||||
function hasWork(summary: TurnSummary): boolean {
|
||||
const { paseoActions, thoughtCount: _thoughtCount, ...counts } = summary.tools;
|
||||
function hasWork(tools: OverviewSummary): boolean {
|
||||
const { paseoActions, thoughtCount: _thoughtCount, ...counts } = tools;
|
||||
return Object.keys(paseoActions).length > 0 || Object.values(counts).some((count) => count > 0);
|
||||
}
|
||||
|
||||
function isSameTurn(a: CollapsedTurn, b: CollapsedTurn): boolean {
|
||||
const { paseoActions: actionsA, ...countsA } = a.summary.tools;
|
||||
const { paseoActions: actionsB, ...countsB } = b.summary.tools;
|
||||
return (
|
||||
a.expanded === b.expanded &&
|
||||
hasSameCounts(countsA, countsB) &&
|
||||
hasSameCounts(actionsA, actionsB)
|
||||
);
|
||||
function hasSameWork(a: OverviewSummary, b: OverviewSummary): boolean {
|
||||
const { paseoActions: actionsA, ...countsA } = a;
|
||||
const { paseoActions: actionsB, ...countsB } = b;
|
||||
return hasSameCounts(countsA, countsB) && hasSameCounts(actionsA, actionsB);
|
||||
}
|
||||
|
||||
function hasSamePosition(a: TimelinePosition | undefined, b: TimelinePosition | undefined) {
|
||||
return a === b || (a !== undefined && b !== undefined && a.epoch === b.epoch && a.seq === b.seq);
|
||||
}
|
||||
|
||||
function hasSameCounts(a: Record<string, number>, b: Record<string, number>): boolean {
|
||||
@@ -81,19 +71,32 @@ function hasSameCounts(a: Record<string, number>, b: Record<string, number>): bo
|
||||
return keys.length === Object.keys(b).length && keys.every((key) => a[key] === b[key]);
|
||||
}
|
||||
|
||||
function findUpdatedHosts(
|
||||
previous: ReadonlyMap<string, CollapsedTurn>,
|
||||
next: ReadonlyMap<string, CollapsedTurn>,
|
||||
): ReadonlySet<string> {
|
||||
const updated = new Set<string>();
|
||||
for (const [hostId, turn] of next) {
|
||||
const before = previous.get(hostId);
|
||||
if (!before || !isSameTurn(before, turn)) updated.add(hostId);
|
||||
function summaryRow(
|
||||
host: StreamItem,
|
||||
expanded: boolean,
|
||||
tools: OverviewSummary,
|
||||
previous: TurnSummaryItem | undefined,
|
||||
): TurnSummaryItem {
|
||||
if (
|
||||
previous &&
|
||||
previous.expanded === expanded &&
|
||||
hasSameWork(previous.tools, tools) &&
|
||||
hasSamePosition(previous.timelineCursor, host.timelineCursor)
|
||||
) {
|
||||
return previous;
|
||||
}
|
||||
for (const hostId of previous.keys()) {
|
||||
if (!next.has(hostId)) updated.add(hostId);
|
||||
}
|
||||
return updated.size === 0 ? EMPTY_IDS : updated;
|
||||
return {
|
||||
kind: "turn_summary",
|
||||
id: `turn-summary:${host.id}`,
|
||||
// The row stands at its host's place in the timeline, so the reading position over it
|
||||
// still resolves to the turn's prompt.
|
||||
...(host.timelineCursor !== undefined ? { timelineCursor: host.timelineCursor } : {}),
|
||||
...(host.turnId !== undefined ? { turnId: host.turnId } : {}),
|
||||
timestamp: host.timestamp,
|
||||
hostId: host.id,
|
||||
expanded,
|
||||
tools,
|
||||
};
|
||||
}
|
||||
|
||||
function staysVisibleWhenCollapsed(item: StreamItem): boolean {
|
||||
@@ -124,15 +127,15 @@ function findLastReplyIndex(tail: StreamItem[], start: number, end: number): num
|
||||
}
|
||||
|
||||
/**
|
||||
* Folds each answered turn into its user message: everything the agent did before its last
|
||||
* message collapses into one summary line, and that message stays in the chat. A turn folds
|
||||
* only once it is complete, its user message is loaded, and it sent messages or called tools
|
||||
* before its reply. Reasoning is revealed only when the turn opens.
|
||||
* Folds each answered turn behind a summary row under its user message: everything the agent
|
||||
* did before its last message collapses into that row, and the last message stays in the chat.
|
||||
* A turn folds only once it is complete, its user message is loaded, and it sent messages or
|
||||
* called tools before its reply. Reasoning is revealed only when the turn opens.
|
||||
*/
|
||||
export function collapseTurns(input: CollapseTurnsInput): TurnCollapse {
|
||||
const { tail } = input;
|
||||
const output: StreamItem[] = [];
|
||||
const turnsByHostId = new Map<string, CollapsedTurn>();
|
||||
const summaryRowsByHostId = new Map<string, TurnSummaryItem>();
|
||||
const nestedItemIds = new Set<string>();
|
||||
const hostIdByFoldedMessageId = new Map<string, string>();
|
||||
const hostIdByHiddenRowId = new Map<string, string>();
|
||||
@@ -152,13 +155,19 @@ export function collapseTurns(input: CollapseTurnsInput): TurnCollapse {
|
||||
return;
|
||||
}
|
||||
const folded = tail.slice(runStart, replyIndex);
|
||||
const summary = summarizeTurn(folded, input.getGroupedCalls);
|
||||
if (!hasWork(summary) && !folded.some(isAssistantReply)) {
|
||||
const tools = summarizeTurn(folded, input.getGroupedCalls);
|
||||
if (!hasWork(tools) && !folded.some(isAssistantReply)) {
|
||||
output.push(...tail.slice(runStart, runEnd));
|
||||
return;
|
||||
}
|
||||
const expanded = input.expandedHostIds.has(host.id);
|
||||
turnsByHostId.set(host.id, { hostId: host.id, expanded, summary });
|
||||
const summary = summaryRow(
|
||||
host,
|
||||
expanded,
|
||||
tools,
|
||||
input.previousSummaryRowsByHostId.get(host.id),
|
||||
);
|
||||
summaryRowsByHostId.set(host.id, summary);
|
||||
const revealed = expanded
|
||||
? folded.flatMap<StreamItem>((item) =>
|
||||
item.kind === "tool_call" || item.kind === "thought"
|
||||
@@ -173,6 +182,7 @@ export function collapseTurns(input: CollapseTurnsInput): TurnCollapse {
|
||||
else hostIdByHiddenRowId.set(item.id, host.id);
|
||||
}
|
||||
output.push(
|
||||
summary,
|
||||
...(expanded ? revealed : folded.filter((item) => staysVisibleWhenCollapsed(item))),
|
||||
);
|
||||
output.push(...tail.slice(replyIndex, runEnd));
|
||||
@@ -190,26 +200,20 @@ export function collapseTurns(input: CollapseTurnsInput): TurnCollapse {
|
||||
}
|
||||
flushRun(tail.length);
|
||||
|
||||
const updatedHostIds = findUpdatedHosts(input.previousTurnsByHostId, turnsByHostId);
|
||||
if (turnsByHostId.size === 0) {
|
||||
if (summaryRowsByHostId.size === 0) {
|
||||
return {
|
||||
tail,
|
||||
turnsByHostId: EMPTY_TURNS,
|
||||
nestedItemIds: EMPTY_IDS,
|
||||
hostIdByFoldedMessageId: EMPTY_HOSTS,
|
||||
hostIdByHiddenRowId: EMPTY_HOSTS,
|
||||
updatedHostIds,
|
||||
summaryRowsByHostId: EMPTY_ROWS,
|
||||
};
|
||||
}
|
||||
return {
|
||||
tail:
|
||||
output.length === tail.length && output.every((item, index) => item === tail[index])
|
||||
? tail
|
||||
: output,
|
||||
turnsByHostId,
|
||||
tail: output,
|
||||
nestedItemIds,
|
||||
hostIdByFoldedMessageId,
|
||||
hostIdByHiddenRowId,
|
||||
updatedHostIds,
|
||||
summaryRowsByHostId,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -4,25 +4,25 @@ import { Summary } from "lucide-react-native";
|
||||
import { useDisclosureHold } from "@/agent-stream/disclosure";
|
||||
import { ExpandableBadge } from "@/components/message";
|
||||
import { useOverviewSummaryLabel } from "@/tool-calls/detail-level/overview/label";
|
||||
import type { CollapsedTurn } from "./model";
|
||||
import type { TurnSummaryItem } from "@/types/stream";
|
||||
|
||||
/**
|
||||
* The row a collapsed turn leaves under its user message, styled like a tool-call group.
|
||||
* Pressing it opens the turn's rows beneath it.
|
||||
*/
|
||||
export const TurnSummaryLine = memo(function TurnSummaryLine({
|
||||
turn,
|
||||
item,
|
||||
onExpandedChange,
|
||||
}: {
|
||||
turn: CollapsedTurn;
|
||||
item: TurnSummaryItem;
|
||||
onExpandedChange: (hostId: string, expanded: boolean) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const label = useOverviewSummaryLabel(turn.summary.tools) || t("turnSummary.details");
|
||||
const label = useOverviewSummaryLabel(item.tools) || t("turnSummary.details");
|
||||
const hold = useDisclosureHold();
|
||||
const toggle = useCallback(
|
||||
() => hold(() => onExpandedChange(turn.hostId, !turn.expanded)),
|
||||
[hold, onExpandedChange, turn.expanded, turn.hostId],
|
||||
() => hold(() => onExpandedChange(item.hostId, !item.expanded)),
|
||||
[hold, onExpandedChange, item.expanded, item.hostId],
|
||||
);
|
||||
|
||||
return (
|
||||
@@ -30,7 +30,7 @@ export const TurnSummaryLine = memo(function TurnSummaryLine({
|
||||
testID="turn-summary"
|
||||
label={label}
|
||||
icon={Summary}
|
||||
isExpanded={turn.expanded}
|
||||
isExpanded={item.expanded}
|
||||
onToggle={toggle}
|
||||
borderlessWhenExpanded
|
||||
/>
|
||||
|
||||
@@ -236,8 +236,8 @@ const stylesheet = StyleSheet.create((theme) => ({
|
||||
turnFooterSlot: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
alignSelf: "flex-start",
|
||||
height: TURN_FOOTER_CONTENT_HEIGHT + TURN_FOOTER_BOTTOM_SPACING,
|
||||
alignSelf: "stretch",
|
||||
minHeight: TURN_FOOTER_CONTENT_HEIGHT + TURN_FOOTER_BOTTOM_SPACING,
|
||||
paddingBottom: TURN_FOOTER_BOTTOM_SPACING,
|
||||
},
|
||||
turnFooterContent: {
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
import { useRef, useState } from "react";
|
||||
import { useStableEvent } from "@/hooks/use-stable-event";
|
||||
import {
|
||||
createHistoryStartPagination,
|
||||
type HistoryStartPaginationInput,
|
||||
type HistoryStartPaginationState,
|
||||
} from "./history-start-pagination";
|
||||
|
||||
export function useHistoryStartPagination(input: {
|
||||
initiallyVisible: boolean;
|
||||
read: () => HistoryStartPaginationInput | null;
|
||||
load: () => Promise<boolean>;
|
||||
scheduleSettle: () => void;
|
||||
}) {
|
||||
const read = useStableEvent(input.read);
|
||||
const load = useStableEvent(input.load);
|
||||
const scheduleSettle = useStableEvent(input.scheduleSettle);
|
||||
const [state, setState] = useState<HistoryStartPaginationState>(() => ({
|
||||
status: "dormant",
|
||||
visible: input.initiallyVisible,
|
||||
}));
|
||||
const controller = useRef<ReturnType<typeof createHistoryStartPagination> | null>(null);
|
||||
if (!controller.current) {
|
||||
controller.current = createHistoryStartPagination({
|
||||
initiallyVisible: input.initiallyVisible,
|
||||
read,
|
||||
load,
|
||||
scheduleSettle,
|
||||
changed: setState,
|
||||
});
|
||||
}
|
||||
return { state, controller: controller.current };
|
||||
}
|
||||
@@ -248,6 +248,8 @@ export interface AgentStreamViewProps {
|
||||
turnPresentation: TurnPresentation;
|
||||
routeBottomAnchorRequest?: BottomAnchorRouteRequest | null;
|
||||
isAuthoritativeHistoryReady?: boolean;
|
||||
isInitialHistoryLoading?: boolean;
|
||||
revealInitialHistory?: boolean;
|
||||
/** Tail space required by a transparent overlay rendered at the bottom edge. */
|
||||
bottomOverlayTailClearance?: number;
|
||||
/** Bottom offset required for controls floating above that overlay. */
|
||||
@@ -345,6 +347,8 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
||||
turnPresentation,
|
||||
routeBottomAnchorRequest = null,
|
||||
isAuthoritativeHistoryReady = true,
|
||||
isInitialHistoryLoading,
|
||||
revealInitialHistory,
|
||||
bottomOverlayTailClearance = 0,
|
||||
bottomOverlayControlClearance,
|
||||
toast,
|
||||
@@ -689,26 +693,23 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
||||
}
|
||||
return revealLoadedHistory(messageId);
|
||||
});
|
||||
// Read through stable events so a collapse rerun does not change the renderer identity;
|
||||
// rows whose turn state changed are revised through `historyRowRevision`.
|
||||
const getCollapsedTurn = useStableEvent((hostId: string) =>
|
||||
presentation.turns.turnsByHostId.get(hostId),
|
||||
);
|
||||
// Read through a stable event so a collapse rerun does not change the renderer identity;
|
||||
// nested rows are revised through `historyRowRevision`.
|
||||
const getTurnIndent = useStableEvent((layoutItem: StreamLayoutItem): TurnIndent => {
|
||||
const { nestedItemIds } = presentation.turns;
|
||||
if (nestedItemIds.has(layoutItem.item.id)) {
|
||||
const { item } = layoutItem;
|
||||
if (nestedItemIds.has(item.id)) {
|
||||
const below = layoutItem.belowItem;
|
||||
return below && nestedItemIds.has(below.id) ? "nested" : "lastNested";
|
||||
}
|
||||
return getCollapsedTurn(layoutItem.item.id)?.expanded ? "openedHost" : "none";
|
||||
return item.kind === "turn_summary" && item.expanded ? "openedHost" : "none";
|
||||
});
|
||||
|
||||
const setToolCallGroupExpanded = toolCallExpansion.setGroupExpanded;
|
||||
|
||||
const renderUserMessageItem = useCallback(
|
||||
(layoutItem: StreamLayoutItem, item: Extract<StreamItem, { kind: "user_message" }>) => {
|
||||
const collapsedTurn = getCollapsedTurn(item.id);
|
||||
const message = (
|
||||
return (
|
||||
<UserMessage
|
||||
serverId={resolvedServerId}
|
||||
agentId={agentId}
|
||||
@@ -728,25 +729,8 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
||||
}
|
||||
/>
|
||||
);
|
||||
if (!collapsedTurn) {
|
||||
return message;
|
||||
}
|
||||
return (
|
||||
<>
|
||||
{message}
|
||||
<TurnSummaryLine turn={collapsedTurn} onExpandedChange={setTurnExpanded} />
|
||||
</>
|
||||
);
|
||||
},
|
||||
[
|
||||
context.capabilities,
|
||||
agentId,
|
||||
client,
|
||||
getCollapsedTurn,
|
||||
pendingClientMessageIds,
|
||||
resolvedServerId,
|
||||
setTurnExpanded,
|
||||
],
|
||||
[context.capabilities, agentId, client, pendingClientMessageIds, resolvedServerId],
|
||||
);
|
||||
|
||||
const renderAssistantMessageItem = useCallback(
|
||||
@@ -931,17 +915,10 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
||||
return renderToolCallItem(layoutItem, item);
|
||||
|
||||
case "tool_call":
|
||||
return (
|
||||
<>
|
||||
{renderToolCallItem(layoutItem, item)}
|
||||
{getCollapsedTurn(item.id) ? (
|
||||
<TurnSummaryLine
|
||||
turn={getCollapsedTurn(item.id)!}
|
||||
onExpandedChange={setTurnExpanded}
|
||||
/>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
return renderToolCallItem(layoutItem, item);
|
||||
|
||||
case "turn_summary":
|
||||
return <TurnSummaryLine item={item} onExpandedChange={setTurnExpanded} />;
|
||||
|
||||
case "notification":
|
||||
return <Notification level={item.level} message={item.message} />;
|
||||
@@ -972,7 +949,6 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
||||
renderUserMessageItem,
|
||||
renderAssistantMessageItem,
|
||||
renderToolCallItem,
|
||||
getCollapsedTurn,
|
||||
setTurnExpanded,
|
||||
resolvedServerId,
|
||||
],
|
||||
@@ -1151,19 +1127,17 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
||||
const streamScrollEnabled =
|
||||
!streamRenderStrategy.shouldDisableParentScrollOnInlineDetailsExpansion() ||
|
||||
expandedInlineToolCallIds.size === 0;
|
||||
const { nestedItemIds, updatedHostIds: updatedTurnHostIds } = presentation.turns;
|
||||
const { nestedItemIds } = presentation.turns;
|
||||
const historyRowRevision = useMemo(
|
||||
() => ({
|
||||
contentById: {
|
||||
has: (id: string) =>
|
||||
presentation.historyGroupUpdatesByHostId.has(id) || updatedTurnHostIds.has(id),
|
||||
has: (id: string) => presentation.historyGroupUpdatesByHostId.has(id),
|
||||
},
|
||||
displayStateById: {
|
||||
// An opened lone call also marks its future group open; that row stays unrevised
|
||||
// until a group actually forms there.
|
||||
has: (id: string) =>
|
||||
(expandedToolCallGroups.has(id) && getToolCallGroup(id) !== undefined) ||
|
||||
expandedTurnHostIds.has(id) ||
|
||||
nestedItemIds.has(id),
|
||||
},
|
||||
globalDisplayState: isMobile,
|
||||
@@ -1171,11 +1145,9 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
||||
[
|
||||
expandedToolCallGroups,
|
||||
getToolCallGroup,
|
||||
expandedTurnHostIds,
|
||||
isMobile,
|
||||
nestedItemIds,
|
||||
presentation.historyGroupUpdatesByHostId,
|
||||
updatedTurnHostIds,
|
||||
],
|
||||
);
|
||||
|
||||
@@ -1209,6 +1181,8 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
||||
viewportRef,
|
||||
routeBottomAnchorRequest,
|
||||
isAuthoritativeHistoryReady,
|
||||
isInitialHistoryLoading,
|
||||
revealInitialHistory,
|
||||
onNearBottomChange: nearBottom.set,
|
||||
onReadingPositionChange: handleReadingPositionChange,
|
||||
onNearHistoryStart: loadOlder,
|
||||
@@ -1351,6 +1325,12 @@ function agentStreamViewPropsEqual(
|
||||
if (left.isAuthoritativeHistoryReady !== right.isAuthoritativeHistoryReady) {
|
||||
reasons.push("isAuthoritativeHistoryReady");
|
||||
}
|
||||
if (left.isInitialHistoryLoading !== right.isInitialHistoryLoading) {
|
||||
reasons.push("isInitialHistoryLoading");
|
||||
}
|
||||
if (left.revealInitialHistory !== right.revealInitialHistory) {
|
||||
reasons.push("revealInitialHistory");
|
||||
}
|
||||
if (left.bottomOverlayTailClearance !== right.bottomOverlayTailClearance) {
|
||||
reasons.push("bottomOverlayTailClearance");
|
||||
}
|
||||
|
||||
@@ -65,6 +65,7 @@ export function estimateStreamItemHeight({
|
||||
case "tool_call":
|
||||
return COLLAPSED_TOOL_SEQUENCE_ROW_HEIGHT_ESTIMATE;
|
||||
case "thought":
|
||||
case "turn_summary":
|
||||
return COLLAPSED_TOOL_SEQUENCE_ROW_HEIGHT_ESTIMATE;
|
||||
case "todo_list":
|
||||
return 144;
|
||||
|
||||
@@ -557,6 +557,15 @@ interface AssistantTurnFooterProps {
|
||||
|
||||
const assistantTurnFooterStylesheet = StyleSheet.create((theme) => ({
|
||||
container: {
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
minHeight: 24,
|
||||
flexDirection: "row",
|
||||
alignItems: "flex-start",
|
||||
gap: theme.spacing[2],
|
||||
},
|
||||
actions: {
|
||||
height: 24,
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[2],
|
||||
@@ -568,21 +577,25 @@ const assistantTurnFooterStylesheet = StyleSheet.create((theme) => ({
|
||||
marginTop: 0,
|
||||
marginLeft: -theme.spacing[1],
|
||||
},
|
||||
labelWrapper: {
|
||||
position: "relative",
|
||||
labelButton: {
|
||||
flex: 1,
|
||||
minHeight: 24,
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
minWidth: 0,
|
||||
},
|
||||
labelSizer: {
|
||||
label: {
|
||||
width: "100%",
|
||||
flexShrink: 0,
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontSize: STREAM_METADATA_FONT_SIZE,
|
||||
},
|
||||
overlappingLabel: {
|
||||
marginLeft: "-100%",
|
||||
},
|
||||
hiddenLabel: {
|
||||
opacity: 0,
|
||||
},
|
||||
labelOverlay: {
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
left: 0,
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontSize: STREAM_METADATA_FONT_SIZE,
|
||||
},
|
||||
}));
|
||||
|
||||
const TIMESTAMP_REVEAL_MS = 3000;
|
||||
@@ -649,31 +662,51 @@ export const AssistantTurnFooter = memo(function AssistantTurnFooter({
|
||||
|
||||
return (
|
||||
<View style={assistantTurnFooterStylesheet.container}>
|
||||
{getContent ? (
|
||||
<TurnCopyButton
|
||||
getContent={getContent}
|
||||
containerStyle={assistantTurnFooterStylesheet.copyButton}
|
||||
/>
|
||||
{getContent || canFork ? (
|
||||
<View style={assistantTurnFooterStylesheet.actions}>
|
||||
{getContent ? (
|
||||
<TurnCopyButton
|
||||
getContent={getContent}
|
||||
containerStyle={assistantTurnFooterStylesheet.copyButton}
|
||||
/>
|
||||
) : null}
|
||||
{canFork ? <AssistantForkMenu onFork={handleFork} /> : null}
|
||||
</View>
|
||||
) : null}
|
||||
{canFork ? <AssistantForkMenu onFork={handleFork} /> : null}
|
||||
{primaryLabel ? (
|
||||
<Pressable
|
||||
style={assistantTurnFooterStylesheet.labelButton}
|
||||
onPress={handlePress}
|
||||
onHoverIn={handleHoverIn}
|
||||
onHoverOut={handleHoverOut}
|
||||
accessibilityRole={canSwap ? "button" : undefined}
|
||||
accessibilityLabel={canSwap ? `${durationLabel}, ended ${timestampLabel}` : primaryLabel}
|
||||
>
|
||||
<View style={assistantTurnFooterStylesheet.labelWrapper}>
|
||||
{/* Sizer reserves space for whichever label is longer so the
|
||||
container width is stable across hover transitions. */}
|
||||
<Text style={assistantTurnFooterStylesheet.labelSizer} aria-hidden>
|
||||
{primaryLabel.length >= timestampLabel.length ? primaryLabel : timestampLabel}
|
||||
{/* Both labels participate in layout at the available width. Overlapping their
|
||||
columns reserves the taller wrapped height without measuring text or characters. */}
|
||||
<Text
|
||||
testID={!showTimestamp ? "turn-footer-timing-label" : undefined}
|
||||
aria-hidden={showTimestamp}
|
||||
style={[
|
||||
assistantTurnFooterStylesheet.label,
|
||||
showTimestamp && assistantTurnFooterStylesheet.hiddenLabel,
|
||||
]}
|
||||
>
|
||||
{primaryLabel}
|
||||
</Text>
|
||||
{canSwap ? (
|
||||
<Text
|
||||
testID={showTimestamp ? "turn-footer-timing-label" : undefined}
|
||||
aria-hidden={!showTimestamp}
|
||||
style={[
|
||||
assistantTurnFooterStylesheet.label,
|
||||
assistantTurnFooterStylesheet.overlappingLabel,
|
||||
!showTimestamp && assistantTurnFooterStylesheet.hiddenLabel,
|
||||
]}
|
||||
>
|
||||
{timestampLabel}
|
||||
</Text>
|
||||
<Text style={assistantTurnFooterStylesheet.labelOverlay}>
|
||||
{showTimestamp ? timestampLabel : primaryLabel}
|
||||
</Text>
|
||||
</View>
|
||||
) : null}
|
||||
</Pressable>
|
||||
) : null}
|
||||
</View>
|
||||
|
||||
@@ -129,7 +129,7 @@ describe("loadOlderAgentHistory", () => {
|
||||
|
||||
expect(client.calls).toEqual([]);
|
||||
expect(inFlight.values).toEqual([true]);
|
||||
expect(started).toBe(true);
|
||||
expect(started).toBe(false);
|
||||
});
|
||||
|
||||
it("requests the page before the current start cursor and clears in-flight on success", async () => {
|
||||
@@ -153,6 +153,7 @@ describe("loadOlderAgentHistory", () => {
|
||||
limit: TIMELINE_FETCH_PAGE_SIZE,
|
||||
projection: "projected",
|
||||
wholeTurns: true,
|
||||
timeout: 10_000,
|
||||
},
|
||||
},
|
||||
]);
|
||||
@@ -169,7 +170,7 @@ describe("loadOlderAgentHistory", () => {
|
||||
const toast = createToast();
|
||||
const logger = createLogger();
|
||||
|
||||
await loadOlderAgentHistory(agentId, {
|
||||
const applied = await loadOlderAgentHistory(agentId, {
|
||||
client,
|
||||
cursor: someCursor,
|
||||
hasOlder: true,
|
||||
@@ -179,6 +180,7 @@ describe("loadOlderAgentHistory", () => {
|
||||
logger,
|
||||
});
|
||||
|
||||
expect(applied).toBe(false);
|
||||
expect(client.calls).toHaveLength(1);
|
||||
expect(toast.shown).toEqual([
|
||||
{
|
||||
|
||||
@@ -18,6 +18,7 @@ export interface LoadOlderAgentHistoryClient {
|
||||
cursor: { epoch: string; seq: number };
|
||||
limit: number;
|
||||
projection: "projected";
|
||||
timeout: number;
|
||||
},
|
||||
) => Promise<unknown>;
|
||||
}
|
||||
@@ -44,7 +45,7 @@ export async function loadOlderAgentHistory(
|
||||
const { client, cursor, hasOlder, isLoadingOlder, setInFlight, toast, logger, failedMessage } =
|
||||
deps;
|
||||
if (isLoadingOlder) {
|
||||
return true;
|
||||
return false;
|
||||
}
|
||||
if (!client || !cursor || !hasOlder) {
|
||||
return false;
|
||||
@@ -52,16 +53,17 @@ export async function loadOlderAgentHistory(
|
||||
|
||||
setInFlight(true);
|
||||
try {
|
||||
await client.fetchAgentTimeline(
|
||||
agentId,
|
||||
planTimelineOlderFetch({ epoch: cursor.epoch, seq: cursor.startSeq }),
|
||||
);
|
||||
await client.fetchAgentTimeline(agentId, {
|
||||
...planTimelineOlderFetch({ epoch: cursor.epoch, seq: cursor.startSeq }),
|
||||
timeout: 10_000,
|
||||
});
|
||||
} catch (error) {
|
||||
(logger ?? console).warn("[Timeline] failed to load older agent history", agentId, error);
|
||||
toast?.show(failedMessage ?? i18n.t("loadOlderHistory.failed"), {
|
||||
durationMs: 2200,
|
||||
testID: "agent-load-older-history-toast",
|
||||
});
|
||||
return false;
|
||||
} finally {
|
||||
setInFlight(false);
|
||||
}
|
||||
|
||||
@@ -1012,6 +1012,7 @@ function ChatAgentContent({
|
||||
effectiveAgent={effectiveAgent}
|
||||
routeBottomAnchorRequest={routeBottomAnchorRequest}
|
||||
hasAppliedAuthoritativeHistory={hasAppliedAuthoritativeHistory}
|
||||
hasCachedHistory={replicaTimelineStatus === "painted"}
|
||||
toastApi={toastApi}
|
||||
toast={toastState}
|
||||
dismiss={dismissToast}
|
||||
@@ -1043,6 +1044,7 @@ const ChatAgentReadyContent = memo(function ChatAgentReadyContent({
|
||||
effectiveAgent,
|
||||
routeBottomAnchorRequest,
|
||||
hasAppliedAuthoritativeHistory,
|
||||
hasCachedHistory,
|
||||
toastApi,
|
||||
toast,
|
||||
dismiss,
|
||||
@@ -1071,6 +1073,7 @@ const ChatAgentReadyContent = memo(function ChatAgentReadyContent({
|
||||
effectiveAgent: AgentScreenAgent | null;
|
||||
routeBottomAnchorRequest: RouteBottomAnchorRequest;
|
||||
hasAppliedAuthoritativeHistory: boolean;
|
||||
hasCachedHistory: boolean;
|
||||
toastApi: ToastApi;
|
||||
toast: ToastState | null;
|
||||
dismiss: () => void;
|
||||
@@ -1198,6 +1201,12 @@ const ChatAgentReadyContent = memo(function ChatAgentReadyContent({
|
||||
draft={draft}
|
||||
routeBottomAnchorRequest={routeBottomAnchorRequest}
|
||||
hasAppliedAuthoritativeHistory={hasAppliedAuthoritativeHistory}
|
||||
isInitialHistoryLoading={
|
||||
routeBottomAnchorRequest?.reason === "initial-entry" &&
|
||||
!hasCachedHistory &&
|
||||
!showHistorySyncError
|
||||
}
|
||||
revealInitialHistory={hasCachedHistory || showHistorySyncError}
|
||||
hasActiveComposer={hasActiveComposer}
|
||||
toast={toastApi}
|
||||
onOpenWorkspaceFile={onOpenWorkspaceFile}
|
||||
@@ -1359,6 +1368,8 @@ const AgentStreamSection = memo(function AgentStreamSection({
|
||||
draft,
|
||||
routeBottomAnchorRequest,
|
||||
hasAppliedAuthoritativeHistory,
|
||||
isInitialHistoryLoading,
|
||||
revealInitialHistory,
|
||||
hasActiveComposer,
|
||||
toast,
|
||||
onOpenWorkspaceFile,
|
||||
@@ -1370,6 +1381,8 @@ const AgentStreamSection = memo(function AgentStreamSection({
|
||||
draft: DraftConversation | null;
|
||||
routeBottomAnchorRequest: RouteBottomAnchorRequest;
|
||||
hasAppliedAuthoritativeHistory: boolean;
|
||||
isInitialHistoryLoading: boolean;
|
||||
revealInitialHistory: boolean;
|
||||
hasActiveComposer: boolean;
|
||||
toast: ReturnType<typeof useToastHost>["api"];
|
||||
onOpenWorkspaceFile?: (request: WorkspaceFileOpenRequest) => void;
|
||||
@@ -1460,6 +1473,8 @@ const AgentStreamSection = memo(function AgentStreamSection({
|
||||
pendingPermissions={pendingPermissions}
|
||||
routeBottomAnchorRequest={routeBottomAnchorRequest}
|
||||
isAuthoritativeHistoryReady={hasAppliedAuthoritativeHistory}
|
||||
isInitialHistoryLoading={isInitialHistoryLoading}
|
||||
revealInitialHistory={revealInitialHistory}
|
||||
bottomOverlayTailClearance={bottomOverlayTailClearance}
|
||||
bottomOverlayControlClearance={bottomOverlayControlClearance}
|
||||
toast={toast}
|
||||
|
||||
@@ -26,6 +26,7 @@ import {
|
||||
providerSubagentLifecycleStatus,
|
||||
refreshProviderSubagents,
|
||||
observeProviderSubagentTimeline,
|
||||
loadOlderProviderSubagentTimeline,
|
||||
useProviderSubagentStore,
|
||||
} from "@/subagents/provider-store";
|
||||
import { useTranslation } from "react-i18next";
|
||||
@@ -177,26 +178,24 @@ function ProviderSubagentPanel() {
|
||||
});
|
||||
}, [client, supported, serverId, target.parentAgentId, target.subagentId]);
|
||||
|
||||
const loadOlder = useCallback((): boolean => {
|
||||
const loadOlder = useCallback(async (): Promise<boolean> => {
|
||||
if (!client || !supported || isLoadingOlder || !timeline?.hasOlder || !timeline.epoch) {
|
||||
return false;
|
||||
}
|
||||
const firstSeq = timeline.cursor?.startSeq ?? null;
|
||||
if (firstSeq === null) return false;
|
||||
setIsLoadingOlder(true);
|
||||
void client
|
||||
.fetchProviderSubagentTimeline(target.parentAgentId, target.subagentId, {
|
||||
direction: "before",
|
||||
cursor: { epoch: timeline.epoch, seq: firstSeq },
|
||||
try {
|
||||
return await loadOlderProviderSubagentTimeline({
|
||||
client,
|
||||
serverId,
|
||||
parentAgentId: target.parentAgentId,
|
||||
subagentId: target.subagentId,
|
||||
limit: TIMELINE_FETCH_PAGE_SIZE,
|
||||
})
|
||||
.then((payload) => {
|
||||
useProviderSubagentStore.getState().replaceTimeline(serverId, payload);
|
||||
return undefined;
|
||||
})
|
||||
.catch(() => undefined)
|
||||
.finally(() => setIsLoadingOlder(false));
|
||||
return true;
|
||||
});
|
||||
} catch {
|
||||
return false;
|
||||
} finally {
|
||||
setIsLoadingOlder(false);
|
||||
}
|
||||
}, [
|
||||
client,
|
||||
isLoadingOlder,
|
||||
|
||||
@@ -76,6 +76,7 @@ function sourceTimelineItem(item: StreamItem): AgentTimelineItem | null {
|
||||
...(item.preTokens !== undefined ? { preTokens: item.preTokens } : {}),
|
||||
};
|
||||
case "plugin":
|
||||
case "turn_summary":
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -505,6 +505,9 @@ function serializeTimelineItem(item: StreamItem): StoredTimelineItem | null {
|
||||
version: item.version,
|
||||
data: item.data,
|
||||
};
|
||||
// A folded turn's summary row is presentation, not timeline.
|
||||
case "turn_summary":
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { createHistoryStartPagination } from "@/agent-stream/history-start-pagination";
|
||||
import { DaemonConnectionError } from "@getpaseo/client/internal/daemon-client";
|
||||
import { afterEach, describe, expect, test } from "vitest";
|
||||
import { afterEach, describe, expect, test, vi } from "vitest";
|
||||
import type { ProviderSubagentDescriptorPayload } from "@getpaseo/protocol/messages";
|
||||
import {
|
||||
loadOlderProviderSubagentTimeline,
|
||||
observeProviderSubagentTimeline,
|
||||
providerSubagentKey,
|
||||
refreshProviderSubagents,
|
||||
@@ -565,6 +567,95 @@ describe("projected child history", () => {
|
||||
.getState()
|
||||
.replaceTimeline(SERVER_ID, response(value, seqStart, seqEnd, direction));
|
||||
}
|
||||
test("older child history completes only after the delayed page is applied", async () => {
|
||||
const tail = { ...response("B", 2, 2), hasOlder: true };
|
||||
useProviderSubagentStore.getState().replaceTimeline(SERVER_ID, tail);
|
||||
let deliver!: (payload: Payload) => void;
|
||||
const client = {
|
||||
fetchProviderSubagentTimeline: () =>
|
||||
new Promise<Payload>((resolve) => {
|
||||
deliver = resolve;
|
||||
}),
|
||||
};
|
||||
let completed = false;
|
||||
const request = loadOlderProviderSubagentTimeline({
|
||||
client,
|
||||
serverId: SERVER_ID,
|
||||
parentAgentId: PARENT_ID,
|
||||
subagentId: SUBAGENT_ID,
|
||||
limit: 100,
|
||||
}).then((result) => {
|
||||
completed = true;
|
||||
return result;
|
||||
});
|
||||
await Promise.resolve();
|
||||
expect(completed).toBe(false);
|
||||
deliver({ ...response("A", 1, 1), direction: "before" });
|
||||
expect(await request).toBe(true);
|
||||
expect(current().cursor?.startSeq).toBe(1);
|
||||
});
|
||||
test("bounds an older child request so a timeout permits another attempt", async () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
useProviderSubagentStore
|
||||
.getState()
|
||||
.replaceTimeline(SERVER_ID, { ...response("B", 2, 2), hasOlder: true });
|
||||
const timeouts: Array<number | undefined> = [];
|
||||
const client = {
|
||||
fetchProviderSubagentTimeline: (
|
||||
_parent: string,
|
||||
_child: string,
|
||||
options: { timeout?: number },
|
||||
) => {
|
||||
timeouts.push(options.timeout);
|
||||
return new Promise<Payload>((_resolve, reject) =>
|
||||
setTimeout(() => reject(new Error("timeout")), options.timeout ?? 60_000),
|
||||
);
|
||||
},
|
||||
};
|
||||
let loading = false;
|
||||
const pagination = createHistoryStartPagination({
|
||||
initiallyVisible: true,
|
||||
read: () => ({
|
||||
distanceFromHistoryStart: 0,
|
||||
isUnderfilled: true,
|
||||
hasOlderHistory: true,
|
||||
isLoadingOlderHistory: loading,
|
||||
isReady: true,
|
||||
progressKey: "e:2",
|
||||
}),
|
||||
load: async () => {
|
||||
loading = true;
|
||||
try {
|
||||
return await loadOlderProviderSubagentTimeline({
|
||||
client,
|
||||
serverId: SERVER_ID,
|
||||
parentAgentId: PARENT_ID,
|
||||
subagentId: SUBAGENT_ID,
|
||||
limit: 100,
|
||||
});
|
||||
} catch {
|
||||
return false;
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
},
|
||||
changed: () => {},
|
||||
scheduleSettle: () => {},
|
||||
});
|
||||
pagination.update();
|
||||
expect(timeouts).toEqual([10_000]);
|
||||
await vi.advanceTimersByTimeAsync(10_000);
|
||||
expect(pagination.state.status).toBe("failed");
|
||||
pagination.rearm();
|
||||
expect(timeouts).toEqual([10_000, 10_000]);
|
||||
expect(pagination.state.status).toBe("loading");
|
||||
pagination.dispose();
|
||||
await vi.advanceTimersByTimeAsync(10_000);
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
test("replaces overlapping fetched text and continues live streaming", () => {
|
||||
page("AB", 1, 2);
|
||||
stream(3, "C");
|
||||
|
||||
@@ -355,6 +355,35 @@ export const useProviderSubagentStore = create<ProviderSubagentState>((set) => (
|
||||
}));
|
||||
|
||||
/** Owns child history bootstrap and recovery while a pane observes the child. */
|
||||
export async function loadOlderProviderSubagentTimeline({
|
||||
client,
|
||||
serverId,
|
||||
parentAgentId,
|
||||
subagentId,
|
||||
limit,
|
||||
}: {
|
||||
client: Pick<DaemonClient, "fetchProviderSubagentTimeline">;
|
||||
serverId: string;
|
||||
parentAgentId: string;
|
||||
subagentId: string;
|
||||
limit: number;
|
||||
}): Promise<boolean> {
|
||||
const timeline = useProviderSubagentStore
|
||||
.getState()
|
||||
.timelines.get(providerSubagentKey(serverId, parentAgentId, subagentId));
|
||||
const seq = timeline?.cursor?.startSeq;
|
||||
if (!timeline?.hasOlder || !timeline.epoch || seq == null) return false;
|
||||
const payload = await client.fetchProviderSubagentTimeline(parentAgentId, subagentId, {
|
||||
direction: "before",
|
||||
cursor: { epoch: timeline.epoch, seq },
|
||||
limit,
|
||||
timeout: 10_000,
|
||||
});
|
||||
if (payload.error) return false;
|
||||
useProviderSubagentStore.getState().replaceTimeline(serverId, payload);
|
||||
return true;
|
||||
}
|
||||
|
||||
export function observeProviderSubagentTimeline({
|
||||
client,
|
||||
serverId,
|
||||
|
||||
@@ -8,6 +8,7 @@ import type {
|
||||
import { timelineItemIdentity } from "@getpaseo/protocol/timeline-identity";
|
||||
import type { AgentAttachment, AgentStreamEventPayload } from "@getpaseo/protocol/messages";
|
||||
import type { AttachmentMetadata } from "@/attachments/types";
|
||||
import type { OverviewSummary } from "@/tool-calls/detail-level/overview/model";
|
||||
import { extractTaskEntriesFromToolCall } from "../utils/tool-call-parsers";
|
||||
|
||||
/**
|
||||
@@ -86,7 +87,8 @@ export type StreamItem =
|
||||
| TodoListItem
|
||||
| NotificationItem
|
||||
| CompactionItem
|
||||
| PluginTimelineStreamItem;
|
||||
| PluginTimelineStreamItem
|
||||
| TurnSummaryItem;
|
||||
|
||||
export type UserMessageImageAttachment = AttachmentMetadata;
|
||||
|
||||
@@ -824,6 +826,25 @@ export interface PluginTimelineStreamItem {
|
||||
data: JsonValue;
|
||||
}
|
||||
|
||||
/**
|
||||
* The row a folded turn leaves between its host and its reply, standing in for the work it
|
||||
* hides. Turn collapse produces it from the loaded rows; it is never stored.
|
||||
*/
|
||||
export interface TurnSummaryItem {
|
||||
kind: "turn_summary";
|
||||
id: string;
|
||||
timelineCursor?: TimelinePosition;
|
||||
turnId?: string;
|
||||
timestamp: Date;
|
||||
/**
|
||||
* The prompt the turn folds under, or its first loaded tool call when the prompt is above the
|
||||
* loaded history. Keys the reader's expanded turns.
|
||||
*/
|
||||
hostId: string;
|
||||
expanded: boolean;
|
||||
tools: OverviewSummary;
|
||||
}
|
||||
|
||||
export interface TodoEntry {
|
||||
text: string;
|
||||
completed: boolean;
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
import { expect, it, vi } from "vitest";
|
||||
import { exportPublicKey, generateKeyPair } from "./crypto.js";
|
||||
import type { Transport } from "./encrypted-channel.js";
|
||||
import { openClientSecureChannel } from "./secure-channel.js";
|
||||
|
||||
/** A socket whose peer never acknowledges the close handshake. */
|
||||
class UnresponsiveTransport implements Transport {
|
||||
onmessage: Transport["onmessage"] = null;
|
||||
onclose: Transport["onclose"] = null;
|
||||
onerror: Transport["onerror"] = null;
|
||||
sends = 0;
|
||||
closing = false;
|
||||
|
||||
send(): void {
|
||||
this.sends += 1;
|
||||
if (this.closing) throw new Error("WebSocket not open (readyState=2)");
|
||||
}
|
||||
|
||||
close(): void {
|
||||
this.closing = true;
|
||||
}
|
||||
}
|
||||
|
||||
it.each(["client close", "handshake rejection"] as const)(
|
||||
"stops handshake retries after %s without a socket acknowledgment",
|
||||
async (trigger) => {
|
||||
vi.useFakeTimers();
|
||||
const transport = new UnresponsiveTransport();
|
||||
const errors: Error[] = [];
|
||||
let closed = 0;
|
||||
try {
|
||||
const channel = await openClientSecureChannel(transport, {
|
||||
daemonPublicKeyB64: exportPublicKey(generateKeyPair().publicKey),
|
||||
minimumMode: "protected",
|
||||
events: { onerror: (error) => errors.push(error) },
|
||||
});
|
||||
channel.onClose(() => closed++);
|
||||
|
||||
// The handshake still retries while the connection is wanted.
|
||||
await vi.advanceTimersByTimeAsync(2_000);
|
||||
expect(transport.sends).toBe(3);
|
||||
if (trigger === "client close") {
|
||||
channel.close();
|
||||
} else {
|
||||
transport.onmessage?.({ data: JSON.stringify({ type: "e2ee_ready" }), isBinary: false });
|
||||
}
|
||||
await vi.advanceTimersByTimeAsync(5_000);
|
||||
|
||||
expect(transport.sends).toBe(3);
|
||||
expect(errors).toHaveLength(trigger === "client close" ? 0 : 1);
|
||||
expect(closed).toBe(1);
|
||||
// A late socket acknowledgment does not close the channel a second time.
|
||||
transport.onclose?.(1000, "Normal closure");
|
||||
expect(closed).toBe(1);
|
||||
} finally {
|
||||
vi.clearAllTimers();
|
||||
vi.useRealTimers();
|
||||
}
|
||||
},
|
||||
);
|
||||
@@ -344,8 +344,7 @@ class ClientSecureChannel implements SecureChannel {
|
||||
|
||||
private fail(code: number, reason: string): void {
|
||||
if (this.state === "closed") return;
|
||||
this.state = "closed";
|
||||
this.handshake = null;
|
||||
this.notifyClosed();
|
||||
try {
|
||||
this.transport.close(code, reason);
|
||||
} catch {
|
||||
@@ -364,13 +363,12 @@ class ClientSecureChannel implements SecureChannel {
|
||||
}
|
||||
|
||||
close(code = 1000, reason = "Normal closure"): void {
|
||||
this.notifyClosed();
|
||||
if (this.inner) {
|
||||
this.inner.close(code, reason);
|
||||
} else {
|
||||
this.transport.close(code, reason);
|
||||
}
|
||||
this.state = "closed";
|
||||
this.handshake = null;
|
||||
}
|
||||
|
||||
isOpen(): boolean {
|
||||
|
||||
+28
-1
@@ -3,7 +3,7 @@ import { execFileSync } from "node:child_process";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { createServer } from "node:http";
|
||||
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { once } from "node:events";
|
||||
|
||||
import { AgentManager } from "../agent-manager.js";
|
||||
@@ -217,6 +217,33 @@ function waitForEvent<TEvent extends AgentStreamEvent>(params: {
|
||||
}
|
||||
|
||||
describe("Codex app-server provider (local e2e)", () => {
|
||||
test.runIf(isCodexInstalled())(
|
||||
"concurrent import scans initialize successfully in a fresh shared Codex home",
|
||||
async () => {
|
||||
const artifacts = path.resolve(".artifacts");
|
||||
mkdirSync(artifacts, { recursive: true });
|
||||
const root = mkdtempSync(path.join(artifacts, "codex-concurrent-startup-"));
|
||||
try {
|
||||
for (let round = 0; round < 4; round++) {
|
||||
const codexHome = path.join(root, String(round));
|
||||
mkdirSync(codexHome);
|
||||
const provider = new CodexAppServerAgentClient(createTestLogger(), {
|
||||
env: { CODEX_HOME: codexHome },
|
||||
});
|
||||
const results = await Promise.allSettled(
|
||||
Array.from({ length: 24 }, () => provider.listImportableSessions({ limit: 1 })),
|
||||
);
|
||||
expect(results).toEqual(
|
||||
Array.from({ length: 24 }, () => ({ status: "fulfilled", value: [] })),
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
},
|
||||
60_000,
|
||||
);
|
||||
|
||||
test.runIf(isCodexInstalled())(
|
||||
"reloads a persisted idle thread repeatedly without overlapping writers",
|
||||
async () => {
|
||||
|
||||
@@ -587,6 +587,10 @@ test("concurrent session APIs share one reconnect after an idle provider exit",
|
||||
createFakeCodexAppServer(),
|
||||
];
|
||||
let spawnCount = 0;
|
||||
let markReconnectStarted!: () => void;
|
||||
const reconnectStarted = new Promise<void>((resolve) => {
|
||||
markReconnectStarted = resolve;
|
||||
});
|
||||
let releaseReconnect: (() => void) | undefined;
|
||||
const reconnectGate = new Promise<void>((resolve) => {
|
||||
releaseReconnect = resolve;
|
||||
@@ -602,6 +606,7 @@ test("concurrent session APIs share one reconnect after an idle provider exit",
|
||||
}
|
||||
spawnCount += 1;
|
||||
if (spawnCount > 1) {
|
||||
markReconnectStarted();
|
||||
await reconnectGate;
|
||||
}
|
||||
return appServer.child;
|
||||
@@ -614,12 +619,14 @@ test("concurrent session APIs share one reconnect after an idle provider exit",
|
||||
|
||||
const runtimeInfo = session.getRuntimeInfo();
|
||||
const turnStart = session.startTurn("continue after reconnect");
|
||||
await reconnectStarted;
|
||||
const reconnectSpawnCount = spawnCount - 1;
|
||||
releaseReconnect?.();
|
||||
|
||||
await expect(runtimeInfo).resolves.toMatchObject({ provider: "codex" });
|
||||
await expect(turnStart).resolves.toMatchObject({ turnId: expect.any(String) });
|
||||
expect(reconnectSpawnCount).toBe(1);
|
||||
expect(spawnCount).toBe(2);
|
||||
} finally {
|
||||
releaseReconnect?.();
|
||||
await session.close();
|
||||
|
||||
+6
-7
@@ -35,15 +35,14 @@ describe("CodexAppServerAgentClient spawn error handling", () => {
|
||||
});
|
||||
|
||||
test("fetchCatalog settles when the refresh deadline aborts an unanswered initialize", async () => {
|
||||
const client = new CodexAppServerAgentClient(logger, {
|
||||
command: {
|
||||
mode: "replace",
|
||||
argv: ["/nonexistent/codex-binary-that-does-not-exist"],
|
||||
},
|
||||
});
|
||||
const child = createCodexAppServerChildProcess();
|
||||
const kill = vi.spyOn(child, "kill");
|
||||
Object.assign(client, { spawnAppServer: async () => child });
|
||||
const client = new CodexAppServerAgentClient(logger, undefined, {
|
||||
appServerProcess: {
|
||||
resolvePrefix: async () => ({ command: "codex", args: [] }),
|
||||
spawn: () => child,
|
||||
},
|
||||
});
|
||||
|
||||
await expect(
|
||||
runProviderRefreshWithDeadline({
|
||||
|
||||
@@ -87,6 +87,7 @@ describe("Codex executable discovery", () => {
|
||||
|
||||
import { CodexAppServerClient } from "./codex/app-server-transport.js";
|
||||
import {
|
||||
createCodexAppServerChildProcess,
|
||||
createFakeCodexAppServer,
|
||||
fakeCodexThreadHistory,
|
||||
type FakeCodexAppServer,
|
||||
@@ -185,18 +186,24 @@ function createProviderWithFakeAppServer(
|
||||
): CodexAppServerAgentClient {
|
||||
const provider = new CodexAppServerAgentClient(createTestLogger(), options?.runtimeSettings, {
|
||||
customProvider: options?.customProvider,
|
||||
appServerProcess: createFakeCodexAppServerProcess(() => appServer.child),
|
||||
});
|
||||
const internals = castInternals<{
|
||||
goalsEnabledPromise: Promise<boolean> | null;
|
||||
autoReviewEnabledPromise: Promise<boolean> | null;
|
||||
spawnAppServer: () => Promise<ChildProcessWithoutNullStreams>;
|
||||
}>(provider);
|
||||
internals.goalsEnabledPromise = Promise.resolve(false);
|
||||
internals.autoReviewEnabledPromise = Promise.resolve(false);
|
||||
internals.spawnAppServer = async () => appServer.child;
|
||||
return provider;
|
||||
}
|
||||
|
||||
function createFakeCodexAppServerProcess(spawn: () => ChildProcessWithoutNullStreams) {
|
||||
return {
|
||||
resolvePrefix: async () => ({ command: "codex", args: [] }),
|
||||
spawn: () => spawn(),
|
||||
};
|
||||
}
|
||||
|
||||
async function startPublicSteeringSession(
|
||||
appServer: FakeCodexAppServer,
|
||||
resolveSlashCommandInvocation?: (prompt: AgentPromptInput) => Promise<{
|
||||
@@ -626,24 +633,25 @@ function codexAgentItem(text: string) {
|
||||
// app-server serving `threads` from thread/read.
|
||||
function createReopenableCodexAgent(threads: Record<string, unknown[]>) {
|
||||
const appServers: FakeCodexAppServer[] = [];
|
||||
const provider = new CodexAppServerAgentClient(createTestLogger());
|
||||
const provider = new CodexAppServerAgentClient(createTestLogger(), undefined, {
|
||||
appServerProcess: createFakeCodexAppServerProcess(() => {
|
||||
const appServer = createFakeCodexAppServer({
|
||||
"thread/read": (params) => {
|
||||
const { threadId } = params as { threadId: string };
|
||||
return { thread: { id: threadId, turns: threads[threadId] ?? [] } };
|
||||
},
|
||||
});
|
||||
appServers.push(appServer);
|
||||
return appServer.child;
|
||||
}),
|
||||
});
|
||||
const internals = castInternals<{
|
||||
goalsEnabledPromise: Promise<boolean> | null;
|
||||
autoReviewEnabledPromise: Promise<boolean> | null;
|
||||
spawnAppServer: () => Promise<ChildProcessWithoutNullStreams>;
|
||||
}>(provider);
|
||||
internals.goalsEnabledPromise = Promise.resolve(false);
|
||||
internals.autoReviewEnabledPromise = Promise.resolve(false);
|
||||
internals.spawnAppServer = async () => {
|
||||
const appServer = createFakeCodexAppServer({
|
||||
"thread/read": (params) => {
|
||||
const { threadId } = params as { threadId: string };
|
||||
return { thread: { id: threadId, turns: threads[threadId] ?? [] } };
|
||||
},
|
||||
});
|
||||
appServers.push(appServer);
|
||||
return appServer.child;
|
||||
};
|
||||
|
||||
// The fake app-server stands in for the Codex CLI, which test machines may not have.
|
||||
const client: AgentClient = {
|
||||
provider: "codex",
|
||||
@@ -2373,10 +2381,9 @@ describe("Codex app-server provider", () => {
|
||||
return { thread: { id: "native-thread-id" } };
|
||||
},
|
||||
});
|
||||
const provider = new CodexAppServerAgentClient(createTestLogger());
|
||||
castInternals<{ spawnAppServer: () => Promise<ChildProcessWithoutNullStreams> }>(
|
||||
provider,
|
||||
).spawnAppServer = async () => appServer.child;
|
||||
const provider = new CodexAppServerAgentClient(createTestLogger(), undefined, {
|
||||
appServerProcess: createFakeCodexAppServerProcess(() => appServer.child),
|
||||
});
|
||||
|
||||
await provider.unarchiveNativeSession({
|
||||
provider: "codex",
|
||||
@@ -2398,10 +2405,9 @@ describe("Codex app-server provider", () => {
|
||||
return { thread: { id: "native-thread-id" } };
|
||||
},
|
||||
});
|
||||
const provider = new CodexAppServerAgentClient(createTestLogger());
|
||||
castInternals<{ spawnAppServer: () => Promise<ChildProcessWithoutNullStreams> }>(
|
||||
provider,
|
||||
).spawnAppServer = async () => appServer.child;
|
||||
const provider = new CodexAppServerAgentClient(createTestLogger(), undefined, {
|
||||
appServerProcess: createFakeCodexAppServerProcess(() => appServer.child),
|
||||
});
|
||||
|
||||
await provider.archiveNativeSession({
|
||||
provider: "codex",
|
||||
@@ -2423,10 +2429,9 @@ describe("Codex app-server provider", () => {
|
||||
return { thread: { id: "persisted-thread-id" } };
|
||||
},
|
||||
});
|
||||
const provider = new CodexAppServerAgentClient(createTestLogger());
|
||||
castInternals<{ spawnAppServer: () => Promise<ChildProcessWithoutNullStreams> }>(
|
||||
provider,
|
||||
).spawnAppServer = async () => appServer.child;
|
||||
const provider = new CodexAppServerAgentClient(createTestLogger(), undefined, {
|
||||
appServerProcess: createFakeCodexAppServerProcess(() => appServer.child),
|
||||
});
|
||||
|
||||
await provider.unarchiveNativeSession({
|
||||
provider: "codex",
|
||||
@@ -2455,10 +2460,9 @@ describe("Codex app-server provider", () => {
|
||||
return { thread: { id: "active-thread-id", turns: [] } };
|
||||
},
|
||||
});
|
||||
const provider = new CodexAppServerAgentClient(createTestLogger());
|
||||
castInternals<{ spawnAppServer: () => Promise<ChildProcessWithoutNullStreams> }>(
|
||||
provider,
|
||||
).spawnAppServer = async () => appServer.child;
|
||||
const provider = new CodexAppServerAgentClient(createTestLogger(), undefined, {
|
||||
appServerProcess: createFakeCodexAppServerProcess(() => appServer.child),
|
||||
});
|
||||
|
||||
await provider.unarchiveNativeSession({
|
||||
provider: "codex",
|
||||
@@ -2488,10 +2492,9 @@ describe("Codex app-server provider", () => {
|
||||
return Promise.reject(new Error("thread not found"));
|
||||
},
|
||||
});
|
||||
const provider = new CodexAppServerAgentClient(createTestLogger());
|
||||
castInternals<{ spawnAppServer: () => Promise<ChildProcessWithoutNullStreams> }>(
|
||||
provider,
|
||||
).spawnAppServer = async () => appServer.child;
|
||||
const provider = new CodexAppServerAgentClient(createTestLogger(), undefined, {
|
||||
appServerProcess: createFakeCodexAppServerProcess(() => appServer.child),
|
||||
});
|
||||
|
||||
await expect(
|
||||
provider.unarchiveNativeSession({
|
||||
@@ -7530,6 +7533,52 @@ describe("Codex app-server provider", () => {
|
||||
});
|
||||
|
||||
describe("Codex importable sessions", () => {
|
||||
test("serializes concurrent app-server startups while allowing all scans to complete", async () => {
|
||||
const firstEntered = deferred<void>();
|
||||
const releaseFirst = deferred<void>();
|
||||
let spawned = 0;
|
||||
let activeInitializations = 0;
|
||||
let maxActiveInitializations = 0;
|
||||
let disposed = 0;
|
||||
const provider = new CodexAppServerAgentClient(createTestLogger(), undefined, {
|
||||
appServerProcess: createFakeCodexAppServerProcess(() => {
|
||||
spawned++;
|
||||
return createCodexAppServerChildProcess();
|
||||
}),
|
||||
_createCodexClient: () => ({
|
||||
request: async (method) => {
|
||||
if (method === "initialize") {
|
||||
activeInitializations++;
|
||||
maxActiveInitializations = Math.max(maxActiveInitializations, activeInitializations);
|
||||
if (spawned === 1) {
|
||||
firstEntered.resolve();
|
||||
await releaseFirst.promise;
|
||||
}
|
||||
activeInitializations--;
|
||||
return {};
|
||||
}
|
||||
if (method === "thread/list") return { data: [] };
|
||||
throw new Error(`Unexpected Codex method: ${method}`);
|
||||
},
|
||||
notify: () => {},
|
||||
dispose: async () => {
|
||||
disposed++;
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
const scans = Array.from({ length: 24 }, () => provider.listImportableSessions({ limit: 1 }));
|
||||
await firstEntered.promise;
|
||||
expect(spawned).toBe(1);
|
||||
releaseFirst.resolve();
|
||||
const results = await Promise.all(scans);
|
||||
|
||||
expect(results).toEqual(Array.from({ length: 24 }, () => []));
|
||||
expect(spawned).toBe(24);
|
||||
expect(maxActiveInitializations).toBe(1);
|
||||
expect(disposed).toBe(24);
|
||||
});
|
||||
|
||||
const CODEX_THREAD_PAGE_CAP = 100;
|
||||
|
||||
// Codex answers thread/list with at most 100 rows per response whatever limit
|
||||
@@ -7792,19 +7841,8 @@ describe("Codex importable sessions", () => {
|
||||
|
||||
const provider = new CodexAppServerAgentClient(createTestLogger(), undefined, {
|
||||
_createCodexClient: () => fakeClient,
|
||||
appServerProcess: createFakeCodexAppServerProcess(createCodexAppServerChildProcess),
|
||||
});
|
||||
castInternals<{ spawnAppServer: () => Promise<ChildProcessWithoutNullStreams> }>(
|
||||
provider,
|
||||
).spawnAppServer = async () => {
|
||||
const child = new EventEmitter() as ChildProcessWithoutNullStreams;
|
||||
child.exitCode = 0;
|
||||
child.signalCode = null;
|
||||
child.stdin = new PassThrough();
|
||||
child.stdout = new PassThrough();
|
||||
child.stderr = new PassThrough();
|
||||
child.kill = vi.fn(() => true) as ChildProcessWithoutNullStreams["kill"];
|
||||
return child;
|
||||
};
|
||||
|
||||
const sessions = await provider.listImportableSessions({ cwd: "/workspace/project-a" });
|
||||
|
||||
|
||||
@@ -75,7 +75,7 @@ import {
|
||||
probeExecutable,
|
||||
} from "../../../executable-resolution/executable-resolution.js";
|
||||
import { createPathEquivalenceMatcher } from "../../../utils/path.js";
|
||||
import { spawnProcess } from "../../../utils/spawn.js";
|
||||
import { spawnProcess, type SpawnProcessOptions } from "../../../utils/spawn.js";
|
||||
import { extractCodexTerminalSessionId, nonEmptyString } from "./tool-call-mapper-utils.js";
|
||||
import {
|
||||
buildCodexFeatures,
|
||||
@@ -94,6 +94,7 @@ import {
|
||||
type CodexThreadRollbackResponse,
|
||||
type CodexAppServerTraceContext,
|
||||
} from "./codex/app-server-transport.js";
|
||||
import { codexAppServerStartup } from "./codex/app-server-startup.js";
|
||||
import { type CodexUserMessageTurnIndex, revertCodexConversation } from "./codex/rewind.js";
|
||||
import {
|
||||
type CodexHistoryRow,
|
||||
@@ -291,14 +292,31 @@ const CODEX_MODES: AgentMode[] = [
|
||||
const DEFAULT_CODEX_MODE_ID = "auto";
|
||||
|
||||
interface CodexAppServerClientLike {
|
||||
request(method: string, params?: unknown): Promise<unknown>;
|
||||
request(method: string, params?: unknown, timeoutMs?: number): Promise<unknown>;
|
||||
forkThread?(params: CodexThreadForkParams): Promise<CodexThreadForkResponse>;
|
||||
rollbackThread?(params: CodexThreadRollbackParams): Promise<CodexThreadRollbackResponse>;
|
||||
notify(method: string, params?: unknown): void;
|
||||
dispose(): Promise<void>;
|
||||
}
|
||||
|
||||
interface CodexAppServerLaunchPrefix {
|
||||
command: string;
|
||||
args: string[];
|
||||
}
|
||||
|
||||
interface CodexAppServerProcessSpawnInput extends CodexAppServerLaunchPrefix {
|
||||
options: SpawnProcessOptions;
|
||||
}
|
||||
|
||||
interface CodexAppServerProcessPort {
|
||||
resolvePrefix(options: {
|
||||
runtimeSettings?: ProviderRuntimeSettings;
|
||||
}): Promise<CodexAppServerLaunchPrefix>;
|
||||
spawn(input: CodexAppServerProcessSpawnInput): ChildProcess;
|
||||
}
|
||||
|
||||
interface CodexAppServerAgentDeps {
|
||||
appServerProcess?: CodexAppServerProcessPort;
|
||||
environment?: Record<string, string>;
|
||||
workspaceGitService?: Pick<WorkspaceGitService, "resolveRepoRoot">;
|
||||
customProvider?: {
|
||||
@@ -610,10 +628,9 @@ export async function findDefaultCodexBinary(): Promise<string | null> {
|
||||
return await findCodexMicrosoftStoreBinary();
|
||||
}
|
||||
|
||||
async function resolveCodexLaunchPrefix(runtimeSettings?: ProviderRuntimeSettings): Promise<{
|
||||
command: string;
|
||||
args: string[];
|
||||
}> {
|
||||
async function resolveCodexLaunchPrefix(
|
||||
runtimeSettings?: ProviderRuntimeSettings,
|
||||
): Promise<CodexAppServerLaunchPrefix> {
|
||||
const launch = await resolveCodexLaunch(runtimeSettings);
|
||||
const availability = await checkCodexLaunchAvailable(launch);
|
||||
if (!availability.available) {
|
||||
@@ -628,6 +645,11 @@ async function resolveCodexLaunchPrefix(runtimeSettings?: ProviderRuntimeSetting
|
||||
};
|
||||
}
|
||||
|
||||
const defaultCodexAppServerProcess: CodexAppServerProcessPort = {
|
||||
resolvePrefix: ({ runtimeSettings }) => resolveCodexLaunchPrefix(runtimeSettings),
|
||||
spawn: ({ command, args, options }) => spawnProcess(command, args, options),
|
||||
};
|
||||
|
||||
async function resolveCodexLaunch(
|
||||
runtimeSettings?: ProviderRuntimeSettings,
|
||||
): Promise<ResolvedProviderLaunch> {
|
||||
@@ -3369,6 +3391,14 @@ export function buildCodexAppServerEnv(
|
||||
});
|
||||
}
|
||||
|
||||
function codexStartupStateDirKey(environment: NodeJS.ProcessEnv): string {
|
||||
const home =
|
||||
process.platform === "win32"
|
||||
? environment.USERPROFILE || os.homedir()
|
||||
: environment.HOME || os.homedir();
|
||||
return path.resolve(environment.CODEX_HOME || path.join(home, ".codex"));
|
||||
}
|
||||
|
||||
function buildCodexAppServerInitializeParams(): {
|
||||
clientInfo: { name: string; title: string; version: string };
|
||||
capabilities: { experimentalApi: true; mcpServerOpenaiFormElicitation: true };
|
||||
@@ -3566,6 +3596,7 @@ export class CodexAppServerAgentSession implements AgentSession {
|
||||
private unpairedCompactionItemCompletions = 0;
|
||||
private connectionState: "disconnected" | "history-ready" | "connected" = "disconnected";
|
||||
private connectionPromise: Promise<void> | null = null;
|
||||
private readonly startupAbort = new AbortController();
|
||||
private closed = false;
|
||||
private collaborationModes: Array<{
|
||||
name: string;
|
||||
@@ -3683,8 +3714,15 @@ export class CodexAppServerAgentSession implements AgentSession {
|
||||
this.connectionState = "history-ready";
|
||||
return;
|
||||
}
|
||||
const child = await this.spawnAppServer();
|
||||
const client = new CodexAppServerClient(child, this.logger, () => this.traceContext());
|
||||
const { client, initialized } = await codexAppServerStartup.start({
|
||||
stateDirKey: codexStartupStateDirKey(this.harnessEnvironment),
|
||||
spawn: this.spawnAppServer,
|
||||
createClient: (child) =>
|
||||
new CodexAppServerClient(child, this.logger, () => this.traceContext()),
|
||||
initializeParams: buildCodexAppServerInitializeParams(),
|
||||
logger: this.logger,
|
||||
signal: this.startupAbort.signal,
|
||||
});
|
||||
if (this.closed) {
|
||||
await client.dispose();
|
||||
throw this.createClosedError();
|
||||
@@ -3697,11 +3735,9 @@ export class CodexAppServerAgentSession implements AgentSession {
|
||||
this.registerRequestHandlers();
|
||||
|
||||
try {
|
||||
const initialized = toObjectRecord(
|
||||
await client.request("initialize", buildCodexAppServerInitializeParams()),
|
||||
this.threadRollbackAvailable = codexServerHasThreadRollback(
|
||||
toObjectRecord(initialized)?.userAgent,
|
||||
);
|
||||
this.threadRollbackAvailable = codexServerHasThreadRollback(initialized?.userAgent);
|
||||
client.notify("initialized", {});
|
||||
|
||||
this.speedModels =
|
||||
CodexModelListResponseSchema.parse(await client.request("model/list", {})).data ?? [];
|
||||
@@ -4029,11 +4065,16 @@ export class CodexAppServerAgentSession implements AgentSession {
|
||||
}
|
||||
|
||||
private async readArchivedHistory(): Promise<CodexThreadHistory> {
|
||||
const child = await this.spawnAppServer();
|
||||
const client = new CodexAppServerClient(child, this.logger, () => this.traceContext());
|
||||
const { client } = await codexAppServerStartup.start({
|
||||
stateDirKey: codexStartupStateDirKey(this.harnessEnvironment),
|
||||
spawn: this.spawnAppServer,
|
||||
createClient: (child) =>
|
||||
new CodexAppServerClient(child, this.logger, () => this.traceContext()),
|
||||
initializeParams: buildCodexAppServerInitializeParams(),
|
||||
logger: this.logger,
|
||||
signal: this.startupAbort.signal,
|
||||
});
|
||||
try {
|
||||
await client.request("initialize", buildCodexAppServerInitializeParams());
|
||||
client.notify("initialized", {});
|
||||
return await this.readThreadHistory(client);
|
||||
} finally {
|
||||
await client.dispose();
|
||||
@@ -5296,6 +5337,7 @@ export class CodexAppServerAgentSession implements AgentSession {
|
||||
|
||||
async close(): Promise<void> {
|
||||
this.closed = true;
|
||||
this.startupAbort.abort(this.createClosedError());
|
||||
this.background.stop();
|
||||
this.clearPendingPermissions();
|
||||
this.pendingToolCallDetails.clear();
|
||||
@@ -7534,31 +7576,55 @@ export class CodexAppServerAgentClient implements AgentClient {
|
||||
}
|
||||
}
|
||||
|
||||
private async spawnAppServer(
|
||||
launchEnv?: Record<string, string>,
|
||||
options?: { goalsEnabled?: boolean; agentId?: string; environment?: Record<string, string> },
|
||||
): Promise<ChildProcessWithoutNullStreams> {
|
||||
const launchPrefix = await resolveCodexLaunchPrefix(this.runtimeSettings);
|
||||
private async prepareAppServerSpawn(options: {
|
||||
environment: Record<string, string>;
|
||||
goalsEnabled?: boolean;
|
||||
agentId?: string;
|
||||
}): Promise<() => ChildProcessWithoutNullStreams> {
|
||||
const processPort = this.deps.appServerProcess ?? defaultCodexAppServerProcess;
|
||||
const launchPrefix = await processPort.resolvePrefix({ runtimeSettings: this.runtimeSettings });
|
||||
const args = [...launchPrefix.args, "app-server"];
|
||||
if (options?.goalsEnabled) {
|
||||
if (options.goalsEnabled) {
|
||||
args.push("--enable", "goals");
|
||||
}
|
||||
this.logger.trace(
|
||||
{
|
||||
agentId: options?.agentId,
|
||||
provider: CODEX_PROVIDER,
|
||||
launchPrefix,
|
||||
goalsEnabled: options?.goalsEnabled === true,
|
||||
},
|
||||
"provider.codex.spawn",
|
||||
);
|
||||
const child = spawnProcess(launchPrefix.command, args, {
|
||||
detached: process.platform !== "win32",
|
||||
stdio: ["pipe", "pipe", "pipe"],
|
||||
baseEnv: options?.environment ?? buildCodexAppServerEnv(this.runtimeSettings, launchEnv),
|
||||
return () => {
|
||||
this.logger.trace(
|
||||
{
|
||||
agentId: options.agentId,
|
||||
provider: CODEX_PROVIDER,
|
||||
launchPrefix,
|
||||
goalsEnabled: options.goalsEnabled === true,
|
||||
},
|
||||
"provider.codex.spawn",
|
||||
);
|
||||
const child = processPort.spawn({
|
||||
command: launchPrefix.command,
|
||||
args,
|
||||
options: {
|
||||
detached: process.platform !== "win32",
|
||||
stdio: ["pipe", "pipe", "pipe"],
|
||||
baseEnv: options.environment,
|
||||
},
|
||||
});
|
||||
assertChildWithPipes(child);
|
||||
return child;
|
||||
};
|
||||
}
|
||||
|
||||
private async startAppServer(
|
||||
options: { signal?: AbortSignal } = {},
|
||||
): Promise<CodexAppServerClient> {
|
||||
const environment = buildCodexAppServerEnv(this.runtimeSettings);
|
||||
const spawn = await this.prepareAppServerSpawn({ environment });
|
||||
const { client } = await codexAppServerStartup.start({
|
||||
stateDirKey: codexStartupStateDirKey(environment),
|
||||
spawn,
|
||||
createClient: (child) => new CodexAppServerClient(child, this.logger),
|
||||
initializeParams: buildCodexAppServerInitializeParams(),
|
||||
logger: this.logger,
|
||||
signal: options.signal,
|
||||
});
|
||||
assertChildWithPipes(child);
|
||||
return child;
|
||||
return client;
|
||||
}
|
||||
|
||||
async createSession(
|
||||
@@ -7577,16 +7643,16 @@ export class CodexAppServerAgentClient implements AgentClient {
|
||||
const goalsEnabled = await this.resolveGoalsEnabled();
|
||||
const autoReviewEnabled = await this.resolveAutoReviewEnabled();
|
||||
const environment = buildCodexAppServerEnv(this.runtimeSettings, launchContext?.env);
|
||||
const spawn = await this.prepareAppServerSpawn({
|
||||
environment,
|
||||
goalsEnabled,
|
||||
agentId: launchContext?.agentId,
|
||||
});
|
||||
const session = new CodexAppServerAgentSession(
|
||||
sessionConfig,
|
||||
null,
|
||||
this.logger,
|
||||
() =>
|
||||
this.spawnAppServer(launchContext?.env, {
|
||||
goalsEnabled,
|
||||
agentId: launchContext?.agentId,
|
||||
environment,
|
||||
}),
|
||||
async () => spawn(),
|
||||
{ ...this.sessionDeps(launchContext?.env), environment },
|
||||
options?.persistSession === false,
|
||||
goalsEnabled,
|
||||
@@ -7614,16 +7680,16 @@ export class CodexAppServerAgentClient implements AgentClient {
|
||||
const goalsEnabled = await this.resolveGoalsEnabled();
|
||||
const autoReviewEnabled = await this.resolveAutoReviewEnabled();
|
||||
const environment = buildCodexAppServerEnv(this.runtimeSettings, launchContext?.env);
|
||||
const spawn = await this.prepareAppServerSpawn({
|
||||
environment,
|
||||
goalsEnabled,
|
||||
agentId: launchContext?.agentId,
|
||||
});
|
||||
const session = new CodexAppServerAgentSession(
|
||||
merged,
|
||||
handle,
|
||||
this.logger,
|
||||
() =>
|
||||
this.spawnAppServer(launchContext?.env, {
|
||||
goalsEnabled,
|
||||
agentId: launchContext?.agentId,
|
||||
environment,
|
||||
}),
|
||||
async () => spawn(),
|
||||
{ ...this.sessionDeps(launchContext?.env), environment },
|
||||
false,
|
||||
goalsEnabled,
|
||||
@@ -7638,15 +7704,19 @@ export class CodexAppServerAgentClient implements AgentClient {
|
||||
async listImportableSessions(
|
||||
options?: ListImportableSessionsOptions,
|
||||
): Promise<ImportableProviderSession[]> {
|
||||
const child = await this.spawnAppServer();
|
||||
const client =
|
||||
this.deps._createCodexClient?.(child, this.logger, () => ({})) ??
|
||||
new CodexAppServerClient(child, this.logger);
|
||||
const environment = buildCodexAppServerEnv(this.runtimeSettings);
|
||||
const spawn = await this.prepareAppServerSpawn({ environment });
|
||||
const { client } = await codexAppServerStartup.start({
|
||||
stateDirKey: codexStartupStateDirKey(environment),
|
||||
spawn,
|
||||
createClient: (child) =>
|
||||
this.deps._createCodexClient?.(child, this.logger, () => ({})) ??
|
||||
new CodexAppServerClient(child, this.logger),
|
||||
initializeParams: buildCodexAppServerInitializeParams(),
|
||||
logger: this.logger,
|
||||
});
|
||||
|
||||
try {
|
||||
await client.request("initialize", buildCodexAppServerInitializeParams());
|
||||
client.notify("initialized", {});
|
||||
|
||||
const limit = options?.limit ?? 20;
|
||||
const scanLimit = Math.min(options?.scanLimit ?? limit, 500);
|
||||
// thread/list returns the cheap `cwd` field. Fetch a wider window when
|
||||
@@ -7749,15 +7819,10 @@ export class CodexAppServerAgentClient implements AgentClient {
|
||||
|
||||
try {
|
||||
await runProviderRefreshActivity(context, "app-server.start", async () => {
|
||||
const child = await this.spawnAppServer();
|
||||
client = new CodexAppServerClient(child, this.logger);
|
||||
client = await this.startAppServer({ signal: context?.signal });
|
||||
if (context?.signal.aborted) await dispose();
|
||||
});
|
||||
if (!client) throw new Error("Codex app-server did not start");
|
||||
await runProviderRefreshActivity(context, "initialize", () =>
|
||||
client!.request("initialize", buildCodexAppServerInitializeParams()),
|
||||
);
|
||||
client.notify("initialized", {});
|
||||
|
||||
const rawResponse = await runProviderRefreshActivity(context, "model/list", () =>
|
||||
client!.request("model/list", {}),
|
||||
@@ -7801,12 +7866,9 @@ export class CodexAppServerAgentClient implements AgentClient {
|
||||
const threadId = handle.nativeHandle ?? handle.sessionId;
|
||||
if (!threadId) return;
|
||||
|
||||
const child = await this.spawnAppServer();
|
||||
const client = new CodexAppServerClient(child, this.logger);
|
||||
const client = await this.startAppServer();
|
||||
|
||||
try {
|
||||
await client.request("initialize", buildCodexAppServerInitializeParams());
|
||||
client.notify("initialized", {});
|
||||
if (state === "archive") {
|
||||
await client.request("thread/archive", { threadId });
|
||||
return;
|
||||
|
||||
@@ -0,0 +1,347 @@
|
||||
import { describe, expect, test, vi } from "vitest";
|
||||
|
||||
import { createTestLogger } from "../../../../test-utils/test-logger.js";
|
||||
import { createCodexAppServerChildProcess } from "./test-utils/fake-app-server.js";
|
||||
import { CodexAppServerStartupCoordinator } from "./app-server-startup.js";
|
||||
import { CodexAppServerExitError } from "./app-server-transport.js";
|
||||
|
||||
function sqliteStartupError(): CodexAppServerExitError {
|
||||
return new CodexAppServerExitError({
|
||||
exitCode: 1,
|
||||
exitSignal: null,
|
||||
stderr: "failed to initialize sqlite state runtime",
|
||||
});
|
||||
}
|
||||
|
||||
function deferred<T = void>() {
|
||||
let resolve!: (value: T) => void;
|
||||
const promise = new Promise<T>((resolvePromise) => {
|
||||
resolve = resolvePromise;
|
||||
});
|
||||
return { promise, resolve };
|
||||
}
|
||||
|
||||
describe("Codex app-server startup coordinator", () => {
|
||||
test("returns initialize metadata so sessions retain server capabilities", async () => {
|
||||
const coordinator = new CodexAppServerStartupCoordinator();
|
||||
const client = {
|
||||
request: async () => ({ userAgent: "codex/0.164.0" }),
|
||||
notify: () => {},
|
||||
dispose: async () => {},
|
||||
};
|
||||
const started = await coordinator.start({
|
||||
stateDirKey: "/codex/metadata",
|
||||
spawn: createCodexAppServerChildProcess,
|
||||
createClient: () => client,
|
||||
initializeParams: {},
|
||||
logger: createTestLogger(),
|
||||
});
|
||||
expect(started).toEqual({ client, initialized: { userAgent: "codex/0.164.0" } });
|
||||
});
|
||||
|
||||
test("serializes a burst through initialize but retains all initialized clients", async () => {
|
||||
const coordinator = new CodexAppServerStartupCoordinator();
|
||||
const firstEntered = deferred();
|
||||
const releaseFirst = deferred();
|
||||
let spawned = 0;
|
||||
let activeInitializations = 0;
|
||||
let maxActiveInitializations = 0;
|
||||
let disposed = 0;
|
||||
|
||||
const starts = Array.from({ length: 24 }, () =>
|
||||
coordinator.start({
|
||||
stateDirKey: "/codex/shared",
|
||||
spawn: () => {
|
||||
spawned++;
|
||||
return createCodexAppServerChildProcess();
|
||||
},
|
||||
createClient: () => ({
|
||||
request: async (method: string) => {
|
||||
expect(method).toBe("initialize");
|
||||
activeInitializations++;
|
||||
maxActiveInitializations = Math.max(maxActiveInitializations, activeInitializations);
|
||||
if (spawned === 1) {
|
||||
firstEntered.resolve();
|
||||
await releaseFirst.promise;
|
||||
}
|
||||
activeInitializations--;
|
||||
return {};
|
||||
},
|
||||
notify: vi.fn(),
|
||||
dispose: async () => {
|
||||
disposed++;
|
||||
},
|
||||
}),
|
||||
initializeParams: {},
|
||||
logger: createTestLogger(),
|
||||
}),
|
||||
);
|
||||
|
||||
await firstEntered.promise;
|
||||
expect(spawned).toBe(1);
|
||||
releaseFirst.resolve();
|
||||
const clients = await Promise.all(starts);
|
||||
|
||||
expect(clients).toHaveLength(24);
|
||||
expect(spawned).toBe(24);
|
||||
expect(maxActiveInitializations).toBe(1);
|
||||
expect(disposed).toBe(0);
|
||||
});
|
||||
|
||||
test("allows different Codex homes to initialize concurrently", async () => {
|
||||
const coordinator = new CodexAppServerStartupCoordinator();
|
||||
const bothEntered = deferred();
|
||||
const release = deferred();
|
||||
let entered = 0;
|
||||
const start = (stateDirKey: string) =>
|
||||
coordinator.start({
|
||||
stateDirKey,
|
||||
spawn: createCodexAppServerChildProcess,
|
||||
createClient: () => ({
|
||||
request: async () => {
|
||||
entered++;
|
||||
if (entered === 2) bothEntered.resolve();
|
||||
await release.promise;
|
||||
return {};
|
||||
},
|
||||
notify: vi.fn(),
|
||||
dispose: vi.fn(async () => {}),
|
||||
}),
|
||||
initializeParams: {},
|
||||
logger: createTestLogger(),
|
||||
});
|
||||
|
||||
const first = start("/codex/one");
|
||||
const second = start("/codex/two");
|
||||
await bothEntered.promise;
|
||||
expect(entered).toBe(2);
|
||||
release.resolve();
|
||||
await Promise.all([first, second]);
|
||||
});
|
||||
|
||||
test("retries only SQLite startup failures after disposing the failed child", async () => {
|
||||
const coordinator = new CodexAppServerStartupCoordinator();
|
||||
const events: string[] = [];
|
||||
let attempt = 0;
|
||||
const client = await coordinator.start({
|
||||
stateDirKey: "/codex/shared",
|
||||
spawn: () => {
|
||||
attempt++;
|
||||
events.push(`spawn ${attempt}`);
|
||||
return createCodexAppServerChildProcess();
|
||||
},
|
||||
createClient: () => {
|
||||
const number = attempt;
|
||||
return {
|
||||
request: async () => {
|
||||
events.push(`initialize ${number}`);
|
||||
if (number === 1) throw sqliteStartupError();
|
||||
return {};
|
||||
},
|
||||
notify: () => events.push(`initialized ${number}`),
|
||||
dispose: async () => {
|
||||
events.push(`dispose ${number}`);
|
||||
},
|
||||
};
|
||||
},
|
||||
initializeParams: {},
|
||||
logger: createTestLogger(),
|
||||
});
|
||||
|
||||
expect(client.client).toEqual(expect.objectContaining({ request: expect.any(Function) }));
|
||||
expect(events).toEqual([
|
||||
"spawn 1",
|
||||
"initialize 1",
|
||||
"dispose 1",
|
||||
"spawn 2",
|
||||
"initialize 2",
|
||||
"initialized 2",
|
||||
]);
|
||||
});
|
||||
|
||||
test("does not retry unrelated startup failures", async () => {
|
||||
const coordinator = new CodexAppServerStartupCoordinator();
|
||||
let spawned = 0;
|
||||
let disposed = 0;
|
||||
await expect(
|
||||
coordinator.start({
|
||||
stateDirKey: "/codex/shared",
|
||||
spawn: () => {
|
||||
spawned++;
|
||||
return createCodexAppServerChildProcess();
|
||||
},
|
||||
createClient: () => ({
|
||||
request: async () => {
|
||||
throw new Error("database is locked while reading unrelated configuration");
|
||||
},
|
||||
notify: vi.fn(),
|
||||
dispose: async () => {
|
||||
disposed++;
|
||||
},
|
||||
}),
|
||||
initializeParams: {},
|
||||
logger: createTestLogger(),
|
||||
}),
|
||||
).rejects.toThrow("database is locked while reading unrelated configuration");
|
||||
expect(spawned).toBe(1);
|
||||
expect(disposed).toBe(1);
|
||||
});
|
||||
|
||||
test("bounds persistent SQLite failures and leaves the lane usable", async () => {
|
||||
const coordinator = new CodexAppServerStartupCoordinator();
|
||||
let spawned = 0;
|
||||
let disposed = 0;
|
||||
let locked = true;
|
||||
const start = () =>
|
||||
coordinator.start({
|
||||
stateDirKey: "/codex/shared",
|
||||
spawn: () => {
|
||||
spawned++;
|
||||
return createCodexAppServerChildProcess();
|
||||
},
|
||||
createClient: () => ({
|
||||
request: async () => {
|
||||
if (locked) throw sqliteStartupError();
|
||||
return {};
|
||||
},
|
||||
notify: vi.fn(),
|
||||
dispose: async () => {
|
||||
disposed++;
|
||||
},
|
||||
}),
|
||||
initializeParams: {},
|
||||
logger: createTestLogger(),
|
||||
});
|
||||
|
||||
await expect(start()).rejects.toThrow("failed to initialize sqlite state runtime");
|
||||
expect(spawned).toBe(3);
|
||||
expect(disposed).toBe(3);
|
||||
locked = false;
|
||||
await expect(start()).resolves.toEqual(
|
||||
expect.objectContaining({
|
||||
client: expect.objectContaining({ request: expect.any(Function) }),
|
||||
}),
|
||||
);
|
||||
expect(spawned).toBe(4);
|
||||
expect(disposed).toBe(3);
|
||||
});
|
||||
|
||||
test("a cancelled waiter never spawns or overtakes the active startup", async () => {
|
||||
const coordinator = new CodexAppServerStartupCoordinator();
|
||||
const firstEntered = deferred();
|
||||
const releaseFirst = deferred();
|
||||
const events: string[] = [];
|
||||
const first = coordinator.start({
|
||||
stateDirKey: "/codex/shared",
|
||||
spawn: () => {
|
||||
events.push("spawn first");
|
||||
return createCodexAppServerChildProcess();
|
||||
},
|
||||
createClient: () => ({
|
||||
request: async () => {
|
||||
firstEntered.resolve();
|
||||
await releaseFirst.promise;
|
||||
return {};
|
||||
},
|
||||
notify: vi.fn(),
|
||||
dispose: vi.fn(async () => {}),
|
||||
}),
|
||||
initializeParams: {},
|
||||
logger: createTestLogger(),
|
||||
});
|
||||
await firstEntered.promise;
|
||||
|
||||
const controller = new AbortController();
|
||||
const queued = coordinator.start({
|
||||
stateDirKey: "/codex/shared",
|
||||
spawn: () => {
|
||||
events.push("spawn cancelled");
|
||||
return createCodexAppServerChildProcess();
|
||||
},
|
||||
createClient: () => ({
|
||||
request: async () => ({}),
|
||||
notify: vi.fn(),
|
||||
dispose: vi.fn(async () => {}),
|
||||
}),
|
||||
initializeParams: {},
|
||||
logger: createTestLogger(),
|
||||
signal: controller.signal,
|
||||
});
|
||||
controller.abort(new Error("cancelled"));
|
||||
await expect(queued).rejects.toThrow("cancelled");
|
||||
const next = coordinator.start({
|
||||
stateDirKey: "/codex/shared",
|
||||
spawn: () => {
|
||||
events.push("spawn next");
|
||||
return createCodexAppServerChildProcess();
|
||||
},
|
||||
createClient: () => ({
|
||||
request: async () => ({}),
|
||||
notify: vi.fn(),
|
||||
dispose: vi.fn(async () => {}),
|
||||
}),
|
||||
initializeParams: {},
|
||||
logger: createTestLogger(),
|
||||
});
|
||||
|
||||
expect(events).toEqual(["spawn first"]);
|
||||
releaseFirst.resolve();
|
||||
await Promise.all([first, next]);
|
||||
expect(events).toEqual(["spawn first", "spawn next"]);
|
||||
});
|
||||
|
||||
test("a timed-out initialize disposes its child and releases the next waiter", async () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
const coordinator = new CodexAppServerStartupCoordinator({
|
||||
attemptTimeoutMs: 10,
|
||||
queueTimeoutMs: 100,
|
||||
});
|
||||
const firstEntered = deferred();
|
||||
const events: string[] = [];
|
||||
const first = coordinator.start({
|
||||
stateDirKey: "/codex/shared",
|
||||
spawn: createCodexAppServerChildProcess,
|
||||
createClient: () => ({
|
||||
request: () => {
|
||||
firstEntered.resolve();
|
||||
return new Promise<never>(() => {});
|
||||
},
|
||||
notify: vi.fn(),
|
||||
dispose: async () => {
|
||||
events.push("dispose first");
|
||||
},
|
||||
}),
|
||||
initializeParams: {},
|
||||
logger: createTestLogger(),
|
||||
});
|
||||
await firstEntered.promise;
|
||||
const next = coordinator.start({
|
||||
stateDirKey: "/codex/shared",
|
||||
spawn: () => {
|
||||
events.push("spawn next");
|
||||
return createCodexAppServerChildProcess();
|
||||
},
|
||||
createClient: () => ({
|
||||
request: async () => ({}),
|
||||
notify: vi.fn(),
|
||||
dispose: vi.fn(async () => {}),
|
||||
}),
|
||||
initializeParams: {},
|
||||
logger: createTestLogger(),
|
||||
});
|
||||
|
||||
const firstResult = expect(first).rejects.toThrow("Timed out initializing Codex app-server");
|
||||
await vi.advanceTimersByTimeAsync(10);
|
||||
await firstResult;
|
||||
await expect(next).resolves.toEqual(
|
||||
expect.objectContaining({
|
||||
client: expect.objectContaining({ request: expect.any(Function) }),
|
||||
}),
|
||||
);
|
||||
expect(events).toEqual(["dispose first", "spawn next"]);
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,189 @@
|
||||
import type { ChildProcessWithoutNullStreams } from "node:child_process";
|
||||
import type { Logger } from "pino";
|
||||
|
||||
import { terminateWithTreeKill } from "../../../../utils/tree-kill.js";
|
||||
import { CodexAppServerExitError } from "./app-server-transport.js";
|
||||
|
||||
const DEFAULT_QUEUE_TIMEOUT_MS = 60_000;
|
||||
const DEFAULT_ATTEMPT_TIMEOUT_MS = 30_000;
|
||||
const DEFAULT_MAX_ATTEMPTS = 3;
|
||||
|
||||
interface InitializableAppServerClient {
|
||||
request(method: string, params?: unknown, timeoutMs?: number): Promise<unknown>;
|
||||
notify(method: string, params?: unknown): void;
|
||||
dispose(): Promise<void>;
|
||||
}
|
||||
|
||||
interface StartupOptions<TClient extends InitializableAppServerClient> {
|
||||
stateDirKey: string;
|
||||
spawn: () => ChildProcessWithoutNullStreams | Promise<ChildProcessWithoutNullStreams>;
|
||||
createClient: (child: ChildProcessWithoutNullStreams) => TClient;
|
||||
initializeParams: unknown;
|
||||
logger: Pick<Logger, "warn" | "debug">;
|
||||
signal?: AbortSignal;
|
||||
}
|
||||
|
||||
interface StartedAppServer<TClient> {
|
||||
client: TClient;
|
||||
initialized: unknown;
|
||||
}
|
||||
|
||||
interface CoordinatorOptions {
|
||||
queueTimeoutMs?: number;
|
||||
attemptTimeoutMs?: number;
|
||||
maxAttempts?: number;
|
||||
}
|
||||
|
||||
async function waitForSignal<T>(operation: Promise<T>, signal: AbortSignal): Promise<T> {
|
||||
signal.throwIfAborted();
|
||||
let onAbort: (() => void) | undefined;
|
||||
const aborted = new Promise<never>((_resolve, reject) => {
|
||||
onAbort = () => reject(signal.reason);
|
||||
signal.addEventListener("abort", onAbort, { once: true });
|
||||
});
|
||||
try {
|
||||
return await Promise.race([operation, aborted]);
|
||||
} finally {
|
||||
if (onAbort) signal.removeEventListener("abort", onAbort);
|
||||
}
|
||||
}
|
||||
|
||||
async function disposeUnownedChild(child: ChildProcessWithoutNullStreams): Promise<void> {
|
||||
const result = await terminateWithTreeKill(child, {
|
||||
gracefulTimeoutMs: 2_000,
|
||||
forceTimeoutMs: 1_000,
|
||||
});
|
||||
if (result === "kill-timeout") {
|
||||
throw new Error("Codex app-server did not report exit after SIGKILL");
|
||||
}
|
||||
}
|
||||
|
||||
export class CodexAppServerStartupCoordinator {
|
||||
private readonly tails = new Map<string, Promise<void>>();
|
||||
private readonly queueTimeoutMs: number;
|
||||
private readonly attemptTimeoutMs: number;
|
||||
private readonly maxAttempts: number;
|
||||
|
||||
constructor(options: CoordinatorOptions = {}) {
|
||||
this.queueTimeoutMs = options.queueTimeoutMs ?? DEFAULT_QUEUE_TIMEOUT_MS;
|
||||
this.attemptTimeoutMs = options.attemptTimeoutMs ?? DEFAULT_ATTEMPT_TIMEOUT_MS;
|
||||
this.maxAttempts = options.maxAttempts ?? DEFAULT_MAX_ATTEMPTS;
|
||||
}
|
||||
|
||||
async start<TClient extends InitializableAppServerClient>(
|
||||
options: StartupOptions<TClient>,
|
||||
): Promise<StartedAppServer<TClient>> {
|
||||
options.signal?.throwIfAborted();
|
||||
const previous = this.tails.get(options.stateDirKey) ?? Promise.resolve();
|
||||
let release!: () => void;
|
||||
const gate = new Promise<void>((resolve) => {
|
||||
release = resolve;
|
||||
});
|
||||
// An aborted waiter must not let later waiters pass the current owner.
|
||||
const tail = previous.then(() => gate);
|
||||
this.tails.set(options.stateDirKey, tail);
|
||||
void tail.then(
|
||||
() => this.tails.get(options.stateDirKey) === tail && this.tails.delete(options.stateDirKey),
|
||||
);
|
||||
|
||||
const queueAbort = new AbortController();
|
||||
const onAbort = () => queueAbort.abort(options.signal?.reason);
|
||||
options.signal?.addEventListener("abort", onAbort, { once: true });
|
||||
const queueTimer = setTimeout(
|
||||
() => queueAbort.abort(new Error("Timed out waiting to start Codex app-server")),
|
||||
this.queueTimeoutMs,
|
||||
);
|
||||
try {
|
||||
await waitForSignal(previous, queueAbort.signal);
|
||||
clearTimeout(queueTimer);
|
||||
options.signal?.throwIfAborted();
|
||||
for (let attempt = 1; attempt <= this.maxAttempts; attempt++) {
|
||||
try {
|
||||
return await this.startAttempt(options);
|
||||
} catch (error) {
|
||||
const isRetryableStartupFailure =
|
||||
error instanceof CodexAppServerExitError &&
|
||||
error.reason === "sqlite_state_initialization";
|
||||
if (
|
||||
options.signal?.aborted ||
|
||||
attempt === this.maxAttempts ||
|
||||
!isRetryableStartupFailure
|
||||
) {
|
||||
throw error;
|
||||
}
|
||||
options.logger.warn(
|
||||
{ err: error, attempt, maxAttempts: this.maxAttempts },
|
||||
"Codex app-server SQLite startup failed; retrying with a fresh process",
|
||||
);
|
||||
}
|
||||
}
|
||||
throw new Error("Codex app-server startup attempts exhausted");
|
||||
} finally {
|
||||
clearTimeout(queueTimer);
|
||||
options.signal?.removeEventListener("abort", onAbort);
|
||||
release();
|
||||
}
|
||||
}
|
||||
|
||||
private async startAttempt<TClient extends InitializableAppServerClient>(
|
||||
options: StartupOptions<TClient>,
|
||||
): Promise<StartedAppServer<TClient>> {
|
||||
const attemptAbort = new AbortController();
|
||||
const onAbort = () => attemptAbort.abort(options.signal?.reason);
|
||||
options.signal?.addEventListener("abort", onAbort, { once: true });
|
||||
const attemptTimer = setTimeout(
|
||||
() => attemptAbort.abort(new Error("Timed out initializing Codex app-server")),
|
||||
this.attemptTimeoutMs,
|
||||
);
|
||||
let child: ChildProcessWithoutNullStreams | undefined;
|
||||
let client: TClient | undefined;
|
||||
try {
|
||||
attemptAbort.signal.throwIfAborted();
|
||||
// Production hands in a synchronous spawn closure. Resolve the binary
|
||||
// before entering the lane, then create the child while holding it.
|
||||
const spawnPromise = Promise.resolve(options.spawn());
|
||||
try {
|
||||
child = await waitForSignal(spawnPromise, attemptAbort.signal);
|
||||
} catch (error) {
|
||||
if (attemptAbort.signal.aborted) {
|
||||
// A future asynchronous spawner may finish after cancellation.
|
||||
void spawnPromise
|
||||
.then((lateChild) => disposeUnownedChild(lateChild))
|
||||
.catch((cleanupError) =>
|
||||
options.logger.warn({ err: cleanupError }, "Failed to clean up late Codex spawn"),
|
||||
);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
attemptAbort.signal.throwIfAborted();
|
||||
client = options.createClient(child);
|
||||
const initialized = await waitForSignal(
|
||||
client.request("initialize", options.initializeParams, this.attemptTimeoutMs),
|
||||
attemptAbort.signal,
|
||||
);
|
||||
attemptAbort.signal.throwIfAborted();
|
||||
client.notify("initialized", {});
|
||||
return { client, initialized };
|
||||
} catch (error) {
|
||||
try {
|
||||
if (client) {
|
||||
await client.dispose();
|
||||
} else if (child) {
|
||||
await disposeUnownedChild(child);
|
||||
}
|
||||
} catch (cleanupError) {
|
||||
options.logger.warn(
|
||||
{ err: cleanupError, startupError: error },
|
||||
"Failed to clean up Codex app-server after startup failure",
|
||||
);
|
||||
throw new Error("Codex app-server startup cleanup failed", { cause: cleanupError });
|
||||
}
|
||||
throw error;
|
||||
} finally {
|
||||
clearTimeout(attemptTimer);
|
||||
options.signal?.removeEventListener("abort", onAbort);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const codexAppServerStartup = new CodexAppServerStartupCoordinator();
|
||||
@@ -5,9 +5,57 @@ import {
|
||||
createCodexAppServerChildProcess,
|
||||
createFakeCodexAppServer,
|
||||
} from "./test-utils/fake-app-server.js";
|
||||
import { CodexAppServerClient } from "./app-server-transport.js";
|
||||
import { CodexAppServerClient, CodexAppServerExitError } from "./app-server-transport.js";
|
||||
|
||||
describe("Codex app-server transport", () => {
|
||||
test("classifies startup diagnostics delivered after the child exits", async () => {
|
||||
const child = createCodexAppServerChildProcess();
|
||||
const client = new CodexAppServerClient(child, createTestLogger());
|
||||
const request = client.request("initialize", {});
|
||||
const result = expect(request).rejects.toMatchObject({
|
||||
name: "CodexAppServerExitError",
|
||||
reason: "sqlite_state_initialization",
|
||||
stderr: "failed to initialize sqlite state runtime",
|
||||
});
|
||||
child.exitCode = 1;
|
||||
child.emit("exit", 1, null);
|
||||
child.stderr.write("failed to initialize sqlite state runtime");
|
||||
child.emit("close", 1, null);
|
||||
try {
|
||||
await result;
|
||||
} finally {
|
||||
child.stdout.end();
|
||||
child.stderr.end();
|
||||
child.stdin.end();
|
||||
}
|
||||
});
|
||||
|
||||
test.each([
|
||||
{
|
||||
stderr: "Error: failed to initialize sqlite state runtime under /isolated/.codex",
|
||||
reason: "sqlite_state_initialization",
|
||||
},
|
||||
{ stderr: "database is locked while loading configuration", reason: "other" },
|
||||
] as const)("classifies a failed startup from child stderr as $reason", async (entry) => {
|
||||
const child = createCodexAppServerChildProcess();
|
||||
const client = new CodexAppServerClient(child, createTestLogger());
|
||||
const request = client.request("initialize", {});
|
||||
child.stderr.write(entry.stderr);
|
||||
child.exitCode = 1;
|
||||
child.emit("exit", 1, null);
|
||||
|
||||
await expect(request).rejects.toMatchObject({
|
||||
name: "CodexAppServerExitError",
|
||||
reason: entry.reason,
|
||||
exitCode: 1,
|
||||
exitSignal: null,
|
||||
stderr: entry.stderr,
|
||||
} satisfies Partial<CodexAppServerExitError>);
|
||||
child.stdout.end();
|
||||
child.stderr.end();
|
||||
child.stdin.end();
|
||||
});
|
||||
|
||||
test("ignores non-JSON stdout lines without dropping pending requests", async () => {
|
||||
const child = createCodexAppServerChildProcess();
|
||||
const client = new CodexAppServerClient(child, createTestLogger());
|
||||
|
||||
@@ -64,6 +64,39 @@ export class CodexAppServerRpcError extends Error {
|
||||
}
|
||||
}
|
||||
|
||||
interface CodexAppServerExitDetails {
|
||||
exitCode: number | null;
|
||||
exitSignal: NodeJS.Signals | null;
|
||||
stderr: string;
|
||||
}
|
||||
|
||||
export class CodexAppServerExitError extends Error {
|
||||
readonly reason: "sqlite_state_initialization" | "other";
|
||||
readonly exitCode: number | null;
|
||||
readonly exitSignal: NodeJS.Signals | null;
|
||||
readonly stderr: string;
|
||||
|
||||
constructor(details: CodexAppServerExitDetails) {
|
||||
const message =
|
||||
details.exitCode === 0 && !details.exitSignal
|
||||
? "Codex app-server exited"
|
||||
: `Codex app-server exited with code ${details.exitCode ?? "null"} and signal ${details.exitSignal ?? "null"}`;
|
||||
super(`${message}\n${details.stderr}`.trim());
|
||||
this.name = "CodexAppServerExitError";
|
||||
this.exitCode = details.exitCode;
|
||||
this.exitSignal = details.exitSignal;
|
||||
this.stderr = details.stderr;
|
||||
// Codex reports this pre-initialize failure only on stderr, not as a
|
||||
// JSON-RPC error. Classify it here so callers branch on a typed reason.
|
||||
this.reason =
|
||||
details.exitCode === 1 &&
|
||||
!details.exitSignal &&
|
||||
details.stderr.includes("failed to initialize sqlite state runtime")
|
||||
? "sqlite_state_initialization"
|
||||
: "other";
|
||||
}
|
||||
}
|
||||
|
||||
type RequestHandler = (params: unknown, requestId: number) => unknown;
|
||||
type NotificationHandler = (method: string, params: unknown) => void;
|
||||
type UnexpectedTerminationHandler = (error: Error) => void;
|
||||
@@ -234,14 +267,24 @@ export class CodexAppServerClient {
|
||||
this.handleUnexpectedTermination(err);
|
||||
});
|
||||
|
||||
const reportExit = (code: number | null, signal: NodeJS.Signals | null) => {
|
||||
this.handleUnexpectedTermination(
|
||||
new CodexAppServerExitError({
|
||||
exitCode: code,
|
||||
exitSignal: signal,
|
||||
stderr: this.stderrBuffer,
|
||||
}),
|
||||
);
|
||||
};
|
||||
child.on("exit", (code, signal) => {
|
||||
const message =
|
||||
code === 0 && !signal
|
||||
? "Codex app-server exited"
|
||||
: `Codex app-server exited with code ${code ?? "null"} and signal ${signal ?? "null"}`;
|
||||
const error = new Error(`${message}\n${this.stderrBuffer}`.trim());
|
||||
this.handleUnexpectedTermination(error);
|
||||
// Startup retries need complete diagnostics. Node drains the child's
|
||||
// stdio before close, but exit can precede the final stderr data.
|
||||
const initializing = Array.from(this.pending.values()).some(
|
||||
(request) => request.method === "initialize",
|
||||
);
|
||||
if (!initializing) reportExit(code, signal);
|
||||
});
|
||||
child.on("close", reportExit);
|
||||
}
|
||||
|
||||
setUnexpectedTerminationHandler(handler: UnexpectedTerminationHandler): void {
|
||||
|
||||
@@ -146,6 +146,10 @@ export function createCodexAppServerChildProcess(): CodexAppServerChildProcess {
|
||||
exitCode: null,
|
||||
signalCode: null,
|
||||
}) as CodexAppServerChildProcess;
|
||||
// A real ChildProcess closes its stdio after exit, then emits close.
|
||||
child.once("exit", (code, signal) => {
|
||||
queueMicrotask(() => child.emit("close", code, signal));
|
||||
});
|
||||
child.kill = ((signal?: NodeJS.Signals | number) => {
|
||||
queueMicrotask(() => child.emit("exit", null, signal ?? null));
|
||||
return true;
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import type { ChildProcessWithoutNullStreams } from "node:child_process";
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import { createTestLogger } from "../../../../test-utils/test-logger.js";
|
||||
@@ -27,15 +26,24 @@ async function openCodexThread(
|
||||
const appServer = createFakeCodexAppServer(
|
||||
fakeCodexThreadHistory({ "saved-thread": thread }, options),
|
||||
);
|
||||
const provider = new CodexAppServerAgentClient(createTestLogger());
|
||||
const provider = new CodexAppServerAgentClient(
|
||||
createTestLogger(),
|
||||
{
|
||||
command: { mode: "replace", argv: ["codex-must-not-run-in-this-test"] },
|
||||
},
|
||||
{
|
||||
appServerProcess: {
|
||||
resolvePrefix: async () => ({ command: "codex", args: [] }),
|
||||
spawn: () => appServer.child,
|
||||
},
|
||||
},
|
||||
);
|
||||
const internals = provider as unknown as {
|
||||
goalsEnabledPromise: Promise<boolean> | null;
|
||||
autoReviewEnabledPromise: Promise<boolean> | null;
|
||||
spawnAppServer: () => Promise<ChildProcessWithoutNullStreams>;
|
||||
};
|
||||
internals.goalsEnabledPromise = Promise.resolve(false);
|
||||
internals.autoReviewEnabledPromise = Promise.resolve(false);
|
||||
internals.spawnAppServer = async () => appServer.child;
|
||||
const session = await provider.resumeSession({ sessionId: "saved-thread" });
|
||||
return { appServer, session };
|
||||
}
|
||||
|
||||
@@ -60,6 +60,7 @@ test.each(["existing", "new"])(
|
||||
await Promise.all(removed.map((path) => rm(path)));
|
||||
vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout", "performance"] });
|
||||
for (const path of removed) notifications.emit("change", "change", path);
|
||||
// The file-only event uses the eight-second optional audit debounce.
|
||||
await vi.advanceTimersByTimeAsync(8_000);
|
||||
vi.useRealTimers();
|
||||
await expect
|
||||
@@ -309,79 +310,98 @@ test("a classification shed onto the scoped audit still recovers the file", asyn
|
||||
// re-link `directory` itself back into its parent -- walkSubtree only
|
||||
// descends through that link, so without the re-link a later ancestor
|
||||
// removal can no longer reach anything below the reconciled directory.
|
||||
test("a recursive-scope reconcile re-links its directory so an ancestor removal still finds nested files", async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), "native-relink-"));
|
||||
const paths = createObserverPaths(process.platform);
|
||||
const deepDirectory = join(root, "a", "b", "c");
|
||||
const deepFile = join(deepDirectory, "deep.txt");
|
||||
await mkdir(deepDirectory, { recursive: true });
|
||||
await writeFile(deepFile, "content");
|
||||
const events: FileChange[] = [];
|
||||
const notifications = new EventEmitter();
|
||||
const observer = createFileObserver();
|
||||
let active = true;
|
||||
const backend = createNativeRecursiveBackend(
|
||||
{
|
||||
root,
|
||||
metrics: observer.getDiagnostics(),
|
||||
isActive: () => active,
|
||||
isIgnored: () => false,
|
||||
isPathInside: paths.isInside,
|
||||
queueEvent: (type, path) => events.push({ type, path }),
|
||||
fail: (error) => {
|
||||
throw error;
|
||||
},
|
||||
},
|
||||
paths,
|
||||
(_root, listener) => {
|
||||
notifications.on("change", listener);
|
||||
return {
|
||||
close: () => notifications.removeAllListeners(),
|
||||
on: (event, onError) => notifications.on(event, onError),
|
||||
};
|
||||
},
|
||||
);
|
||||
const settled = () =>
|
||||
expect
|
||||
.poll(
|
||||
() => {
|
||||
const diagnostics = backend.getDiagnostics();
|
||||
return (
|
||||
diagnostics.pendingReconciliationWorkCount === 0 && !diagnostics.reconciliationInFlight
|
||||
);
|
||||
test.each([
|
||||
{ notification: "ancestor", deletedPath: ["a"] },
|
||||
{ notification: "file", deletedPath: ["a", "b", "c", "deep.txt"] },
|
||||
])(
|
||||
"a recursive-scope reconcile preserves nested deletions announced by $notification",
|
||||
async ({ deletedPath }) => {
|
||||
const root = await mkdtemp(join(tmpdir(), "native-relink-"));
|
||||
const paths = createObserverPaths(process.platform);
|
||||
const deepDirectory = join(root, "a", "b", "c");
|
||||
const deepFile = join(deepDirectory, "deep.txt");
|
||||
await mkdir(deepDirectory, { recursive: true });
|
||||
await writeFile(deepFile, "content");
|
||||
const events: FileChange[] = [];
|
||||
const notifications = new EventEmitter();
|
||||
const observer = createFileObserver();
|
||||
let active = true;
|
||||
const backend = createNativeRecursiveBackend(
|
||||
{
|
||||
root,
|
||||
metrics: observer.getDiagnostics(),
|
||||
isActive: () => active,
|
||||
isIgnored: () => false,
|
||||
isPathInside: paths.isInside,
|
||||
queueEvent: (type, path) => events.push({ type, path }),
|
||||
fail: (error) => {
|
||||
throw error;
|
||||
},
|
||||
{ timeout: 10_000 },
|
||||
)
|
||||
.toBe(true);
|
||||
try {
|
||||
await backend.start(); // Tracks a, a/b, a/b/c, and deep.txt.
|
||||
|
||||
// A rename event landing on the already-known directory a/b produces a
|
||||
// local scope on its parent "a" (rebuilding a's DirectoryEntry from a
|
||||
// fresh shallow scan) and, once classify()'s real stat() confirms a/b is
|
||||
// still a directory, a recursive scope on a/b itself -- the same "known
|
||||
// directory changes in place" shape the directory-churn benchmark
|
||||
// exercises via atomic replace.
|
||||
notifications.emit("change", "rename", join(root, "a", "b"));
|
||||
await settled();
|
||||
|
||||
// Remove the ancestor while the native event names only a deleted file.
|
||||
// The scoped scan must report its missing directory as well as that file.
|
||||
await rm(join(root, "a"), { recursive: true, force: true });
|
||||
notifications.emit("change", "rename", deepFile);
|
||||
await settled();
|
||||
|
||||
expect(events.filter((event) => event.type === "delete").map((event) => event.path)).toContain(
|
||||
deepFile,
|
||||
},
|
||||
paths,
|
||||
(_root, listener) => {
|
||||
notifications.on("change", listener);
|
||||
return {
|
||||
close: () => notifications.removeAllListeners(),
|
||||
on: (event, onError) => notifications.on(event, onError),
|
||||
};
|
||||
},
|
||||
);
|
||||
expect(events).toContainEqual({ type: "delete", path: deepDirectory });
|
||||
} finally {
|
||||
active = false;
|
||||
await backend.close();
|
||||
await observer.close();
|
||||
await rm(root, { recursive: true, force: true });
|
||||
}
|
||||
}, 15_000);
|
||||
const settled = async () => {
|
||||
// Classification uses real filesystem I/O and can request another audit.
|
||||
// Drain it before advancing the debounce clock so no later classification
|
||||
// leaves a fresh timer behind.
|
||||
await expect
|
||||
.poll(() => backend.getDiagnostics().pendingClassificationCount, { timeout: 10_000 })
|
||||
.toBe(0);
|
||||
// The file-only event uses the eight-second optional audit debounce.
|
||||
await vi.advanceTimersByTimeAsync(8_000);
|
||||
await expect
|
||||
.poll(
|
||||
() => {
|
||||
const diagnostics = backend.getDiagnostics();
|
||||
return (
|
||||
diagnostics.pendingReconciliationWorkCount === 0 &&
|
||||
!diagnostics.reconciliationInFlight
|
||||
);
|
||||
},
|
||||
{ timeout: 10_000 },
|
||||
)
|
||||
.toBe(true);
|
||||
};
|
||||
try {
|
||||
vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout", "performance"] });
|
||||
await backend.start(); // Tracks a, a/b, a/b/c, and deep.txt.
|
||||
|
||||
// A rename event landing on the already-known directory a/b produces a
|
||||
// local scope on its parent "a" (rebuilding a's DirectoryEntry from a
|
||||
// fresh shallow scan) and, once classify()'s real stat() confirms a/b is
|
||||
// still a directory, a recursive scope on a/b itself -- the same "known
|
||||
// directory changes in place" shape the directory-churn benchmark
|
||||
// exercises via atomic replace.
|
||||
notifications.emit("change", "rename", join(root, "a", "b"));
|
||||
await settled();
|
||||
|
||||
// An ancestor notification must walk the re-linked subtree. A file-only
|
||||
// notification must also recover the missing directory from its scoped scan.
|
||||
await rm(join(root, "a"), { recursive: true, force: true });
|
||||
notifications.emit("change", "rename", join(root, ...deletedPath));
|
||||
await settled();
|
||||
|
||||
expect(
|
||||
events.filter((event) => event.type === "delete").map((event) => event.path),
|
||||
).toContain(deepFile);
|
||||
expect(events).toContainEqual({ type: "delete", path: deepDirectory });
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
active = false;
|
||||
await backend.close();
|
||||
await observer.close();
|
||||
await rm(root, { recursive: true, force: true });
|
||||
}
|
||||
},
|
||||
15_000,
|
||||
);
|
||||
|
||||
test("a full native inventory audit reports a removed directory", async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), "native-full-audit-"));
|
||||
|
||||
@@ -16,13 +16,13 @@ export function createTerminalSerializer(
|
||||
// absolutely; an x==cols cursor must keep its pending wrap for the next byte.
|
||||
const position =
|
||||
buffer.cursorX < terminal.cols ? `\x1b[${buffer.cursorY + 1};${buffer.cursorX + 1}H` : "";
|
||||
return Buffer.from(data + position + serializeCursorPresentation(terminal), "utf8");
|
||||
return Buffer.from(data + position + serializeAdditionalModes(terminal), "utf8");
|
||||
};
|
||||
}
|
||||
|
||||
// Stable addon 0.14 serializes buffer/modes but omits DECSCUSR and cursor visibility.
|
||||
// xterm 6 keeps the program-selected presentation in coreService, not public options.
|
||||
function serializeCursorPresentation(terminal: TerminalType): string {
|
||||
// Addon 0.14 omits mouse encoding, DECSCUSR and cursor visibility. xterm 6
|
||||
// retains these in internal services rather than the public modes/options.
|
||||
function serializeAdditionalModes(terminal: TerminalType): string {
|
||||
const core = (
|
||||
terminal as unknown as {
|
||||
_core: {
|
||||
@@ -30,13 +30,17 @@ function serializeCursorPresentation(terminal: TerminalType): string {
|
||||
isCursorHidden: boolean;
|
||||
decPrivateModes: { cursorStyle?: "block" | "underline" | "bar"; cursorBlink?: boolean };
|
||||
};
|
||||
mouseStateService: { activeEncoding: string };
|
||||
};
|
||||
}
|
||||
)._core.coreService;
|
||||
const visibility = `\x1b[?25${core.isCursorHidden ? "l" : "h"}`;
|
||||
const cursorStyle = core.decPrivateModes.cursorStyle;
|
||||
)._core;
|
||||
let mouseEncoding = "";
|
||||
if (core.mouseStateService.activeEncoding === "SGR") mouseEncoding = "\x1b[?1006h";
|
||||
else if (core.mouseStateService.activeEncoding === "SGR_PIXELS") mouseEncoding = "\x1b[?1016h";
|
||||
const visibility = `${mouseEncoding}\x1b[?25${core.coreService.isCursorHidden ? "l" : "h"}`;
|
||||
const cursorStyle = core.coreService.decPrivateModes.cursorStyle;
|
||||
// Unset presentation belongs to each renderer's configured defaults.
|
||||
if (cursorStyle === undefined) return visibility;
|
||||
const style = { block: 2, underline: 4, bar: 6 }[cursorStyle];
|
||||
return `${visibility}\x1b[${style - (core.decPrivateModes.cursorBlink ? 1 : 0)} q`;
|
||||
return `${visibility}\x1b[${style - (core.coreService.decPrivateModes.cursorBlink ? 1 : 0)} q`;
|
||||
}
|
||||
|
||||
@@ -1702,6 +1702,52 @@ it.each([false, true])(
|
||||
},
|
||||
);
|
||||
|
||||
it.each([
|
||||
{ name: "SGR", modes: "\x1b[?1006h", wheel: "\x1b[<64;10;5M" },
|
||||
{ name: "SGR pixels", modes: "\x1b[?1016h", wheel: "\x1b[<64;80;90M" },
|
||||
{ name: "disabled SGR", modes: "\x1b[?1006h\x1b[?1006l", wheel: "\x1b[M`*%" },
|
||||
])("preserves $name wheel reports across repeated terminal replays", async (input) => {
|
||||
const source = new xterm.Terminal({ rows: 10, cols: 80, allowProposedApi: true });
|
||||
const receiver = new xterm.Terminal({ rows: 10, cols: 80, allowProposedApi: true });
|
||||
const serialize = createTerminalSerializer(source);
|
||||
const wheel = {
|
||||
button: 4,
|
||||
action: 0,
|
||||
col: 10,
|
||||
row: 5,
|
||||
x: 80,
|
||||
y: 90,
|
||||
ctrl: false,
|
||||
alt: false,
|
||||
shift: false,
|
||||
};
|
||||
// Mouse encoding is not exposed by xterm's public modes API.
|
||||
const mouse = (
|
||||
receiver as unknown as {
|
||||
_core: {
|
||||
mouseStateService: {
|
||||
encodeMouseEvent(event: typeof wheel): string;
|
||||
};
|
||||
};
|
||||
}
|
||||
)._core.mouseStateService;
|
||||
try {
|
||||
await new Promise<void>((resolve) =>
|
||||
source.write("\x1b[?1049h\x1b[?1002h" + input.modes + "TRANSCRIPT", resolve),
|
||||
);
|
||||
for (let replay = 0; replay < 2; replay++) {
|
||||
receiver.reset();
|
||||
await new Promise<void>((resolve) => receiver.write(serialize(), resolve));
|
||||
expect(receiver.buffer.active.type).toBe("alternate");
|
||||
expect(receiver.modes.mouseTrackingMode).toBe("drag");
|
||||
expect(mouse.encodeMouseEvent(wheel)).toBe(input.wheel);
|
||||
}
|
||||
} finally {
|
||||
receiver.dispose();
|
||||
source.dispose();
|
||||
}
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ name: "unset", presentation: "", style: undefined, visible: true },
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user