fix: the practice live-view law — live is positive evidence; absorb custom markers
The thinking-submission incident (owner report, 2026-10-05): two spinners sandwiching the submitted thinking card, and the card doubling once tokens flowed. Records on disk were clean — both bugs were live-view-only. - view.ts LIVE IS POSITIVE EVIDENCE (owner ruling): a tool call renders live (input-streaming) ONLY when its own message is streaming THIS turn (streamingIds) — session busyness can never resurrect a settled call. The pose turn's problem call kept flipping to a mid-transcript spinner because the fallback keyed on live.inFlight. applyResultTo's placeholder fork now also MARKS the settled ack (input-available) instead of leaving the guessed state standing. Live cards now sit at the bottom by construction, never mid-transcript. - session-core.ts absorbed(): the custom role gets its identity key (customType + details deep-equal) — the optimistic thinking marker is dropped when the write-ahead's server copy lands, so the +400ms adoption window can no longer double the card. Total over roles; types that never appear optimistically never reach the branch. Pinned: settled problem never live mid-turn; this turn's streaming call stays shimmer; dangling-settled is input-available; absorbed by customType+details (different details = different submission); model-level adoption keeps exactly one marker.
This commit is contained in:
@@ -23,6 +23,7 @@ import {
|
||||
newUserMessage,
|
||||
projectView,
|
||||
type ChatInput,
|
||||
type LhMarkerMessage,
|
||||
type LhMessage,
|
||||
type LhViewMessage,
|
||||
type LiveExecution,
|
||||
@@ -134,9 +135,30 @@ export function absorbed(m: LhMessage, record: readonly LhMessage[]): boolean {
|
||||
(r as ToolResultMessage).toolCallId === toolCallId,
|
||||
);
|
||||
}
|
||||
if (role === "custom") {
|
||||
// Markers (the thinking submission, 2026-10-05 — the doubled-card
|
||||
// bug: the optimistic practice-thinking card was never absorbed, so
|
||||
// the adoption window rendered it beside the write-ahead's server
|
||||
// copy for the whole turn). TOTAL over roles by identity key — a
|
||||
// marker is itself when its customType AND structured details match;
|
||||
// types that never appear optimistically simply never reach here.
|
||||
const marker = m as LhMarkerMessage;
|
||||
return record.some(
|
||||
(r) =>
|
||||
(r as { role?: string }).role === "custom" &&
|
||||
(r as LhMarkerMessage).customType === marker.customType &&
|
||||
sameDetails((r as LhMarkerMessage).details, marker.details),
|
||||
);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/** Structural deep-equal for marker details (JSON-stable payloads — the
|
||||
* constructors mint plain data). */
|
||||
function sameDetails(a: unknown, b: unknown): boolean {
|
||||
return JSON.stringify(a) === JSON.stringify(b);
|
||||
}
|
||||
|
||||
export interface SessionState {
|
||||
messages: LhViewMessage[];
|
||||
nativeMessages: LhMessage[];
|
||||
|
||||
Reference in New Issue
Block a user