mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-22 01:02:41 +00:00
Compare commits
8
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0464264c62 | ||
|
|
a7a21d91c2 | ||
|
|
b2da58b5bf | ||
|
|
e5d9307cf9 | ||
|
|
90c6de0580 | ||
|
|
9bd58f66b2 | ||
|
|
e2ad26d238 | ||
|
|
142cdac8d2 |
+1
-1
@@ -1 +1 @@
|
||||
sha256-1WmrHvE68Lzyil4On7xtsPMQTODXZ3Jb3/juUg3nHi4=
|
||||
sha256-O8sNh6oXFIddUljYiC6RbVZF5/7a7A7W9UWrOd0tIV4=
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -1246,6 +1246,49 @@ describe("runAcpProvider streamed chunks without messageId (#4699)", () => {
|
||||
messageId?: string;
|
||||
}
|
||||
|
||||
it("keeps reasoning separate from a reply sharing its native message ID", async () => {
|
||||
const harness = connectorHarness({
|
||||
promptUpdates: () => [
|
||||
textChunk("private reasoning", "agent_thought_chunk", "shared-id"),
|
||||
textChunk("public reply", "agent_message_chunk", "shared-id"),
|
||||
],
|
||||
});
|
||||
const registration = runAcpProvider({
|
||||
id: "chunk-acp",
|
||||
label: "Chunk ACP",
|
||||
connector: harness.connector,
|
||||
});
|
||||
const connection = await registration.connect({
|
||||
versions: [1],
|
||||
capabilities: ["prompt.message"],
|
||||
});
|
||||
const events: ProviderEvent[] = [];
|
||||
connection.onEvent((event) => events.push(event));
|
||||
try {
|
||||
await connection.send(openInput());
|
||||
await waitForEvent(events, (event) => event.type === "session.ready");
|
||||
await connection.send({
|
||||
type: "session.prompt",
|
||||
sessionId: "session-1",
|
||||
prompt: {
|
||||
clientMessageId: "same-id",
|
||||
delivery: "auto",
|
||||
input: { type: "message", content: [{ type: "text", text: "hello" }] },
|
||||
},
|
||||
});
|
||||
await waitForEvent(
|
||||
events,
|
||||
(event) => event.type === "session.turn" && event.state === "completed",
|
||||
);
|
||||
const reply = events.findLast(
|
||||
(event) => event.type === "timeline.item" && event.item.type === "assistant_message",
|
||||
);
|
||||
expect(reply).toMatchObject({ item: { text: "public reply" } });
|
||||
} finally {
|
||||
await connection.close();
|
||||
}
|
||||
});
|
||||
|
||||
it("continues one timeline item per turn instead of one per chunk", async () => {
|
||||
const chunksByPrompt: Record<string, Chunk[]> = {
|
||||
first: [
|
||||
|
||||
@@ -287,6 +287,45 @@ function expectNestedChildren(events: AgentStreamEvent[]) {
|
||||
}
|
||||
|
||||
describe("PluginAgentClientRegistry", () => {
|
||||
test("returns unavailable when steering a provider without prompt.steer capability", async () => {
|
||||
const harness = createProviderHarness();
|
||||
const registry = new PluginAgentClientRegistry(createTestLogger());
|
||||
registry.replace([harness.registration]);
|
||||
const session = await registry.clients()[harness.registration.id]!.createSession({
|
||||
provider: harness.registration.id,
|
||||
cwd: "/tmp",
|
||||
});
|
||||
try {
|
||||
await expect(
|
||||
session.steerActiveTurn!("follow up", { expectedTurnId: "turn-1" }),
|
||||
).resolves.toEqual({ status: "unavailable" });
|
||||
expect(harness.inputs.filter((input) => input.type === "session.prompt")).toHaveLength(0);
|
||||
} finally {
|
||||
await session.close();
|
||||
registry.replace([]);
|
||||
}
|
||||
});
|
||||
|
||||
test("resumes a native ACP handle after the same provider ID moves to a plugin", async () => {
|
||||
const harness = createProviderHarness();
|
||||
const registry = new PluginAgentClientRegistry(createTestLogger());
|
||||
registry.replace([harness.registration]);
|
||||
try {
|
||||
const session = await registry.clients()[harness.registration.id]!.resumeSession(
|
||||
{
|
||||
provider: harness.registration.id,
|
||||
sessionId: "native-session-id",
|
||||
nativeHandle: "native-session-id",
|
||||
metadata: { provider: "acp", cwd: "/tmp" },
|
||||
},
|
||||
{ provider: harness.registration.id, cwd: "/tmp" },
|
||||
);
|
||||
await session.close();
|
||||
} finally {
|
||||
registry.replace([]);
|
||||
}
|
||||
});
|
||||
|
||||
test("stores only agent options while the plugin receives merged defaults", async () => {
|
||||
const logger = createTestLogger();
|
||||
const harness = createProviderHarness();
|
||||
|
||||
@@ -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>> {
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user