diff --git a/packages/chat-ui/src/streaming-reply.ts b/packages/chat-ui/src/streaming-reply.ts index 730c6e958..fe352d013 100644 --- a/packages/chat-ui/src/streaming-reply.ts +++ b/packages/chat-ui/src/streaming-reply.ts @@ -198,9 +198,17 @@ export function hydrateStreamingReplyFromTurn( return awaiting(runningTurn.textSnapshot ?? ""); } -/** How long an empty pending reply may sit with no tokens before the - * indicator clears itself — the backstop for a turn whose stream events - * never arrive (agent down, SSE dropped mid-reconnect). */ +/** How long a turn may go without a single new token before it's declared + * dead — the backstop for both a turn whose stream events never arrive at + * all (agent down, SSE dropped mid-reconnect) and one that starts streaming + * and then stalls (model OOM, dropped Ollama connection, sidecar crash: all + * routine with local models, CL-6486). This measures the gap *since the + * last token*, not total turn duration — a healthy local model can + * legitimately run 200s+ end to end (round 1 measured ~216s on + * `qwen3.8:27b`), so a total-elapsed timeout would fire on working replies. + * 120s of dead air with zero new output, on the other hand, is never a + * healthy sign even for a slow model — it's long past any single decode + * step, tool round-trip, or reconnect a client is expected to ride out. */ const PENDING_REPLY_CLEAR_MS = 120_000; /** Floor on how long the empty typing pulse stays up after it first @@ -244,7 +252,16 @@ export function useStreamingReply( }, [workbenchId]); useEffect(() => { - if (!isPendingReply(streamingReply)) return; + // Arm for the whole "awaiting" phase, not just its tokenless prefix + // (CL-6486): a token still growing the reply is not evidence the turn + // is alive forever, only that it was alive as of that token. Every + // token produces a new `streamingReply` object (see `awaiting`), so + // this effect's own dependency below tears down the previous timer and + // arms a fresh one on each token — the window this constructs is + // therefore inter-token silence, never total elapsed time. + if (streamingReply === null || streamingReply.phase !== "awaiting") { + return; + } const timer = setTimeout(() => { // The dependency below re-arms this effect (clearing this exact // timer) the instant `streamingReply` changes, so this callback only diff --git a/packages/chat-ui/test/use-streaming-reply.test.tsx b/packages/chat-ui/test/use-streaming-reply.test.tsx index 7e4a90878..eb174ed7f 100644 --- a/packages/chat-ui/test/use-streaming-reply.test.tsx +++ b/packages/chat-ui/test/use-streaming-reply.test.tsx @@ -163,17 +163,35 @@ describe("useStreamingReply's reply-timeout backstop (CL-6252 #6)", () => { harness.unmount(); }); - test("a token arriving before the backstop never marks it timed out", async () => { + test("a token resets the backstop window instead of clearing it (CL-6486)", async () => { const harness = mount("chan_a", 30); harness.awaitReply(); + await harness.settle(20); + // A token arrives just before the original deadline would have fired — + // it must push the deadline out from here, not merely have prevented + // the earlier one. harness.send("chat.agent", delta("Hi")); - - await harness.settle(60); + await harness.settle(20); expect(harness.get()).toEqual({ phase: "awaiting", text: "Hi" }); expect(harness.timedOut()).toBe(false); harness.unmount(); }); + test("silence after a token still times out a mid-stream stall (CL-6486)", async () => { + const harness = mount("chan_a", 30); + harness.awaitReply(); + harness.send("chat.agent", delta("Hi")); + expect(harness.get()).toEqual({ phase: "awaiting", text: "Hi" }); + + // The model died mid-sentence: no further tokens, no terminal event. + // The backstop must still fire off the last token, not stay armed + // forever just because *some* text streamed. + await harness.settle(60); + expect(harness.get()).toBeNull(); + expect(harness.timedOut()).toBe(true); + harness.unmount(); + }); + test("switching workbenches clears a stale timed-out flag from the one just left", async () => { const harness = mount("chan_a", 30); harness.awaitReply();