Compare commits

..
Author SHA1 Message Date
paseo-bot[bot] 47d3910943 test(claude): name replay notification fixture options 2026-11-07 13:36:44 +00:00
paseo-bot[bot] 2095efb07c fix(claude): restore stopped background subagents as canceled
Resolve task-only replay notifications through the parent launch identity so stopped children keep their recorded outcome after reopening. Preserve transcript order across task and tool notifications.
2026-11-07 13:29:44 +00:00
68 changed files with 1065 additions and 2842 deletions
+2 -2
View File
@@ -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. An underfilled viewport
loads older history until it fills or history is exhausted. Further history is user-driven.
and presentation; it cannot recover historical batching or steering. Older history remains
user-driven by scrolling upward.
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,81 +198,3 @@ 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);
});
@@ -19,7 +19,6 @@ import {
expectWorkspaceHeaderVisible,
resizeComposerViewport,
submitMessage,
submitMessageWithButton,
typeIntoFocusedComposer,
} from "../support/helpers/composer";
import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent";
@@ -147,14 +146,6 @@ test("submitting a message leaves the composer ready for the next message", asyn
await openAgentRoute(page, agent);
await expectComposerVisible(page);
await submitMessageWithButton(page, "Pointer submission");
await expectComposerDraft(page, "");
await expectComposerFocused(page);
await typeIntoFocusedComposer(page, "Ready after Send");
await expectComposerDraft(page, "Ready after Send");
await agent.client.waitForFinish(agent.agentId, 15_000);
await expectComposerFocused(page);
await submitMessage(page, "First message");
await expectComposerFocused(page);
@@ -182,7 +173,7 @@ test("submitting a message leaves the composer ready for the next message", asyn
"Investigate: [this FR](https://example.com/issues/39150) next. Thanks",
);
await agent.client.waitForFinish(agent.agentId, 15_000);
await expect(page.getByRole("button", { name: "Copy turn", exact: true }).last()).toBeVisible();
await expect(page.getByRole("button", { name: "Copy turn", exact: true })).toBeVisible();
await connection.drop();
await connection.waitForBlockedConnection();
await pasteRichTextIntoComposer({
@@ -322,9 +313,6 @@ test.describe("touch screen wide enough for the desktop layout", () => {
// Autofocus makes its first attempt on the next animation frame.
await waitForAnimationFrames(page, 4);
await expectComposerNotFocused(page);
await submitMessageWithButton(page, "Touch submission");
await expectComposerDraft(page, "");
await expectComposerNotFocused(page);
} finally {
await agent.cleanup();
}
@@ -60,15 +60,6 @@ async function verifyChronologicalFiltering(page: Page): Promise<void> {
});
}
async function verifyHistorySurvivesReload(page: Page): Promise<void> {
await test.step("reloads History without visiting a workspace to reconnect", async () => {
await expectVisibleTitles(page, [TITLES.unbilled, TITLES.billing, TITLES.terminal]);
await page.reload();
await expectVisibleTitles(page, [TITLES.unbilled, TITLES.billing, TITLES.terminal]);
await expect(page.getByTestId("sessions-host-errors")).toBeHidden();
});
}
async function verifyRecencyBeforeMatchStrength(page: Page): Promise<void> {
await test.step("keeps newer partial matches before older stronger matches", async () => {
await search(page, `${NONCE} bill`);
@@ -160,8 +151,6 @@ test.describe("History search", () => {
await verifyChronologicalFiltering(page);
await verifyHistorySurvivesReload(page);
await verifyRecencyBeforeMatchStrength(page);
await verifyExactAndTypoHighlights(page);
+7 -81
View File
@@ -63,26 +63,6 @@ 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]")];
@@ -228,7 +208,6 @@ 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);
@@ -491,87 +470,34 @@ async function seedWholeTurnHistory() {
return agent;
}
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 scrollTimelineRowIntoView(page, latest);
await latest.scrollIntoViewIfNeeded();
await latest.click();
await expect.poll(() => rowsBetweenLatestSummaryAndReply(page)).toBe(0);
} finally {
@@ -575,10 +575,8 @@ 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 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();
// The host row carries the folded turn's summary line.
await expect(promptRow.getByTestId("turn-summary")).toBeVisible();
await expect
.poll(
async () => {
@@ -1,310 +1,197 @@
import { nativeHistoryStartGeometry } from "./native-history-start-geometry";
import { describe, expect, it, vi } from "vitest";
import { describe, expect, it } from "vitest";
import {
createHistoryStartPagination,
abandonHistoryStartPaginationRequest,
createHistoryStartPaginationState,
evaluateHistoryStartPagination,
isHistoryStartLoadingOperation,
rearmHistoryStartPagination,
settleHistoryStartPagination,
type HistoryStartPaginationInput,
} from "./history-start-pagination";
const initial: HistoryStartPaginationInput = {
function createArmedHistoryStartPaginationState() {
return rearmHistoryStartPagination(createHistoryStartPaginationState());
}
const visibleHistoryStart: HistoryStartPaginationInput = {
distanceFromHistoryStart: 0,
isUnderfilled: true,
hasOlderHistory: true,
isLoadingOlderHistory: false,
isReady: true,
progressKey: "20",
progressKey: "epoch-1: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("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 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("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("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("keeps visible history visible while filling", () => {
const h = harness(true);
h.pagination.update();
expect(h.pagination.state.visible).toBe(true);
h.pagination.dispose();
});
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("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,
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",
});
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: () => {},
expect(first.shouldLoad).toBe(true);
expect(localReveal.state).toEqual({
status: "settling",
loadedProgressKey: "epoch-1:20:local-60",
});
});
pagination.rearm();
expect(requests).toBe(0);
offsetY = 155;
pagination.update();
expect(requests).toBe(1);
pagination.dispose();
});
it("waits for user scroll intent before loading", () => {
const dormant = createHistoryStartPaginationState();
const evaluated = evaluateHistoryStartPagination(dormant, visibleHistoryStart);
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: () => {},
expect(evaluated).toEqual({ state: { status: "dormant" }, shouldLoad: false });
});
it("waits for anchored page geometry before authorizing another page", () => {
const first = evaluateHistoryStartPagination(
createArmedHistoryStartPaginationState(),
visibleHistoryStart,
);
const inFlight = evaluateHistoryStartPagination(first.state, {
...visibleHistoryStart,
isLoadingOlderHistory: true,
});
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();
}
},
);
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("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("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("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("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]);
});
});
@@ -1,149 +1,138 @@
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 HistoryStartPaginationState {
status: "dormant" | "ready" | "loading" | "settling" | "failed";
visible: boolean;
export interface HistoryStartPaginationTransition {
state: HistoryStartPaginationState;
shouldLoad: boolean;
}
export function createHistoryStartPaginationState(): HistoryStartPaginationState {
return { status: "dormant" };
}
export function isHistoryStartLoadingOperation(state: HistoryStartPaginationState): boolean {
return state.status === "loading" || state.status === "settling";
}
/** 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 rearmHistoryStartPagination(
state: HistoryStartPaginationState,
): HistoryStartPaginationState {
return state.status === "dormant" || state.status === "latched" ? { status: "ready" } : state;
}
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 abandonHistoryStartPaginationRequest(
state: HistoryStartPaginationState,
requestedProgressKey: string,
): HistoryStartPaginationState {
if (
state.status !== "loading" ||
state.requestObserved ||
state.requestedProgressKey !== requestedProgressKey
) {
return state;
}
return { status: "latched" };
}
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);
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,
};
}
};
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 };
}
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();
},
);
};
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 };
}
return {
get state() {
return state;
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,
};
}
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,
},
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,
shouldLoad: true,
};
}
@@ -1,12 +0,0 @@
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,11 +608,8 @@ 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",
"turn-summary:prompt",
"probe-plugin/reply/0",
]);
expect(result.tail.map((item) => item.id)).toEqual(["prompt", "probe-plugin/reply/0"]);
expect([...result.turns.turnsByHostId.keys()]).toEqual(["prompt"]);
expect(pluginData(result.tail)).toEqual([{ text: "$$x^2$$", phase: "complete" }]);
});
@@ -639,14 +636,12 @@ 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",
]);
@@ -654,7 +649,6 @@ 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",
]);
});
@@ -681,7 +675,7 @@ it.each(["full", "collapseToolCalls"] as const)(
"tool-one",
"probe-plugin/reply/0",
]);
expect(result.turns.summaryRowsByHostId.size).toBe(0);
expect(result.turns.turnsByHostId.size).toBe(0);
},
);
@@ -703,13 +697,7 @@ 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([
"prompt",
"turn-summary:prompt",
"tool-one",
"tool-two",
"reply",
]);
expect(result.tail.map((item) => item.id)).toEqual(tail.map((item) => item.id));
expect(result.groupsByHostId.size).toBe(0);
expect(result.readingRowReplacements.get("tool-two")).toBeUndefined();
});
@@ -751,7 +739,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's host.
// Both the original partial group and its replacement resolve directly to the turn.
expect(folded.readingRowReplacements.get(calls[1]!.id)).toBe("prompt");
expect(anchor.getRowId(folded.readingRowReplacements)).toBe("prompt");
const opened = present({ ...input, tail: calls, display: "full" });
@@ -800,7 +788,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", "turn-summary:prompt", "reply"]);
expect(result.tail.map((item) => item.id)).toEqual(["prompt", "reply"]);
});
// A sent prompt counts as activity before the daemon opens its turn, so the only known turn is
@@ -822,7 +810,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", "turn-summary:prompt", "reply"]);
expect(result.tail.map((item) => item.id)).toEqual(["prompt", "reply"]);
});
it("groups reasoning with tools unless Expand thinking is enabled, including setting changes", () => {
@@ -879,13 +867,7 @@ 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.map((item) => item.id)).toEqual([
"prompt",
"turn-summary:prompt",
"thought",
"tool-one",
"reply",
]);
expect(opened.tail).toEqual(tail);
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,
previousSummaryRowsByHostId: new Map(),
previousTurnsByHostId: new Map(),
});
/** Source messages reach plugins before display projection. */
@@ -135,8 +135,7 @@ export function createStreamPresentation() {
isLatestTurnOpen,
expandedHostIds: input.expandedTurnHostIds,
getGroupedCalls: (itemId) => projection.groupsByHostId.get(itemId)?.run.calls,
previousSummaryRowsByHostId:
collapsed?.summaryRowsByHostId ?? NO_COLLAPSED_TURNS.summaryRowsByHostId,
previousTurnsByHostId: collapsed?.turnsByHostId ?? NO_COLLAPSED_TURNS.turnsByHostId,
});
collapsedSource = projection.tail;
collapsedIsLatestTurnOpen = isLatestTurnOpen;
@@ -1,6 +1,5 @@
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",
@@ -8,27 +7,6 @@ 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");
@@ -39,18 +17,4 @@ 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 -12
View File
@@ -11,12 +11,8 @@ export function getAssistantMessageSpacing(params: {
}
const isUserMessageItem = (item?: StreamItem | null) => item?.kind === "user_message";
/** 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";
const isToolSequenceItem = (item?: StreamItem | null) =>
item?.kind === "tool_call" || item?.kind === "thought" || item?.kind === "todo_list";
export function getGapBetweenStreamItems(
item: StreamItem | null,
@@ -29,16 +25,19 @@ export function getGapBetweenStreamItems(
if (isUserMessageItem(item) && isUserMessageItem(belowItem)) {
return SPACING[1];
}
if (isUserMessageItem(item)) {
return SPACING[4];
}
if (isWorkItem(item) && isWorkItem(belowItem)) {
if (item.kind === "user_message" && belowItem.kind === "assistant_message") {
return 0;
}
if (item.kind === "assistant_message" && isWorkItem(belowItem)) {
if (isToolSequenceItem(item) && isToolSequenceItem(belowItem)) {
return 0;
}
if (item.kind === "user_message" && isToolSequenceItem(belowItem)) {
return SPACING[4];
}
if (item.kind === "assistant_message" && isToolSequenceItem(belowItem)) {
return SPACING[1];
}
if (isWorkItem(item) && belowItem.kind === "assistant_message") {
if (isToolSequenceItem(item) && belowItem.kind === "assistant_message") {
return SPACING[1];
}
return SPACING[4];
+101 -111
View File
@@ -25,9 +25,6 @@ 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";
@@ -41,11 +38,15 @@ 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,
@@ -107,16 +108,6 @@ 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;
@@ -134,8 +125,6 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
viewportRef,
routeBottomAnchorRequest,
isAuthoritativeHistoryReady,
isInitialHistoryLoading,
revealInitialHistory,
onNearBottomChange,
onNearHistoryStart,
isLoadingOlderHistory,
@@ -146,8 +135,6 @@ 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.
@@ -170,6 +157,10 @@ 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(() => {
@@ -200,36 +191,48 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
metrics.viewportMeasuredForKey === metrics.containerKey &&
metrics.contentMeasuredForKey === metrics.containerKey;
return {
...nativeHistoryStartGeometry({
contentHeight: metrics.contentHeight,
viewportHeight: metrics.viewportHeight,
offsetY: metrics.offsetY,
topOverlayInset,
}),
distanceFromHistoryStart: metrics.offsetY,
hasOlderHistory,
isLoadingOlderHistory,
isReady:
isActive &&
isAuthoritativeHistoryReady &&
historyStartReadyRef.current &&
hasMeasuredViewport,
isReady: historyStartReadyRef.current && hasMeasuredViewport,
progressKey: olderHistoryProgressKey,
};
});
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 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 finishInitialFill = useStableEvent(() => historyStartPagination.reveal());
const retryHistoryStart = useStableEvent(() => historyStartPagination.rearm());
const evaluateHistoryStart = useStableEvent(() => {
const transition = evaluateHistoryStartPagination(
historyStartPaginationStateRef.current,
getHistoryStartPaginationInput(),
);
applyHistoryStartPaginationTransition(transition);
});
const scheduleHistoryStartSettle = useStableEvent(() => {
let scheduler = historyStartSettleSchedulerRef.current;
if (!scheduler) {
@@ -237,9 +240,15 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
settleFrames: HISTORY_START_SETTLE_FRAMES,
requestFrame: requestAnimationFrame,
cancelFrame: cancelAnimationFrame,
isSettling: () => isHistoryStartSettling(),
isSettling: () => historyStartPaginationStateRef.current.status === "settling",
isLoading: () => getHistoryStartPaginationInput().isLoadingOlderHistory,
onSettle: () => historyStartPagination.settle(),
onSettle: () => {
const transition = settleHistoryStartPagination(
historyStartPaginationStateRef.current,
getHistoryStartPaginationInput(),
);
applyHistoryStartPaginationTransition(transition);
},
});
historyStartSettleSchedulerRef.current = scheduler;
}
@@ -381,11 +390,10 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
return () => {
cancelAnimationFrame(frame);
clearPendingUserScrollEnd();
disposeHistoryStart();
historyStartSettleSchedulerRef.current?.cancel();
historyStartSettleSchedulerRef.current = null;
};
}, [clearPendingUserScrollEnd, disposeHistoryStart, evaluateHistoryStart]);
}, [clearPendingUserScrollEnd, evaluateHistoryStart]);
useEffect(() => {
const handle: StreamViewportHandle = {
@@ -498,7 +506,6 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
});
const handleScrollBeginDrag = useStableEvent((event: NativeSyntheticEvent<NativeScrollEvent>) => {
finishInitialFill();
clearPendingUserScrollEnd();
isUserScrollActiveRef.current = true;
scrollKeyboardDismiss.onScrollBeginDrag(event);
@@ -507,13 +514,14 @@ 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();
historyStartPagination.rearm();
const rearmed = rearmHistoryStartPagination(historyStartPaginationStateRef.current);
if (rearmed !== historyStartPaginationStateRef.current) {
historyStartPaginationStateRef.current = rearmed;
setHistoryStartPaginationState(rearmed);
evaluateHistoryStart();
}
});
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
@@ -590,7 +598,7 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
publishNearBottom();
}
evaluateHistoryStart();
if (isHistoryStartSettling()) {
if (historyStartPaginationStateRef.current.status === "settling") {
scheduleHistoryStartSettle();
}
});
@@ -603,23 +611,19 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
contentMeasuredForKey: "native-virtualized",
};
bottomAnchorController.handleContentSizeChange();
evaluateHistoryStart();
});
useEffect(() => {
evaluateHistoryStart();
if (isHistoryStartSettling()) {
if (historyStartPaginationStateRef.current.status === "settling") {
scheduleHistoryStartSettle();
}
}, [
evaluateHistoryStart,
hasOlderHistory,
isActive,
isAuthoritativeHistoryReady,
isLoadingOlderHistory,
olderHistoryProgressKey,
scheduleHistoryStartSettle,
isHistoryStartSettling,
]);
// LegendList re-renders a mounted row only when its item or `extraData` changes, not when
@@ -654,65 +658,51 @@ 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>
)}
{!isLoadingOperation &&
historyStartPaginationState.status === "failed" &&
hasOlderHistory && (
<Button variant="ghost" size="sm" onPress={retryHistoryStart}>
{t("paneFind.retry")}
</Button>
)}
) : null}
</View>
);
}, [historyStartPaginationState, hasOlderHistory, retryHistoryStart, t]);
}, [historyStartPaginationState]);
return (
<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>
<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
/>
);
}
@@ -67,7 +67,7 @@ function mountTimeline(historyMounted: StreamItem[]) {
routeBottomAnchorRequest: null,
isAuthoritativeHistoryReady: true,
onNearBottomChange: () => {},
onNearHistoryStart: async () => true,
onNearHistoryStart: () => true,
isLoadingOlderHistory: false,
hasOlderHistory: false,
olderHistoryProgressKey: null,
@@ -152,7 +152,7 @@ describe("createWebStreamStrategy", () => {
routeBottomAnchorRequest: null,
isAuthoritativeHistoryReady: true,
onNearBottomChange: () => {},
onNearHistoryStart: async () => true,
onNearHistoryStart: () => true,
isLoadingOlderHistory: false,
hasOlderHistory: false,
olderHistoryProgressKey: null,
@@ -274,7 +274,7 @@ describe("createWebStreamStrategy", () => {
routeBottomAnchorRequest: null,
isAuthoritativeHistoryReady: true,
onNearBottomChange: () => {},
onNearHistoryStart: async () => true,
onNearHistoryStart: () => true,
isLoadingOlderHistory: false,
hasOlderHistory: false,
olderHistoryProgressKey: null,
+77 -70
View File
@@ -20,7 +20,6 @@ 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";
@@ -31,10 +30,15 @@ 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,
@@ -73,14 +77,6 @@ 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",
@@ -302,8 +298,6 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
viewportRef,
routeBottomAnchorRequest,
isAuthoritativeHistoryReady,
isInitialHistoryLoading,
revealInitialHistory,
onNearBottomChange,
onReadingPositionChange,
onNearHistoryStart,
@@ -324,9 +318,6 @@ 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);
@@ -360,6 +351,10 @@ 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);
@@ -486,36 +481,60 @@ 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 &&
isAuthoritativeHistoryReady &&
bottomAnchorSettled &&
!isJumpSettling(),
isReady: historyStartReadyRef.current && bottomAnchorSettled && !isJumpSettling(),
progressKey: olderHistoryProgressKey,
};
});
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 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 finishInitialFill = useStableEvent(() => historyStartPagination.reveal());
const rearmHistoryStartFromUserIntent = useStableEvent(() => historyStartPagination.rearm());
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 scheduleHistoryStartPrependSettle = useStableEvent(() => {
let scheduler = historyStartSettleSchedulerRef.current;
if (!scheduler) {
@@ -523,12 +542,22 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
settleFrames: HISTORY_START_SETTLE_FRAMES,
requestFrame: (callback) => window.requestAnimationFrame(callback),
cancelFrame: (frame) => window.cancelAnimationFrame(frame),
isSettling: () => isHistoryStartSettling(),
isSettling: () => historyStartPaginationStateRef.current.status === "settling",
isLoading: () => {
const input = getHistoryStartPaginationInput();
return !input || input.isLoadingOlderHistory;
},
onSettle: () => historyStartPagination.settle(),
onSettle: () => {
const input = getHistoryStartPaginationInput();
if (!input) {
return;
}
const transition = settleHistoryStartPagination(
historyStartPaginationStateRef.current,
input,
);
applyHistoryStartPaginationTransition(transition);
},
});
historyStartSettleSchedulerRef.current = scheduler;
}
@@ -765,7 +794,6 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
]);
const stopFollowingOutputFromUserIntent = useStableEvent(() => {
finishInitialFill();
cancelPendingStickToBottom();
if (followOutputRef.current) {
setFollowOutput(false);
@@ -773,10 +801,6 @@ 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)) {
@@ -822,11 +846,10 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
return () => {
window.cancelAnimationFrame(frame);
historyStartReadyRef.current = false;
disposeHistoryStart();
historyStartSettleSchedulerRef.current?.cancel();
historyStartSettleSchedulerRef.current = null;
};
}, [disposeHistoryStart, evaluateHistoryStart]);
}, [evaluateHistoryStart]);
useLayoutEffect(() => {
if (!isActiveRef.current || !isActivationReady) {
@@ -915,18 +938,16 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
updateScrollMetrics();
}
evaluateHistoryStart();
if (isHistoryStartSettling()) {
if (historyStartPaginationStateRef.current.status === "settling") {
scheduleHistoryStartPrependSettle();
}
}, [
evaluateHistoryStart,
hasOlderHistory,
isActive,
isAuthoritativeHistoryReady,
isLoadingOlderHistory,
olderHistoryProgressKey,
scheduleHistoryStartPrependSettle,
isHistoryStartSettling,
segments.historyMounted.length,
segments.historyVirtualized.length,
segments.liveHead.length,
@@ -965,7 +986,7 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
lastObservedViewportGeometryRef.current = nextGeometry;
}
reconcileReadingPosition();
if (isHistoryStartSettling()) {
if (historyStartPaginationStateRef.current.status === "settling") {
scheduleHistoryStartPrependSettle();
}
updateScrollMetrics();
@@ -999,7 +1020,6 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
isActive,
reportReadingPosition,
scheduleHistoryStartPrependSettle,
isHistoryStartSettling,
forceStickToBottom,
updateScrollMetrics,
measureVirtualizedRows,
@@ -1221,9 +1241,8 @@ 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, historyStartPaginationState.visible, isMobileBreakpoint, scrollEnabled]);
}, [followOutput, isMobileBreakpoint, scrollEnabled]);
const viewportStyle = useMemo(
(): CSSProperties => ({
position: "relative",
@@ -1300,21 +1319,14 @@ 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>
)}
{!isLoadingOperation &&
historyStartPaginationState.status === "failed" &&
hasOlderHistory && (
<button type="button" onClick={rearmHistoryStartFromUserIntent}>
{t("paneFind.retry")}
</button>
)}
) : null}
</div>
);
}, [historyStartPaginationState, hasOlderHistory, rearmHistoryStartFromUserIntent, t]);
}, [historyStartPaginationState]);
const shouldRenderEmpty =
!boundary.hasMountedHistory &&
!boundary.hasVirtualizedHistory &&
@@ -1334,7 +1346,7 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
>
<div ref={handleContentRef} style={contentContainerStyle}>
{historyStartSlot}
<div ref={rowsHostRef} style={streamRowsHostStyle}>
<div style={streamRowsHostStyle}>
<div style={virtualRowsContainerStyle} />
{[
...virtualRows.map((virtualRow) => {
@@ -1362,15 +1374,10 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
...mountedRows,
]}
</div>
<div ref={auxiliaryRef}>{liveAuxiliary}</div>
{liveAuxiliary}
{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}
+1 -3
View File
@@ -85,13 +85,11 @@ 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: () => Promise<boolean>;
onNearHistoryStart: () => boolean | 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,
previousSummaryRowsByHostId: new Map(),
previousTurnsByHostId: new Map(),
...options,
});
}
@@ -73,29 +73,17 @@ describe("collapseTurns", () => {
pluginReply("final", "right"),
];
const result = collapse(items);
expect(ids(result.tail)).toEqual([
"prompt",
"turn-summary:prompt",
"math/final/left",
"math/final/right",
]);
expect(ids(result.tail)).toEqual(["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(ids(expanded.tail)).toEqual([
"prompt",
"turn-summary:prompt",
"acknowledgment",
"call",
"math/final/left",
"math/final/right",
]);
expect(expanded.tail).toEqual(items);
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", "turn-summary:prompt", "math/final/0"]);
expect(ids(result.tail)).toEqual(["prompt", "math/final/0"]);
expect(result.hostIdByFoldedMessageId.get("intermediate")).toBe("prompt");
});
@@ -105,7 +93,7 @@ describe("collapseTurns", () => {
const card = { ...pluginReply("card"), sourceKind };
const items = [user("prompt"), shell("call"), card];
expect(collapse(items).tail).toEqual(items);
expect(collapse(items).summaryRowsByHostId.size).toBe(0);
expect(collapse(items).turnsByHostId.size).toBe(0);
},
);
@@ -114,41 +102,38 @@ describe("collapseTurns", () => {
expect(collapse(items, { isLatestTurnOpen: true }).tail).toEqual(items);
});
// 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")], {
// 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")], {
isLatestTurnOpen: true,
});
expect([...open.updatedHostIds]).toEqual(["u1"]);
const closed = collapse(
[user("u1"), shell("c1"), reply("r1"), user("u2"), shell("c2"), reply("r2")],
{ previousSummaryRowsByHostId: first.summaryRowsByHostId },
{
previousTurnsByHostId: open.turnsByHostId,
},
);
expect(closed.summaryRowsByHostId.get("u1")).toBe(first.summaryRowsByHostId.get("u1"));
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);
const moreWork = collapse(
[user("u1"), shell("c1"), reply("r1"), user("u2"), shell("c2"), shell("c3"), reply("r2")],
{ previousSummaryRowsByHostId: closed.summaryRowsByHostId },
{ previousTurnsByHostId: unchanged.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([...moreWork.updatedHostIds]).toEqual(["u2"]);
const rewound = collapse([user("u1"), shell("c1"), reply("r1")], {
previousTurnsByHostId: moreWork.turnsByHostId,
});
expect(opened.summaryRowsByHostId.get("u1")).not.toBe(moreWork.summaryRowsByHostId.get("u1"));
expect([...rewound.updatedHostIds]).toEqual(["u2"]);
});
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", () => {
it("maps hidden rows only while their 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")], {
@@ -183,15 +168,15 @@ describe("collapseTurns", () => {
reply("done"),
].map((item) => Object.assign(item, { turnId: "same-run" }));
const result = collapse(items);
expect(ids(result.tail)).toEqual(["u1", "turn-summary:u1", "finished", "compaction", "done"]);
expect(result.summaryRowsByHostId.get("u1")?.tools).toMatchObject({
expect(ids(result.tail)).toEqual(["u1", "finished", "compaction", "done"]);
expect(result.turnsByHostId.get("u1")?.summary.tools).toMatchObject({
commandCount: 1,
otherToolCount: 0,
paseoActions: { "Created agent": 1 },
});
});
it("folds a finished turn's work into a summary row under its user message and keeps the reply", () => {
it("folds a finished turn's work into its user message and keeps the reply", () => {
const result = collapse([
user("u1"),
thought("t1"),
@@ -201,16 +186,14 @@ describe("collapseTurns", () => {
reply("final"),
]);
expect(ids(result.tail)).toEqual(["u1", "turn-summary:u1", "final"]);
expect(result.tail[1]).toEqual({
kind: "turn_summary",
id: "turn-summary:u1",
expect(ids(result.tail)).toEqual(["u1", "final"]);
expect(result.turnsByHostId.get("u1")).toEqual({
hostId: "u1",
timestamp: at,
expanded: false,
tools: expect.objectContaining({ commandCount: 2 }),
summary: {
tools: expect.objectContaining({ commandCount: 2 }),
},
});
expect(result.summaryRowsByHostId.get("u1")).toBe(result.tail[1]);
expect(result.hostIdByFoldedMessageId.get("early")).toBe("u1");
});
@@ -219,9 +202,9 @@ describe("collapseTurns", () => {
expandedHostIds: new Set(["u1"]),
});
expect(ids(result.tail)).toEqual(["u1", "turn-summary:u1", "t1", "c1", "final"]);
expect(ids(result.tail)).toEqual(["u1", "t1", "c1", "final"]);
expect([...result.nestedItemIds]).toEqual(["t1", "c1"]);
expect(result.summaryRowsByHostId.get("u1")?.expanded).toBe(true);
expect(result.turnsByHostId.get("u1")?.expanded).toBe(true);
});
it("leaves the latest turn alone while it is still producing output", () => {
@@ -229,8 +212,8 @@ describe("collapseTurns", () => {
const result = collapse(tail, { isLatestTurnOpen: true });
expect(ids(result.tail)).toEqual(["u1", "turn-summary:u1", "r1", "u2", "c2", "r2"]);
expect([...result.summaryRowsByHostId.keys()]).toEqual(["u1"]);
expect(ids(result.tail)).toEqual(["u1", "r1", "u2", "c2", "r2"]);
expect([...result.turnsByHostId.keys()]).toEqual(["u1"]);
});
it("keeps rows after the reply, and turns with no work before the reply, as they are", () => {
@@ -243,8 +226,8 @@ describe("collapseTurns", () => {
reply("r2"),
]);
expect(ids(result.tail)).toEqual(["u1", "turn-summary:u1", "r1", "after", "u2", "r2"]);
expect([...result.summaryRowsByHostId.keys()]).toEqual(["u1"]);
expect(ids(result.tail)).toEqual(["u1", "r1", "after", "u2", "r2"]);
expect([...result.turnsByHostId.keys()]).toEqual(["u1"]);
});
it("does not fold a turn that only thought before its reply", () => {
@@ -253,7 +236,7 @@ describe("collapseTurns", () => {
const result = collapse(tail);
expect(result.tail).toBe(tail);
expect(result.summaryRowsByHostId.size).toBe(0);
expect(result.turnsByHostId.size).toBe(0);
});
it("does not fold a turn without a reply or whose prompt is above the loaded history", () => {
@@ -262,7 +245,7 @@ describe("collapseTurns", () => {
const result = collapse(tail);
expect(result.tail).toBe(tail);
expect(result.summaryRowsByHostId.size).toBe(0);
expect(result.turnsByHostId.size).toBe(0);
});
it("keeps a completed response folded when a promptless canonical turn starts", () => {
@@ -271,15 +254,15 @@ describe("collapseTurns", () => {
);
const next = { ...shell("heartbeat"), turnId: "second" };
const result = collapse([...first, next], { isLatestTurnOpen: true });
expect(ids(result.tail)).toEqual(["u1", "turn-summary:u1", "r1", "heartbeat"]);
expect(result.tail[1]).toMatchObject({ kind: "turn_summary", turnId: "first" });
expect(ids(result.tail)).toEqual(["u1", "r1", "heartbeat"]);
expect(result.turnsByHostId.has("u1")).toBe(true);
});
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", "turn-summary:prompt", "steer", "done"]);
expect(ids(result.tail)).toEqual(["prompt", "steer", "done"]);
expect(result.hostIdByFoldedMessageId.has("steer")).toBe(false);
});
@@ -289,7 +272,7 @@ describe("collapseTurns", () => {
expandedHostIds: new Set(["prompt"]),
getGroupedCalls: (id) => (id === "one" ? calls : undefined),
});
expect(ids(result.tail)).toEqual(["prompt", "turn-summary:prompt", "one", "two", "done"]);
expect(ids(result.tail)).toEqual(["prompt", "one", "two", "done"]);
expect([...result.nestedItemIds]).toEqual(["one", "two"]);
});
@@ -299,6 +282,8 @@ describe("collapseTurns", () => {
getGroupedCalls: (itemId) => (itemId === "c1" ? group : undefined),
});
expect(result.summaryRowsByHostId.get("u1")?.tools).toMatchObject({ commandCount: 3 });
expect(result.turnsByHostId.get("u1")?.summary).toMatchObject({
tools: { commandCount: 3 },
});
});
});
@@ -3,25 +3,35 @@ 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 host of the turn a folded message belongs to, for surfaces that must reveal it. */
/** 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>;
/**
* 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.
* 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.
*/
summaryRowsByHostId: ReadonlyMap<string, TurnSummaryItem>;
updatedHostIds: ReadonlySet<string>;
}
export interface CollapseTurnsInput {
@@ -31,39 +41,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, whose unchanged summary rows are reused. */
previousSummaryRowsByHostId: ReadonlyMap<string, TurnSummaryItem>;
/** The previous fold of the same history, the baseline for `updatedHostIds`. */
previousTurnsByHostId: ReadonlyMap<string, CollapsedTurn>;
}
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"],
): OverviewSummary {
): TurnSummary {
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 summarizeToolCalls(calls);
return { tools: summarizeToolCalls(calls) };
}
function hasWork(tools: OverviewSummary): boolean {
const { paseoActions, thoughtCount: _thoughtCount, ...counts } = tools;
function hasWork(summary: TurnSummary): boolean {
const { paseoActions, thoughtCount: _thoughtCount, ...counts } = summary.tools;
return Object.keys(paseoActions).length > 0 || Object.values(counts).some((count) => count > 0);
}
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 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 hasSameCounts(a: Record<string, number>, b: Record<string, number>): boolean {
@@ -71,32 +81,19 @@ 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 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;
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);
}
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,
};
for (const hostId of previous.keys()) {
if (!next.has(hostId)) updated.add(hostId);
}
return updated.size === 0 ? EMPTY_IDS : updated;
}
function staysVisibleWhenCollapsed(item: StreamItem): boolean {
@@ -127,15 +124,15 @@ function findLastReplyIndex(tail: StreamItem[], start: number, end: number): num
}
/**
* 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.
* 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.
*/
export function collapseTurns(input: CollapseTurnsInput): TurnCollapse {
const { tail } = input;
const output: StreamItem[] = [];
const summaryRowsByHostId = new Map<string, TurnSummaryItem>();
const turnsByHostId = new Map<string, CollapsedTurn>();
const nestedItemIds = new Set<string>();
const hostIdByFoldedMessageId = new Map<string, string>();
const hostIdByHiddenRowId = new Map<string, string>();
@@ -155,19 +152,13 @@ export function collapseTurns(input: CollapseTurnsInput): TurnCollapse {
return;
}
const folded = tail.slice(runStart, replyIndex);
const tools = summarizeTurn(folded, input.getGroupedCalls);
if (!hasWork(tools) && !folded.some(isAssistantReply)) {
const summary = summarizeTurn(folded, input.getGroupedCalls);
if (!hasWork(summary) && !folded.some(isAssistantReply)) {
output.push(...tail.slice(runStart, runEnd));
return;
}
const expanded = input.expandedHostIds.has(host.id);
const summary = summaryRow(
host,
expanded,
tools,
input.previousSummaryRowsByHostId.get(host.id),
);
summaryRowsByHostId.set(host.id, summary);
turnsByHostId.set(host.id, { hostId: host.id, expanded, summary });
const revealed = expanded
? folded.flatMap<StreamItem>((item) =>
item.kind === "tool_call" || item.kind === "thought"
@@ -182,7 +173,6 @@ 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));
@@ -200,20 +190,26 @@ export function collapseTurns(input: CollapseTurnsInput): TurnCollapse {
}
flushRun(tail.length);
if (summaryRowsByHostId.size === 0) {
const updatedHostIds = findUpdatedHosts(input.previousTurnsByHostId, turnsByHostId);
if (turnsByHostId.size === 0) {
return {
tail,
turnsByHostId: EMPTY_TURNS,
nestedItemIds: EMPTY_IDS,
hostIdByFoldedMessageId: EMPTY_HOSTS,
hostIdByHiddenRowId: EMPTY_HOSTS,
summaryRowsByHostId: EMPTY_ROWS,
updatedHostIds,
};
}
return {
tail: output,
tail:
output.length === tail.length && output.every((item, index) => item === tail[index])
? tail
: output,
turnsByHostId,
nestedItemIds,
hostIdByFoldedMessageId,
hostIdByHiddenRowId,
summaryRowsByHostId,
updatedHostIds,
};
}
@@ -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 { TurnSummaryItem } from "@/types/stream";
import type { CollapsedTurn } from "./model";
/**
* 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({
item,
turn,
onExpandedChange,
}: {
item: TurnSummaryItem;
turn: CollapsedTurn;
onExpandedChange: (hostId: string, expanded: boolean) => void;
}) {
const { t } = useTranslation();
const label = useOverviewSummaryLabel(item.tools) || t("turnSummary.details");
const label = useOverviewSummaryLabel(turn.summary.tools) || t("turnSummary.details");
const hold = useDisclosureHold();
const toggle = useCallback(
() => hold(() => onExpandedChange(item.hostId, !item.expanded)),
[hold, onExpandedChange, item.expanded, item.hostId],
() => hold(() => onExpandedChange(turn.hostId, !turn.expanded)),
[hold, onExpandedChange, turn.expanded, turn.hostId],
);
return (
@@ -30,7 +30,7 @@ export const TurnSummaryLine = memo(function TurnSummaryLine({
testID="turn-summary"
label={label}
icon={Summary}
isExpanded={item.expanded}
isExpanded={turn.expanded}
onToggle={toggle}
borderlessWhenExpanded
/>
@@ -236,8 +236,8 @@ const stylesheet = StyleSheet.create((theme) => ({
turnFooterSlot: {
flexDirection: "row",
alignItems: "center",
alignSelf: "stretch",
minHeight: TURN_FOOTER_CONTENT_HEIGHT + TURN_FOOTER_BOTTOM_SPACING,
alignSelf: "flex-start",
height: TURN_FOOTER_CONTENT_HEIGHT + TURN_FOOTER_BOTTOM_SPACING,
paddingBottom: TURN_FOOTER_BOTTOM_SPACING,
},
turnFooterContent: {
@@ -1,33 +0,0 @@
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 };
}
+45 -25
View File
@@ -248,8 +248,6 @@ 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. */
@@ -347,8 +345,6 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
turnPresentation,
routeBottomAnchorRequest = null,
isAuthoritativeHistoryReady = true,
isInitialHistoryLoading,
revealInitialHistory,
bottomOverlayTailClearance = 0,
bottomOverlayControlClearance,
toast,
@@ -693,23 +689,26 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
}
return revealLoadedHistory(messageId);
});
// Read through a stable event so a collapse rerun does not change the renderer identity;
// nested rows are revised through `historyRowRevision`.
// 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),
);
const getTurnIndent = useStableEvent((layoutItem: StreamLayoutItem): TurnIndent => {
const { nestedItemIds } = presentation.turns;
const { item } = layoutItem;
if (nestedItemIds.has(item.id)) {
if (nestedItemIds.has(layoutItem.item.id)) {
const below = layoutItem.belowItem;
return below && nestedItemIds.has(below.id) ? "nested" : "lastNested";
}
return item.kind === "turn_summary" && item.expanded ? "openedHost" : "none";
return getCollapsedTurn(layoutItem.item.id)?.expanded ? "openedHost" : "none";
});
const setToolCallGroupExpanded = toolCallExpansion.setGroupExpanded;
const renderUserMessageItem = useCallback(
(layoutItem: StreamLayoutItem, item: Extract<StreamItem, { kind: "user_message" }>) => {
return (
const collapsedTurn = getCollapsedTurn(item.id);
const message = (
<UserMessage
serverId={resolvedServerId}
agentId={agentId}
@@ -729,8 +728,25 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
}
/>
);
if (!collapsedTurn) {
return message;
}
return (
<>
{message}
<TurnSummaryLine turn={collapsedTurn} onExpandedChange={setTurnExpanded} />
</>
);
},
[context.capabilities, agentId, client, pendingClientMessageIds, resolvedServerId],
[
context.capabilities,
agentId,
client,
getCollapsedTurn,
pendingClientMessageIds,
resolvedServerId,
setTurnExpanded,
],
);
const renderAssistantMessageItem = useCallback(
@@ -915,10 +931,17 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
return renderToolCallItem(layoutItem, item);
case "tool_call":
return renderToolCallItem(layoutItem, item);
case "turn_summary":
return <TurnSummaryLine item={item} onExpandedChange={setTurnExpanded} />;
return (
<>
{renderToolCallItem(layoutItem, item)}
{getCollapsedTurn(item.id) ? (
<TurnSummaryLine
turn={getCollapsedTurn(item.id)!}
onExpandedChange={setTurnExpanded}
/>
) : null}
</>
);
case "notification":
return <Notification level={item.level} message={item.message} />;
@@ -949,6 +972,7 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
renderUserMessageItem,
renderAssistantMessageItem,
renderToolCallItem,
getCollapsedTurn,
setTurnExpanded,
resolvedServerId,
],
@@ -1127,17 +1151,19 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
const streamScrollEnabled =
!streamRenderStrategy.shouldDisableParentScrollOnInlineDetailsExpansion() ||
expandedInlineToolCallIds.size === 0;
const { nestedItemIds } = presentation.turns;
const { nestedItemIds, updatedHostIds: updatedTurnHostIds } = presentation.turns;
const historyRowRevision = useMemo(
() => ({
contentById: {
has: (id: string) => presentation.historyGroupUpdatesByHostId.has(id),
has: (id: string) =>
presentation.historyGroupUpdatesByHostId.has(id) || updatedTurnHostIds.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,
@@ -1145,9 +1171,11 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
[
expandedToolCallGroups,
getToolCallGroup,
expandedTurnHostIds,
isMobile,
nestedItemIds,
presentation.historyGroupUpdatesByHostId,
updatedTurnHostIds,
],
);
@@ -1181,8 +1209,6 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
viewportRef,
routeBottomAnchorRequest,
isAuthoritativeHistoryReady,
isInitialHistoryLoading,
revealInitialHistory,
onNearBottomChange: nearBottom.set,
onReadingPositionChange: handleReadingPositionChange,
onNearHistoryStart: loadOlder,
@@ -1325,12 +1351,6 @@ 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,7 +65,6 @@ 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;
+25 -58
View File
@@ -557,15 +557,6 @@ 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],
@@ -577,25 +568,21 @@ const assistantTurnFooterStylesheet = StyleSheet.create((theme) => ({
marginTop: 0,
marginLeft: -theme.spacing[1],
},
labelButton: {
flex: 1,
minHeight: 24,
flexDirection: "row",
alignItems: "center",
minWidth: 0,
labelWrapper: {
position: "relative",
},
label: {
width: "100%",
flexShrink: 0,
labelSizer: {
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;
@@ -662,51 +649,31 @@ export const AssistantTurnFooter = memo(function AssistantTurnFooter({
return (
<View style={assistantTurnFooterStylesheet.container}>
{getContent || canFork ? (
<View style={assistantTurnFooterStylesheet.actions}>
{getContent ? (
<TurnCopyButton
getContent={getContent}
containerStyle={assistantTurnFooterStylesheet.copyButton}
/>
) : null}
{canFork ? <AssistantForkMenu onFork={handleFork} /> : null}
</View>
{getContent ? (
<TurnCopyButton
getContent={getContent}
containerStyle={assistantTurnFooterStylesheet.copyButton}
/>
) : 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}
>
{/* 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}
<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}
</Text>
) : null}
<Text style={assistantTurnFooterStylesheet.labelOverlay}>
{showTimestamp ? timestampLabel : primaryLabel}
</Text>
</View>
</Pressable>
) : null}
</View>
@@ -1791,9 +1791,6 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
handleSendMessage,
handleQueueMessage,
});
if (hasFinePointer()) {
textInputRef.current?.focus();
}
}, [defaultSendBehavior, isAgentRunning, onQueue, handleQueueMessage, handleSendMessage]);
const handleAlternateSendAction = useCallback(() => {
@@ -46,31 +46,6 @@ function makeHost(): HostProfile {
};
}
function makeSnapshot(host: HostProfile): HostRuntimeSnapshot {
return {
serverId: host.serverId,
activeConnectionId: "relay:relay.secret.test:443",
activeConnection: {
type: "relay",
endpoint: "relay.secret.test:443",
display: "relay",
},
connectionStatus: "online",
client: null,
lastError: null,
lastOnlineAt: "2026-06-25T00:00:00.000Z",
agentDirectoryStatus: "ready",
agentDirectoryError: null,
hasEverLoadedAgentDirectory: true,
probeByConnectionId: new Map([
["direct:secret.example.test:6767", { status: "available", latencyMs: 42 }],
["relay:relay.secret.test:443", { status: "available", latencyMs: 8 }],
]),
clientGeneration: 1,
connectionEpoch: 1,
};
}
describe("app diagnostics report", () => {
test("reports whether the connected daemon is managed by Paseo Desktop", () => {
const report = formatServerInfoSection({
@@ -86,11 +61,31 @@ describe("app diagnostics report", () => {
test("formats connection rows without raw connection details", () => {
const host = makeHost();
const snapshot = makeSnapshot(host);
const snapshot: HostRuntimeSnapshot = {
serverId: host.serverId,
activeConnectionId: "relay:relay.secret.test:443",
activeConnection: {
type: "relay",
endpoint: "relay.secret.test:443",
display: "relay",
},
connectionStatus: "online",
client: null,
lastError: null,
lastOnlineAt: "2026-06-25T00:00:00.000Z",
agentDirectoryStatus: "ready",
agentDirectoryError: null,
hasEverLoadedAgentDirectory: true,
probeByConnectionId: new Map([
["direct:secret.example.test:6767", { status: "available", latencyMs: 42 }],
["relay:relay.secret.test:443", { status: "available", latencyMs: 8 }],
]),
clientGeneration: 1,
connectionEpoch: 1,
};
const report = formatHostRuntimeSection({ host, snapshot });
expect(report).toContain("Last online: 2026-06-25T00:00:00.000Z");
expect(report).toContain("direct TCP");
expect(report).toContain("relay");
expect(report).toContain("local socket");
@@ -102,27 +97,6 @@ describe("app diagnostics report", () => {
expect(report).not.toContain("tcp-password");
});
test.each(["connecting", "offline", "error"] as const)(
"does not claim a previously connected host was never online while %s",
(connectionStatus) => {
const host = makeHost();
const snapshot = makeSnapshot(host);
const report = formatHostRuntimeSection({
host,
snapshot: { ...snapshot, connectionStatus, lastOnlineAt: null },
});
expect(report).toContain("Last online: unknown");
expect(report).not.toContain("Last online: never");
},
);
test("does not infer connection history when the runtime has not started", () => {
const report = formatHostRuntimeSection({ host: makeHost(), snapshot: null });
expect(report).toContain("Last online: unknown");
});
test("redacts saved connection secrets from collected daemon and desktop text", () => {
const host = makeHost();
const redacted = redactAppDiagnosticReport(
@@ -45,7 +45,7 @@ export function formatHostRuntimeSection(input: {
? describeConnectionKind(snapshot.activeConnection.type)
: "none",
},
{ label: "Last online", value: snapshot?.lastOnlineAt ?? "unknown" },
{ label: "Last online", value: snapshot?.lastOnlineAt ?? "never" },
{ label: "Last error", value: snapshot?.lastError ?? "none" },
{
label: "Agent directory",
+12 -13
View File
@@ -4,14 +4,10 @@ import type {
FetchAgentHistoryPageInfo,
} from "@getpaseo/client/internal/daemon-client";
import { useInfiniteQuery } from "@tanstack/react-query";
import { useCallback, useMemo } from "react";
import { useCallback, useMemo, useSyncExternalStore } from "react";
import { useTranslation } from "react-i18next";
import type { AggregatedAgent } from "@/hooks/use-aggregated-agents";
import {
getHostRuntimeStore,
useHostRuntimeConnectionStatuses,
useHosts,
} from "@/runtime/host-runtime";
import { getHostRuntimeStore, isHostRuntimeConnected, useHosts } from "@/runtime/host-runtime";
import { useSessionStore } from "@/stores/session-store";
import { buildAgentDirectoryState } from "@/utils/agent-directory-sync";
import { agentHistoryQueryKey, allAgentHistoryQueryKey } from "./agent-history-query-key";
@@ -226,29 +222,32 @@ export function useAgentHistory(options: {
const { t } = useTranslation();
const daemons = useHosts();
const runtime = getHostRuntimeStore();
const runtimeVersion = useSyncExternalStore(
(onStoreChange) => runtime.subscribeAll(onStoreChange),
() => runtime.getVersion(),
() => runtime.getVersion(),
);
const serverId = useMemo(() => {
const value = options.serverId;
return typeof value === "string" && value.trim().length > 0 ? value.trim() : null;
}, [options.serverId]);
const enabled = options.enabled ?? true;
const serverIds = useMemo(
() => (serverId ? [serverId] : daemons.map((daemon) => daemon.serverId)),
[daemons, serverId],
);
const connectionStatuses = useHostRuntimeConnectionStatuses(serverIds);
// A host the user asked about splits two ways: one this fetch can reach, and
// one whose sessions will be missing from the answer. Both come out of here,
// because dropping the unreachable ones silently is what lets the list — and
// worse, "No sessions match" — overstate what was actually searched.
const { targetHosts, unreachableHosts } = useMemo(() => {
void runtimeVersion;
const serverLabelById = new Map(daemons.map((daemon) => [daemon.serverId, daemon.label]));
const serverIds = serverId ? [serverId] : daemons.map((daemon) => daemon.serverId);
const hosts: AgentHistoryHost[] = [];
const unreachable: AgentHistoryHostError[] = [];
for (const targetServerId of serverIds) {
const snapshot = runtime.getSnapshot(targetServerId);
const client = runtime.getClient(targetServerId);
const serverName = serverLabelById.get(targetServerId) ?? targetServerId;
if (!client || connectionStatuses.get(targetServerId) !== "online") {
if (!client || !isHostRuntimeConnected(snapshot)) {
unreachable.push({ serverId: targetServerId, serverName });
continue;
}
@@ -256,7 +255,7 @@ export function useAgentHistory(options: {
}
return { targetHosts: hosts, unreachableHosts: unreachable };
}, [daemons, runtime, connectionStatuses, serverIds]);
}, [daemons, runtime, runtimeVersion, serverId]);
const targetServerIds = useMemo(() => targetHosts.map((host) => host.serverId), [targetHosts]);
// One gate, checked before the field is offered: a fleet where any host
// predates search has no search, rather than a list that silently omits that
@@ -129,7 +129,7 @@ describe("loadOlderAgentHistory", () => {
expect(client.calls).toEqual([]);
expect(inFlight.values).toEqual([true]);
expect(started).toBe(false);
expect(started).toBe(true);
});
it("requests the page before the current start cursor and clears in-flight on success", async () => {
@@ -153,7 +153,6 @@ describe("loadOlderAgentHistory", () => {
limit: TIMELINE_FETCH_PAGE_SIZE,
projection: "projected",
wholeTurns: true,
timeout: 10_000,
},
},
]);
@@ -170,7 +169,7 @@ describe("loadOlderAgentHistory", () => {
const toast = createToast();
const logger = createLogger();
const applied = await loadOlderAgentHistory(agentId, {
await loadOlderAgentHistory(agentId, {
client,
cursor: someCursor,
hasOlder: true,
@@ -180,7 +179,6 @@ describe("loadOlderAgentHistory", () => {
logger,
});
expect(applied).toBe(false);
expect(client.calls).toHaveLength(1);
expect(toast.shown).toEqual([
{
@@ -18,7 +18,6 @@ export interface LoadOlderAgentHistoryClient {
cursor: { epoch: string; seq: number };
limit: number;
projection: "projected";
timeout: number;
},
) => Promise<unknown>;
}
@@ -45,7 +44,7 @@ export async function loadOlderAgentHistory(
const { client, cursor, hasOlder, isLoadingOlder, setInFlight, toast, logger, failedMessage } =
deps;
if (isLoadingOlder) {
return false;
return true;
}
if (!client || !cursor || !hasOlder) {
return false;
@@ -53,17 +52,16 @@ export async function loadOlderAgentHistory(
setInFlight(true);
try {
await client.fetchAgentTimeline(agentId, {
...planTimelineOlderFetch({ epoch: cursor.epoch, seq: cursor.startSeq }),
timeout: 10_000,
});
await client.fetchAgentTimeline(
agentId,
planTimelineOlderFetch({ epoch: cursor.epoch, seq: cursor.startSeq }),
);
} 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);
}
-15
View File
@@ -1012,7 +1012,6 @@ function ChatAgentContent({
effectiveAgent={effectiveAgent}
routeBottomAnchorRequest={routeBottomAnchorRequest}
hasAppliedAuthoritativeHistory={hasAppliedAuthoritativeHistory}
hasCachedHistory={replicaTimelineStatus === "painted"}
toastApi={toastApi}
toast={toastState}
dismiss={dismissToast}
@@ -1044,7 +1043,6 @@ const ChatAgentReadyContent = memo(function ChatAgentReadyContent({
effectiveAgent,
routeBottomAnchorRequest,
hasAppliedAuthoritativeHistory,
hasCachedHistory,
toastApi,
toast,
dismiss,
@@ -1073,7 +1071,6 @@ const ChatAgentReadyContent = memo(function ChatAgentReadyContent({
effectiveAgent: AgentScreenAgent | null;
routeBottomAnchorRequest: RouteBottomAnchorRequest;
hasAppliedAuthoritativeHistory: boolean;
hasCachedHistory: boolean;
toastApi: ToastApi;
toast: ToastState | null;
dismiss: () => void;
@@ -1201,12 +1198,6 @@ 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}
@@ -1368,8 +1359,6 @@ const AgentStreamSection = memo(function AgentStreamSection({
draft,
routeBottomAnchorRequest,
hasAppliedAuthoritativeHistory,
isInitialHistoryLoading,
revealInitialHistory,
hasActiveComposer,
toast,
onOpenWorkspaceFile,
@@ -1381,8 +1370,6 @@ 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;
@@ -1473,8 +1460,6 @@ const AgentStreamSection = memo(function AgentStreamSection({
pendingPermissions={pendingPermissions}
routeBottomAnchorRequest={routeBottomAnchorRequest}
isAuthoritativeHistoryReady={hasAppliedAuthoritativeHistory}
isInitialHistoryLoading={isInitialHistoryLoading}
revealInitialHistory={revealInitialHistory}
bottomOverlayTailClearance={bottomOverlayTailClearance}
bottomOverlayControlClearance={bottomOverlayControlClearance}
toast={toast}
@@ -26,7 +26,6 @@ import {
providerSubagentLifecycleStatus,
refreshProviderSubagents,
observeProviderSubagentTimeline,
loadOlderProviderSubagentTimeline,
useProviderSubagentStore,
} from "@/subagents/provider-store";
import { useTranslation } from "react-i18next";
@@ -178,24 +177,26 @@ function ProviderSubagentPanel() {
});
}, [client, supported, serverId, target.parentAgentId, target.subagentId]);
const loadOlder = useCallback(async (): Promise<boolean> => {
const loadOlder = useCallback((): boolean => {
if (!client || !supported || isLoadingOlder || !timeline?.hasOlder || !timeline.epoch) {
return false;
}
const firstSeq = timeline.cursor?.startSeq ?? null;
if (firstSeq === null) return false;
setIsLoadingOlder(true);
try {
return await loadOlderProviderSubagentTimeline({
client,
serverId,
parentAgentId: target.parentAgentId,
subagentId: target.subagentId,
void client
.fetchProviderSubagentTimeline(target.parentAgentId, target.subagentId, {
direction: "before",
cursor: { epoch: timeline.epoch, seq: firstSeq },
limit: TIMELINE_FETCH_PAGE_SIZE,
});
} catch {
return false;
} finally {
setIsLoadingOlder(false);
}
})
.then((payload) => {
useProviderSubagentStore.getState().replaceTimeline(serverId, payload);
return undefined;
})
.catch(() => undefined)
.finally(() => setIsLoadingOlder(false));
return true;
}, [
client,
isLoadingOlder,
@@ -76,7 +76,6 @@ function sourceTimelineItem(item: StreamItem): AgentTimelineItem | null {
...(item.preTokens !== undefined ? { preTokens: item.preTokens } : {}),
};
case "plugin":
case "turn_summary":
return null;
}
}
@@ -1,72 +0,0 @@
import { expect, it } from "vitest";
import { createHostLogger } from "./host-logger";
it("preserves the original transport cause and tolerates a cyclic cause chain", () => {
const lines: string[] = [];
const capture = (...values: unknown[]) => lines.push(values.map(String).join(" "));
const logger = createHostLogger(() => "test-host", {
log: capture,
warn: capture,
error: capture,
});
const transport = new Error("socket closed");
const wrapper = new Error("Subscription failed", { cause: transport });
Object.defineProperty(transport, "cause", { value: wrapper });
logger.error({ err: wrapper }, "subscription_error");
const record = JSON.parse(lines[0]!.slice("subscription_error ".length));
expect(record.err.cause.message).toBe("socket closed");
expect(record.err.cause.stack).toBe(transport.stack);
expect(record.err.cause.cause).toBe("[Circular]");
});
it("retains host and error details in the desktop's text-only console capture", () => {
const lines: { level: string; text: string }[] = [];
const capture =
(level: string) =>
(...values: unknown[]) => {
lines.push({ level, text: values.map(String).join(" ") });
};
const logger = createHostLogger(() => "offline-host", {
log: capture("info"),
warn: capture("warn"),
error: capture("error"),
});
logger.warn(
{ err: { name: "Error", message: "malformed ready envelope" } },
"relay_e2ee_channel_error",
);
expect(lines).toEqual([
{
level: "warn",
text: 'relay_e2ee_channel_error {"serverId":"offline-host","err":{"name":"Error","message":"malformed ready envelope"}}',
},
]);
});
it("keeps native Error messages and severity without failing on circular diagnostics", () => {
const lines: { level: string; text: string }[] = [];
const capture =
(level: string) =>
(...values: unknown[]) => {
lines.push({ level, text: values.map(String).join(" ") });
};
const logger = createHostLogger(() => "test-host", {
log: capture("info"),
warn: capture("warn"),
error: capture("error"),
});
const record: { err: Error; self?: object } = { err: new Error("connection lost") };
record.self = record;
logger.error(record, "Connection failed");
expect(lines[0]?.level).toBe("error");
expect(lines[0]?.text).toContain('"message":"connection lost"');
expect(lines[0]?.text).toContain('"serverId":"test-host"');
expect(lines[0]?.text).toContain('"[Circular]"');
logger.info({ status: "offline" }, "Host status");
expect(lines[1]).toEqual({
level: "info",
text: 'Host status {"serverId":"test-host","status":"offline"}',
});
logger.debug({ status: "offline" }, "Host status");
expect(lines).toHaveLength(2);
});
-36
View File
@@ -1,36 +0,0 @@
import type { Logger } from "@getpaseo/client/internal/daemon-client";
export function createHostLogger(
readServerId: () => string | undefined,
output: Pick<Console, "log" | "warn" | "error"> = console,
): Logger {
const format = (record: object, message?: string): string => {
const seen = new WeakSet<object>();
const details = JSON.stringify(
{ serverId: readServerId(), ...record },
(_key, value: unknown) => {
if (value && typeof value === "object") {
if (seen.has(value)) return "[Circular]";
seen.add(value);
if (value instanceof Error) {
return {
...value,
name: value.name,
message: value.message,
stack: value.stack,
cause: value.cause,
};
}
}
return value;
},
);
return message ? `${message} ${details}` : details;
};
return {
debug: () => {},
info: (record, message) => output.log(format(record, message)),
warn: (record, message) => output.warn(format(record, message)),
error: (record, message) => output.error(format(record, message)),
};
}
-3
View File
@@ -1,6 +1,5 @@
import { createHostPluginClientBundleStore } from "@/plugins/client-bundles";
import { watchdog } from "@/watchdog";
import { createHostLogger } from "./host-logger";
import { createHostChannelTransport } from "@getpaseo/client/internal/hosts";
import type { OwnedSubscription } from "@getpaseo/client";
import type { HostDirectory } from "@getpaseo/protocol/hosts";
@@ -590,7 +589,6 @@ function createDefaultDeps(
runtimeGeneration,
capabilities: appCapabilities,
trace: createPerformanceTrace({ host: host.serverId, connection: connection.id }),
logger: createHostLogger(() => host.serverId),
providerSnapshots: "wire",
createPluginClientBundleStore: createHostPluginClientBundleStore,
...(host.password ? { password: host.password } : {}),
@@ -671,7 +669,6 @@ function createDefaultDeps(
null,
),
reconnect: { enabled: false },
logger: createHostLogger(() => host.serverId),
appVersion: resolveAppVersion() ?? undefined,
createPluginClientBundleStore: createHostPluginClientBundleStore,
});
@@ -505,9 +505,6 @@ 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,9 +1,7 @@
import { createHistoryStartPagination } from "@/agent-stream/history-start-pagination";
import { DaemonConnectionError } from "@getpaseo/client/internal/daemon-client";
import { afterEach, describe, expect, test, vi } from "vitest";
import { afterEach, describe, expect, test } from "vitest";
import type { ProviderSubagentDescriptorPayload } from "@getpaseo/protocol/messages";
import {
loadOlderProviderSubagentTimeline,
observeProviderSubagentTimeline,
providerSubagentKey,
refreshProviderSubagents,
@@ -567,95 +565,6 @@ 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,35 +355,6 @@ 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,
+1 -22
View File
@@ -8,7 +8,6 @@ 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";
/**
@@ -87,8 +86,7 @@ export type StreamItem =
| TodoListItem
| NotificationItem
| CompactionItem
| PluginTimelineStreamItem
| TurnSummaryItem;
| PluginTimelineStreamItem;
export type UserMessageImageAttachment = AttachmentMetadata;
@@ -826,25 +824,6 @@ 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;
@@ -2,11 +2,9 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
import type { DaemonClientConfig } from "@getpaseo/client/internal/daemon-client";
import { DaemonAuthenticationError } from "@getpaseo/client/internal/daemon-client";
import type { DaemonConnectionDependencies, DaemonProbeClient } from "./test-daemon-connection";
import { createHostLogger } from "@/runtime/host-logger";
class FakeDaemonClient implements DaemonProbeClient {
readonly lastError: string | null;
private connected = false;
constructor(
private readonly probe: FakeDaemonProbe,
@@ -19,11 +17,9 @@ class FakeDaemonClient implements DaemonProbeClient {
if (this.probe.nextConnectError) {
throw this.probe.nextConnectError;
}
this.connected = true;
}
getLastServerInfoMessage() {
if (!this.connected) return null;
return {
serverId: "srv_probe_test",
hostname: "probe-host",
@@ -31,13 +27,11 @@ class FakeDaemonClient implements DaemonProbeClient {
}
async close(): Promise<void> {
this.connected = false;
this.probe.closedClients.push(this);
}
}
class FakeDaemonProbe {
diagnosticLines: string[] = [];
createdClients: FakeDaemonClient[] = [];
closedClients: FakeDaemonClient[] = [];
clientIdsRequested = 0;
@@ -45,12 +39,6 @@ class FakeDaemonProbe {
nextLastError: string | null = null;
readonly deps: DaemonConnectionDependencies<FakeDaemonClient> = {
createLogger: (readServerId) => {
const capture = (...values: unknown[]) => {
this.diagnosticLines.push(values.map(String).join(" "));
};
return createHostLogger(readServerId, { log: capture, warn: capture, error: capture });
},
getClientId: async () => {
this.clientIdsRequested += 1;
return "cid_shared_probe_test";
@@ -88,34 +76,6 @@ describe("test-daemon-connection connectToDaemon", () => {
probe = new FakeDaemonProbe();
});
it.each([undefined, "srv_probe_test"])(
"logs learned host identity on an adopted probe initially given %s",
async (serverId) => {
const { connectToDaemon } = await import("./test-daemon-connection");
const result = await connectToDaemon(
{ id: "direct:test", type: "directTcp", endpoint: "localhost:6767" },
{ serverId },
probe.deps,
);
try {
result.client.config.logger!.error({ err: new Error("socket closed") }, "connection_error");
const record = JSON.parse(probe.diagnosticLines[0]!.slice("connection_error ".length));
expect(record.serverId).toBe("srv_probe_test");
expect(record.err.message).toBe("socket closed");
await result.client.close();
expect(result.client.getLastServerInfoMessage()).toBeNull();
result.client.config.logger!.error({ err: new Error("disconnected") }, "connection_error");
const closedRecord = JSON.parse(
probe.diagnosticLines[1]!.slice("connection_error ".length),
);
expect(closedRecord.serverId).toBe("srv_probe_test");
expect(closedRecord.err.message).toBe("disconnected");
} finally {
await result.client.close();
}
},
);
it.each([
{ id: "direct:lan:6767", type: "directTcp" as const, endpoint: "lan:6767" },
{
@@ -7,7 +7,6 @@ import {
import type { DaemonClientConfig } from "@getpaseo/client/internal/daemon-client";
import type { HostConnection } from "@/types/host-connection";
import { createAppWebSocketFactory } from "@/runtime/websocket-factory";
import { createHostLogger } from "@/runtime/host-logger";
import { getOrCreateClientId } from "./client-id";
import { resolveAppVersion } from "./app-version";
import {
@@ -30,7 +29,6 @@ export interface DaemonProbeClient {
}
export interface DaemonConnectionDependencies<TClient extends DaemonProbeClient> {
createLogger: typeof createHostLogger;
getClientId(): Promise<string>;
resolveAppVersion(): string | null;
createDesktopTransportFactory(): DaemonClientConfig["transportFactory"] | null;
@@ -39,7 +37,6 @@ export interface DaemonConnectionDependencies<TClient extends DaemonProbeClient>
}
const defaultDaemonConnectionDependencies: DaemonConnectionDependencies<DaemonClient> = {
createLogger: createHostLogger,
getClientId: getOrCreateClientId,
resolveAppVersion,
createDesktopTransportFactory: createDesktopDaemonTransportFactory,
@@ -320,9 +317,5 @@ export async function connectToDaemon(
deps: DaemonConnectionDependencies<DaemonProbeClient> = defaultDaemonConnectionDependencies,
): Promise<{ client: DaemonProbeClient; serverId: string; hostname: string | null }> {
const config = await buildClientConfig(connection, options?.serverId, options, deps);
let serverId = options?.serverId;
config.logger = deps.createLogger(() => serverId);
const result = await connectAndProbe(config, resolveTimeout(connection, options), deps);
serverId = result.serverId;
return result;
return connectAndProbe(config, resolveTimeout(connection, options), deps);
}
@@ -49,7 +49,7 @@ const paseoHome = join(root, "home");
const cliHome = join(root, "os-home");
const workDir = join(root, "work");
for (const dir of [paseoHome, cliHome, workDir]) mkdirSync(dir, { recursive: true });
const currentProtocolVersion = 6;
const currentProtocolVersion = 5;
const zsh = ["/bin/zsh", "/usr/bin/zsh"].find((candidate) => existsSync(candidate)) ?? null;
interface Installation {
@@ -456,8 +456,8 @@ try {
});
console.log(`Competing listener owns released port ${releasedPort}`);
// Protocols 0–4 have no draining adapter. Protocol 5 is now the supported previous wire;
// its classification is covered by terminal-worker-protocol.test.ts.
// There is no supported previous wire at protocol 5. Future previous-version classification
// is covered by terminal-worker-protocol.test.ts; do not simulate an unreleased drain adapter.
for (const protocolVersion of [0, 1, 2, 3, 4]) {
console.log(`Test 4: obsolete protocol ${protocolVersion} is closed before serving terminals`);
const obsolete = await leaveWorkerWithProtocol(
@@ -1,108 +0,0 @@
import { build } from "esbuild";
import { fileURLToPath } from "node:url";
import { expect } from "@playwright/test";
export async function buildFillFixture() {
const { outputFiles } = await build({
stdin: {
resolveDir: fileURLToPath(new URL("../../app", import.meta.url)),
contents: `
import { createElement as e, useState } from 'react';
import { createRoot } from 'react-dom/client';
function Form() {
const [title, setTitle] = useState('');
const [notes, setNotes] = useState('');
const [submitted, setSubmitted] = useState(null);
return e('form', { onSubmit: ev => {
ev.preventDefault(); setSubmitted({ title, notes });
} },
e('input', { 'aria-label': 'React title', value: title,
onChange: ev => setTitle(ev.target.value) }),
e('textarea', { 'aria-label': 'React notes', value: notes,
onChange: ev => setNotes(ev.target.value) }),
e('p', null, 'React state: ' + JSON.stringify({ title, notes })),
e('button', { type: 'submit' }, 'Submit'),
e('p', null, 'Submitted: ' + JSON.stringify(submitted)));
}
createRoot(document.getElementById('root')).render(e(Form));
document.getElementById('plain').addEventListener('input', ev => {
document.getElementById('plain-state').textContent = 'Plain state: ' + ev.target.value;
});
`,
},
bundle: true,
write: false,
format: "iife",
platform: "browser",
define: { "process.env.NODE_ENV": '"production"' },
});
return `<!doctype html><title>React fill fixture</title><div id="root"></div>
<input aria-label="Plain input" id="plain"><p id="plain-state"></p>
<div role="textbox" aria-label="Editable text" contenteditable="true"></div>
<script>${outputFiles[0].text}</script>`;
}
async function findRef(call, browserId, role, name) {
const { snapshot } = await call("browser_snapshot", { browserId });
const line = snapshot.split("\n").find((candidate) => candidate.includes(`${role} "${name}"`));
const ref = line?.match(/\[ref=([^\]]+)\]/)?.[1];
expect(ref, `Snapshot must expose ${role} ${name}: ${snapshot}`).toBeTruthy();
return ref;
}
async function fillField(call, browserId, name, value) {
const ref = await findRef(call, browserId, "textbox", name);
await call("browser_fill", { browserId, ref, value });
}
async function submitForm(call, browserId) {
const ref = await findRef(call, browserId, "button", "Submit");
await call("browser_click", { browserId, ref });
}
async function expectPageText(call, browserId, text) {
await expect
.poll(async () => {
const result = await call("browser_evaluate", {
browserId,
function: "() => document.body.innerText",
});
return JSON.parse(result.resultJson);
})
.toContain(text);
}
export async function verifyReactFormFill(call, targetUrl) {
const { browserId } = await call("browser_new_tab", {
url: new URL("/react-fill", targetUrl).href,
});
try {
await call("browser_wait", { browserId, text: "React state:", timeoutMs: 15_000 });
await fillField(call, browserId, "Plain input", "plain value");
await expectPageText(call, browserId, "Plain state: plain value");
await fillField(call, browserId, "Editable text", "editable value");
await expectPageText(call, browserId, "editable value");
await fillField(call, browserId, "React title", "Hello from browser_fill");
await fillField(call, browserId, "React notes", "First line\nSecond line");
await expectPageText(
call,
browserId,
'React state: {"title":"Hello from browser_fill","notes":"First line\\nSecond line"}',
);
await submitForm(call, browserId);
await expectPageText(
call,
browserId,
'Submitted: {"title":"Hello from browser_fill","notes":"First line\\nSecond line"}',
);
await fillField(call, browserId, "React title", "replacement");
await fillField(call, browserId, "React notes", "");
await submitForm(call, browserId);
await expectPageText(call, browserId, 'Submitted: {"title":"replacement","notes":""}');
await fillField(call, browserId, "React title", "");
await submitForm(call, browserId);
await expectPageText(call, browserId, 'Submitted: {"title":"","notes":""}');
} finally {
await call("browser_close_tab", { browserId });
}
}
@@ -17,7 +17,6 @@ import { hideDeveloperToasts } from "../../app/e2e/support/developer-ui.mjs";
import { runAppearanceFontSizeRegression } from "./appearance-font-size.electron.mjs";
import { runAdaptiveThemeRegression } from "./appearance-theme.electron.mjs";
import { runSettingsScrollRegression } from "./settings-scroll.electron.mjs";
import { buildFillFixture, verifyReactFormFill } from "./browser-fill.electron.mjs";
import { runSettingsMemoryRegression } from "./settings-memory.electron.mjs";
import { seedPluginLinks, runPluginLinksRegression } from "./plugin-links.electron.mjs";
@@ -217,13 +216,7 @@ async function settleDesktopStartup(page) {
const CSP_PAGE_PATH = "/csp-no-eval";
async function startTargetPage() {
const fillFixture = await buildFillFixture();
const server = createServer((request, response) => {
if (request.url === "/react-fill") {
response.writeHead(200, { "content-type": "text/html; charset=utf-8" });
response.end(fillFixture);
return;
}
if (request.url === CSP_PAGE_PATH) {
response.writeHead(200, {
"content-type": "text/html; charset=utf-8",
@@ -789,7 +782,6 @@ async function runRegression({
document.body.appendChild(overlayRoot);
});
await verifyReactFormFill((name, args) => callBrowserTool(client, name, args), targetUrl);
await verifyEvaluateOnPageWithoutUnsafeEval(client, targetUrl);
const created = await callBrowserTool(client, "browser_new_tab", { url: targetUrl });
@@ -316,16 +316,7 @@ function buildFillScript(metadata: BrowserRefMetadata, value: string): string {
element.focus();
const nextValue = ${JSON.stringify(value)};
if ('value' in element) {
const view = element.ownerDocument.defaultView;
let prototype = null;
if (element.tagName === 'INPUT') {
prototype = view.HTMLInputElement.prototype;
} else if (element.tagName === 'TEXTAREA') {
prototype = view.HTMLTextAreaElement.prototype;
}
// Bypass instance setters so framework value trackers observe the input event as a change.
if (prototype) Object.getOwnPropertyDescriptor(prototype, 'value').set.call(element, nextValue);
else element.value = nextValue;
element.value = nextValue;
element.dispatchEvent(new Event('input', { bubbles: true }));
element.dispatchEvent(new Event('change', { bubbles: true }));
return { ok: true };
@@ -1,60 +0,0 @@
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();
}
},
);
+4 -2
View File
@@ -344,7 +344,8 @@ class ClientSecureChannel implements SecureChannel {
private fail(code: number, reason: string): void {
if (this.state === "closed") return;
this.notifyClosed();
this.state = "closed";
this.handshake = null;
try {
this.transport.close(code, reason);
} catch {
@@ -363,12 +364,13 @@ 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 {
@@ -806,57 +806,6 @@ test("emits inventory only when relevant observations change it", () => {
).toEqual([{ id: "m", kind: "shell", description: null, canStop: true }]);
});
test("Claude runtime safety releases a process that has not been sent a turn", () => {
const safety = new ClaudeRuntimeSafety();
// A resumed process that only answered control requests never sends init.
expect(safety.canStopRuntime()).toBe(true);
safety.notePromptSent();
expect(safety.canStopRuntime()).toBe(false);
safety.observe({ type: "system", subtype: "init", claude_code_version: "2.1.292" } as SDKMessage);
expect(safety.canStopRuntime()).toBe(true);
safety.reset();
expect(safety.canStopRuntime()).toBe(true);
// A CLI without a roster that ran a turn may own background work.
safety.observe({ type: "system", subtype: "init", claude_code_version: "2.1.220" } as SDKMessage);
expect(safety.canStopRuntime()).toBe(false);
safety.retire();
expect(safety.canStopRuntime()).toBe(false);
});
test("Claude session started by a control request without a turn can stop its runtime", async () => {
let launched = 0;
const query = {
next: () => new Promise<IteratorResult<SDKMessage>>(() => {}),
return: async () => ({ done: true, value: undefined }),
close: () => {},
interrupt: async () => {},
setPermissionMode: async () => {},
setModel: async () => {},
supportedModels: async () => [],
supportedCommands: async () => [],
[Symbol.asyncIterator]() {
return this;
},
} as unknown as Query;
const session = await new ClaudeAgentClient({
logger: createTestLogger(),
resolveBinary: async () => "/test/claude/bin",
queryFactory: ({ prompt }) => {
if (typeof prompt === "string") throw new Error("Expected streaming input");
launched++;
return query;
},
}).createSession({ provider: "claude", cwd: process.cwd() });
try {
// Reopening a chat lists its commands, which launches Claude with no prompt.
await session.listCommands!();
expect(launched).toBe(1);
expect(await session.canStopRuntime!()).toBe(true);
} finally {
await session.close();
}
});
test("Claude session runtime safety observes pumped background work and autonomous replies", async () => {
let wake: (() => void) | undefined;
const channel = { closed: false };
@@ -607,14 +607,23 @@ describe("ClaudeAgentSession persisted subagent replay", () => {
expect(statuses).toContain("running");
});
function taskNotification(status: string, form = "user"): string {
const text = `<task-notification>\n<task-id>${AGENT_ID}</task-id>\n<tool-use-id>${TOOL_USE_ID}</tool-use-id>\n<status>${status}</status>\n<summary>Background task ended</summary>\n</task-notification>`;
function taskNotification({
status,
form = "user",
includeToolUseId = true,
}: {
status: string;
form?: string;
includeToolUseId?: boolean;
}): string {
const toolUseTag = includeToolUseId ? `<tool-use-id>${TOOL_USE_ID}</tool-use-id>\n` : "";
const text = `<task-notification>\n<task-id>${AGENT_ID}</task-id>\n${toolUseTag}<status>${status}</status>\n<summary>Background task ended</summary>\n</task-notification>`;
const records = {
user: { message: { role: "user", content: [{ type: "text", text }] } },
system: {
subtype: "task_notification",
task_id: AGENT_ID,
tool_use_id: TOOL_USE_ID,
...(includeToolUseId ? { tool_use_id: TOOL_USE_ID } : {}),
status,
},
"queue-operation": { operation: "enqueue", content: text },
@@ -636,7 +645,7 @@ describe("ClaudeAgentSession persisted subagent replay", () => {
`restores a %s task notification from ${form} when the child stop_reason is null`,
async (status, expected) => {
writeSession({
parentLines: [...backgroundLaunch(), taskNotification(status, form)],
parentLines: [...backgroundLaunch(), taskNotification({ status, form })],
meta: JSON.stringify({ toolUseId: TOOL_USE_ID }),
sidechainLines: [sidechainEntry({ stopReason: null })],
});
@@ -648,9 +657,73 @@ describe("ClaudeAgentSession persisted subagent replay", () => {
);
}
for (const form of ["user", "system", "queue-operation"]) {
test.each([
["completed", "completed"],
["failed", "failed"],
["stopped", "canceled"],
["unknown", "running"],
])(`restores a task-id-only %s notification from ${form}`, async (status, expected) => {
writeSession({
parentLines: [
...backgroundLaunch(),
taskNotification({ status, form, includeToolUseId: false }),
],
meta: JSON.stringify({ toolUseId: TOOL_USE_ID }),
sidechainLines: [sidechainEntry({ stopReason: "tool_use" })],
});
expect(upserts(await replayDescriptors()).at(-1)).toMatchObject({
id: TOOL_USE_ID,
status: expected,
});
});
}
test("keeps the latest outcome across task-only and tool-linked notifications", async () => {
writeSession({
parentLines: [
...backgroundLaunch(),
taskNotification({ status: "stopped", form: "queue-operation", includeToolUseId: false }),
taskNotification({ status: "stopped", form: "user", includeToolUseId: false }),
taskNotification({ status: "completed" }),
],
meta: JSON.stringify({ toolUseId: TOOL_USE_ID }),
sidechainLines: [sidechainEntry({ stopReason: "tool_use" })],
});
expect(upserts(await replayDescriptors()).at(-1)).toMatchObject({ status: "completed" });
});
test("honors a later task-only stopped notification after a tool-linked completion", async () => {
writeSession({
parentLines: [
...backgroundLaunch(),
taskNotification({ status: "completed" }),
taskNotification({ status: "stopped", form: "user", includeToolUseId: false }),
],
meta: JSON.stringify({ toolUseId: TOOL_USE_ID }),
sidechainLines: [sidechainEntry({ stopReason: "tool_use" })],
});
expect(upserts(await replayDescriptors()).at(-1)).toMatchObject({ status: "canceled" });
});
test("does not apply an unrelated task's stopped outcome to a child", async () => {
writeSession({
parentLines: [
...backgroundLaunch(),
taskNotification({ status: "stopped", form: "user", includeToolUseId: false }).replace(
AGENT_ID,
"unrelated-task",
),
],
meta: JSON.stringify({ toolUseId: TOOL_USE_ID }),
sidechainLines: [sidechainEntry({ stopReason: "tool_use" })],
});
expect(upserts(await replayDescriptors()).at(-1)).toMatchObject({ status: "running" });
});
test("prefers a stopped notification over the child's end_turn", async () => {
writeSession({
parentLines: [...backgroundLaunch(), taskNotification("stopped")],
parentLines: [...backgroundLaunch(), taskNotification({ status: "stopped" })],
meta: JSON.stringify({ toolUseId: TOOL_USE_ID }),
sidechainLines: [sidechainEntry({ stopReason: "end_turn" })],
});
@@ -659,7 +732,7 @@ describe("ClaudeAgentSession persisted subagent replay", () => {
test("does not invent an outcome for an unknown notification status", async () => {
writeSession({
parentLines: [...backgroundLaunch(), taskNotification("unknown")],
parentLines: [...backgroundLaunch(), taskNotification({ status: "unknown" })],
meta: JSON.stringify({ toolUseId: TOOL_USE_ID }),
sidechainLines: [sidechainEntry({ stopReason: null })],
});
@@ -2591,7 +2591,6 @@ class ClaudeAgentSession implements AgentSession {
this.activeForegroundQuery = this.query;
this.activeForegroundInput = this.input;
this.startQueryPump();
this.runtimeSafety.notePromptSent();
this.input.push(sdkMessage);
setTimeout(() => {
if (this.activeForegroundTurnId === turnId) {
@@ -6436,10 +6435,9 @@ async function readClaudeReplayParentFacts(
const linksByAgentId = new Map<string, { toolCallId: string }>();
const outcomes: Map<string, { status: ProviderSubagentStatus }> = new Map();
for await (const entry of entries) {
const notification = readTaskNotificationFromHistoryRecord(entry);
const status = mapTaskStatus(notification?.status ?? undefined);
if (notification?.toolUseId && status) {
outcomes.set(notification.toolUseId, { status });
const notificationOutcome = readClaudeReplayNotificationOutcome(entry, linksByAgentId);
if (notificationOutcome) {
outcomes.set(notificationOutcome.toolCallId, { status: notificationOutcome.status });
}
const content = toObjectRecord(entry.message)?.content;
if (!Array.isArray(content)) continue;
@@ -6471,6 +6469,19 @@ async function readClaudeReplayParentFacts(
};
}
function readClaudeReplayNotificationOutcome(
entry: ClaudeHistoryEntry,
linksByAgentId: ReadonlyMap<string, { toolCallId: string }>,
): { toolCallId: string; status: ProviderSubagentStatus } | null {
const notification = readTaskNotificationFromHistoryRecord(entry);
const status = mapTaskStatus(notification?.status ?? undefined);
// Stopped tasks can omit tool-use-id; their launch result already links the native ID.
const toolCallId =
notification?.toolUseId ??
(notification?.taskId ? linksByAgentId.get(notification.taskId)?.toolCallId : undefined);
return toolCallId && status ? { toolCallId, status } : null;
}
function readClaudeReplayTaskDeclaration(
entryType: unknown,
block: Record<string, unknown> | undefined,
@@ -6,10 +6,6 @@ import { toBackgroundWorkInputs, type ClaudeBackgroundTask } from "./background-
/** Process-local background ownership. Completion delivery can follow an empty roster. */
export class ClaudeRuntimeSafety {
private inventoryState: "unknown" | "supported" | "retired" = "unknown";
// Background work starts inside a turn, and init only arrives with a turn. A process that
// has neither been sent a prompt nor reported init (a resumed chat that only answered
// control requests) owns no background work.
private turnSeen = false;
private liveTasks: readonly ClaudeBackgroundTask[] = [];
private published: AgentBackgroundWorkInput[] | undefined;
private readonly awaitingNotifications = new Set<string>();
@@ -19,7 +15,6 @@ export class ClaudeRuntimeSafety {
reset(): void {
this.inventoryState = "unknown";
this.turnSeen = false;
this.liveTasks = [];
this.published = undefined;
this.awaitingNotifications.clear();
@@ -33,11 +28,6 @@ export class ClaudeRuntimeSafety {
this.inventoryState = "retired";
}
/** Called before a prompt is written to this process. */
notePromptSent(): void {
this.turnSeen = true;
}
/** Returns a full replacement only when the visible inventory changes. */
observe(message: SDKMessage): AgentBackgroundWorkInput[] | undefined {
if (this.inventoryState === "retired") return undefined;
@@ -103,7 +93,6 @@ export class ClaudeRuntimeSafety {
);
}
if (message.subtype === "init") {
this.turnSeen = true;
const version = parseClaudeCodeVersion(message.claude_code_version ?? "");
if (
version &&
@@ -126,7 +115,6 @@ export class ClaudeRuntimeSafety {
}
canStopRuntime(): boolean {
if (this.inventoryState === "unknown" && !this.turnSeen) return true;
return (
this.inventoryState === "supported" &&
this.liveTasks.length === 0 &&
@@ -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 { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { once } from "node:events";
import { AgentManager } from "../agent-manager.js";
@@ -217,33 +217,6 @@ 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,10 +587,6 @@ 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;
@@ -606,7 +602,6 @@ test("concurrent session APIs share one reconnect after an idle provider exit",
}
spawnCount += 1;
if (spawnCount > 1) {
markReconnectStarted();
await reconnectGate;
}
return appServer.child;
@@ -619,14 +614,12 @@ 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();
@@ -35,14 +35,15 @@ describe("CodexAppServerAgentClient spawn error handling", () => {
});
test("fetchCatalog settles when the refresh deadline aborts an unanswered initialize", async () => {
const child = createCodexAppServerChildProcess();
const kill = vi.spyOn(child, "kill");
const client = new CodexAppServerAgentClient(logger, undefined, {
appServerProcess: {
resolvePrefix: async () => ({ command: "codex", args: [] }),
spawn: () => child,
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 });
await expect(
runProviderRefreshWithDeadline({
@@ -87,7 +87,6 @@ describe("Codex executable discovery", () => {
import { CodexAppServerClient } from "./codex/app-server-transport.js";
import {
createCodexAppServerChildProcess,
createFakeCodexAppServer,
fakeCodexThreadHistory,
type FakeCodexAppServer,
@@ -186,24 +185,18 @@ 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<{
@@ -633,25 +626,24 @@ 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(), 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 provider = new CodexAppServerAgentClient(createTestLogger());
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",
@@ -2381,9 +2373,10 @@ describe("Codex app-server provider", () => {
return { thread: { id: "native-thread-id" } };
},
});
const provider = new CodexAppServerAgentClient(createTestLogger(), undefined, {
appServerProcess: createFakeCodexAppServerProcess(() => appServer.child),
});
const provider = new CodexAppServerAgentClient(createTestLogger());
castInternals<{ spawnAppServer: () => Promise<ChildProcessWithoutNullStreams> }>(
provider,
).spawnAppServer = async () => appServer.child;
await provider.unarchiveNativeSession({
provider: "codex",
@@ -2405,9 +2398,10 @@ describe("Codex app-server provider", () => {
return { thread: { id: "native-thread-id" } };
},
});
const provider = new CodexAppServerAgentClient(createTestLogger(), undefined, {
appServerProcess: createFakeCodexAppServerProcess(() => appServer.child),
});
const provider = new CodexAppServerAgentClient(createTestLogger());
castInternals<{ spawnAppServer: () => Promise<ChildProcessWithoutNullStreams> }>(
provider,
).spawnAppServer = async () => appServer.child;
await provider.archiveNativeSession({
provider: "codex",
@@ -2429,9 +2423,10 @@ describe("Codex app-server provider", () => {
return { thread: { id: "persisted-thread-id" } };
},
});
const provider = new CodexAppServerAgentClient(createTestLogger(), undefined, {
appServerProcess: createFakeCodexAppServerProcess(() => appServer.child),
});
const provider = new CodexAppServerAgentClient(createTestLogger());
castInternals<{ spawnAppServer: () => Promise<ChildProcessWithoutNullStreams> }>(
provider,
).spawnAppServer = async () => appServer.child;
await provider.unarchiveNativeSession({
provider: "codex",
@@ -2460,9 +2455,10 @@ describe("Codex app-server provider", () => {
return { thread: { id: "active-thread-id", turns: [] } };
},
});
const provider = new CodexAppServerAgentClient(createTestLogger(), undefined, {
appServerProcess: createFakeCodexAppServerProcess(() => appServer.child),
});
const provider = new CodexAppServerAgentClient(createTestLogger());
castInternals<{ spawnAppServer: () => Promise<ChildProcessWithoutNullStreams> }>(
provider,
).spawnAppServer = async () => appServer.child;
await provider.unarchiveNativeSession({
provider: "codex",
@@ -2492,9 +2488,10 @@ describe("Codex app-server provider", () => {
return Promise.reject(new Error("thread not found"));
},
});
const provider = new CodexAppServerAgentClient(createTestLogger(), undefined, {
appServerProcess: createFakeCodexAppServerProcess(() => appServer.child),
});
const provider = new CodexAppServerAgentClient(createTestLogger());
castInternals<{ spawnAppServer: () => Promise<ChildProcessWithoutNullStreams> }>(
provider,
).spawnAppServer = async () => appServer.child;
await expect(
provider.unarchiveNativeSession({
@@ -7533,52 +7530,6 @@ 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
@@ -7841,8 +7792,19 @@ 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, type SpawnProcessOptions } from "../../../utils/spawn.js";
import { spawnProcess } from "../../../utils/spawn.js";
import { extractCodexTerminalSessionId, nonEmptyString } from "./tool-call-mapper-utils.js";
import {
buildCodexFeatures,
@@ -94,7 +94,6 @@ 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,
@@ -292,31 +291,14 @@ const CODEX_MODES: AgentMode[] = [
const DEFAULT_CODEX_MODE_ID = "auto";
interface CodexAppServerClientLike {
request(method: string, params?: unknown, timeoutMs?: number): Promise<unknown>;
request(method: string, params?: unknown): 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?: {
@@ -628,9 +610,10 @@ export async function findDefaultCodexBinary(): Promise<string | null> {
return await findCodexMicrosoftStoreBinary();
}
async function resolveCodexLaunchPrefix(
runtimeSettings?: ProviderRuntimeSettings,
): Promise<CodexAppServerLaunchPrefix> {
async function resolveCodexLaunchPrefix(runtimeSettings?: ProviderRuntimeSettings): Promise<{
command: string;
args: string[];
}> {
const launch = await resolveCodexLaunch(runtimeSettings);
const availability = await checkCodexLaunchAvailable(launch);
if (!availability.available) {
@@ -645,11 +628,6 @@ async function resolveCodexLaunchPrefix(
};
}
const defaultCodexAppServerProcess: CodexAppServerProcessPort = {
resolvePrefix: ({ runtimeSettings }) => resolveCodexLaunchPrefix(runtimeSettings),
spawn: ({ command, args, options }) => spawnProcess(command, args, options),
};
async function resolveCodexLaunch(
runtimeSettings?: ProviderRuntimeSettings,
): Promise<ResolvedProviderLaunch> {
@@ -3391,14 +3369,6 @@ 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 };
@@ -3596,7 +3566,6 @@ 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;
@@ -3714,15 +3683,8 @@ export class CodexAppServerAgentSession implements AgentSession {
this.connectionState = "history-ready";
return;
}
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,
});
const child = await this.spawnAppServer();
const client = new CodexAppServerClient(child, this.logger, () => this.traceContext());
if (this.closed) {
await client.dispose();
throw this.createClosedError();
@@ -3735,9 +3697,11 @@ export class CodexAppServerAgentSession implements AgentSession {
this.registerRequestHandlers();
try {
this.threadRollbackAvailable = codexServerHasThreadRollback(
toObjectRecord(initialized)?.userAgent,
const initialized = toObjectRecord(
await client.request("initialize", buildCodexAppServerInitializeParams()),
);
this.threadRollbackAvailable = codexServerHasThreadRollback(initialized?.userAgent);
client.notify("initialized", {});
this.speedModels =
CodexModelListResponseSchema.parse(await client.request("model/list", {})).data ?? [];
@@ -4065,16 +4029,11 @@ export class CodexAppServerAgentSession implements AgentSession {
}
private async readArchivedHistory(): Promise<CodexThreadHistory> {
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,
});
const child = await this.spawnAppServer();
const client = new CodexAppServerClient(child, this.logger, () => this.traceContext());
try {
await client.request("initialize", buildCodexAppServerInitializeParams());
client.notify("initialized", {});
return await this.readThreadHistory(client);
} finally {
await client.dispose();
@@ -5337,7 +5296,6 @@ 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();
@@ -7576,55 +7534,31 @@ export class CodexAppServerAgentClient implements AgentClient {
}
}
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 });
private async spawnAppServer(
launchEnv?: Record<string, string>,
options?: { goalsEnabled?: boolean; agentId?: string; environment?: Record<string, string> },
): Promise<ChildProcessWithoutNullStreams> {
const launchPrefix = await resolveCodexLaunchPrefix(this.runtimeSettings);
const args = [...launchPrefix.args, "app-server"];
if (options.goalsEnabled) {
if (options?.goalsEnabled) {
args.push("--enable", "goals");
}
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,
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 client;
assertChildWithPipes(child);
return child;
}
async createSession(
@@ -7643,16 +7577,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,
async () => spawn(),
() =>
this.spawnAppServer(launchContext?.env, {
goalsEnabled,
agentId: launchContext?.agentId,
environment,
}),
{ ...this.sessionDeps(launchContext?.env), environment },
options?.persistSession === false,
goalsEnabled,
@@ -7680,16 +7614,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,
async () => spawn(),
() =>
this.spawnAppServer(launchContext?.env, {
goalsEnabled,
agentId: launchContext?.agentId,
environment,
}),
{ ...this.sessionDeps(launchContext?.env), environment },
false,
goalsEnabled,
@@ -7704,19 +7638,15 @@ export class CodexAppServerAgentClient implements AgentClient {
async listImportableSessions(
options?: ListImportableSessionsOptions,
): Promise<ImportableProviderSession[]> {
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,
});
const child = await this.spawnAppServer();
const client =
this.deps._createCodexClient?.(child, this.logger, () => ({})) ??
new CodexAppServerClient(child, 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
@@ -7819,10 +7749,15 @@ export class CodexAppServerAgentClient implements AgentClient {
try {
await runProviderRefreshActivity(context, "app-server.start", async () => {
client = await this.startAppServer({ signal: context?.signal });
const child = await this.spawnAppServer();
client = new CodexAppServerClient(child, this.logger);
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", {}),
@@ -7866,9 +7801,12 @@ export class CodexAppServerAgentClient implements AgentClient {
const threadId = handle.nativeHandle ?? handle.sessionId;
if (!threadId) return;
const client = await this.startAppServer();
const child = await this.spawnAppServer();
const client = new CodexAppServerClient(child, this.logger);
try {
await client.request("initialize", buildCodexAppServerInitializeParams());
client.notify("initialized", {});
if (state === "archive") {
await client.request("thread/archive", { threadId });
return;
@@ -1,347 +0,0 @@
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();
}
});
});
@@ -1,189 +0,0 @@
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,57 +5,9 @@ import {
createCodexAppServerChildProcess,
createFakeCodexAppServer,
} from "./test-utils/fake-app-server.js";
import { CodexAppServerClient, CodexAppServerExitError } from "./app-server-transport.js";
import { CodexAppServerClient } 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,39 +64,6 @@ 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;
@@ -267,24 +234,14 @@ 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) => {
// 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);
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);
});
child.on("close", reportExit);
}
setUnexpectedTerminationHandler(handler: UnexpectedTerminationHandler): void {
@@ -146,10 +146,6 @@ 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,3 +1,4 @@
import type { ChildProcessWithoutNullStreams } from "node:child_process";
import { describe, expect, test } from "vitest";
import { createTestLogger } from "../../../../test-utils/test-logger.js";
@@ -26,24 +27,15 @@ async function openCodexThread(
const appServer = createFakeCodexAppServer(
fakeCodexThreadHistory({ "saved-thread": thread }, options),
);
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 provider = new CodexAppServerAgentClient(createTestLogger());
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,7 +60,6 @@ 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
@@ -310,98 +309,79 @@ 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.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;
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
);
},
},
paths,
(_root, listener) => {
notifications.on("change", listener);
return {
close: () => notifications.removeAllListeners(),
on: (event, onError) => notifications.on(event, onError),
};
},
{ 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,
);
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,
);
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);
test("a full native inventory audit reports a removed directory", async () => {
const root = await mkdtemp(join(tmpdir(), "native-full-audit-"));
-30
View File
@@ -9,23 +9,6 @@ async function chooseLanguage(page: Page, language: string) {
await page.getByRole("menuitemradio", { name: language }).click();
}
async function withPageScriptsHeld(page: Page, run: (release: () => void) => Promise<void>) {
let releaseScripts!: () => void;
const scriptsReleased = new Promise<void>((resolve) => {
releaseScripts = resolve;
});
await page.route("**/*.js", async (route) => {
await scriptsReleased;
await route.continue();
});
try {
await run(releaseScripts);
} finally {
releaseScripts();
await page.unrouteAll({ behavior: "wait" });
}
}
function describeAlternates(links: Element[]): string[] {
return links.map((link) => `${link.getAttribute("hreflang")} ${link.getAttribute("href")}`);
}
@@ -131,7 +114,6 @@ test("shows the language as an abbreviation and lists every language by name", a
test("switches language from the keyboard", async ({ page }) => {
await page.goto("/");
await expect(languageButton(page)).toBeEnabled();
await languageButton(page).focus();
await page.keyboard.press("Enter");
await expect(page.getByRole("menuitemradio", { name: "English" })).toBeFocused();
@@ -201,15 +183,3 @@ test("shows the changelog in Chinese", async ({ page }) => {
await expect(page.getByRole("heading", { name: "新增" }).first()).toBeVisible();
await expect(page.getByRole("note")).toHaveCount(0);
});
test("waits for scripts before enabling the language menu", async ({ page }) => {
await withPageScriptsHeld(page, async (releaseScripts) => {
await page.goto("/docs/cli#running-agents", { waitUntil: "commit" });
await expect(languageButton(page)).toBeVisible();
await expect(languageButton(page)).toBeDisabled();
releaseScripts();
await languageButton(page).click();
await expect(page.getByRole("menuitemradio", { name: "English" })).toBeVisible();
});
});
@@ -1,4 +1,4 @@
import { useHydrated, useLocation, useNavigate } from "@tanstack/react-router";
import { useLocation, useNavigate } from "@tanstack/react-router";
import { AnimatePresence, motion, type Transition } from "framer-motion";
import { Check, ChevronDown, Languages } from "lucide-react";
import { useCallback, useEffect, useId, useLayoutEffect, useRef, useState } from "react";
@@ -33,7 +33,6 @@ type OpenFocus = "current" | "first" | "last";
* the menu button pattern: arrows move between languages, Escape and outside presses close it.
*/
export function LanguageSelector() {
const hydrated = useHydrated();
const { t } = useTranslation();
const locale = useLocale();
const location = useLocation();
@@ -126,7 +125,6 @@ export function LanguageSelector() {
<button
ref={triggerRef}
type="button"
disabled={!hydrated}
aria-label={t("languageSelector.current", { language: LANGUAGE_NAMES[locale] })}
aria-haspopup="menu"
aria-expanded={open}