Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions packages/chat-ui/src/clock.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
// The one seam `streaming-reply.ts` and `turn-activity.tsx` read wall-clock
// time and arm backstop timers through, so a test can swap in a fake that
// advances synchronously instead of sleeping on the real clock — real-timer
// waits on these hooks' 30ms/60ms backstops are exactly what made them flake
// under CI's sharded, CPU-contended `bun run test` (CL-7488).
export type Clock = {
readonly now: () => number;
readonly setTimeout: (callback: () => void, ms: number) => unknown;
readonly clearTimeout: (handle: unknown) => void;
};

export const REAL_CLOCK: Clock = {
now: () => Date.now(),
setTimeout: (callback, ms) => setTimeout(callback, ms),
clearTimeout: (handle) =>
clearTimeout(handle as ReturnType<typeof setTimeout>),
};
23 changes: 13 additions & 10 deletions packages/chat-ui/src/streaming-reply.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import { useEffect, useRef, useState } from "react";
import { isAgentAddress, mentionedParticipants } from "@corbits/chat/mentions";
import { reportError } from "@corbits/error-sink";
import type { Part, ParticipantRecord } from "./api";
import { REAL_CLOCK, type Clock } from "./clock";
import {
displayNameForAddress,
type AgentDisplayNames,
Expand Down Expand Up @@ -307,6 +308,7 @@ export function useStreamingReply(
workbenchId: string | null,
clearMs: number = PENDING_REPLY_CLEAR_MS,
minVisibleMs: number = TYPING_INDICATOR_MIN_VISIBLE_MS,
clock: Clock = REAL_CLOCK,
): {
readonly streamingReply: StreamingReplyState;
/** Set once the backstop above has fired for the turn just cleared — a
Expand All @@ -333,17 +335,17 @@ export function useStreamingReply(
null,
);
const pendingSinceRef = useRef<number | null>(null);
const holdTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const holdTimerRef = useRef<unknown>(null);

useEffect(() => {
setStreamingReply(null);
setReplyTimedOutRefId(null);
pendingSinceRef.current = null;
if (holdTimerRef.current !== null) {
clearTimeout(holdTimerRef.current);
clock.clearTimeout(holdTimerRef.current);
holdTimerRef.current = null;
}
}, [workbenchId]);
}, [workbenchId, clock]);

useEffect(() => {
// Arm for the whole "awaiting" phase, not just its tokenless prefix
Expand All @@ -356,7 +358,7 @@ export function useStreamingReply(
if (streamingReply === null || streamingReply.phase !== "awaiting") {
return;
}
const timer = setTimeout(() => {
const timer = clock.setTimeout(() => {
// The dependency below re-arms this effect (clearing this exact
// timer) the instant `streamingReply` changes, so this callback only
// ever runs while it's still the same pending reply it was armed
Expand All @@ -375,14 +377,14 @@ export function useStreamingReply(
setReplyTimedOutRefId(refId);
pendingSinceRef.current = null;
}, clearMs);
return () => clearTimeout(timer);
}, [streamingReply, clearMs, workbenchId]);
return () => clock.clearTimeout(timer);
}, [streamingReply, clearMs, workbenchId, clock]);

function commitReply(
next: StreamingReplyState,
current: StreamingReplyState,
): StreamingReplyState {
const now = Date.now();
const now = clock.now();
const becamePending = isPendingReply(next) && !isPendingReply(current);
if (becamePending) pendingSinceRef.current = now;

Expand All @@ -392,10 +394,11 @@ export function useStreamingReply(
pendingSinceRef.current !== null &&
now - pendingSinceRef.current < minVisibleMs
) {
if (holdTimerRef.current !== null) clearTimeout(holdTimerRef.current);
if (holdTimerRef.current !== null)
clock.clearTimeout(holdTimerRef.current);
const remaining = minVisibleMs - (now - pendingSinceRef.current);
const held = next;
holdTimerRef.current = setTimeout(() => {
holdTimerRef.current = clock.setTimeout(() => {
holdTimerRef.current = null;
pendingSinceRef.current = null;
setStreamingReply(held);
Expand All @@ -404,7 +407,7 @@ export function useStreamingReply(
}

if (holdTimerRef.current !== null) {
clearTimeout(holdTimerRef.current);
clock.clearTimeout(holdTimerRef.current);
holdTimerRef.current = null;
}
if (!isPendingReply(next)) pendingSinceRef.current = null;
Expand Down
14 changes: 9 additions & 5 deletions packages/chat-ui/src/turn-activity.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@

import { useEffect, useState } from "react";

import { REAL_CLOCK, type Clock } from "./clock";
import {
describeToolCall,
resolveToolIdentity,
Expand Down Expand Up @@ -336,11 +337,14 @@ const TURN_ACTIVITY_STALE_MS = 120_000;
* workbench switch — activity from the workbench just left belongs to that
* workbench, not the new one. `staleMs` (default `TURN_ACTIVITY_STALE_MS`)
* is a test seam, mirroring `useTypingIndicator`'s own configurable
* timeout.
* timeout. `clock` (default `REAL_CLOCK`) is the same kind of seam for the
* wall-clock reads and the backstop timer itself — a test drives both
* synchronously with a fake clock instead of sleeping on the real one.
*/
export function useTurnActivity(
workbenchId: string | null,
staleMs: number = TURN_ACTIVITY_STALE_MS,
clock: Clock = REAL_CLOCK,
): {
readonly activity: TurnActivityState;
readonly handleStreamEvent: (eventType: string, data: unknown) => void;
Expand All @@ -358,15 +362,15 @@ export function useTurnActivity(
// thing that ever clears it in that case.
useEffect(() => {
if (activity === null) return;
const timer = setTimeout(() => {
const timer = clock.setTimeout(() => {
setActivity((current) => (current === activity ? null : current));
}, staleMs);
return () => clearTimeout(timer);
}, [activity, staleMs]);
return () => clock.clearTimeout(timer);
}, [activity, staleMs, clock]);

function handleStreamEvent(eventType: string, data: unknown) {
setActivity((current) =>
nextTurnActivityState(current, { eventType, data }, Date.now()),
nextTurnActivityState(current, { eventType, data }, clock.now()),
);
}

Expand Down
61 changes: 61 additions & 0 deletions packages/chat-ui/test/fake-clock.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
// A synchronous stand-in for `../src/clock.ts`'s `REAL_CLOCK`: `advance`
// fires every due timer against a virtual clock with no real sleep, so a
// test asserting a 30ms backstop actually fired takes 0ms of wall time
// instead of racing CI's CPU-contended shards for those 30ms (CL-7488).
import type { Clock } from "../src/clock";

export type FakeClock = {
readonly clock: Clock;
/** Moves the virtual clock forward by `ms`, firing (in fire-time order)
* every timer due at or before the new time — including one a firing
* timer's own callback re-arms within this same window, mirroring how a
* real timer queue drains. */
readonly advance: (ms: number) => void;
};

export function createFakeClock(): FakeClock {
let now = 0;
let nextId = 1;
const timers = new Map<number, { fireAt: number; callback: () => void }>();

function fakeSetTimeout(callback: () => void, ms: number): number {
const id = nextId++;
timers.set(id, { fireAt: now + ms, callback });
return id;
}

function fakeClearTimeout(handle: unknown): void {
timers.delete(handle as number);
}

function advance(ms: number): void {
const target = now + ms;
for (;;) {
let dueId: number | null = null;
let dueEntry: { fireAt: number; callback: () => void } | null = null;
for (const [id, entry] of timers) {
if (
entry.fireAt <= target &&
(dueEntry === null || entry.fireAt < dueEntry.fireAt)
) {
dueId = id;
dueEntry = entry;
}
}
if (dueId === null || dueEntry === null) break;
timers.delete(dueId);
now = dueEntry.fireAt;
dueEntry.callback();
}
now = target;
}

return {
clock: {
now: () => now,
setTimeout: fakeSetTimeout,
clearTimeout: fakeClearTimeout,
},
advance,
};
}
8 changes: 4 additions & 4 deletions packages/chat-ui/test/use-streaming-reply.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,7 @@ import { createRoot } from "react-dom/client";

import { useStreamingReply } from "../src/streaming-reply";
import type { StreamingReplyState } from "../src/streaming-reply";

const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
import { createFakeClock } from "./fake-clock";

function mount(
initialWorkbenchId: string | null,
Expand All @@ -21,6 +20,7 @@ function mount(
const container = document.createElement("div");
document.body.appendChild(container);
const root = createRoot(container);
const fakeClock = createFakeClock();
let latestState: StreamingReplyState = null;
let latestTimedOutRefId: string | null = null;
let send: (eventType: string, data: unknown) => void = () => {};
Expand All @@ -39,7 +39,7 @@ function mount(
handleStreamEvent,
noteAwaitingReply,
resumeFromTurn,
} = useStreamingReply(workbenchId, clearMs, minVisibleMs);
} = useStreamingReply(workbenchId, clearMs, minVisibleMs, fakeClock.clock);
latestState = streamingReply;
latestTimedOutRefId = replyTimedOutRefId;
send = handleStreamEvent;
Expand Down Expand Up @@ -71,7 +71,7 @@ function mount(
act(() => {
resume(runningTurn);
}),
settle: (ms: number) => act(() => sleep(ms)),
settle: (ms: number) => act(() => fakeClock.advance(ms)),
get: () => latestState,
timedOutRefId: () => latestTimedOutRefId,
unmount: () => act(() => root.unmount()),
Expand Down
7 changes: 4 additions & 3 deletions packages/chat-ui/test/use-turn-activity.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,13 +10,13 @@ import { act, createElement, useState } from "react";
import { createRoot } from "react-dom/client";

import { useTurnActivity, TurnActivityStrip } from "../src/turn-activity";

const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
import { createFakeClock } from "./fake-clock";

function mount(initialWorkbenchId: string | null, staleMs?: number) {
const container = document.createElement("div");
document.body.appendChild(container);
const root = createRoot(container);
const fakeClock = createFakeClock();
let send: (eventType: string, data: unknown) => void = () => {};
let setWorkbenchId: (id: string | null) => void = () => {};

Expand All @@ -26,6 +26,7 @@ function mount(initialWorkbenchId: string | null, staleMs?: number) {
const { activity, handleStreamEvent } = useTurnActivity(
workbenchId,
staleMs,
fakeClock.clock,
);
send = handleStreamEvent;
return createElement(TurnActivityStrip, { activity });
Expand All @@ -44,7 +45,7 @@ function mount(initialWorkbenchId: string | null, staleMs?: number) {
act(() => {
setWorkbenchId(id);
}),
settle: (ms: number) => act(() => sleep(ms)),
settle: (ms: number) => act(() => fakeClock.advance(ms)),
container,
unmount: () => act(() => root.unmount()),
};
Expand Down
Loading