mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-09 16:02:36 +01:00
## Thinking Path > - Paperclip should show live agent output without repeating presentation work for every view. > - Execution, acknowledgements and durable checkpoints must remain independent of browser interest. > - The server already owns authorization and redaction at delivery. > - A shared browser connection can declare which visible runs need full transcripts. > - Catch-up must handle subscription races, disconnects, terminal output and slow consumers. > - This PR delivers the backend and UI contract together without a database migration. ## Linked Issues or Issue Description **What existing behavior does this improve?** Visible transcript consumers receive recoverable output through one shared browser connection. **Current behavior** Independent views can create duplicate connections, presentation work and recurring history reads. A subscription race or filtered native history can lose the saved tail. **Proposed behavior** Negotiate run interests, subscribe before catch-up and retain independent canonical recovery cursors. Keep lightweight lifecycle and attention events available without full transcript delivery. PR 4 of #17097 → #17439 → #17455 → #17456. The runtime feature is independent of hot-upgrade recovery; the stack order is for review convenience. ## What Changed - Add additive WebSocket capability negotiation, replacement interests, revision acknowledgement and resynchronization notices. - Reference-count visible consumer interests on one company-scoped browser connection. Preserve older-client/server behavior. - Reuse canonical structural/current-user/registered-secret redaction for plugin run notifications through the same bounded delivery queue. Keep producer and uninterested streams free of recipient lookups. - Refresh application queries after the matching recovery acknowledgement, including one follow-up after an older in-flight read. Ordinary interest acknowledgements add no refresh. - Keep authorization and redaction at delivery. Browser interest cannot change harness execution, PRP ACKs, checkpoints, accounting or canonical logs. - Deduplicate by sequence and log identity. Catch up terminal output and saved tails without recurring reads during healthy negotiated streaming. - Bound delivery to 1,000 items and 1 MiB. Overflow requires resynchronization; persistent congestion closes the connection. - Preserve compact-history expansion from current master across paginated catch-up. Snapshot the original read window before React can defer its update. ## Verification On current source `f6710f0b3c`, Linux [job 122604880503](https://github.com/paperclipai/paperclip/actions/runs/40624101891/job/122604880503) executes all five run-read permission cases successfully (2.574 seconds), including grant and revocation on the same connection. Its shard passes all 2,430 cases. Current [server-shard job 122604880416](https://github.com/paperclipai/paperclip/actions/runs/40624101891/job/122604880416) has 1,513 passes, one failure and three skips: an unchanged nested-clone expiration/restore test exceeds its 15-second deadline. The preceding `5efc6c48bf` source has identical non-document bytes; [its corresponding job 122601359272](https://github.com/paperclipai/paperclip/actions/runs/40623017917/job/122601359272) passes all 21 nested-clone cases, including that case in 9.505 seconds. The exact timing cause is unproven. The single diagnostic [retry job 122609332597](https://github.com/paperclipai/paperclip/actions/runs/40624101891/job/122609332597) passes all 21 nested-clone cases (61.316 seconds), including the formerly timed-out case in 7.003 seconds, and all 1,514 shard cases (three skips). The failure remains recorded; no timeout or assertion was changed. After the server squash merge and qualification retarget, all 50 focused server cases and all 91 focused UI cases pass again, with both typechecks and token gates. Runner vendor preparation rebuilds current master inputs before those tests. This source needs fresh CI and reviews; prior CI and browser receipts do not qualify it by inference. Current candidate `f6710f0b3c` now targets master `6987c2f20b`, after merged #17097, #17439 and #17455. Its diff contains only transcript delivery and its documentation. It adds no database migration. Retargeting changes only the evidence document; all production and test code is byte-identical to `5efc6c48bf`. On the eighth integration, all 50 focused server delivery cases and all 91 focused UI cases pass, with both typechecks and token gates. The following 35-case receipt belongs to the earlier delivery correction. Four low-queue authorization/decoration stall variants and the late-rejection case fail before correction. They now verify the five-second read deadline, old and negotiated clients, revisions that cannot reset the deadline, unaffected sibling connections, rejected late results, and timer cleanup. The first combined toast run has four loopback EPERM failures and 31 passes; its permitted repeat passes all 35. Earlier 28-case evidence excluded the toast file. The permission correction requires the current HTTP company-scope transcript permission, plus task access, for both native events and legacy logs. Prior Linux source `49a8783e9b`, job 122549913643, executes all five run-read cases, including grant/revocation on the same connection, successfully. That source placed this file in server shard 11; later shard assignments can differ and must be checked against their actual inventories. The older `405bedd189` receipt retains a separate scheduled-retry teardown race and inherited fixture collision; neither is waived. The new head must pass again in Linux CI. The seven extracted stream-recovery tests preserve their assertions. All 39 related legacy/native cases, UI typecheck and token gates pass after restoring the initially missed fixture helper. Earlier full workspace build/typecheck and Chrome journeys at `445a76563c` cover two views, company switches, reconnect, terminal catch-up, reload and zero recurring healthy transcript reads. Those browser receipts are historical source evidence and do not qualify this new head by inference. The [qualification record](https://github.com/paperclipai/paperclip/blob/f6710f0b3c84c6fdb2021df67206261b446dfc97/doc/plans/2026-10-22-hot-upgrade-qualification.md) retains exact earlier UI/history, source-boundary and cleanup failures with their fixes and limits. The workload-cost test preserves bounds and authorization callback counts for zero interests, one viewer, multiple browsers and a slow viewer. Its socket congestion is simulated. It measures neither SQL nor network costs. Current permission checks and the final hosted active-agent/viewer matrix remain unmeasured; no production savings are claimed. Fresh exact-head CI, legacy status, completed Greptile 5/5, resolved feedback and ordinary merge eligibility remain required. The earlier recipient corrections pass all 47 registry/plugin/delivery/WebSocket/toast cases and all 174 UI provider/recovery/interest cases, both typechecks and token gates on source `b202e59a25`. A real bus/host-service test registers a synthetic run secret through the production registry and reproduces its raw notification before the fix; native/legacy/final notifications now redact it, disposal prevents late worker notification. A later P1 review reproduces lost final notifications on settings-read and secret-resolution failure. Both cases fail before correction. Any delivery failure or subscription timeout now sends one content-free stream error and removes the plugin subscription. It never changes the agent execution or run status. Later events start no new redaction reads. The final integrated six-file server lane passes all 50 cases, with server typecheck and token gates. Existing browser delivery behavior is unchanged. Storage and secret-provider seams are scripted. Two real React Query/provider regressions first miss a run during resync; both now refresh after the valid replacement ACK, including pending-read and stale-scope cases. The prerequisite qualification batch passes all 118 cases and E2E typecheck after master integration. Fresh CI, security scan and reviews remain pending on this head. The contribution-rule review correction removes 104 explanatory comments introduced by this slice and keeps the ordering, deadline and plugin-delivery explanations in the delivery document. Existing comments and functional test/lint directives remain. All edited source files have identical parsed non-comment tokens before and after the change. All 50 server delivery cases and all 91 cases in six affected UI suites pass, along with token gates. The new head requires fresh CI, security and review. ## Risks A slow or disconnected viewer must resynchronize from authorized canonical history. It may see a display gap until catch-up completes. Company changes and revoked access must remove delivery interests. This slice adds no database migration and cannot change execution or recovery authority. Full transcript delivery evaluates current company-scope permission per delivery. Active plugin recipients also perform canonical redaction reads; viewer-load database costs on this final source remain unmeasured. ## Model Used OpenAI GPT-6 through Codex, with reasoning, repository tools and code execution. The runtime does not expose a more specific model ID or context-window size. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [ ] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] All Paperclip CI gates are green - [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Paperclip <noreply@paperclip.ing>
804 lines
30 KiB
TypeScript
804 lines
30 KiB
TypeScript
// @vitest-environment jsdom
|
|
|
|
import { act, type ReactNode } from "react";
|
|
import { createRoot as createReactRoot } from "react-dom/client";
|
|
import type { LiveEvent } from "@paperclipai/shared";
|
|
import { __liveUpdatesTestUtils, type CompanyLiveEventHandler } from "../../context/LiveUpdatesProvider";
|
|
import { LiveTranscriptInterests } from "../../lib/live-transcript-interests";
|
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
import { ApiError } from "../../api/client";
|
|
import { useLiveRunTranscripts } from "./useLiveRunTranscripts";
|
|
import { TRANSCRIPT_REQUEST_TIMEOUT_MS } from "./read-transcript-request";
|
|
|
|
const { useQueryMock, logMock, buildTranscriptMock } = vi.hoisted(() => ({
|
|
useQueryMock: vi.fn(() => ({ data: { censorUsernameInLogs: false } })),
|
|
logMock: vi.fn(async (_runId: string, _offset: number, _limit: number, _options: { signal: AbortSignal }) =>
|
|
({ runId: "run-1", store: "memory", logRef: "log-1", content: "", nextOffset: 0 })),
|
|
buildTranscriptMock: vi.fn((chunks: unknown[]) => chunks),
|
|
}));
|
|
|
|
vi.mock("@tanstack/react-query", () => ({
|
|
useQuery: useQueryMock,
|
|
}));
|
|
|
|
vi.mock("../../api/instanceSettings", () => ({
|
|
instanceSettingsApi: {
|
|
getGeneral: vi.fn(),
|
|
},
|
|
}));
|
|
|
|
vi.mock("../../api/heartbeats", () => ({
|
|
heartbeatsApi: {
|
|
log: logMock,
|
|
},
|
|
}));
|
|
|
|
vi.mock("../../adapters", () => ({
|
|
buildTranscript: buildTranscriptMock,
|
|
getUIAdapter: () => null,
|
|
onAdapterChange: () => () => {},
|
|
}));
|
|
|
|
const { LiveEventSubscriptionContext } = __liveUpdatesTestUtils;
|
|
let listeners: Set<CompanyLiveEventHandler>;
|
|
let interests: LiveTranscriptInterests;
|
|
let sent: Array<{ type: string; revision?: number; runIds?: string[] }>;
|
|
let socketConstructed: ReturnType<typeof vi.fn<() => void>>;
|
|
function createRoot(container: HTMLDivElement) {
|
|
const root = createReactRoot(container);
|
|
const subscription = {
|
|
companyId: "company-1", transcripts: interests,
|
|
subscribe(handler: CompanyLiveEventHandler) {
|
|
listeners.add(handler);
|
|
return () => { listeners.delete(handler); };
|
|
},
|
|
};
|
|
return {
|
|
unmount: () => root.unmount(),
|
|
render: (children: ReactNode) => root.render(
|
|
<LiveEventSubscriptionContext.Provider value={subscription}>{children}</LiveEventSubscriptionContext.Provider>,
|
|
),
|
|
};
|
|
}
|
|
function sendEvent(event: Omit<LiveEvent, "id">) {
|
|
for (const listener of listeners) listener({ ...event, id: 1 });
|
|
}
|
|
function negotiate() {
|
|
interests.open({ send: data => sent.push(JSON.parse(data)) });
|
|
interests.receive({ type: "live.capabilities", version: 1, capabilities: ["run-interests"] });
|
|
acknowledge();
|
|
}
|
|
function acknowledge() {
|
|
interests.receive({ type: "live.subscribed", version: 1, revision: sent.at(-1)?.revision });
|
|
}
|
|
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;
|
|
|
|
describe("useLiveRunTranscripts", () => {
|
|
const OriginalWebSocket = globalThis.WebSocket;
|
|
|
|
beforeEach(() => {
|
|
vi.spyOn(document, "visibilityState", "get").mockReturnValue("visible");
|
|
listeners = new Set(); interests = new LiveTranscriptInterests(); sent = [];
|
|
socketConstructed = vi.fn();
|
|
useQueryMock.mockClear();
|
|
logMock.mockReset();
|
|
logMock.mockImplementation(async (_id, offset) => ({ runId: "run-1", store: "memory", logRef: "log-1", content: "", nextOffset: offset }));
|
|
buildTranscriptMock.mockClear();
|
|
globalThis.WebSocket = class { constructor() { socketConstructed(); } } as unknown as typeof WebSocket;
|
|
});
|
|
|
|
afterEach(() => {
|
|
globalThis.WebSocket = OriginalWebSocket;
|
|
vi.restoreAllMocks();
|
|
vi.useRealTimers();
|
|
});
|
|
|
|
it("pauses hidden-tab reads and resumes at the retained log offset", async () => {
|
|
vi.useFakeTimers();
|
|
const visibility = vi.spyOn(document, "visibilityState", "get").mockReturnValue("hidden");
|
|
logMock.mockResolvedValue({ runId: "run-1", store: "memory", logRef: "log-1", content: "", nextOffset: 42 });
|
|
const runs = [{ id: "run-1", status: "running", adapterType: "codex_local" }];
|
|
function Harness() {
|
|
useLiveRunTranscripts({ companyId: "company-1", runs, enableRealtimeUpdates: false });
|
|
return null;
|
|
}
|
|
const container = document.createElement("div");
|
|
const root = createRoot(container);
|
|
try {
|
|
await act(async () => root.render(<Harness />));
|
|
await act(async () => vi.advanceTimersByTimeAsync(10_000));
|
|
expect(logMock).not.toHaveBeenCalled();
|
|
expect(socketConstructed).not.toHaveBeenCalled();
|
|
await act(async () => {
|
|
visibility.mockReturnValue("visible");
|
|
document.dispatchEvent(new Event("visibilitychange"));
|
|
});
|
|
expect(logMock).toHaveBeenCalledTimes(1);
|
|
await act(async () => {
|
|
visibility.mockReturnValue("hidden");
|
|
document.dispatchEvent(new Event("visibilitychange"));
|
|
});
|
|
await act(async () => vi.advanceTimersByTimeAsync(10_000));
|
|
expect(logMock).toHaveBeenCalledTimes(1);
|
|
await act(async () => {
|
|
visibility.mockReturnValue("visible");
|
|
document.dispatchEvent(new Event("visibilitychange"));
|
|
});
|
|
expect(logMock).toHaveBeenLastCalledWith("run-1", 42, 256_000, expect.anything());
|
|
} finally {
|
|
await act(async () => root.unmount());
|
|
}
|
|
});
|
|
|
|
it("expands a compact log tail without hiding or duplicating the latest output", async () => {
|
|
let truncatedRunIds: ReadonlySet<string> = new Set();
|
|
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<typeof result>) => void;
|
|
logMock.mockResolvedValueOnce(result(`${row(2, "Latest answer")}\n`));
|
|
logMock.mockResolvedValueOnce(result(""));
|
|
logMock.mockImplementationOnce(() => new Promise(resolve => { resolveExpanded = resolve; }));
|
|
function Harness({ limit, bytes = 100_000 }: { limit: number; bytes?: number }) {
|
|
({ truncatedRunIds } = 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(<Harness limit={32_000} />));
|
|
expect(logMock).toHaveBeenNthCalledWith(1, "run-1", 68_000, 32_000, expect.objectContaining({ alignStartToRecord: true }));
|
|
expect(truncatedRunIds.has("run-1")).toBe(true);
|
|
expect(chunks()).toEqual([expect.objectContaining({ chunk: "Latest answer" })]);
|
|
await act(async () => root.render(<Harness limit={256_000} />));
|
|
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(truncatedRunIds.has("run-1")).toBe(false);
|
|
expect(chunks()).toEqual([
|
|
expect.objectContaining({ chunk: "Earlier activity" }),
|
|
expect.objectContaining({ chunk: "Latest answer" }),
|
|
]);
|
|
await act(async () => root.render(<Harness limit={256_000} bytes={120_000} />));
|
|
expect(logMock).toHaveBeenLastCalledWith("run-1", 100_000, 256_000, expect.anything());
|
|
} finally {
|
|
await act(async () => root.unmount());
|
|
}
|
|
});
|
|
|
|
it("does not report omitted output when a run grows beyond the window after reading from the start", async () => {
|
|
let truncatedRunIds: ReadonlySet<string> = new Set();
|
|
const row = (seq: number) => `${JSON.stringify({ seq, chunk: `Output ${seq}`, stream: "stdout", ts: "2026-10-24T00:00:00Z" })}\n`;
|
|
const response = { runId: "run-1", store: "memory", logRef: "log-1" };
|
|
logMock.mockResolvedValueOnce({ ...response, content: row(1), nextOffset: 100 });
|
|
logMock.mockResolvedValueOnce({ ...response, content: "", nextOffset: 100 });
|
|
logMock.mockResolvedValueOnce({ ...response, content: row(2), nextOffset: 100_000 });
|
|
logMock.mockResolvedValueOnce({ ...response, content: "", nextOffset: 100_000 });
|
|
function Harness({ bytes }: { bytes: number }) {
|
|
({ truncatedRunIds } = useLiveRunTranscripts({
|
|
runs: [{ id: "run-1", status: "succeeded", adapterType: "codex_local", logBytes: bytes }],
|
|
enableRealtimeUpdates: false, logReadLimitBytes: 32_000,
|
|
}));
|
|
return null;
|
|
}
|
|
const root = createRoot(document.createElement("div"));
|
|
try {
|
|
await act(async () => root.render(<Harness bytes={100} />));
|
|
expect(logMock).toHaveBeenNthCalledWith(1, "run-1", 0, 32_000, expect.anything());
|
|
expect(truncatedRunIds.has("run-1")).toBe(false);
|
|
await act(async () => root.render(<Harness bytes={100_000} />));
|
|
expect(logMock).toHaveBeenNthCalledWith(3, "run-1", 100, 32_000, expect.anything());
|
|
expect(truncatedRunIds.has("run-1")).toBe(false);
|
|
} finally { await act(async () => root.unmount()); }
|
|
});
|
|
|
|
it("keeps record alignment pending when an expanded log read needs retry", 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: 500_000 });
|
|
const earlier = row(1, "Earlier activity");
|
|
const recent = row(2, "Latest answer");
|
|
logMock.mockResolvedValueOnce(result(`${recent}\n`));
|
|
logMock.mockResolvedValueOnce(result(""));
|
|
logMock.mockRejectedValueOnce(new Error("Temporary storage failure"));
|
|
logMock.mockImplementationOnce(async (...args: unknown[]) => {
|
|
const options = args[3] as { alignStartToRecord?: boolean };
|
|
return result(`${options.alignStartToRecord ? earlier : earlier.slice(15)}\n${recent}\n`);
|
|
});
|
|
let latest!: ReturnType<typeof useLiveRunTranscripts>;
|
|
function Harness({ limit, bytes = 500_000 }: { limit: number; bytes?: number }) {
|
|
latest = 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(<Harness limit={32_000} />));
|
|
await act(async () => root.render(<Harness limit={256_000} />));
|
|
expect(latest.errorsByRun.get("run-1")?.message).toBe("Temporary storage failure");
|
|
expect(chunks()).toEqual([expect.objectContaining({ chunk: "Latest answer" })]);
|
|
await act(async () => latest.retry());
|
|
expect(chunks()).toEqual([
|
|
expect.objectContaining({ chunk: "Earlier activity" }),
|
|
expect.objectContaining({ chunk: "Latest answer" }),
|
|
]);
|
|
expect(logMock).toHaveBeenCalledWith("run-1", 244_000, 256_000, expect.objectContaining({ alignStartToRecord: true }));
|
|
expect(latest.errorsByRun.size).toBe(0);
|
|
await act(async () => root.render(<Harness limit={256_000} bytes={520_000} />));
|
|
expect(logMock).toHaveBeenLastCalledWith("run-1", 500_000, 256_000, expect.not.objectContaining({ alignStartToRecord: true }));
|
|
} finally {
|
|
await act(async () => root.unmount());
|
|
}
|
|
});
|
|
|
|
it("registers interests on the shared connection without creating a socket", async () => {
|
|
function Harness() {
|
|
useLiveRunTranscripts({ companyId: "company-1", runs: [{ id: "run-1", status: "running", adapterType: "codex_local" }] });
|
|
return null;
|
|
}
|
|
const root = createRoot(document.createElement("div"));
|
|
await act(async () => root.render(<Harness />));
|
|
await act(async () => negotiate());
|
|
expect(sent.at(-1)?.runIds).toEqual(["run-1"]);
|
|
expect(socketConstructed).not.toHaveBeenCalled();
|
|
await act(async () => root.unmount());
|
|
expect(sent.at(-1)?.runIds).toEqual([]);
|
|
expect(listeners.size).toBe(0);
|
|
});
|
|
|
|
it("treats stored run output as available before transcript chunks finish loading", async () => {
|
|
let latestHasOutput = false;
|
|
|
|
function Harness() {
|
|
const { hasOutputForRun } = useLiveRunTranscripts({
|
|
companyId: "company-1",
|
|
runs: [{ id: "run-1", status: "succeeded", adapterType: "codex_local", hasStoredOutput: true }],
|
|
});
|
|
latestHasOutput = hasOutputForRun("run-1");
|
|
return null;
|
|
}
|
|
|
|
const container = document.createElement("div");
|
|
document.body.appendChild(container);
|
|
const root = createRoot(container);
|
|
|
|
await act(async () => {
|
|
root.render(<Harness />);
|
|
await Promise.resolve();
|
|
});
|
|
|
|
expect(latestHasOutput).toBe(true);
|
|
|
|
act(() => {
|
|
root.unmount();
|
|
});
|
|
container.remove();
|
|
});
|
|
|
|
it("reports initial hydration until the first persisted-log read completes", async () => {
|
|
let latestIsInitialHydrating = false;
|
|
type RunLogResult = { runId: string; store: string; logRef: string; content: string; nextOffset: number };
|
|
let resolveLog: ((value: RunLogResult | PromiseLike<RunLogResult>) => void) | null = null;
|
|
logMock.mockImplementationOnce(
|
|
() =>
|
|
new Promise<RunLogResult>((resolve) => {
|
|
resolveLog = resolve;
|
|
}),
|
|
);
|
|
|
|
function Harness() {
|
|
const { isInitialHydrating } = useLiveRunTranscripts({
|
|
companyId: "company-1",
|
|
runs: [{ id: "run-1", status: "succeeded", adapterType: "codex_local" }],
|
|
});
|
|
latestIsInitialHydrating = isInitialHydrating;
|
|
return null;
|
|
}
|
|
|
|
const container = document.createElement("div");
|
|
document.body.appendChild(container);
|
|
const root = createRoot(container);
|
|
|
|
await act(async () => {
|
|
root.render(<Harness />);
|
|
await Promise.resolve();
|
|
});
|
|
|
|
expect(latestIsInitialHydrating).toBe(true);
|
|
|
|
await act(async () => {
|
|
resolveLog?.({ runId: "run-1", store: "memory", logRef: "log-1", content: "", nextOffset: 0 });
|
|
await Promise.resolve();
|
|
});
|
|
|
|
expect(latestIsInitialHydrating).toBe(false);
|
|
|
|
act(() => {
|
|
root.unmount();
|
|
});
|
|
container.remove();
|
|
});
|
|
|
|
it("releases stalled log hydration and permits a fresh retry without accepting late data", async () => {
|
|
vi.useFakeTimers();
|
|
const container = document.createElement("div");
|
|
const root = createRoot(container);
|
|
let latest!: ReturnType<typeof useLiveRunTranscripts>;
|
|
let resolveLate!: (result: Awaited<ReturnType<typeof logMock>>) => void;
|
|
const empty = { runId: "run-1", store: "memory", logRef: "log-1", content: "", nextOffset: 0 };
|
|
logMock.mockImplementationOnce(() => new Promise((resolve) => { resolveLate = resolve; }));
|
|
function Harness() {
|
|
latest = useLiveRunTranscripts({ companyId: "company-1", runs: [{ id: "run-1", status: "succeeded", adapterType: "codex_local" }] });
|
|
return null;
|
|
}
|
|
try {
|
|
await act(async () => { root.render(<Harness />); });
|
|
expect(latest.isInitialHydrating).toBe(true);
|
|
await act(async () => { await vi.advanceTimersByTimeAsync(TRANSCRIPT_REQUEST_TIMEOUT_MS); });
|
|
expect(latest.isInitialHydrating).toBe(false);
|
|
expect(latest.errorsByRun.get("run-1")?.message).toContain("too long");
|
|
await act(async () => { resolveLate(empty); });
|
|
expect(latest.errorsByRun.has("run-1")).toBe(true);
|
|
logMock.mockResolvedValue(empty);
|
|
await act(async () => { latest.retry(); });
|
|
expect(latest.errorsByRun.size).toBe(0);
|
|
expect(logMock).toHaveBeenCalledTimes(2);
|
|
} finally {
|
|
act(() => root.unmount());
|
|
vi.useRealTimers();
|
|
}
|
|
});
|
|
|
|
it("stops retrying terminal runs whose persisted log never existed", async () => {
|
|
logMock.mockReset();
|
|
logMock.mockRejectedValue(new ApiError("Run log not found", 404, { error: "Run log not found" }));
|
|
|
|
function Harness() {
|
|
useLiveRunTranscripts({
|
|
companyId: "company-1",
|
|
runs: [{ id: "run-404", status: "failed", adapterType: "codex_local" }],
|
|
});
|
|
return null;
|
|
}
|
|
|
|
const container = document.createElement("div");
|
|
document.body.appendChild(container);
|
|
const root = createRoot(container);
|
|
|
|
await act(async () => {
|
|
root.render(<Harness />);
|
|
await Promise.resolve();
|
|
});
|
|
|
|
expect(logMock).toHaveBeenCalledTimes(1);
|
|
|
|
await act(async () => {
|
|
root.render(<Harness />);
|
|
await Promise.resolve();
|
|
});
|
|
|
|
expect(logMock).toHaveBeenCalledTimes(1);
|
|
|
|
act(() => {
|
|
root.unmount();
|
|
});
|
|
container.remove();
|
|
});
|
|
|
|
it("does not request persisted logs until a queued run starts", async () => {
|
|
function Harness({ status }: { status: "queued" | "running" }) {
|
|
useLiveRunTranscripts({
|
|
companyId: "company-1",
|
|
runs: [{ id: "run-queued", status, adapterType: "codex_local" }],
|
|
});
|
|
return null;
|
|
}
|
|
|
|
const container = document.createElement("div");
|
|
document.body.appendChild(container);
|
|
const root = createRoot(container);
|
|
|
|
await act(async () => {
|
|
root.render(<Harness status="queued" />);
|
|
await Promise.resolve();
|
|
});
|
|
|
|
expect(logMock).not.toHaveBeenCalled();
|
|
expect(socketConstructed).not.toHaveBeenCalled();
|
|
|
|
await act(async () => {
|
|
root.render(<Harness status="running" />);
|
|
await Promise.resolve();
|
|
});
|
|
|
|
expect(logMock).toHaveBeenCalledTimes(1);
|
|
expect(logMock).toHaveBeenCalledWith("run-queued", 0, 256_000, expect.objectContaining({ signal: expect.any(AbortSignal) }));
|
|
expect(socketConstructed).not.toHaveBeenCalled();
|
|
|
|
act(() => {
|
|
root.unmount();
|
|
});
|
|
container.remove();
|
|
});
|
|
|
|
it("can hydrate active runs without opening the live event socket", async () => {
|
|
function Harness() {
|
|
useLiveRunTranscripts({
|
|
companyId: "company-1",
|
|
runs: [{ id: "run-1", status: "running", adapterType: "codex_local" }],
|
|
enableRealtimeUpdates: false,
|
|
logReadLimitBytes: 64_000,
|
|
});
|
|
return null;
|
|
}
|
|
|
|
const container = document.createElement("div");
|
|
document.body.appendChild(container);
|
|
const root = createRoot(container);
|
|
|
|
await act(async () => {
|
|
root.render(<Harness />);
|
|
await Promise.resolve();
|
|
});
|
|
|
|
expect(socketConstructed).not.toHaveBeenCalled();
|
|
expect(logMock).toHaveBeenCalledWith("run-1", 0, 64_000, expect.objectContaining({ signal: expect.any(AbortSignal) }));
|
|
|
|
act(() => {
|
|
root.unmount();
|
|
});
|
|
container.remove();
|
|
});
|
|
|
|
it("starts persisted-log hydration from the newest bytes when the visible window is truncated", async () => {
|
|
function Harness() {
|
|
useLiveRunTranscripts({
|
|
companyId: "company-1",
|
|
runs: [{ id: "run-1", status: "running", adapterType: "codex_local", lastOutputBytes: 100_000 }],
|
|
enableRealtimeUpdates: false,
|
|
logReadLimitBytes: 64_000,
|
|
});
|
|
return null;
|
|
}
|
|
|
|
const container = document.createElement("div");
|
|
document.body.appendChild(container);
|
|
const root = createRoot(container);
|
|
|
|
await act(async () => {
|
|
root.render(<Harness />);
|
|
await Promise.resolve();
|
|
});
|
|
|
|
expect(logMock).toHaveBeenCalledWith("run-1", 36_000, 64_000, expect.objectContaining({ signal: expect.any(AbortSignal) }));
|
|
|
|
act(() => {
|
|
root.unmount();
|
|
});
|
|
container.remove();
|
|
});
|
|
|
|
it("keeps identical same-timestamp log records that carry distinct seq values", async () => {
|
|
const ts = "2026-04-20T00:00:00.000Z";
|
|
const tokenRow = (seq: number) =>
|
|
JSON.stringify({ ts, stream: "stdout", chunk: '{"type":"acpx.text_delta","text":" the"}\n', seq });
|
|
logMock.mockImplementationOnce(async () => ({
|
|
runId: "run-1",
|
|
store: "memory",
|
|
logRef: "log-1",
|
|
content: `${tokenRow(1)}\n${tokenRow(2)}\n${tokenRow(3)}\n`,
|
|
nextOffset: 300,
|
|
}));
|
|
|
|
function Harness() {
|
|
useLiveRunTranscripts({
|
|
companyId: "company-1",
|
|
runs: [{ id: "run-1", status: "running", adapterType: "gemini_local" }],
|
|
enableRealtimeUpdates: false,
|
|
});
|
|
return null;
|
|
}
|
|
|
|
const container = document.createElement("div");
|
|
document.body.appendChild(container);
|
|
const root = createRoot(container);
|
|
|
|
await act(async () => {
|
|
root.render(<Harness />);
|
|
await Promise.resolve();
|
|
});
|
|
|
|
const lastCall = buildTranscriptMock.mock.calls.at(-1) as unknown[] | undefined;
|
|
expect(lastCall?.[0]).toEqual([
|
|
{ ts, stream: "stdout", chunk: '{"type":"acpx.text_delta","text":" the"}\n', seq: 1 },
|
|
{ ts, stream: "stdout", chunk: '{"type":"acpx.text_delta","text":" the"}\n', seq: 2 },
|
|
{ ts, stream: "stdout", chunk: '{"type":"acpx.text_delta","text":" the"}\n', seq: 3 },
|
|
]);
|
|
|
|
act(() => {
|
|
root.unmount();
|
|
});
|
|
container.remove();
|
|
});
|
|
|
|
it("keeps repeated unsequenced structured text deltas instead of content-deduping tokens", async () => {
|
|
const ts = "2026-04-20T00:00:00.000Z";
|
|
const tokenRow = JSON.stringify({
|
|
ts,
|
|
stream: "stdout",
|
|
chunk: '{"type":"acpx.text_delta","text":" the"}\n',
|
|
});
|
|
logMock.mockImplementationOnce(async () => ({
|
|
runId: "run-1",
|
|
store: "memory",
|
|
logRef: "log-1",
|
|
content: `${tokenRow}\n${tokenRow}\n${tokenRow}\n`,
|
|
nextOffset: 300,
|
|
}));
|
|
|
|
function Harness() {
|
|
useLiveRunTranscripts({
|
|
companyId: "company-1",
|
|
runs: [{ id: "run-1", status: "running", adapterType: "gemini_local" }],
|
|
enableRealtimeUpdates: false,
|
|
});
|
|
return null;
|
|
}
|
|
|
|
const container = document.createElement("div");
|
|
document.body.appendChild(container);
|
|
const root = createRoot(container);
|
|
|
|
await act(async () => {
|
|
root.render(<Harness />);
|
|
await Promise.resolve();
|
|
});
|
|
|
|
const lastCall = buildTranscriptMock.mock.calls.at(-1) as unknown[] | undefined;
|
|
expect(lastCall?.[0]).toEqual([
|
|
{ ts, stream: "stdout", chunk: '{"type":"acpx.text_delta","text":" the"}\n', seq: undefined },
|
|
{ ts, stream: "stdout", chunk: '{"type":"acpx.text_delta","text":" the"}\n', seq: undefined },
|
|
{ ts, stream: "stdout", chunk: '{"type":"acpx.text_delta","text":" the"}\n', seq: undefined },
|
|
]);
|
|
|
|
act(() => {
|
|
root.unmount();
|
|
});
|
|
container.remove();
|
|
});
|
|
|
|
it("orders and dedupes sequenced chunks across websocket and persisted-log delivery", async () => {
|
|
type RunLogResult = { runId: string; store: string; logRef: string; content: string; nextOffset: number };
|
|
let resolveLog: ((value: RunLogResult) => void) | null = null;
|
|
logMock.mockImplementationOnce(
|
|
() =>
|
|
new Promise<RunLogResult>((resolve) => {
|
|
resolveLog = resolve;
|
|
}),
|
|
);
|
|
|
|
function Harness() {
|
|
useLiveRunTranscripts({
|
|
companyId: "company-1",
|
|
runs: [{ id: "run-1", status: "running", adapterType: "gemini_local" }],
|
|
});
|
|
return null;
|
|
}
|
|
|
|
const container = document.createElement("div");
|
|
document.body.appendChild(container);
|
|
const root = createRoot(container);
|
|
|
|
await act(async () => {
|
|
root.render(<Harness />);
|
|
await Promise.resolve();
|
|
});
|
|
|
|
expect(socketConstructed).not.toHaveBeenCalled();
|
|
|
|
const sendLogEvent = (seq: number, chunk: string) => {
|
|
sendEvent({
|
|
companyId: "company-1", type: "heartbeat.run.log", createdAt: "2026-04-20T00:00:01.000Z",
|
|
payload: { runId: "run-1", ts: "2026-04-20T00:00:01.000Z", stream: "stdout", chunk, seq },
|
|
});
|
|
};
|
|
|
|
// The websocket races ahead of the poller and its seq-2 chunk arrives
|
|
// tail-truncated.
|
|
await act(async () => {
|
|
sendLogEvent(2, "rld\n");
|
|
sendLogEvent(3, "!\n");
|
|
await Promise.resolve();
|
|
});
|
|
|
|
const persistedRow = (seq: number, chunk: string) =>
|
|
JSON.stringify({ ts: "2026-04-20T00:00:00.500Z", stream: "stdout", chunk, seq });
|
|
await act(async () => {
|
|
resolveLog?.({
|
|
runId: "run-1",
|
|
store: "memory",
|
|
logRef: "log-1",
|
|
content: `${persistedRow(1, "hello\n")}\n${persistedRow(2, "world\n")}\n${persistedRow(3, "!\n")}\n`,
|
|
nextOffset: 300,
|
|
});
|
|
await Promise.resolve();
|
|
});
|
|
|
|
const lastCall = buildTranscriptMock.mock.calls.at(-1) as unknown[] | undefined;
|
|
expect(lastCall?.[0]).toEqual([
|
|
{ ts: "2026-04-20T00:00:00.500Z", stream: "stdout", chunk: "hello\n", seq: 1 },
|
|
{ ts: "2026-04-20T00:00:00.500Z", stream: "stdout", chunk: "world\n", seq: 2 },
|
|
{ ts: "2026-04-20T00:00:01.000Z", stream: "stdout", chunk: "!\n", seq: 3 },
|
|
]);
|
|
|
|
act(() => {
|
|
root.unmount();
|
|
});
|
|
container.remove();
|
|
});
|
|
|
|
it("rebuilds only the transcript for the run that receives live output", async () => {
|
|
function Harness() {
|
|
useLiveRunTranscripts({
|
|
companyId: "company-1",
|
|
runs: [
|
|
{ id: "run-1", status: "running", adapterType: "codex_local" },
|
|
{ id: "run-2", status: "running", adapterType: "codex_local" },
|
|
],
|
|
});
|
|
return null;
|
|
}
|
|
|
|
const container = document.createElement("div");
|
|
document.body.appendChild(container);
|
|
const root = createRoot(container);
|
|
|
|
await act(async () => {
|
|
root.render(<Harness />);
|
|
await Promise.resolve();
|
|
await Promise.resolve();
|
|
});
|
|
|
|
expect(socketConstructed).not.toHaveBeenCalled();
|
|
expect(buildTranscriptMock).toHaveBeenCalledTimes(2);
|
|
buildTranscriptMock.mockClear();
|
|
|
|
await act(async () => {
|
|
sendEvent({
|
|
companyId: "company-1", type: "heartbeat.run.log", createdAt: "2026-04-20T00:00:00.000Z",
|
|
payload: { runId: "run-1", ts: "2026-04-20T00:00:00.000Z", stream: "stdout", chunk: "hello from run 1\n" },
|
|
});
|
|
await Promise.resolve();
|
|
});
|
|
|
|
expect(buildTranscriptMock).toHaveBeenCalledTimes(1);
|
|
expect(buildTranscriptMock).toHaveBeenCalledWith(
|
|
[{ ts: "2026-04-20T00:00:00.000Z", stream: "stdout", chunk: "hello from run 1\n" }],
|
|
null,
|
|
{ censorUsernameInLogs: false },
|
|
);
|
|
|
|
act(() => {
|
|
root.unmount();
|
|
});
|
|
container.remove();
|
|
});
|
|
|
|
it("retains an accumulated buffer through a transient empty poll (PAP-462 B3)", async () => {
|
|
const ts = "2026-08-08T00:00:00.000Z";
|
|
const row = JSON.stringify({
|
|
ts,
|
|
stream: "stdout",
|
|
chunk: '{"type":"acpx.text_delta","text":"hello"}\n',
|
|
seq: 1,
|
|
});
|
|
// Serve the buffered chunk on the FIRST persisted-log read only; every later
|
|
// read (including after the run reappears) returns nothing new. So the chunk
|
|
// can only be present the second time if the buffer survived the gap.
|
|
logMock.mockResolvedValue({ runId: "run-1", store: "memory", logRef: "log-1", content: "", nextOffset: 100 });
|
|
logMock.mockResolvedValueOnce({ runId: "run-1", store: "memory", logRef: "log-1", content: `${row}\n`, nextOffset: 100 });
|
|
|
|
const captured: { value: ReturnType<typeof useLiveRunTranscripts> | null } = { value: null };
|
|
function Harness({ runs }: { runs: Array<{ id: string; status: string; adapterType: string }> }) {
|
|
captured.value = useLiveRunTranscripts({ companyId: "company-1", runs, enableRealtimeUpdates: false });
|
|
return null;
|
|
}
|
|
|
|
const container = document.createElement("div");
|
|
document.body.appendChild(container);
|
|
const root = createRoot(container);
|
|
const runList = [{ id: "run-1", status: "running", adapterType: "gemini_local" }];
|
|
|
|
await act(async () => {
|
|
root.render(<Harness runs={runList} />);
|
|
await Promise.resolve();
|
|
});
|
|
expect(captured.value?.transcriptByRun.get("run-1")).toHaveLength(1);
|
|
|
|
// Transient empty poll: run momentarily absent from the list.
|
|
await act(async () => {
|
|
root.render(<Harness runs={[]} />);
|
|
await Promise.resolve();
|
|
});
|
|
|
|
// Run reappears within the grace window — the buffer must survive rather than
|
|
// re-hydrate from a (now empty) truncated read.
|
|
await act(async () => {
|
|
root.render(<Harness runs={runList} />);
|
|
await Promise.resolve();
|
|
});
|
|
expect(captured.value?.transcriptByRun.get("run-1")).toHaveLength(1);
|
|
|
|
act(() => {
|
|
root.unmount();
|
|
});
|
|
container.remove();
|
|
});
|
|
|
|
it("prunes a buffer once a run stays absent past the grace window (PAP-462 B3)", async () => {
|
|
vi.useFakeTimers();
|
|
try {
|
|
const ts = "2026-08-08T00:00:00.000Z";
|
|
const row = JSON.stringify({
|
|
ts,
|
|
stream: "stdout",
|
|
chunk: '{"type":"acpx.text_delta","text":"hello"}\n',
|
|
seq: 1,
|
|
});
|
|
logMock.mockResolvedValue({ runId: "run-1", store: "memory", logRef: "log-1", content: "", nextOffset: 100 });
|
|
logMock.mockResolvedValueOnce({ runId: "run-1", store: "memory", logRef: "log-1", content: `${row}\n`, nextOffset: 100 });
|
|
|
|
const captured: { value: ReturnType<typeof useLiveRunTranscripts> | null } = { value: null };
|
|
function Harness({ runs }: { runs: Array<{ id: string; status: string; adapterType: string }> }) {
|
|
captured.value = useLiveRunTranscripts({ companyId: "company-1", runs, enableRealtimeUpdates: false });
|
|
return null;
|
|
}
|
|
|
|
const container = document.createElement("div");
|
|
document.body.appendChild(container);
|
|
const root = createRoot(container);
|
|
const runList = [{ id: "run-1", status: "running", adapterType: "gemini_local" }];
|
|
|
|
await act(async () => {
|
|
root.render(<Harness runs={runList} />);
|
|
await Promise.resolve();
|
|
});
|
|
expect(captured.value?.transcriptByRun.get("run-1")).toHaveLength(1);
|
|
|
|
await act(async () => {
|
|
root.render(<Harness runs={[]} />);
|
|
await Promise.resolve();
|
|
});
|
|
|
|
// Stay absent long enough for the grace window to lapse and the deferred
|
|
// prune to fire.
|
|
await act(async () => {
|
|
await vi.advanceTimersByTimeAsync(25_000);
|
|
});
|
|
|
|
// On reappear the buffer is gone, so the (now empty) read rebuilds nothing.
|
|
await act(async () => {
|
|
root.render(<Harness runs={runList} />);
|
|
await Promise.resolve();
|
|
});
|
|
expect(captured.value?.transcriptByRun.get("run-1") ?? []).toHaveLength(0);
|
|
|
|
act(() => {
|
|
root.unmount();
|
|
});
|
|
container.remove();
|
|
} finally {
|
|
vi.useRealTimers();
|
|
}
|
|
});
|
|
|
|
});
|