From 971a762f8007572f57c9852285d254e0405081e4 Mon Sep 17 00:00:00 2001 From: Dotta Date: Sun, 25 Oct 2026 06:44:41 -0500 Subject: [PATCH] fix(tasks): recover compact history after cancellation and expansion Co-Authored-By: Paperclip --- tests/e2e/chat-adapters-ui-messaging.spec.ts | 12 +++---- .../transcript/useLiveRunTranscripts.test.tsx | 35 ++++++++++++++++++ .../transcript/useLiveRunTranscripts.ts | 15 ++++++++ ui/src/hooks/useIssueHistoryWindow.test.tsx | 36 +++++++++++++++++++ ui/src/hooks/useIssueHistoryWindow.ts | 15 +++++++- 5 files changed, 106 insertions(+), 7 deletions(-) diff --git a/tests/e2e/chat-adapters-ui-messaging.spec.ts b/tests/e2e/chat-adapters-ui-messaging.spec.ts index 286f8f24f83..17208596a08 100644 --- a/tests/e2e/chat-adapters-ui-messaging.spec.ts +++ b/tests/e2e/chat-adapters-ui-messaging.spec.ts @@ -1042,7 +1042,7 @@ test.describe("Board send delivery refresh", () => { ); await page.route( new RegExp( - `/api/issues/(${issue.id}|${issue.identifier})/comments(?:\\?|$)`, + `/api/issues/(${issue.id}|${issue.identifier})/(?:recent-comments|comments)(?:\\?|$)`, ), async (route) => { if ( @@ -1050,9 +1050,7 @@ test.describe("Board send delivery refresh", () => { route.request().url().includes(`/issues/${issue.identifier}/`) ) canonicalCommentReadsAfterSend += 1; - await fulfill( - route, - sends + const comments = sends ? [ { id: publicationId, @@ -1066,8 +1064,10 @@ test.describe("Board send delivery refresh", () => { updatedAt: new Date().toISOString(), }, ] - : [], - ); + : []; + await fulfill(route, route.request().url().includes("/recent-comments") + ? { comments, latestCommentId: comments.at(-1)?.id ?? null, hasMore: false } + : comments); }, ); await page.route( diff --git a/ui/src/components/transcript/useLiveRunTranscripts.test.tsx b/ui/src/components/transcript/useLiveRunTranscripts.test.tsx index b54d92b3728..618dfd983fe 100644 --- a/ui/src/components/transcript/useLiveRunTranscripts.test.tsx +++ b/ui/src/components/transcript/useLiveRunTranscripts.test.tsx @@ -130,6 +130,41 @@ describe("useLiveRunTranscripts", () => { } }); + it("expands a compact log tail without hiding or duplicating the latest output", async () => { + const row = (seq: number, chunk: string) => JSON.stringify({ seq, chunk, ts: `2026-10-24T00:00:0${seq}Z`, stream: "stdout" }); + const result = (content: string) => ({ runId: "run-1", store: "memory", logRef: "log-1", content, nextOffset: 100_000 }); + let resolveExpanded!: (value: ReturnType) => void; + logMock.mockResolvedValueOnce(result(`${row(2, "Latest answer")}\n`)); + logMock.mockImplementationOnce(() => new Promise(resolve => { resolveExpanded = resolve; })); + function Harness({ limit, bytes = 100_000 }: { limit: number; bytes?: number }) { + useLiveRunTranscripts({ + runs: [{ id: "run-1", status: "succeeded", adapterType: "codex_local", logBytes: bytes }], + enableRealtimeUpdates: false, + logReadLimitBytes: limit, + }); + return null; + } + const root = createRoot(document.createElement("div")); + const chunks = () => buildTranscriptMock.mock.calls.at(-1)?.[0]; + try { + await act(async () => root.render()); + expect(logMock).toHaveBeenLastCalledWith("run-1", 68_000, 32_000, expect.anything()); + expect(chunks()).toEqual([expect.objectContaining({ chunk: "Latest answer" })]); + await act(async () => root.render()); + expect(logMock).toHaveBeenLastCalledWith("run-1", 0, 256_000, expect.anything()); + expect(chunks()).toEqual([expect.objectContaining({ chunk: "Latest answer" })]); + await act(async () => resolveExpanded(result(`${row(1, "Earlier activity")}\n${row(2, "Latest answer")}\n`))); + expect(chunks()).toEqual([ + expect.objectContaining({ chunk: "Earlier activity" }), + expect.objectContaining({ chunk: "Latest answer" }), + ]); + await act(async () => root.render()); + expect(logMock).toHaveBeenLastCalledWith("run-1", 100_000, 256_000, expect.anything()); + } finally { + await act(async () => root.unmount()); + } + }); + it("waits for a connecting socket to open before closing it during cleanup", async () => { function Harness() { useLiveRunTranscripts({ diff --git a/ui/src/components/transcript/useLiveRunTranscripts.ts b/ui/src/components/transcript/useLiveRunTranscripts.ts index 73ffaa332d8..002c2867d3c 100644 --- a/ui/src/components/transcript/useLiveRunTranscripts.ts +++ b/ui/src/components/transcript/useLiveRunTranscripts.ts @@ -143,6 +143,7 @@ export function useLiveRunTranscripts({ const trimmedSeqFloorByRunRef = useRef(new Map()); const pendingLogRowsByRunRef = useRef(new Map()); const logOffsetByRunRef = useRef(new Map()); + const logReadLimitByRunRef = useRef(new Map()); const missingTerminalLogRunIdsRef = useRef(new Set()); // PAP-462 B3: buffered runs that dropped out of the `runs` list, mapped to the // wall-clock deadline (ms) after which their buffer may be pruned. A run still @@ -269,6 +270,7 @@ export function useLiveRunTranscripts({ for (const runId of logOffsetByRunRef.current.keys()) { if (!retainedRunIds.has(runId)) { logOffsetByRunRef.current.delete(runId); + logReadLimitByRunRef.current.delete(runId); } } for (const runId of trimmedSeqFloorByRunRef.current.keys()) { @@ -312,6 +314,19 @@ export function useLiveRunTranscripts({ return; } inFlightRunIds.add(run.id); + const previousReadLimit = logReadLimitByRunRef.current.get(run.id); + if (previousReadLimit !== undefined && logReadLimitBytes > previousReadLimit) { + // Expanding a compact tail must reread the earlier range. Retain the + // visible chunks; merging by sequence deduplicates the overlapping tail. + const expandedOffset = resolveInitialLogOffset(run, logReadLimitBytes); + const previousOffset = logOffsetByRunRef.current.get(run.id); + if (previousOffset !== undefined && expandedOffset < previousOffset) { + logOffsetByRunRef.current.set(run.id, expandedOffset); + pendingLogRowsByRunRef.current.delete(run.id); + pendingLogRowsByRunRef.current.delete(`${run.id}:records`); + } + } + logReadLimitByRunRef.current.set(run.id, logReadLimitBytes); const offset = logOffsetByRunRef.current.get(run.id) ?? resolveInitialLogOffset(run, logReadLimitBytes); try { const result = await readTranscriptRequest( diff --git a/ui/src/hooks/useIssueHistoryWindow.test.tsx b/ui/src/hooks/useIssueHistoryWindow.test.tsx index 8d59895feac..e1f519a1d7c 100644 --- a/ui/src/hooks/useIssueHistoryWindow.test.tsx +++ b/ui/src/hooks/useIssueHistoryWindow.test.tsx @@ -34,3 +34,39 @@ it.each([true, false])("retains a burst larger than the preview window (initiall expect(new Set(current!.recent.data?.comments.map(c=>c.id)).size).toBe(empty ? 60 : 61); } finally { await act(async () => root.unmount()); client.clear(); } }); + +it.each([true, false])("catches up after canceled input and a reply burst (earlier anchor survives: %s)", async (earlierAnchorSurvives) => { + const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + const root = createRoot(document.createElement("div")); + const comment = (n: number) => ({ id: `comment-${n}`, createdAt: new Date(n * 1000), body: `Message ${n}` } as IssueComment); + let current: ReturnType; + function Probe() { + current = useIssueHistoryWindow("one", false); + return {current.recent.data?.comments.length}; + } + api.recentComments.mockResolvedValue({ comments: [comment(0), comment(1)], latestCommentId: "comment-1", hasMore: true }); + api.listComments.mockReset(); + const wait = () => act(async () => { await new Promise(resolve => setTimeout(resolve, 10)); }); + try { + await act(async () => root.render()); + for (let i = 0; i < 20 && !current!.recent.isSuccess; i++) await wait(); + expect(current!.recent.isSuccess).toBe(true); + api.recentComments.mockResolvedValue({ comments: [58, 59, 60, 61].map(comment), latestCommentId: "comment-61", hasMore: true }); + api.listComments.mockImplementation(async (_issue, options) => { + if (options.after === "comment-1") return []; // Canceled queued input. + if (options.after === "comment-0" && !earlierAnchorSurvives) return []; + const start = options.after === "comment-51" ? 52 : 2; + return Array.from({ length: Math.min(50, 62 - start) }, (_, i) => comment(start + i)); + }); + await act(async () => { await client.invalidateQueries({ queryKey: queryKeys.issues.comments("one") }); }); + for (let i = 0; i < 20 && current!.recent.data?.latestCommentId !== "comment-61"; i++) await wait(); + expect(current!.recent.error).toBeNull(); + expect(current!.recent.data?.comments.filter(c => Number(c.id.split("-")[1]) >= 2).map(c => c.id)) + .toEqual(Array.from({ length: 60 }, (_, i) => `comment-${i + 2}`)); + expect(current!.recent.data?.latestCommentId).toBe("comment-61"); + expect(api.listComments).toHaveBeenCalledTimes(earlierAnchorSurvives ? 3 : 4); + } finally { + await act(async () => root.unmount()); + client.clear(); + } +}); diff --git a/ui/src/hooks/useIssueHistoryWindow.ts b/ui/src/hooks/useIssueHistoryWindow.ts index c5f204b972b..856d4139972 100644 --- a/ui/src/hooks/useIssueHistoryWindow.ts +++ b/ui/src/hooks/useIssueHistoryWindow.ts @@ -46,6 +46,13 @@ export function useIssueHistoryWindow( // saved cursor instead of dropping messages between two invalidations. let cursor = previous.latestCommentId; const incoming: IssueComment[] = []; + // Canceling queued input deletes its comment, including a saved cursor. + // Retry from an earlier observed row, then from the start if that row + // was also deleted. Never advance to the preview past unread replies. + const recoveryAnchors = [ + previous.comments.findLast((comment) => comment.id !== cursor)?.id ?? null, + null, + ]; while (cursor !== snapshot.latestCommentId) { const page = await issuesApi.listComments(issueRef!, { order: "asc", @@ -53,7 +60,13 @@ export function useIssueHistoryWindow( limit: 50, }, { signal }); if (signal.aborted) throw new DOMException("Aborted", "AbortError"); - if (!page.length) break; + if (!page.length) { + if (cursor && recoveryAnchors.length) { + cursor = recoveryAnchors.shift() ?? null; + continue; + } + break; + } incoming.push(...page); const next = page.at(-1)!.id; if (next === cursor) break;