1- import { inFlightToolName , liveInvestigation } from "./progress-line" ;
1+ import { IN_FLIGHT_TOOL_STATES , inFlightToolName , liveInvestigation } from "./progress-line" ;
22
33/**
44 * Re-reading the stored transcript once a turn settles.
@@ -11,17 +11,47 @@ import { inFlightToolName, liveInvestigation } from "./progress-line";
1111
1212type Identified = { id : string } ;
1313
14+ /** A message whose stream died mid-tool: a `tool-*` part still reads as running. */
15+ function stillRunning ( message : unknown ) : boolean {
16+ const parts = ( message as { parts ?: ReadonlyArray < { type ?: string ; state ?: string } > } ) ?. parts ;
17+ if ( ! Array . isArray ( parts ) ) return false ;
18+ return parts . some (
19+ ( part ) =>
20+ typeof part ?. type === "string" &&
21+ part . type . startsWith ( "tool-" ) &&
22+ IN_FLIGHT_TOOL_STATES . has ( part . state ?? "" )
23+ ) ;
24+ }
25+
1426/**
15- * Append-only, keyed on the message id. Ids are stable (a settlement card is
16- * `investigation-settlement:{id}:{revision}`), so re-reading the same transcript any
17- * number of times can never produce a second copy of a card, and nothing already
18- * rendered is reordered or replaced.
27+ * Merge the authoritative re-read into what the panel holds, keyed on the message id.
28+ *
29+ * Genuinely-new messages (a settlement card is `investigation-settlement:{id}:{revision}`)
30+ * are appended, so re-reading the same transcript any number of times never produces a
31+ * second copy. A message whose in-memory copy died mid-tool — a stream that EOF'd before
32+ * the part settled — is replaced by its finished version from the re-read; otherwise it
33+ * would show that step running forever. We only replace a still-running copy with a copy
34+ * that has itself settled, so a live turn streaming under the same id is left alone and
35+ * ordering is preserved.
1936 */
2037export function mergeSettledMessages < T extends Identified > ( current : T [ ] , fetched : T [ ] ) : T [ ] {
38+ const byId = new Map ( fetched . map ( ( message ) => [ message . id , message ] ) ) ;
39+
40+ let replaced = false ;
41+ const next = current . map ( ( existing ) => {
42+ const settled = byId . get ( existing . id ) ;
43+ if ( settled && settled !== existing && stillRunning ( existing ) && ! stillRunning ( settled ) ) {
44+ replaced = true ;
45+ return settled ;
46+ }
47+ return existing ;
48+ } ) ;
49+
2150 const missing = fetched . filter (
2251 ( message ) => ! current . some ( ( existing ) => existing . id === message . id )
2352 ) ;
24- return missing . length === 0 ? current : [ ...current , ...missing ] ;
53+ if ( missing . length === 0 ) return replaced ? next : current ;
54+ return [ ...next , ...missing ] ;
2555}
2656
2757/** Whether the transcript still resolves to a card mid-investigation. */
0 commit comments