fix: streaming appended a message per chunk - track the in-flight slot

pi streams AssistantMessages with NO id (verified against a captured
live stream and pi-ai's AssistantMessage type): the id exists only once
the server's persistTurn stamps the record. applyWireEvent minted a
FRESH c_ id per event, so message_start appended, every message_update
found no match and APPENDED another full copy, and message_end appended
again - the tail grew one message per chunk. projectView's step
separators + thinking blocks then filled the transcript with dividers
and thinking indicators mid-stream (with fresh React keys remounting
the whole subtree per chunk), cleaned up only by the settle sync's
setMessages(record).

The fix: the in-flight assistant message is tracked by SLOT -
message_start mints and remembers one client id; message_update and
message_end REPLACE that same tail entry (pi guarantees exactly one
streaming message at a time via its strict start-updates-end ordering;
the reattach replay replays the same sequence). An explicit
message.id on the wire still wins; the slot clears at message_end,
stop(), and setMessages().

Tests: all streaming fixtures rewritten to pi's real shape (no
message.id) - the old ones encoded the wrong assumption - plus a
regression pin asserting N id-less updates leave exactly ONE assistant
message in the tail with a stable slot id.

Verified live mid-stream (DOM sampled every 500ms during a teaching
turn): 0 separators throughout, thinking blocks 1-2 (one per step, not
per chunk), text growing smoothly.
This commit is contained in:
amosblomqvist
2026-09-26 02:25:04 +03:00
parent 0803fbd88d
commit 8e097752da
2 changed files with 84 additions and 23 deletions
+19 -1
View File
@@ -181,6 +181,8 @@ export class SessionModel {
// The live turn's execution layer (view model mid-turn states):
private executions = new Map<string, LiveExecution>();
private streamingIds = new Set<string>();
/** The in-flight assistant message's client id (see applyWireEvent). */
private streamingMessageId: string | null = null;
private statusValue: SessionStatus = "ready";
private errorValue: { message: string } | null = null;
// The single live consume (POST response or reattach stream): a newer
@@ -289,9 +291,22 @@ export class SessionModel {
id?: string;
};
if ((message as { role?: string }).role !== "assistant") return;
const id = message.id ?? localId();
// pi streams AssistantMessages with NO id — the id exists only
// once the server's persistTurn stamps the record (the live
// streaming bug, 2026-09-26: minting a fresh id per event made
// every message_update APPEND a full copy — a divider + thinking
// block per chunk, cleaned up only by the settle sync). The
// in-flight message is tracked by SLOT instead: message_start
// mints and remembers the id; updates and the end REPLACE that
// same tail entry. Exactly one assistant message streams at a
// time (pi's strict start → updates → end ordering per step; the
// reattach replay replays the same sequence), so the slot is
// unambiguous. An explicit message.id (if one ever appears on
// the wire) still wins.
const id = message.id ?? this.streamingMessageId ?? localId();
const withId = { ...message, id } as LhMessage;
if (agent.type === "message_start") {
this.streamingMessageId = id;
this.streamingIds.add(id);
this.mutate(() => {
this.tail = [...this.tail, withId];
@@ -303,6 +318,7 @@ export class SessionModel {
});
}
if (agent.type === "message_end") {
this.streamingMessageId = null; // slot done — next start mints fresh
const stop = (message as { stopReason?: string }).stopReason;
if (stop === "error") {
// Provider error, verbatim (owner ruling) — the record
@@ -479,6 +495,7 @@ export class SessionModel {
this.consumeAbort?.abort();
this.consumeAbort = null;
this.streamingIds = new Set();
this.streamingMessageId = null;
this.executions = new Map();
this.setStatus("ready");
}
@@ -488,6 +505,7 @@ export class SessionModel {
this.mutate(() => {
this.base = record;
this.tail = [];
this.streamingMessageId = null; // the tail (and its slot) is gone
});
}