68899a2c2e
Squashed for a clean rebase onto develop (was 19 commits; the reviewer approved
the net diff at fb246080). Detaches an agent run from the HTTP request/browser
window: a run is a first-class lifecycle object (ai_chat_runs), a browser
disconnect no longer kills it, a concurrent-run insert-gate prevents double runs,
and a reopened chat live-follows a still-running run via a polled observer merge.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
304 lines
9.9 KiB
TypeScript
304 lines
9.9 KiB
TypeScript
import { describe, it, expect } from "vitest";
|
|
import type { UIMessage } from "@ai-sdk/react";
|
|
import type { IAiChatRun } from "@/features/ai-chat/types/ai-chat.types.ts";
|
|
import {
|
|
RUN_POLL_INTERVAL_MS,
|
|
isRunActive,
|
|
runPollInterval,
|
|
shouldObserveRun,
|
|
shouldClearStoppingLatch,
|
|
shouldClearLatchOnQueryError,
|
|
mergeObservedMessage,
|
|
} from "./run-polling.ts";
|
|
|
|
function makeRun(status: string): IAiChatRun {
|
|
return { id: "run-1", chatId: "c1", status };
|
|
}
|
|
|
|
function makeMsg(id: string, text: string): UIMessage {
|
|
return {
|
|
id,
|
|
role: "assistant",
|
|
parts: [{ type: "text", text }],
|
|
} as UIMessage;
|
|
}
|
|
|
|
describe("isRunActive", () => {
|
|
it("treats pending and running as active", () => {
|
|
expect(isRunActive(makeRun("pending"))).toBe(true);
|
|
expect(isRunActive(makeRun("running"))).toBe(true);
|
|
});
|
|
|
|
it("treats terminal / unknown / nullish as not active", () => {
|
|
expect(isRunActive(makeRun("succeeded"))).toBe(false);
|
|
expect(isRunActive(makeRun("failed"))).toBe(false);
|
|
expect(isRunActive(makeRun("aborted"))).toBe(false);
|
|
expect(isRunActive(makeRun("weird-future-status"))).toBe(false);
|
|
expect(isRunActive(null)).toBe(false);
|
|
expect(isRunActive(undefined)).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe("runPollInterval (the refetchInterval helper)", () => {
|
|
it("returns 2000ms while the run is pending/running", () => {
|
|
expect(runPollInterval(makeRun("pending"))).toBe(RUN_POLL_INTERVAL_MS);
|
|
expect(runPollInterval(makeRun("running"))).toBe(RUN_POLL_INTERVAL_MS);
|
|
expect(RUN_POLL_INTERVAL_MS).toBe(2000);
|
|
});
|
|
|
|
it("returns false (stop polling) once the run is terminal", () => {
|
|
expect(runPollInterval(makeRun("succeeded"))).toBe(false);
|
|
expect(runPollInterval(makeRun("failed"))).toBe(false);
|
|
expect(runPollInterval(makeRun("aborted"))).toBe(false);
|
|
});
|
|
|
|
it("returns false (no polling) when there is no run", () => {
|
|
expect(runPollInterval(null)).toBe(false);
|
|
expect(runPollInterval(undefined)).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe("shouldObserveRun (observer-vs-streamer decision)", () => {
|
|
it("observes an active run when this tab is NOT the local streamer", () => {
|
|
expect(shouldObserveRun(makeRun("running"), false)).toBe(true);
|
|
expect(shouldObserveRun(makeRun("pending"), false)).toBe(true);
|
|
});
|
|
|
|
it("observes a terminal run too (so the final output shows on reopen)", () => {
|
|
expect(shouldObserveRun(makeRun("succeeded"), false)).toBe(true);
|
|
});
|
|
|
|
it("does NOT observe when this tab IS the streamer (no double-render)", () => {
|
|
expect(shouldObserveRun(makeRun("running"), true)).toBe(false);
|
|
expect(shouldObserveRun(makeRun("succeeded"), true)).toBe(false);
|
|
});
|
|
|
|
it("does NOT observe when there is no run", () => {
|
|
expect(shouldObserveRun(null, false)).toBe(false);
|
|
expect(shouldObserveRun(undefined, false)).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe("shouldClearStoppingLatch (#234 latch-release decision)", () => {
|
|
// The one case the latch SHOULD clear: we requested a stop, we are the passive
|
|
// observer (not streaming), and the CURRENT run is terminal.
|
|
it("clears only when stopping, observing, and the run is terminal", () => {
|
|
expect(
|
|
shouldClearStoppingLatch({
|
|
stoppingRun: true,
|
|
run: makeRun("aborted"),
|
|
isLocalStreaming: false,
|
|
}),
|
|
).toBe(true);
|
|
expect(
|
|
shouldClearStoppingLatch({
|
|
stoppingRun: true,
|
|
run: makeRun("succeeded"),
|
|
isLocalStreaming: false,
|
|
}),
|
|
).toBe(true);
|
|
expect(
|
|
shouldClearStoppingLatch({
|
|
stoppingRun: true,
|
|
run: makeRun("failed"),
|
|
isLocalStreaming: false,
|
|
}),
|
|
).toBe(true);
|
|
});
|
|
|
|
// Round-3 regression: clearing while THIS tab is still the local streamer would
|
|
// re-open the flash for the current turn the moment we switch to observer role.
|
|
// A predicate lacking the streaming gate would (wrongly) return true here.
|
|
it("does NOT clear while this tab is the local streamer", () => {
|
|
expect(
|
|
shouldClearStoppingLatch({
|
|
stoppingRun: true,
|
|
run: makeRun("aborted"),
|
|
isLocalStreaming: true,
|
|
}),
|
|
).toBe(false);
|
|
expect(
|
|
shouldClearStoppingLatch({
|
|
stoppingRun: true,
|
|
run: makeRun("succeeded"),
|
|
isLocalStreaming: true,
|
|
}),
|
|
).toBe(false);
|
|
});
|
|
|
|
// The detached run keeps growing after a local abort — while it is still
|
|
// active the latch MUST hold so the observer merge stays suppressed.
|
|
it("does NOT clear while the run is still active", () => {
|
|
expect(
|
|
shouldClearStoppingLatch({
|
|
stoppingRun: true,
|
|
run: makeRun("running"),
|
|
isLocalStreaming: false,
|
|
}),
|
|
).toBe(false);
|
|
expect(
|
|
shouldClearStoppingLatch({
|
|
stoppingRun: true,
|
|
run: makeRun("pending"),
|
|
isLocalStreaming: false,
|
|
}),
|
|
).toBe(false);
|
|
});
|
|
|
|
// #234 F4: on Stop the stale PREVIOUS-turn run is removed from the cache, so the
|
|
// observed `run` is null until the current turn's run is fetched fresh. A null
|
|
// run HOLDS the latch — it can never clear against the just-removed stale run,
|
|
// only against the current turn's own terminal run once observed.
|
|
it("does NOT clear against a removed/absent run (F4 stale-run guard)", () => {
|
|
expect(
|
|
shouldClearStoppingLatch({
|
|
stoppingRun: true,
|
|
run: null,
|
|
isLocalStreaming: false,
|
|
}),
|
|
).toBe(false);
|
|
expect(
|
|
shouldClearStoppingLatch({
|
|
stoppingRun: true,
|
|
run: undefined,
|
|
isLocalStreaming: false,
|
|
}),
|
|
).toBe(false);
|
|
});
|
|
|
|
it("does NOT clear when no stop was requested", () => {
|
|
expect(
|
|
shouldClearStoppingLatch({
|
|
stoppingRun: false,
|
|
run: makeRun("aborted"),
|
|
isLocalStreaming: false,
|
|
}),
|
|
).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe("shouldClearLatchOnQueryError (#234 F7 error-safety-net decision)", () => {
|
|
// This guards the REAL anti-flash decision the component's run-query-error
|
|
// safety-net effect uses (ai-chat-window.tsx wires the effect to THIS helper,
|
|
// not a copy — so the test is non-vacuous vs the live code).
|
|
|
|
// (b) The F7 hole: a TRANSIENT run-query error while `run` is STILL ACTIVE must
|
|
// NOT clear the latch. TanStack Query v5 retains `data` on error, so
|
|
// runQueryFailed can be true while the held run is still pending/running.
|
|
// Against the PRE-F7 condition (without `!isRunActive(run)`) this would return
|
|
// true — so this assertion fails on the buggy code (non-vacuous).
|
|
it("does NOT clear on a transient error while the run is still ACTIVE (F7)", () => {
|
|
expect(
|
|
shouldClearLatchOnQueryError({
|
|
stoppingRun: true,
|
|
isLocalStreaming: false,
|
|
runQueryFailed: true,
|
|
run: makeRun("running"),
|
|
}),
|
|
).toBe(false);
|
|
expect(
|
|
shouldClearLatchOnQueryError({
|
|
stoppingRun: true,
|
|
isLocalStreaming: false,
|
|
runQueryFailed: true,
|
|
run: makeRun("pending"),
|
|
}),
|
|
).toBe(false);
|
|
});
|
|
|
|
// (a) The genuine permanent-null-freeze: run cache cleared by removeQueries +
|
|
// the refetch keeps ERRORING, so `run === null`. This is the ONLY case the
|
|
// safety-net exists to cure — it MUST clear so the frozen view resumes.
|
|
it("clears on a permanent error when the run is null (permanent-null-freeze)", () => {
|
|
expect(
|
|
shouldClearLatchOnQueryError({
|
|
stoppingRun: true,
|
|
isLocalStreaming: false,
|
|
runQueryFailed: true,
|
|
run: null,
|
|
}),
|
|
).toBe(true);
|
|
expect(
|
|
shouldClearLatchOnQueryError({
|
|
stoppingRun: true,
|
|
isLocalStreaming: false,
|
|
runQueryFailed: true,
|
|
run: undefined,
|
|
}),
|
|
).toBe(true);
|
|
});
|
|
|
|
// A TERMINAL run also satisfies `!isRunActive`; clearing then is harmless — the
|
|
// terminal effect (shouldClearStoppingLatch) already clears for a terminal run,
|
|
// so this only ever agrees with it. Asserted so the (c) reasoning is pinned.
|
|
it("clears on an error when the run is terminal (harmless, agrees with terminal effect)", () => {
|
|
expect(
|
|
shouldClearLatchOnQueryError({
|
|
stoppingRun: true,
|
|
isLocalStreaming: false,
|
|
runQueryFailed: true,
|
|
run: makeRun("aborted"),
|
|
}),
|
|
).toBe(true);
|
|
});
|
|
|
|
it("does NOT clear without an actual query error", () => {
|
|
expect(
|
|
shouldClearLatchOnQueryError({
|
|
stoppingRun: true,
|
|
isLocalStreaming: false,
|
|
runQueryFailed: false,
|
|
run: null,
|
|
}),
|
|
).toBe(false);
|
|
});
|
|
|
|
it("does NOT clear while this tab is the local streamer", () => {
|
|
expect(
|
|
shouldClearLatchOnQueryError({
|
|
stoppingRun: true,
|
|
isLocalStreaming: true,
|
|
runQueryFailed: true,
|
|
run: null,
|
|
}),
|
|
).toBe(false);
|
|
});
|
|
|
|
it("does NOT clear when no stop was requested", () => {
|
|
expect(
|
|
shouldClearLatchOnQueryError({
|
|
stoppingRun: false,
|
|
isLocalStreaming: false,
|
|
runQueryFailed: true,
|
|
run: null,
|
|
}),
|
|
).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe("mergeObservedMessage", () => {
|
|
it("replaces the message with the same id in place (per-step growth)", () => {
|
|
const prev = [makeMsg("u1", "hi"), makeMsg("a1", "step 1")];
|
|
const observed = makeMsg("a1", "step 1\nstep 2");
|
|
const next = mergeObservedMessage(prev, observed);
|
|
expect(next).toHaveLength(2);
|
|
expect(next[1]).toBe(observed);
|
|
expect(next[0]).toBe(prev[0]); // untouched
|
|
expect(next).not.toBe(prev); // new array (never mutates input)
|
|
});
|
|
|
|
it("appends when the observed message is not yet present", () => {
|
|
const prev = [makeMsg("u1", "hi")];
|
|
const observed = makeMsg("a1", "first token");
|
|
const next = mergeObservedMessage(prev, observed);
|
|
expect(next).toHaveLength(2);
|
|
expect(next[1]).toBe(observed);
|
|
});
|
|
|
|
it("returns the original list unchanged when there is nothing to merge", () => {
|
|
const prev = [makeMsg("u1", "hi")];
|
|
expect(mergeObservedMessage(prev, null)).toBe(prev);
|
|
expect(mergeObservedMessage(prev, undefined)).toBe(prev);
|
|
});
|
|
});
|