fix: defer the jobs settle-sync until the turn is over (coalescing follow-up 2)

With continuation coalescing, a job flips done at CLAIM time — while the
learner's own answer turn is still streaming. The poller's settle re-sync
fired on the flip, swapping in a mid-turn record snapshot mid-stream; the
steer after the assistant message broke the SDK's in-place continuation
merge, and the streaming response landed as a SECOND message with the same
id — duplicate React keys in the live transcript (the user's live test hit
it; the record itself stayed clean).

A settle during a busy turn (local stream OR server turnInFlight) now marks
a pending sync; the first idle poll tick adopts the finished record. This
also fixes the latent multi-job race: steer B settling while steer A's wake
turn is in flight would have grabbed the same mid-turn snapshot.

Out-of-lane surgical touch (flagged for master review): the wave-1 poller
block in components/chat.tsx.
This commit is contained in:
amosblomqvist
2026-09-09 05:54:04 +03:00
parent 91ae2e137d
commit 2535d7ad35
2 changed files with 32 additions and 8 deletions
+3 -2
View File
@@ -27,6 +27,7 @@ import assert from "node:assert/strict";
import * as fs from "node:fs";
import * as path from "node:path";
import { after, describe, it } from "node:test";
import type { UIMessage } from "ai";
import { MockLanguageModelV4, simulateReadableStream } from "ai/test";
import type { LanguageModelV4StreamPart } from "@ai-sdk/provider";
import {
@@ -771,8 +772,8 @@ describe("persistTurn id hygiene", () => {
// response copy under one id — the record must keep only the merge.
const chat = chatStore.createChat("Test: dedupe continuation copies");
trackChat(chat.id);
const short = { id: "msg_dup_1", role: "assistant", parts: [{ type: "text", text: "step 1" }] };
const long = { id: "msg_dup_1", role: "assistant", parts: [{ type: "text", text: "step 1" }, { type: "text", text: "step 2 (continued)" }] };
const short: UIMessage = { id: "msg_dup_1", role: "assistant", parts: [{ type: "text", text: "step 1" }] };
const long: UIMessage = { id: "msg_dup_1", role: "assistant", parts: [{ type: "text", text: "step 1" }, { type: "text", text: "step 2 (continued)" }] };
persistTurn(chatStore, chat.id, [
{ id: "u1", role: "user", parts: [{ type: "text", text: "hi" }] },
short,