Compare commits

...
Author SHA1 Message Date
paseo-bot[bot]andDarwin Wu a9dd7b5c19 test(speech): reproduce native runtime drift in consumer installs
Clean npm installs bypass the repository lockfile and select a newer native speech package than the pinned wrapper. Exercise a packed server both in a fresh consumer and when upgrading from 0.8.0. These reproductions intentionally fail pending a packaging decision for #5223.

Co-authored-by: Darwin Wu <3530333+dwaxe@users.noreply.github.com>
2026-10-18 05:59:44 +02:00
paseo-bot[bot] 1a6a9fa8d0 fix(claude): keep background child action logs after the parent finishes (#7266)
Late child frames were discarded after the launching turn ended, and parent completion cleared accumulated child actions. Keep card updates attached to the launching turn and release child actions on task completion.

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-10-18 05:02:33 +02:00
paseo-bot[bot] a7a21d91c2 Keep overlapping directory searches from exhausting the heap (#7265)
* fix(server): keep overlapping directory searches within their scan budget

Construct child paths and branch iterators as the round-robin walk consumes them. Eager sibling expansion retained hundreds of thousands of unvisited children per search and exhausted the heap when requests overlapped.

* test(server): stop directory-search children before fixture cleanup

---------

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-10-18 03:52:15 +02:00
paseo-bot[bot] b2da58b5bf fix(app): keep chat keyboard scrolling after closing Find (#7263)
Closing Find focused its non-scrolling wrapper, leaving Page Up and Page Down unable to move the feed. Return focus through the viewport handle while preserving the reading position.

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-10-18 01:51:49 +02:00
paseo-bot[bot] e5d9307cf9 fix(terminal): copy wide characters selected from either half (#7260)
Include the leading cell when a selection starts on a wide character continuation, matching the native renderer highlight. Keep continuation cells out of copied text so real spaces remain unchanged.

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-10-18 00:55:08 +02:00
paseo-bot[bot] 90c6de0580 fix(app): keep streaming replies complete after reloading (#7256)
A catch-up projection could replace the live reply while leaving its cached prefix in the committed rows. Completion then discarded the complete reply as a duplicate. Reconcile the covered cached prefix together with the live reply.

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-10-18 00:01:33 +02:00
paseo-bot[bot]andhelloimdevman 9bd58f66b2 Copy Korean terminal text without extra spaces (#7254)
* fix(terminal): preserve Korean text when copying native terminal cells

* fix(terminal): omit continuation cells from copied text

Native terminal buffers already retain authoritative cell widths. Skip zero-width continuation cells when serializing a selection so Korean text copies with only its real spaces, while preserving the buffer representation.

Co-authored-by: helloimdevman <333048235+helloimdevman@users.noreply.github.com>

---------

Co-authored-by: helloimdevman <333048235+helloimdevman@users.noreply.github.com>
Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-10-17 22:57:04 +02:00
paseo-bot[bot]anda-grasso e2ad26d238 Find source-message text in plugin timeline rows (#7253)
* fix(app): find text in rows a timeline plugin draws

Chat find located a replaced message by its source id but counted matches
only inside elements marked as message text, which a plugin renderer never
sets, so a replaced prompt or reply reported no matches. Mark plugin rows and
search the whole row, with each view bounding a match.

* docs(plugins): qualify search in replacement timeline rows

Chat find discovers source-message matches before inspecting a replacement renderer. Document that renderer-added text is not independently indexed.

Co-authored-by: a-grasso <antonino.grasso@device-insight.com>

* fix(app): find visible text within contiguous plugin blocks

A whole-row walk counted closed popover text and reunited text before and after nested blocks. Keep consecutive block groups and exclude plugin text the browser is not drawing.

---------

Co-authored-by: a-grasso <antonino.grasso@device-insight.com>
Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-10-17 21:58:07 +02:00
paseo-ai[bot] 142cdac8d2 fix: update lockfile signatures and Nix hash [skip ci] 2026-10-17 18:18:08 +00:00
20 changed files with 864 additions and 200 deletions
+1 -1
View File
@@ -1 +1 @@
sha256-1WmrHvE68Lzyil4On7xtsPMQTODXZ3Jb3/juUg3nHi4=
sha256-O8sNh6oXFIddUljYiC6RbVZF5/7a7A7W9UWrOd0tIV4=
+77 -1
View File
@@ -5,6 +5,7 @@ import {
seedMockAgentWorkspace,
type MockAgentWorkspace,
} from "../support/helpers/mock-agent";
import { waitForTimelineGeometryToSettle } from "../support/helpers/timeline-pagination";
import { installDaemonWebSocketGate } from "../support/helpers/daemon-websocket-gate";
import {
expectReconnectingToastGone,
@@ -19,6 +20,10 @@ import {
} from "../support/helpers/composer";
import { openCommandCenter, closeCommandCenter } from "../support/helpers/command-center";
import {
expectPromptsDrawnByPlugin,
withPromptTimelinePlugin,
} from "../support/helpers/plugin-timeline";
const MAC_USER_AGENT =
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36";
@@ -120,6 +125,49 @@ async function closeFind(page: Page) {
.toEqual([]);
}
async function expectChatKeyboardScroll(page: Page, key: "PageUp" | "PageDown") {
// The scrolling surface has no accessible name; use its existing test id.
const viewport = page.getByTestId("agent-chat-scroll");
await waitForTimelineGeometryToSettle(page);
const before = await viewport.evaluate((element) => element.scrollTop);
await page.keyboard.press(key);
const direction = key === "PageUp" ? -1 : 1;
await expect
.poll(
async () => direction * ((await viewport.evaluate((element) => element.scrollTop)) - before),
)
.toBeGreaterThan(100);
}
test("keeps keyboard scrolling the chat after closing Find", async ({ page }, testInfo) => {
const agent = await seedMockAgentWorkspace({
repoPrefix: "chat-find-scroll-focus-",
title: "Chat Find keyboard scrolling",
initialPrompt: "Render the response",
featureValues: { mockAssistantResponse: TALL_RESPONSE },
});
try {
await agent.client.waitForFinish(agent.agentId, 15_000);
await openAgentRoute(page, agent);
await expect(page.getByTestId("assistant-message").first()).toBeVisible();
await searchChat(page, "Closing paragraph");
await expectHighlight(page, "Closing paragraph");
await waitForTimelineGeometryToSettle(page);
await closeFind(page);
await expectChatKeyboardScroll(page, "PageUp");
await page.screenshot({ path: testInfo.outputPath("chat-after-find-page-up.png") });
await expectChatKeyboardScroll(page, "PageDown");
await searchChat(page, "Closing paragraph");
await expectHighlight(page, "Closing paragraph");
await waitForTimelineGeometryToSettle(page);
await page.getByRole("button", { name: "Close Find", exact: true }).click();
await expect(query(page)).toHaveCount(0);
await expectChatKeyboardScroll(page, "PageUp");
} finally {
await agent.cleanup();
}
});
test("loads older history through prompt navigation and finds repeated rendered Markdown", async ({
page,
}, testInfo) => {
@@ -174,7 +222,7 @@ async function highlightedMessage(page: Page) {
const range = entry && Array.from(entry[1])[0];
if (!(range instanceof Range)) return null;
const message = range.startContainer.parentElement?.closest<HTMLElement>(
'[data-testid="user-message"], [data-testid="assistant-message"]',
'[data-testid="user-message"], [data-testid="plugin-prompt"], [data-testid="assistant-message"]',
);
return message?.dataset.testid ?? null;
});
@@ -219,6 +267,34 @@ test("counts and steps through every match in the chat, not just the selected me
}
});
test("finds text in a prompt a timeline plugin draws", async ({ page }, testInfo) => {
testInfo.setTimeout(120_000);
const agent = await seedMockAgentWorkspace({
repoPrefix: "chat-find-plugin-prompt-",
title: "Chat Find plugin prompt",
initialPrompt: ACROSS_PROMPT,
featureValues: { mockAssistantResponse: ACROSS_RESPONSE },
});
try {
await agent.client.waitForFinish(agent.agentId, 15_000);
await withPromptTimelinePlugin(async () => {
await openAgentRoute(page, agent);
await expectPromptsDrawnByPlugin(page);
await openChatFind(page);
await query(page).fill("scope-needle");
await expect(status(page)).toHaveText("1 of 3");
await expectHighlight(page, "scope-needle");
await expectHighlightedMessage(page, "plugin-prompt");
await page.screenshot({ path: testInfo.outputPath("chat-find-plugin-prompt.png") });
await expectNextMatch(page, "2 of 3", "scope-needle");
await expectHighlightedMessage(page, "assistant-message");
await closeFind(page);
});
} finally {
await agent.cleanup();
}
});
test("rejects invisible source matches and ignores a late reply after closing", async ({
page,
}) => {
@@ -118,8 +118,8 @@ export function ChatFind({
}, [active, isInteractive, model]);
const close = useCallback(() => {
model.close();
root.current?.focus({ preventScroll: true });
}, [model]);
viewportRef.current?.focus?.();
}, [model, viewportRef]);
let status = "";
if (state.phase === "searching") status = t("paneFind.searching");
else if (state.phase === "loading") status = t("paneFind.loading");
@@ -76,3 +76,49 @@ it("keeps semantic Markdown paragraphs separate while finding inline matches", (
]);
expect(findRenderedMatches(row, ".another")).toEqual([]);
});
function pluginRow(html: string) {
const row = document.createElement("div");
row.dataset.pluginRow = "true";
row.dataset.messageId = "message-1";
row.innerHTML = html;
document.body.append(row);
return row;
}
it("searches a plugin row that sets no text marker, bounding matches by view", () => {
const row = pluginRow(
"<div><div>Prompt</div><div>find <span>this</span> needle</div><div>needle</div><button>needle</button></div>",
);
expect(findRenderedMatches(row, "needle").map((range) => range.toString())).toEqual([
"needle",
"needle",
]);
expect(findRenderedMatches(row, "Promptfind")).toEqual([]);
expect(findRenderedMatches(row, "find this needle")).toHaveLength(1);
expect(findMessageMatches(row.parentElement, "message-1", "needle")).toHaveLength(2);
});
it("leaves rows without the plugin marker limited to marked message text", () => {
const row = document.createElement("div");
row.innerHTML = "<div>needle</div>";
document.body.append(row);
expect(findRenderedMatches(row, "needle")).toEqual([]);
});
it("counts only drawn text in a plugin row, including an opened popover", () => {
const row = pluginRow(
'<div><span>visible needle</span><div hidden>hidden needle</div><div style="display:none">display needle</div><span style="visibility:hidden">visibility needle</span><span style="opacity:0">transparent needle</span><div popover="manual">popover needle</div></div>',
);
expect(findRenderedMatches(row, "needle").map((range) => range.toString())).toEqual(["needle"]);
row.querySelector<HTMLElement>("[popover]")!.showPopover();
expect(findRenderedMatches(row, "needle")).toHaveLength(2);
});
it("keeps text around nested plugin blocks in separate occurrences", () => {
const row = pluginRow("<div>alpha <div>details</div>beta</div><div>gamma <div></div>delta</div>");
expect(findRenderedMatches(row, "alpha beta")).toEqual([]);
expect(findRenderedMatches(row, "gamma delta")).toEqual([]);
expect(findRenderedMatches(row, "details").map((range) => range.toString())).toEqual(["details"]);
expect(findRenderedMatches(row, "beta").map((range) => range.toString())).toEqual(["beta"]);
});
@@ -21,28 +21,55 @@ export function findMessageMatches(
return findMessageRows(root, messageId).flatMap((row) => findRenderedMatches(row, query));
}
function renderedTextGroups(row: HTMLElement): Text[][] {
const groups: Text[][] = [];
// A plugin renderer owns the DOM of the message it replaced and sets no markers, so the
// whole row is searched and each div (a React Native View or Text) bounds a match.
const pluginRow = row.dataset.pluginRow === "true";
const blocks = pluginRow ? `${BLOCKS},div` : BLOCKS;
const contents = pluginRow
? [row]
: row.querySelectorAll<HTMLElement>('[data-message-text="true"]');
for (const content of contents) {
let group: { block: Element; texts: Text[] } | undefined;
const walker = document.createTreeWalker(
content,
NodeFilter.SHOW_ELEMENT | NodeFilter.SHOW_TEXT,
);
let node: Node | null;
while ((node = walker.nextNode())) {
if (node instanceof Element) {
// Even an empty nested block separates the text before and after it.
if (node.matches(blocks)) group = undefined;
continue;
}
if (!(node instanceof Text) || node.parentElement?.closest(IGNORED)) continue;
if (
pluginRow &&
!node.parentElement?.checkVisibility({ opacityProperty: true, visibilityProperty: true })
)
continue;
const block = node.parentElement?.closest(blocks) ?? content;
if (group?.block !== block) {
group = { block, texts: [] };
groups.push(group.texts);
}
group.texts.push(node);
}
}
return groups;
}
/** Local offsets belong to the DOM that supplied the text, never to a host parser. */
export function findRenderedMatches(row: HTMLElement, query: string): Range[] {
if (!query.trim()) return [];
const groups = new Map<Element, Text[]>();
for (const content of row.querySelectorAll<HTMLElement>('[data-message-text="true"]')) {
const walker = document.createTreeWalker(content, NodeFilter.SHOW_TEXT);
let node: Node | null;
while ((node = walker.nextNode())) {
if (!(node instanceof Text) || node.parentElement?.closest(IGNORED)) continue;
const block = node.parentElement?.closest(BLOCKS) ?? content;
const texts = groups.get(block) ?? [];
texts.push(node);
groups.set(block, texts);
}
}
const pattern = query
.trim()
.split(/\s+/)
.map((part) => part.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"))
.join("\\s+");
const ranges: Range[] = [];
for (const texts of groups.values()) {
for (const texts of renderedTextGroups(row)) {
const points = texts.map((node) => ({ node, start: 0, end: 0 }));
let text = "";
for (const point of points) {
@@ -1162,6 +1162,7 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
useEffect(() => {
const handle: StreamViewportHandle = {
focus: () => scrollContainerRef.current?.focus({ preventScroll: true }),
scrollToBottom: () => {
setFollowOutput(true);
cancelPendingStickToBottom();
@@ -1250,6 +1251,7 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
key={item.id}
data-history-row-id={item.id}
data-message-id={getStreamItemMessageId(item)}
data-plugin-row={item.kind === "plugin" ? "true" : undefined}
style={streamRowStyle}
>
<StreamRowContent
@@ -1267,6 +1269,7 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
key={item.id}
data-history-row-id={item.id}
data-message-id={getStreamItemMessageId(item)}
data-plugin-row={item.kind === "plugin" ? "true" : undefined}
style={streamRowStyle}
>
<StreamRowContent
@@ -1329,6 +1332,7 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
data-index={virtualRow.index}
data-history-row-id={item.id}
data-message-id={getStreamItemMessageId(item)}
data-plugin-row={item.kind === "plugin" ? "true" : undefined}
ref={measureVirtualizedRowElement}
style={renderVirtualRowStyle(virtualRow.start)}
>
@@ -51,6 +51,8 @@ export interface ScrollToMessageOccurrence {
}
export interface StreamViewportHandle {
/** Give keyboard navigation back to the scrolling surface on web. */
focus?: () => void;
scrollToBottom: (reason?: BottomAnchorLocalRequest["reason"]) => void;
prepareForViewportChange: () => void;
scrollToMessage?: (messageId: string, occurrence?: ScrollToMessageOccurrence) => void;
@@ -41,6 +41,93 @@ function rowWithText(
}
describe("native terminal selection", () => {
it.each([
{ startCol: 1, endCol: 8, expected: "한글 문장" },
{ startCol: 3, endCol: 8, expected: "글 문장" },
{ startCol: 1, endCol: 1, expected: "한" },
{ startCol: 0, endCol: 0, expected: "한" },
{ startCol: 0, endCol: 1, expected: "한" },
{ startCol: 4, endCol: 6, expected: " 문" },
])(
"copies complete wide characters for selected columns $startCol through $endCol",
async ({ startCol, endCol, expected }) => {
const terminal = createNativeHeadlessTerminal({ rows: 3, cols: 30 });
const copied: string[] = [];
try {
await terminal.write("한글 문장");
const bounds = terminal.getBufferBounds();
const selection = createTerminalSelectionModel();
selection.begin({
coordinate: { row: bounds.cursorRow, col: endCol },
bounds,
});
selection.update({
coordinate: { row: bounds.cursorRow, col: startCol },
bounds,
});
await copyTerminalSelection({
terminal,
selection: selection.getSnapshot().range,
clipboard: {
writeText: async (text) => {
copied.push(text);
},
},
});
expect(copied).toEqual([expected]);
} finally {
terminal.dispose();
}
},
);
it("copies Korean output without adding spaces for continuation cells", async () => {
const terminal = createNativeHeadlessTerminal({ rows: 3, cols: 30 });
const copied: string[] = [];
try {
await terminal.write("한글 문장");
const bounds = terminal.getBufferBounds();
await copyTerminalSelection({
terminal,
selection: {
start: { row: bounds.cursorRow, col: 0 },
end: { row: bounds.cursorRow, col: bounds.cursorCol - 1 },
coordinateEpoch: bounds.coordinateEpoch,
},
clipboard: {
writeText: async (text) => {
copied.push(text);
},
},
});
expect(copied).toEqual(["한글 문장"]);
} finally {
terminal.dispose();
}
});
it("copies Korean text and real spaces without wide-character continuation spaces", async () => {
const terminal = createNativeHeadlessTerminal({ rows: 3, cols: 30 });
try {
await terminal.write("한글 문장 | 성공");
const bounds = terminal.getBufferBounds();
expect(
extractTerminalSelectedText({
terminal,
selection: {
start: { row: bounds.cursorRow, col: 0 },
end: { row: bounds.cursorRow, col: bounds.cursorCol - 1 },
coordinateEpoch: bounds.coordinateEpoch,
},
}),
).toBe("한글 문장 | 성공");
} finally {
terminal.dispose();
}
});
it("expands a long-press coordinate to the whole terminal word", async () => {
const terminal = createNativeHeadlessTerminal({ rows: 4, cols: 32, scrollbackLines: 20 });
await terminal.write("before PASEO_TARGET after\r\n");
@@ -324,10 +324,17 @@ export function extractTerminalSelectedText(input: TerminalSelectedTextInput): s
const cells = window.rows[offset] ?? [];
const startsOnThisRow = row === selection.start.row;
const endsOnThisRow = row === selection.end.row;
const startCol = startsOnThisRow ? selection.start.col : 0;
let startCol = Math.max(0, startsOnThisRow ? selection.start.col : 0);
// Selecting either half highlights the whole wide character, so copy its leading cell too.
if (startCol > 0 && cells[startCol]?.width === 0) {
startCol -= 1;
}
const endCol = endsOnThisRow ? selection.end.col : cells.length - 1;
const selectedCells = cells.slice(Math.max(0, startCol), Math.max(0, endCol) + 1);
const line = selectedCells.map((cell) => cell.char || " ").join("");
const selectedCells = cells.slice(startCol, Math.max(0, endCol) + 1);
const line = selectedCells
.filter((cell) => cell.width !== 0)
.map((cell) => cell.char)
.join("");
if (offset > 0 && window.wrappedRows[offset] !== true) {
text += "\n";
}
+84
View File
@@ -77,6 +77,90 @@ function applySynced(agentId: string, seq: number): void {
afterEach(() => useSessionStore.getState().clearSession(SERVER_ID));
describe("viewed timeline persistence", () => {
it("keeps one complete reply when live chunks arrive before a cached chat catches up", async () => {
useSessionStore.getState().initializeSession(SERVER_ID, null);
const partial = "Paragraph 1. Paragraph 2.";
const complete = `${partial} Paragraph 3. END`;
const owner = createOwner({
readTimeline: async () => ({
agentId: AGENT_ID,
items: [{ ...item("reply-1", partial, 4), messageId: "reply-1" }],
range: { epoch: "epoch-1", startSeq: 1, endSeq: 4 },
hasOlder: false,
}),
commitTimeline: () => undefined,
});
try {
owner.replaceVisibleAgentIds("test", [AGENT_ID]);
await expect
.poll(
() =>
selectAgentTimelineState(useSessionStore.getState().sessions[SERVER_ID], AGENT_ID)
.status,
)
.toBe("painted");
owner.enqueueStreamEvent(AGENT_ID, {
event: {
type: "timeline",
provider: "codex",
item: { type: "assistant_message", text: " Paragraph 3.", messageId: "reply-1" },
} as AgentStreamEventPayload,
seq: 5,
epoch: "epoch-1",
timestamp: new Date("2026-08-26T10:00:01.000Z"),
});
owner.flushStreamAgent(AGENT_ID);
owner.applyTimelineResponse({
requestId: "resume",
agentId: AGENT_ID,
agent: null,
direction: "after",
projection: "projected",
reset: false,
epoch: "epoch-1",
window: { minSeq: 1, maxSeq: 6, nextSeq: 7 },
startCursor: { epoch: "epoch-1", seq: 5 },
endCursor: { epoch: "epoch-1", seq: 6 },
entries: [
{
provider: "codex",
item: { type: "assistant_message", text: complete, messageId: "reply-1" },
timestamp: "2026-08-26T10:00:02.000Z",
seqStart: 2,
seqEnd: 6,
sourceSeqRanges: [{ startSeq: 2, endSeq: 6 }],
collapsed: ["assistant_merge"],
},
],
error: null,
hasNewer: false,
hasOlder: false,
staleCursor: false,
gap: false,
});
const replyTexts = () => {
const session = useSessionStore.getState().sessions[SERVER_ID];
return [
...(session?.agentStreamTail.get(AGENT_ID) ?? []),
...(session?.agentStreamHead.get(AGENT_ID) ?? []),
]
.filter((entry) => entry.kind === "assistant_message")
.map((entry) => entry.text);
};
expect.soft(replyTexts()).toEqual([complete]);
owner.enqueueStreamEvent(AGENT_ID, {
event: { type: "turn_completed", provider: "codex" } as AgentStreamEventPayload,
seq: 7,
epoch: "epoch-1",
timestamp: new Date("2026-08-26T10:00:03.000Z"),
});
owner.flushStreamAgent(AGENT_ID);
expect.soft(replyTexts()).toEqual([complete]);
} finally {
owner.dispose();
}
});
it("shares an in-flight cache preparation with the viewed owner", async () => {
useSessionStore.getState().initializeSession(SERVER_ID, null);
let release!: (value: CachedTimeline) => void;
@@ -811,7 +811,7 @@ function reconcileOverlappingProjectedAssistant(params: {
};
const headMatch = findMatch(params.head);
const tailMatch = headMatch ? null : findMatch(params.tail);
const tailMatch = findMatch(params.tail);
const match = headMatch ?? tailMatch;
if (!match) {
return { tail: params.tail, head: params.head, reconciled: false };
@@ -833,8 +833,20 @@ function reconcileOverlappingProjectedAssistant(params: {
};
if (headMatch) {
const tailCursor = tailMatch?.current.timelineCursor;
const replacesCachedPrefix =
tailMatch !== null &&
tailCursor?.epoch === params.epoch &&
unit.sourceSeqRanges.some(
(range) => range.startSeq <= tailCursor.seq && tailCursor.seq <= range.endSeq,
);
return {
tail: params.tail,
// Live chunks can arrive before catch-up while their cached prefix is still painted.
// The projected reply owns both copies; keeping the prefix would duplicate it and
// cause completion to discard the updated head as an already-committed item.
tail: replacesCachedPrefix
? [...params.tail.slice(0, tailMatch.index), ...params.tail.slice(tailMatch.index + 1)]
: params.tail,
head: replaceMatch(params.head, headMatch.index),
reconciled: true,
};
@@ -122,7 +122,7 @@ async function collectTimelineAcrossTurns(
seq: rows.length + 1,
timestamp: new Date(Date.UTC(2026, 9, 4, 0, 0, rows.length)).toISOString(),
item: event.item,
...(currentTurnId ? { turnId: currentTurnId } : {}),
...((event.turnId ?? currentTurnId) ? { turnId: event.turnId ?? currentTurnId } : {}),
});
}
if (event.type === "turn_completed" && ++completedTurns === turnCount) resolve();
@@ -469,122 +469,145 @@ describe("background Claude subagents", () => {
});
});
test("keeps one logged Task card when the child streams on after the parent's turn ends", async () => {
// A child can stream across the main session's turn boundary. Claude opens the next
// main-session turn with system/init; bare child frames do not open a turn.
queryFactory.mockImplementation(() =>
buildQueryMock([
{ type: "system", subtype: "init", session_id: "bg-session", permissionMode: "default" },
{
type: "assistant",
message: {
model: "claude-haiku-4-5",
content: [
{
type: "tool_use",
id: "toolu_background",
name: "Agent",
input: {
subagent_type: "general-purpose",
description: "Count files here",
prompt: "Run ls and reply with the number of entries.",
run_in_background: true,
test.each(["before", "after"])(
"keeps one logged Task card when child tools start %s the parent finishes",
async (timing) => {
// A child can stream across the main session's turn boundary. Claude opens the next
// main-session turn with system/init; bare child frames do not open a turn.
queryFactory.mockImplementation(() =>
buildQueryMock([
{ type: "system", subtype: "init", session_id: "bg-session", permissionMode: "default" },
{
type: "assistant",
message: {
model: "claude-haiku-4-5",
content: [
{
type: "tool_use",
id: "toolu_background",
name: "Agent",
input: {
subagent_type: "general-purpose",
description: "Count files here",
prompt: "Run ls and reply with the number of entries.",
run_in_background: true,
},
},
},
],
],
},
},
},
{
type: "system",
subtype: "task_started",
task_id: "bg-task",
tool_use_id: "toolu_background",
task_type: "local_agent",
subagent_type: "general-purpose",
{
type: "system",
subtype: "task_started",
task_id: "bg-task",
tool_use_id: "toolu_background",
task_type: "local_agent",
subagent_type: "general-purpose",
description: "Count files here",
prompt: "Run ls and reply with the number of entries.",
},
{
type: "user",
message: {
role: "user",
content: [
{
type: "tool_result",
tool_use_id: "toolu_background",
content: "Async agent launched successfully.",
},
],
},
},
...(timing === "after"
? [
{ type: "result", subtype: "success", usage: {}, total_cost_usd: 0 },
{
type: "system",
subtype: "init",
session_id: "bg-session",
permissionMode: "default",
},
]
: []),
{
type: "assistant",
parent_tool_use_id: "toolu_background",
message: {
model: "claude-haiku-4-5",
content: [
{
type: "tool_use",
id: "toolu_ls",
name: "Bash",
input: { command: "ls -1", description: "List files" },
},
],
},
},
...(timing === "before"
? [
{ type: "result", subtype: "success", usage: {}, total_cost_usd: 0 },
{
type: "system",
subtype: "init",
session_id: "bg-session",
permissionMode: "default",
},
]
: []),
{
type: "user",
parent_tool_use_id: "toolu_background",
message: {
role: "user",
content: [{ type: "tool_result", tool_use_id: "toolu_ls", content: "a\nb\nc" }],
},
},
{
type: "assistant",
parent_tool_use_id: "toolu_background",
message: { model: "claude-haiku-4-5", content: [{ type: "text", text: "3" }] },
},
{
type: "system",
subtype: "task_updated",
task_id: "bg-task",
patch: { status: "completed" },
},
{
type: "system",
subtype: "task_notification",
task_id: "bg-task",
tool_use_id: "toolu_background",
status: "completed",
},
{ type: "result", subtype: "success", usage: {}, total_cost_usd: 0 },
]),
);
const session = await new ClaudeAgentClient({
logger: createTestLogger(),
queryFactory,
resolveBinary: async () => "/test/claude/bin",
}).createSession({ provider: "claude", cwd: process.cwd() });
const rows = await collectTimelineAcrossTurns(session, "delegate work", 2);
const cards = projectTimelineRows({ rows, mode: "projected" })
.map((entry) => entry.item)
.filter((item) => item.type === "tool_call" && item.callId === "toolu_background");
expect(cards).toHaveLength(1);
expect(cards[0]).toMatchObject({
status: "completed",
detail: {
type: "sub_agent",
subAgentType: "general-purpose",
description: "Count files here",
prompt: "Run ls and reply with the number of entries.",
log: "[Bash] ls -1",
},
{
type: "user",
message: {
role: "user",
content: [
{
type: "tool_result",
tool_use_id: "toolu_background",
content: "Async agent launched successfully.",
},
],
},
},
{
type: "assistant",
parent_tool_use_id: "toolu_background",
message: {
model: "claude-haiku-4-5",
content: [
{
type: "tool_use",
id: "toolu_ls",
name: "Bash",
input: { command: "ls -1", description: "List files" },
},
],
},
},
{ type: "result", subtype: "success", usage: {}, total_cost_usd: 0 },
{ type: "system", subtype: "init", session_id: "bg-session", permissionMode: "default" },
{
type: "user",
parent_tool_use_id: "toolu_background",
message: {
role: "user",
content: [{ type: "tool_result", tool_use_id: "toolu_ls", content: "a\nb\nc" }],
},
},
{
type: "assistant",
parent_tool_use_id: "toolu_background",
message: { model: "claude-haiku-4-5", content: [{ type: "text", text: "3" }] },
},
{
type: "system",
subtype: "task_updated",
task_id: "bg-task",
patch: { status: "completed" },
},
{
type: "system",
subtype: "task_notification",
task_id: "bg-task",
tool_use_id: "toolu_background",
status: "completed",
},
{ type: "result", subtype: "success", usage: {}, total_cost_usd: 0 },
]),
);
const session = await new ClaudeAgentClient({
logger: createTestLogger(),
queryFactory,
resolveBinary: async () => "/test/claude/bin",
}).createSession({ provider: "claude", cwd: process.cwd() });
const rows = await collectTimelineAcrossTurns(session, "delegate work", 2);
const cards = projectTimelineRows({ rows, mode: "projected" })
.map((entry) => entry.item)
.filter((item) => item.type === "tool_call" && item.callId === "toolu_background");
expect(cards).toHaveLength(1);
expect(cards[0]).toMatchObject({
status: "completed",
detail: {
type: "sub_agent",
subAgentType: "general-purpose",
description: "Count files here",
log: "[Bash] ls -1",
},
});
});
});
},
);
test.each([
{
@@ -2250,7 +2250,7 @@ class ClaudeAgentSession implements AgentSession {
private readonly sidechainTracker = new ClaudeSidechainTracker({
getToolInput: (toolUseId) =>
this.toolUseCache.get(toolUseId)?.input ??
this.settledSubagentToolCallInCurrentTurn(toolUseId)?.input ??
this.settledSubagentToolCalls.get(toolUseId)?.input ??
null,
// Releases that predate the task protocol announce nothing, so the tracker keeps deriving
// identity and status from frames for them. Detecting the capability beats comparing version
@@ -2258,13 +2258,11 @@ class ClaudeAgentSession implements AgentSession {
isDescriptorOwnedElsewhere: () => this.taskProtocolSource.isActive,
// A background child streams its frames after Claude has already answered the call, so its
// card is rebuilt from the settled call: a running one would replace the settled, labeled card.
// A card belongs to the turn that answered the call, so frames from a later turn leave it be.
// Late card updates retain the turn that answered the call.
needsSyntheticParentToolCard: (toolUseId) =>
(this.toolUseCache.has(toolUseId) ||
this.settledSubagentToolCallInCurrentTurn(toolUseId) !== undefined) &&
(this.toolUseCache.has(toolUseId) || this.settledSubagentToolCalls.has(toolUseId)) &&
this.taskProtocolSource.needsSyntheticParentToolCard(toolUseId),
getSettledParentToolCall: (toolUseId) =>
this.settledSubagentToolCallInCurrentTurn(toolUseId)?.item,
getSettledParentToolCall: (toolUseId) => this.settledSubagentToolCalls.get(toolUseId)?.item,
});
private persistedHistory: PersistedTimelineEntry[] = [];
private persistedProviderSubagentEvents: Extract<
@@ -4490,7 +4488,12 @@ class ClaudeAgentSession implements AgentSession {
),
).map((event): AgentStreamEvent => ({ type: "provider_subagent", provider: "claude", event }));
const routedId = canonicalSubagentId ?? parentToolUseId;
return [...runtimeEvents, ...this.sidechainTracker.handleMessage(message, routedId)];
const settled = this.settledSubagentToolCalls.get(routedId);
const sidechainEvents = this.sidechainTracker.handleMessage(message, routedId).map((event) => {
if (event.type === "timeline" && settled?.turnId) event.turnId = settled.turnId;
return event;
});
return [...runtimeEvents, ...sidechainEvents];
}
/**
@@ -5145,7 +5148,8 @@ class ClaudeAgentSession implements AgentSession {
}
private notifySubscribers(event: AgentStreamEvent): void {
const turnId = this.activeForegroundTurnId ?? this.autonomousTurn?.id;
const turnId =
getAgentStreamEventTurnId(event) ?? this.activeForegroundTurnId ?? this.autonomousTurn?.id;
const tagged = turnId ? { ...event, turnId } : event;
this.logger.trace(
{
@@ -5621,17 +5625,12 @@ class ClaudeAgentSession implements AgentSession {
});
}
private settledSubagentToolCallInCurrentTurn(subagentId: string) {
const settled = this.settledSubagentToolCalls.get(subagentId);
const currentTurnId = this.activeForegroundTurnId ?? this.autonomousTurn?.id ?? null;
return settled && settled.turnId === currentTurnId ? settled : undefined;
}
/** A finished child streams no more frames, so its settled call has nothing left to update. */
private releaseFinishedSubagentToolCalls(observations: SubagentObservation[]): void {
for (const observation of observations) {
if (observation.kind === "status" && observation.status !== "running") {
this.settledSubagentToolCalls.delete(observation.id);
this.sidechainTracker.delete(observation.id);
}
}
}
@@ -4,6 +4,40 @@ import type { SDKMessage } from "@anthropic-ai/claude-agent-sdk";
import { ClaudeSidechainTracker } from "./sidechain-tracker.js";
describe("ClaudeSidechainTracker", () => {
it("retains a background child's actions across the parent finishing", () => {
const tracker = new ClaudeSidechainTracker({
getToolInput: () => ({ subagent_type: "general-purpose", description: "List files" }),
isDescriptorOwnedElsewhere: () => true,
});
const action = (id: string, command: string) =>
({
type: "assistant",
parent_tool_use_id: "task-1",
message: { content: [{ type: "tool_use", id, name: "Bash", input: { command } }] },
}) as unknown as SDKMessage;
tracker.handleMessage(action("ls", "ls -1"), "task-1");
expect(tracker.finishAll("completed")).toEqual([]);
const events = tracker.handleMessage(action("pwd", "pwd"), "task-1");
expect(events).toContainEqual(
expect.objectContaining({
type: "timeline",
item: expect.objectContaining({
detail: expect.objectContaining({ log: "[Bash] ls -1\n[Bash] pwd" }),
}),
}),
);
tracker.delete("task-1");
const next = tracker.handleMessage(action("next", "ls"), "task-1");
expect(next).toContainEqual(
expect.objectContaining({
type: "timeline",
item: expect.objectContaining({
detail: expect.objectContaining({ log: "[Bash] ls" }),
}),
}),
);
});
it("uses Claude's native agent name for the provider subagent title", () => {
const tracker = new ClaudeSidechainTracker({
getToolInput: () => ({
@@ -199,7 +199,7 @@ export class ClaudeSidechainTracker {
finishAll(status: "completed" | "failed" | "canceled"): AgentStreamEvent[] {
const events: AgentStreamEvent[] = [];
if (this.isDescriptorOwnedElsewhere()) {
this.activeSidechains.clear();
// The task protocol owns child completion; the parent finishing does not end its children.
return events;
}
for (const [id, state] of this.activeSidechains) {
@@ -0,0 +1,105 @@
import { execFile } from "node:child_process";
import { promisify } from "node:util";
import { fileURLToPath } from "node:url";
import { mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import path from "node:path";
import pino from "pino";
import { expect, test } from "vitest";
import { ClaudeAgentClient } from "../agent/providers/claude/agent.js";
import { createTestPaseoDaemon } from "../test-utils/paseo-daemon.js";
import { DaemonClient } from "../test-utils/daemon-client.js";
test("background cards retain child actions after the parent finishes (#6253)", async () => {
const cwd = mkdtempSync(path.join(tmpdir(), "paseo-background-card-"));
const logger = pino({ level: "silent" });
const daemon = await createTestPaseoDaemon({
logger,
agentClients: { claude: new ClaudeAgentClient({ logger }) },
});
const client = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws` });
try {
await client.connect();
await client.fetchAgents({ subscribe: {} });
const agent = await client.createAgent({
provider: "claude",
model: "haiku",
modeId: "bypassPermissions",
cwd,
title: "Background action log reproduction",
});
await client.sendMessage(
agent.id,
'In one message, call Agent twice with run_in_background true: first with subagent_type "general-purpose", description "List files", prompt "Run ls -1, then run sleep 20, then reply with the number of entries."; second with subagent_type "general-purpose", description "Print cwd", prompt "Run pwd, then run sleep 20, then reply with the path." After launching both, reply "launched" and end your turn immediately without waiting.',
);
await client.waitForFinish(agent.id, 180_000);
const atFinish = await client.fetchAgentTimeline(agent.id, {
direction: "tail",
limit: 0,
projection: "projected",
});
await expect
.poll(
async () => {
const { subagents } = await client.listProviderSubagents(agent.id);
return subagents.length === 2 && subagents.every((child) => child.status === "completed");
},
{ timeout: 180_000, interval: 1000 },
)
.toBe(true);
const { subagents } = await client.listProviderSubagents(agent.id);
const children = await Promise.all(
subagents.map(async (child) => ({
description: child.description,
timeline: await client.fetchProviderSubagentTimeline(agent.id, child.id, {
direction: "tail",
limit: 0,
}),
})),
);
const final = await client.fetchAgentTimeline(agent.id, {
direction: "tail",
limit: 0,
projection: "projected",
});
const { stdout } = await promisify(execFile)(
process.execPath,
[
"--conditions=source",
"--import",
"tsx",
fileURLToPath(new URL("../../../../cli/src/index.ts", import.meta.url)),
"--host",
`127.0.0.1:${daemon.port}`,
"logs",
agent.id,
],
{ env: { ...process.env, PASEO_HOME: daemon.paseoHome }, timeout: 30_000 },
);
expect(stdout).toContain("[Bash] ls -1");
expect(stdout).toContain("[Bash] pwd");
expect(
atFinish.entries.filter(
(row) => row.item.type === "tool_call" && row.item.detail?.type === "sub_agent",
),
).toHaveLength(2);
const cards = final.entries
.map((row) => row.item)
.filter((item) => item.type === "tool_call" && item.detail?.type === "sub_agent");
expect(cards).toHaveLength(2);
for (const child of children) {
expect(
child.timeline.rows.some(
(row) => row.item.type === "tool_call" && row.item.name === "Bash",
),
).toBe(true);
}
for (const card of cards) {
expect(card.detail?.type === "sub_agent" ? card.detail.log : "").toContain("[Bash]");
}
} finally {
await client.close();
await daemon.close();
rmSync(cwd, { recursive: true, force: true });
}
}, 420_000);
@@ -0,0 +1,63 @@
import { execFileSync } from "node:child_process";
import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
import { createRequire } from "node:module";
import { tmpdir } from "node:os";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { expect, onTestFinished, test } from "vitest";
// Exercises npm's consumer resolution, without the repository lockfile. Requires registry access.
test.runIf(process.platform === "linux" && process.arch === "x64").each([false, true])(
"a server consumer installs the pinned local speech native runtime (upgrade: %s)",
(upgrade) => {
const directory = mkdtempSync(path.join(tmpdir(), "paseo-speech-consumer-"));
onTestFinished(() => rmSync(directory, { recursive: true, force: true }));
const serverRoot = fileURLToPath(new URL("../../../../../../", import.meta.url));
const packed = JSON.parse(
execFileSync("npm", ["pack", "--ignore-scripts", "--json", "--pack-destination", directory], {
cwd: serverRoot,
encoding: "utf8",
timeout: 60_000,
}),
) as Array<{ filename: string }>;
const consumer = path.join(directory, "consumer");
mkdirSync(consumer);
writeFileSync(path.join(consumer, "package.json"), '{"private":true}');
if (upgrade) {
execFileSync(
"npm",
[
"install",
"--ignore-scripts",
"--omit=dev",
"--no-audit",
"--no-fund",
"@getpaseo/server@0.8.0",
],
{ cwd: consumer, encoding: "utf8", timeout: 120_000 },
);
}
execFileSync(
"npm",
[
"install",
"--ignore-scripts",
"--omit=dev",
"--no-audit",
"--no-fund",
path.join(directory, packed[0].filename),
],
{ cwd: consumer, encoding: "utf8", timeout: 120_000 },
);
const serverRequire = createRequire(
path.join(consumer, "node_modules/@getpaseo/server/package.json"),
);
const wrapperPath = serverRequire.resolve("sherpa-onnx-node/package.json");
const wrapperRequire = createRequire(wrapperPath);
const wrapper = wrapperRequire(wrapperPath) as { version: string };
const native = wrapperRequire("sherpa-onnx-linux-x64/package.json") as { version: string };
expect(native.version).toBe(wrapper.version);
},
180_000,
);
@@ -1,5 +1,5 @@
import { createHook } from "node:async_hooks";
import { execFileSync } from "node:child_process";
import { execFileSync, spawn, type ChildProcess } from "node:child_process";
import {
mkdtempSync,
mkdirSync,
@@ -1066,6 +1066,16 @@ describe("relative typed-entry configuration", () => {
});
});
interface SearchProcessExit {
code: number | null;
signal: string | null;
}
interface SearchProcess {
child: ChildProcess;
exited: Promise<SearchProcessExit>;
}
// Reading a tree is cheap; re-deriving each entry's path state is not. A scan that asks whether
// every ancestor of every entry is inside the root, or is Git-ignored, costs multiples of the
// read and is what made large home directories exceed the client request timeout.
@@ -1073,6 +1083,7 @@ describe("home-tree scan cost", () => {
const DEPTH = 8;
const CHAINS = 40;
let scanRoot: string;
let searchProcess: SearchProcess | null = null;
beforeEach(() => {
scanRoot = realpathSync.native(mkdtempSync(path.join(tmpdir(), "directory-scan-cost-")));
@@ -1083,8 +1094,16 @@ describe("home-tree scan cost", () => {
}
});
afterEach(() => {
rmSync(scanRoot, { recursive: true, force: true });
afterEach(async () => {
try {
if (searchProcess) {
searchProcess.child.kill();
await searchProcess.exited;
}
} finally {
searchProcess = null;
rmSync(scanRoot, { recursive: true, force: true });
}
});
// Nothing matches, so the scan walks the whole tree instead of stopping at a confident result.
@@ -1102,6 +1121,78 @@ describe("home-tree scan cost", () => {
return stopPathContainmentMetrics();
}
it("finishes overlapping broad-tree searches within a bounded heap", async () => {
const broadRoot = path.join(scanRoot, "broad");
mkdirSync(broadRoot);
for (let branch = 0; branch < 30; branch += 1) {
const branchRoot = path.join(broadRoot, `branch-${branch}`);
mkdirSync(branchRoot);
for (let group = 0; group < 30; group += 1) {
const groupRoot = path.join(branchRoot, `group-${group}`);
mkdirSync(groupRoot);
for (let leaf = 0; leaf < 30; leaf += 1) {
mkdirSync(path.join(groupRoot, `leaf-${leaf}`));
}
}
}
// A smaller real tree and explicit heap budget keep the million-directory repro cheap in CI.
const moduleUrl = new URL("./directory-suggestions.ts", import.meta.url).href;
const source = `
const { searchDirectoryEntries } = await import(${JSON.stringify(moduleUrl)});
const results = await Promise.all(Array.from({ length: 14 }, (_, index) =>
searchDirectoryEntries({
root: ${JSON.stringify(broadRoot)},
query: "z".repeat(index + 1),
pathFormat: "absolute",
includeFiles: false,
includeDirectories: true,
pathQueryPolicy: "rooted",
rootAliases: ["~"],
blankQueryBehavior: "none",
confidentResultScanThreshold: 5000,
maxEntriesScanned: 2000,
limit: 30,
})
));
process.stdout.write(JSON.stringify(results));
`;
const child = spawn(
process.execPath,
[
"--max-old-space-size=128",
"--conditions=source",
"--import",
"tsx",
"--input-type=module",
"--eval",
source,
],
{ stdio: ["ignore", "pipe", "pipe"], timeout: 20_000 },
);
let stdout = "";
let stderr = "";
child.stdout.setEncoding("utf8");
child.stderr.setEncoding("utf8");
child.stdout.on("data", (chunk: string) => {
stdout += chunk;
});
child.stderr.on("data", (chunk: string) => {
stderr += chunk;
});
const exited = new Promise<SearchProcessExit>((resolve, reject) => {
child.on("error", reject);
child.on("close", (code, signal) => resolve({ code, signal }));
});
searchProcess = { child, exited };
const exit = await exited;
expect({ ...exit, outOfMemory: stderr.includes("heap out of memory") }).toEqual({
code: 0,
signal: null,
outOfMemory: false,
});
expect(JSON.parse(stdout)).toEqual(Array.from({ length: 14 }, () => []));
});
it("derives no containment for a tree that cannot leave the root", async () => {
const checks = await countContainmentChecks(scanWholeTree);
@@ -63,6 +63,13 @@ interface WalkStart {
resolvedPath: string;
}
interface WalkChildrenOptions {
parent: WalkStart;
depth: number;
input: SearchInput;
visited: Set<string>;
}
interface TraversedEntry extends ChildEntry {
visiblePath: string;
depth: number;
@@ -318,17 +325,18 @@ async function searchChildren(input: SearchInput): Promise<RankedEntry[]> {
const discoveryInput = isInsideExcludedPath(parent, input)
? { ...input, excludedDiscoveryPaths: new Set<string>() }
: input;
const entries = await readChildren(parent);
return entries.flatMap((entry) => {
const ranked: RankedEntry[] = [];
for await (const entry of readChildren(parent)) {
if (!isPathInsideRoot(input.root, entry.resolvedPath) || !shouldDiscover(entry, discoveryInput))
return [];
continue;
const candidate: TraversedEntry = {
...entry,
visiblePath: path.join(visibleParent, entry.name),
depth: 1,
};
return shouldSuggest(candidate, input) ? [rank(candidate, input)] : [];
});
if (shouldSuggest(candidate, input)) ranked.push(rank(candidate, input));
}
return ranked;
}
async function searchTree(
@@ -336,21 +344,8 @@ async function searchTree(
start: WalkStart = { visiblePath: input.root, resolvedPath: input.root },
): Promise<RankedEntry[]> {
if (!(input.maxEntriesScanned > 0)) return [];
const roots = (await readChildren(start.resolvedPath)).filter((entry) =>
staysInsideRoot(entry, input.root),
);
const visited = new Set<string>([start.resolvedPath]);
const branches = roots.flatMap((entry) =>
shouldDiscover(entry, input)
? [
walkBranch(
{ ...entry, visiblePath: path.join(start.visiblePath, entry.name), depth: 1 },
input,
visited,
),
]
: [],
);
const branches = walkChildren({ parent: start, depth: 1, input, visited });
const ranked: RankedEntry[] = [];
let scanned = 0;
const threshold = input.confidentResultScanThreshold;
@@ -381,38 +376,39 @@ async function* walkBranch(
)
return;
visited.add(entry.resolvedPath);
const children = (await readChildren(entry.resolvedPath)).filter((child) =>
staysInsideRoot(child, input.root),
);
const branches = children.flatMap((child) =>
shouldDiscover(child, input)
? [
walkBranch(
{
...child,
visiblePath: path.join(entry.visiblePath, child.name),
depth: entry.depth + 1,
},
input,
visited,
),
]
: [],
);
yield* roundRobin(branches);
yield* roundRobin(walkChildren({ parent: entry, depth: entry.depth + 1, input, visited }));
}
async function* roundRobin<T>(branches: Array<AsyncGenerator<T>>): AsyncGenerator<T> {
let active = branches;
while (active.length) {
// Create a child's path and iterator only when its branch gets a turn. Eagerly constructing every
// sibling's subtree retained hundreds of thousands of unvisited children beyond the scan budget.
async function* walkChildren({
parent,
depth,
input,
visited,
}: WalkChildrenOptions): AsyncGenerator<AsyncGenerator<TraversedEntry>> {
for await (const child of readChildren(parent.resolvedPath)) {
if (!staysInsideRoot(child, input.root) || !shouldDiscover(child, input)) continue;
yield walkBranch(
{ ...child, visiblePath: path.join(parent.visiblePath, child.name), depth },
input,
visited,
);
}
}
async function* roundRobin<T>(branches: AsyncIterable<AsyncGenerator<T>>): AsyncGenerator<T> {
let active: AsyncIterable<AsyncGenerator<T>> | Array<AsyncGenerator<T>> = branches;
while (true) {
const nextRound: Array<AsyncGenerator<T>> = [];
for (const branch of active) {
for await (const branch of active) {
const next = await branch.next();
if (!next.done) {
nextRound.push(branch);
yield next.value;
}
}
if (!nextRound.length) return;
active = nextRound;
}
}
@@ -742,9 +738,9 @@ async function resolveDirectory(inputPath: string): Promise<string | null> {
return info?.isDirectory() ? resolved : null;
}
async function readChildren(directory: string): Promise<ChildEntry[]> {
async function* readChildren(directory: string): AsyncGenerator<ChildEntry> {
const directoryInfo = await stat(directory).catch(() => null);
if (!directoryInfo?.isDirectory()) return [];
if (!directoryInfo?.isDirectory()) return;
const cached = CAN_VALIDATE_DIRECTORY_CACHE_FROM_METADATA
? directoryListCache.get(directory)
@@ -774,9 +770,10 @@ async function readChildren(directory: string): Promise<ChildEntry[]> {
}
}
return (await Promise.all(rawEntries.map((entry) => resolveChild(directory, entry))))
.filter((entry): entry is ChildEntry => entry !== null)
.sort((left, right) => left.name.localeCompare(right.name));
for (const rawEntry of rawEntries) {
const entry = await resolveChild(directory, rawEntry);
if (entry) yield entry;
}
}
async function loadGitIgnoredPaths(root: string): Promise<Set<string>> {
+7
View File
@@ -1318,6 +1318,13 @@ the source row, so updates to one streaming item do not remount its renderer. Us
`useRevealedText(text, phase)` hook when a renderer should pace streaming text like Paseo's built-in
assistant rows.
Chat find (Ctrl or Cmd+F) finds and highlights text in replacement rows when the query also
matches the original message. The host searches the original prompt text or the assistant reply
with Markdown syntax removed, then the app counts matches in the text your renderer draws.
Text added only by your renderer is not independently indexed. Matches stay within a rendered
block, and find skips buttons, icons, and anything with `aria-hidden="true"`. Find only runs on
the web and desktop apps.
### Append a timeline row from the daemon
A server handler can add a plugin-owned row to canonical history: