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:
amosblomqvist
2026-10-05 01:02:43 +03:00
parent 85022ae143
commit 9ccb33ea3b
3 changed files with 157 additions and 8 deletions
+22
View File
@@ -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[];