refactor(review): address PR #186 review (#183 — recency sweep, #174 export, tests, cleanups)

15-point review of the persistent-history PR. Architecture decisions: crash
recovery = recency threshold; tool-label duplication = leave as-is.

Must-fix:
1. Boot-sweep bounded by recency. sweepStreaming now also requires
   `updatedAt < now() - SWEEP_STREAMING_STALE_MS` (10 min), so a fresh replica's
   startup sweep can't abort a turn another replica is actively streaming
   (multi-instance deploy). Int-spec: a FRESH 'streaming' row is NOT swept, a
   STALE one IS.
2. Restore export during the FIRST streaming turn of a new chat (#174). The
   server chatId is now adopted EARLY (in-place, on the start-chunk metadata) via
   a new `onServerChatId` callback wired through use-chat-session → chat-thread,
   so `activeChatId` is set at turn start and the Copy button is live mid-first-
   turn (canExport = !!activeChatId). Hook tests for early/in-place/no-op adopt.
3. Cover finalizeAssistant's fallback-insert branch: extracted pure
   `planFinalizeAssistant(assistantId)` (update when id present, insert when the
   upfront insert failed) + a dispatch harness test for both arms.

Tests: onModuleInit lifecycle spec (sweep called; throw → resolves + warns);
int-spec updatedAt assertion → toBeGreaterThan.

Cleanups: cap findAllByChat at 5000 rows; upfront-insert-failure log carries
chatId+workspaceId; removed the now-dead buildPartialAssistantRecord (only the
spec consumed it; shapes still pinned by the flushAssistant suite); controller
passes `lang: dto.lang` (normalizeLang handles undefined); dropped a no-op
`?? undefined` in errorOf; documented the content-column semantics change
(concatenated step text, UI renders from metadata.parts); CHANGELOG [Unreleased]
entry (#183, #174); reworded the stale LABELS parity comment.

Verified: server build + 323 ai-chat unit + 5 integration; client tsc + 160
ai-chat unit; prettier clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
claude code agent 227
2026-06-25 11:53:25 +03:00
parent ae6faf3abc
commit ea61c96a7c
13 changed files with 408 additions and 165 deletions

View File

@@ -194,6 +194,7 @@ export default function AiChatWindow() {
threadKey,
waitingForHistory,
onTurnFinished,
onServerChatId,
cancelPendingAdoption,
} = useChatSession({
activeChatId,
@@ -238,7 +239,10 @@ export default function AiChatWindow() {
// SERVER-sourced (the DB is the single source of truth — #183): the assistant
// row is persisted upfront + per step, so even a brand-new chat whose first
// turn is streaming/interrupted has a server row to render. Enable the button
// whenever a persisted chat is active (`activeChatId` is set).
// whenever a persisted chat is active (`activeChatId` is set). For a BRAND-NEW
// chat that id is adopted EARLY — at the stream's `start` chunk via
// onServerChatId (#174) — so the Copy button is available during the first
// turn's stream, not only after it terminates.
const activeChat = useMemo(
() => chats?.items?.find((c) => c.id === activeChatId) ?? null,
[chats, activeChatId],
@@ -629,6 +633,7 @@ export default function AiChatWindow() {
onRolePicked={(role) => setSelectedRoleId(role.id)}
assistantName={currentRole?.name}
onTurnFinished={onTurnFinished}
onServerChatId={onServerChatId}
onLiveTurnTokens={setLiveTurnTokens}
/>
)}

View File

@@ -61,6 +61,12 @@ interface ChatThreadProps {
* authoritative id the server streamed on the assistant message metadata, or
* undefined on a failed turn — see adopt-chat-id.ts for the full #137 design. */
onTurnFinished: (serverChatId?: string) => void;
/** Called EARLY (at the stream's `start` chunk) with the authoritative server
* chat id streamed on the assistant message metadata, so a brand-new chat
* adopts its real id WHILE the first turn is still streaming (#174 — makes the
* Copy/export button available mid-stream). Distinct from onTurnFinished,
* which fires only at the terminal outcome. */
onServerChatId?: (serverChatId?: string) => void;
/** Reports the live turn-token total (reasoning + output) for the in-flight
* turn so the parent can show a header badge that ticks mid-stream. THROTTLED
* here (~8 Hz) so the parent re-renders a handful of times a second, not on
@@ -110,6 +116,7 @@ export default function ChatThread({
onRolePicked,
assistantName,
onTurnFinished,
onServerChatId,
onLiveTurnTokens,
}: ChatThreadProps) {
const { t } = useTranslation();
@@ -279,6 +286,26 @@ export default function ChatThread({
// Keep the flush helper pointed at the latest sendMessage instance.
sendMessageRef.current = sendMessage;
// EARLY chat-id adoption (#174): the server streams the authoritative chat id
// on the assistant message metadata at the `start` chunk (message.metadata.
// chatId — see adopt-chat-id.ts / chatStreamMetadata). Forward it to the parent
// AS SOON AS it appears (mid-stream), so a brand-new chat adopts its real id
// WHILE the first turn is still streaming and activeChatId-gated affordances
// (the Copy/export button) light up immediately, instead of only at onFinish.
// Keyed by the last-seen id so we forward each distinct id exactly once. The
// parent's onServerChatId is idempotent and a no-op once the chat has an id.
const lastForwardedChatIdRef = useRef<string | undefined>(undefined);
useEffect(() => {
if (!onServerChatId) return;
const tail = messages[messages.length - 1];
if (tail?.role !== "assistant") return;
const serverChatId = extractServerChatId(tail);
if (!serverChatId || serverChatId === lastForwardedChatIdRef.current)
return;
lastForwardedChatIdRef.current = serverChatId;
onServerChatId(serverChatId);
}, [messages, onServerChatId]);
// Live "turn was interrupted" marker for the CURRENT session. The red error
// banner (driven by `error`) covers the error case; this covers an aborted
// turn, distinguishing a manual Stop (`isAbort`) from a dropped connection

View File

@@ -64,7 +64,10 @@ describe("useChatSession", () => {
result.current.onTurnFinished(undefined);
expect(setActiveChatId).not.toHaveBeenCalled();
// The refetch lands with the new row => adopt it.
rerender({ activeChatId: null, chats: { items: [{ id: "x" }, { id: "new" }] } });
rerender({
activeChatId: null,
chats: { items: [{ id: "x" }, { id: "new" }] },
});
expect(setActiveChatId).toHaveBeenCalledWith("new");
});
@@ -88,7 +91,10 @@ describe("useChatSession", () => {
});
result.current.onTurnFinished(undefined);
// a was deleted, new was added — same length, but membership changed.
rerender({ activeChatId: null, chats: { items: [{ id: "b" }, { id: "new" }] } });
rerender({
activeChatId: null,
chats: { items: [{ id: "b" }, { id: "new" }] },
});
expect(setActiveChatId).toHaveBeenCalledWith("new");
});
@@ -171,6 +177,40 @@ describe("useChatSession", () => {
expect(setActiveChatId).not.toHaveBeenCalledWith("late");
});
it("#174 early adopt: onServerChatId adopts the streamed id mid-stream (Copy button available during the first turn)", () => {
// Brand-new chat: no id yet. The server streams the real chat id "A" on the
// `start` chunk WHILE the first turn is still streaming (before onTurnFinished
// fires at the terminal outcome). The hook must adopt it immediately so the
// window's activeChatId-gated Copy/export button lights up during the stream.
const { result, setActiveChatId } = setup({
activeChatId: null,
chats: { items: [] },
});
result.current.onServerChatId("A");
expect(setActiveChatId).toHaveBeenCalledWith("A");
});
it("#174 early adopt is in-place: threadKey stays stable (live stream not torn down)", () => {
const chats = { items: [] };
const { result, rerender } = setup({ activeChatId: null, chats });
const keyBefore = result.current.threadKey;
result.current.onServerChatId("A");
// Parent reflects the adopted id back in; the SAME mount key is kept so the
// in-flight useChat store (the streaming turn) is preserved.
rerender({ activeChatId: "A", chats });
expect(result.current.threadKey).toBe(keyBefore);
});
it("#174 early adopt: no-op for an existing chat and for a missing id", () => {
const { result, setActiveChatId } = setup({
activeChatId: "chat-1",
chats: { items: [{ id: "chat-1" }] },
});
result.current.onServerChatId("chat-1"); // already has an id
result.current.onServerChatId(undefined); // no streamed id
expect(setActiveChatId).not.toHaveBeenCalled();
});
it("in-place adopt keeps threadKey stable; an external switch remounts", () => {
const chats = { items: [{ id: "B" }] };
const { result, rerender } = setup({ activeChatId: null, chats });

View File

@@ -34,6 +34,13 @@ export interface UseChatSessionResult {
/** Call when a turn finishes; `serverChatId` is the authoritative streamed id
* (undefined on a failed turn). Handles new-chat id adoption + invalidations. */
onTurnFinished: (serverChatId?: string) => void;
/** Call EARLY (at the stream's `start` chunk) with the authoritative streamed
* chat id so a brand-new chat adopts its real id WHILE its first turn is still
* streaming — making `activeChatId`-gated affordances (e.g. the Copy/export
* button, #174) available immediately. In-place adoption only (same mount key,
* no list/messages invalidation — that is left to onTurnFinished at the end).
* Idempotent and a no-op once the chat already has an id. */
onServerChatId: (serverChatId?: string) => void;
/** Disarm any pending error-path new-chat fallback. The window calls this from
* startNewChat/selectChat so a late refetch can't yank the user back into a
* just-failed chat after they explicitly moved on. */
@@ -85,13 +92,10 @@ export function useChatSession(
// `newThread`/`switchThread` to (re)mount, `adoptThread` for in-place adoption.
// Initial: a non-null activeChatId switches to it; a null one gets a fresh
// session key with no chat id yet.
const [thread, dispatch] = useReducer(
threadSessionReducer,
undefined,
() =>
activeChatId === null
? newThread(`new-${generateId()}`)
: switchThread(activeChatId),
const [thread, dispatch] = useReducer(threadSessionReducer, undefined, () =>
activeChatId === null
? newThread(`new-${generateId()}`)
: switchThread(activeChatId),
);
// Error-path fallback for new-chat id adoption. When a brand-new chat's first
@@ -150,6 +154,31 @@ export function useChatSession(
[chats, setActiveChatId, onInvalidateChatList, onInvalidateChatMessages],
);
// EARLY adoption (#174): adopt the authoritative streamed chat id the moment
// the server emits it on the `start` chunk, so a brand-new chat gets its real
// `activeChatId` WHILE its first turn streams — not only at terminal
// onTurnFinished. This makes the activeChatId-gated Copy/export button
// available during the first turn. Pure in-place adoption (same mount key, like
// the primary path) with NO invalidation: the list/messages refresh stays on
// onTurnFinished at the end of the turn. Reads the live id from the ref so a
// repeat call after adoption is a no-op (resolveAdoptedChatId only fires for a
// still-new chat).
const onServerChatId = useCallback(
(serverChatId?: string) => {
const adopted = resolveAdoptedChatId(
activeChatIdRef.current,
serverChatId,
);
if (!adopted) return;
activeChatIdRef.current = adopted;
setActiveChatId(adopted);
dispatch({ type: "adopt", chatId: adopted });
// Early adoption beat the error-path fallback to it — disarm.
pendingNewChatRef.current = null;
},
[setActiveChatId],
);
// FALLBACK resolver. Armed only by onTurnFinished when a brand-new chat's first
// turn errored before the `start` chunk (no authoritative id streamed). Once
// the per-user list refetch lands with the just-created row, adopt the SINGLE
@@ -233,6 +262,7 @@ export function useChatSession(
threadKey: thread.key,
waitingForHistory,
onTurnFinished,
onServerChatId,
cancelPendingAdoption,
};
}