From e1584aed94d2a3a6a0fdd7deca52276fefefff58 Mon Sep 17 00:00:00 2001 From: metyatech Date: Thu, 21 May 2026 18:58:16 +0900 Subject: [PATCH 1/3] fix(web): ignore task log catch-up frames --- apps/web/src/hooks/use-logs.test.ts | 35 +++++++++++++++++++++++++++++ apps/web/src/hooks/use-logs.ts | 4 ++++ 2 files changed, 39 insertions(+) diff --git a/apps/web/src/hooks/use-logs.test.ts b/apps/web/src/hooks/use-logs.test.ts index ed4750b2..377ff676 100644 --- a/apps/web/src/hooks/use-logs.test.ts +++ b/apps/web/src/hooks/use-logs.test.ts @@ -130,4 +130,39 @@ describe("useLogs", () => { const contents = result.current.logs.map((l) => l.content); expect(contents).toEqual(["historical-1", "duplicate", "live-only"]); }); + + it("ignores catchUp WebSocket frames while appending normal live frames", async () => { + let logHandler: ((event: any) => void) | undefined; + mockOn.mockImplementation((eventType: string, handler: (event: any) => void) => { + if (eventType === "task:log") { + logHandler = handler; + } + return () => {}; + }); + mockGetTaskLogs.mockResolvedValue({ + logs: [{ content: "historical", stream: "stdout", timestamp: "2025-06-01T00:00:00Z" }], + }); + + const { result } = renderHook(() => useLogs("task-1")); + + await waitFor(() => { + expect(result.current.logs).toHaveLength(1); + }); + + act(() => { + logHandler?.({ + content: "catch-up frame", + stream: "stdout", + timestamp: "2025-06-01T00:00:01Z", + catchUp: true, + }); + logHandler?.({ + content: "live frame", + stream: "stdout", + timestamp: "2025-06-01T00:00:02Z", + }); + }); + + expect(result.current.logs.map((l) => l.content)).toEqual(["historical", "live frame"]); + }); }); diff --git a/apps/web/src/hooks/use-logs.ts b/apps/web/src/hooks/use-logs.ts index 43b00071..c676d7df 100644 --- a/apps/web/src/hooks/use-logs.ts +++ b/apps/web/src/hooks/use-logs.ts @@ -36,6 +36,10 @@ export function useLogs(taskId: string) { clientRef.current = client; client.on("task:log", (event) => { + // The WebSocket replays recent logs with `catchUp: true` on connect. + // REST history is canonical, so ignore those replay frames. + if (event.catchUp) return; + const entry: LogEntry = { content: event.content, stream: event.stream, From 6c7db7868918885fd78cd29aa58f9fd58aa4f1f3 Mon Sep 17 00:00:00 2001 From: metyatech Date: Thu, 21 May 2026 18:58:52 +0900 Subject: [PATCH 2/3] fix(web): ignore workflow log catch-up frames --- .../src/hooks/use-workflow-run-logs.test.ts | 30 +++++++++++++++++++ apps/web/src/hooks/use-workflow-run-logs.ts | 4 +++ 2 files changed, 34 insertions(+) diff --git a/apps/web/src/hooks/use-workflow-run-logs.test.ts b/apps/web/src/hooks/use-workflow-run-logs.test.ts index 8574d7a7..404ce233 100644 --- a/apps/web/src/hooks/use-workflow-run-logs.test.ts +++ b/apps/web/src/hooks/use-workflow-run-logs.test.ts @@ -105,6 +105,36 @@ describe("useWorkflowRunLogs", () => { expect(result.current.logs[2].content).toBe("New live event"); }); + it("ignores catchUp WebSocket frames while appending normal live frames", async () => { + const { result } = renderHook(() => useWorkflowRunLogs("run-1", true)); + + await waitFor(() => { + expect(result.current.logs).toHaveLength(2); + }); + + act(() => { + wsHandler?.({ + content: "catch-up frame", + stream: "stdout", + timestamp: "2025-06-01T00:00:02Z", + logType: "text", + catchUp: true, + }); + wsHandler?.({ + content: "live frame", + stream: "stdout", + timestamp: "2025-06-01T00:00:03Z", + logType: "text", + }); + }); + + expect(result.current.logs.map((l) => l.content)).toEqual([ + "Starting agent", + "Running tool", + "live frame", + ]); + }); + it("deduplicates identical events", async () => { const { result } = renderHook(() => useWorkflowRunLogs("run-1", true)); diff --git a/apps/web/src/hooks/use-workflow-run-logs.ts b/apps/web/src/hooks/use-workflow-run-logs.ts index fd38cffb..2f5faf4d 100644 --- a/apps/web/src/hooks/use-workflow-run-logs.ts +++ b/apps/web/src/hooks/use-workflow-run-logs.ts @@ -29,6 +29,10 @@ export function useWorkflowRunLogs(runId: string, isActive: boolean) { clientRef.current = client; client.on("workflow_run:log", (event) => { + // The WebSocket replays recent logs with `catchUp: true` on connect. + // REST history is canonical, so ignore those replay frames. + if (event.catchUp) return; + const entry: LogEntry = { content: event.content, stream: event.stream, From 71e94a164ebe1ee8832c19c8d89cad3dae34cc96 Mon Sep 17 00:00:00 2001 From: metyatech Date: Thu, 21 May 2026 18:59:20 +0900 Subject: [PATCH 3/3] fix(web): ignore review log catch-up frames --- apps/web/src/hooks/use-pr-review-logs.test.ts | 120 ++++++++++++++++++ apps/web/src/hooks/use-pr-review-logs.ts | 4 + 2 files changed, 124 insertions(+) create mode 100644 apps/web/src/hooks/use-pr-review-logs.test.ts diff --git a/apps/web/src/hooks/use-pr-review-logs.test.ts b/apps/web/src/hooks/use-pr-review-logs.test.ts new file mode 100644 index 00000000..f2a77dc6 --- /dev/null +++ b/apps/web/src/hooks/use-pr-review-logs.test.ts @@ -0,0 +1,120 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { renderHook, waitFor, act } from "@testing-library/react"; + +const mockListPrReviewLogs = vi.fn(); +vi.mock("@/lib/api-client", () => ({ + api: { + listPrReviewLogs: (...args: any[]) => mockListPrReviewLogs(...args), + }, +})); + +const mockConnect = vi.fn(); +const mockDisconnect = vi.fn(); +let wsHandler: ((event: any) => void) | null = null; +vi.mock("@/lib/ws-client", () => ({ + createPrReviewLogClient: () => ({ + on: (_event: string, handler: (event: any) => void) => { + wsHandler = handler; + }, + connect: mockConnect, + disconnect: mockDisconnect, + }), +})); + +vi.mock("@/lib/ws-auth", () => ({ + getWsTokenProvider: () => undefined, +})); + +import { usePrReviewLogs } from "./use-pr-review-logs"; + +describe("usePrReviewLogs", () => { + beforeEach(() => { + vi.clearAllMocks(); + wsHandler = null; + mockListPrReviewLogs.mockResolvedValue({ + logs: [ + { + content: "Historical review log", + stream: "stdout", + timestamp: "2025-06-01T00:00:00Z", + logType: "text", + metadata: null, + }, + ], + }); + }); + + it("fetches historical logs on mount", async () => { + const { result } = renderHook(() => usePrReviewLogs("review-1")); + + await waitFor(() => { + expect(result.current.logs).toHaveLength(1); + }); + + expect(mockListPrReviewLogs).toHaveBeenCalledWith("review-1"); + expect(result.current.logs[0].content).toBe("Historical review log"); + }); + + it("appends live WebSocket frames after historical logs", async () => { + const { result } = renderHook(() => usePrReviewLogs("review-1")); + + await waitFor(() => { + expect(result.current.logs).toHaveLength(1); + }); + + act(() => { + wsHandler?.({ + content: "Live review log", + stream: "stdout", + timestamp: "2025-06-01T00:00:01Z", + logType: "text", + }); + }); + + expect(result.current.logs.map((l) => l.content)).toEqual([ + "Historical review log", + "Live review log", + ]); + }); + + it("ignores catchUp WebSocket frames while appending normal live frames", async () => { + const { result } = renderHook(() => usePrReviewLogs("review-1")); + + await waitFor(() => { + expect(result.current.logs).toHaveLength(1); + }); + + act(() => { + wsHandler?.({ + content: "Catch-up review log", + stream: "stdout", + timestamp: "2025-06-01T00:00:01Z", + logType: "text", + catchUp: true, + }); + wsHandler?.({ + content: "Live review log", + stream: "stdout", + timestamp: "2025-06-01T00:00:02Z", + logType: "text", + }); + }); + + expect(result.current.logs.map((l) => l.content)).toEqual([ + "Historical review log", + "Live review log", + ]); + }); + + it("disconnects WebSocket on unmount", async () => { + const { unmount } = renderHook(() => usePrReviewLogs("review-1")); + + await waitFor(() => { + expect(mockConnect).toHaveBeenCalled(); + }); + + unmount(); + + expect(mockDisconnect).toHaveBeenCalled(); + }); +}); diff --git a/apps/web/src/hooks/use-pr-review-logs.ts b/apps/web/src/hooks/use-pr-review-logs.ts index 3f8af417..ad2a3383 100644 --- a/apps/web/src/hooks/use-pr-review-logs.ts +++ b/apps/web/src/hooks/use-pr-review-logs.ts @@ -34,6 +34,10 @@ export function usePrReviewLogs(prReviewId: string) { clientRef.current = client; client.on("pr_review_run:log", (event) => { + // The WebSocket replays recent logs with `catchUp: true` on connect. + // REST history is canonical, so ignore those replay frames. + if (event.catchUp) return; + const entry: LogEntry = { content: event.content, stream: event.stream,