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:
+30
-7
@@ -181,13 +181,20 @@ export function projectView(messages: readonly LhMessage[], live?: LiveTurnState
|
||||
const reasoning: LhViewPart[] = [];
|
||||
let textN = 0;
|
||||
let reasoningN = 0;
|
||||
// LIVE IS POSITIVE EVIDENCE (owner, 2026-10-05): a call renders
|
||||
// live ONLY when its own message is streaming THIS turn — session
|
||||
// busyness alone can never resurrect a settled call (the practice
|
||||
// bug: a thinking submission flipped the POSE turn's problem call
|
||||
// back to a spinner mid-transcript; live cards belong at the
|
||||
// bottom, and this gate is what puts them there by construction).
|
||||
const streamingMessage = live?.streamingIds.has(m.id) === true;
|
||||
for (const block of assistant.content) {
|
||||
if (block.type === "text") {
|
||||
rest.push({ type: "text", id: `${m.id}-t${textN++}`, text: block.text, state: "done" });
|
||||
} else if (block.type === "thinking") {
|
||||
reasoning.push({ type: "reasoning", id: `${m.id}-r${reasoningN++}`, text: block.thinking, state: "done" });
|
||||
} else if (block.type === "toolCall") {
|
||||
rest.push(toolPartOf(block, live));
|
||||
rest.push(toolPartOf(block, live, streamingMessage));
|
||||
}
|
||||
}
|
||||
const parts: LhViewPart[] = [...reasoning, ...rest];
|
||||
@@ -232,10 +239,15 @@ function isPlaceholderResultTool(name: string): boolean {
|
||||
return (QUESTION_TOOLS as readonly string[]).includes(name) || name === "problem";
|
||||
}
|
||||
|
||||
/** A tool call block → its view part, with the live execution layer. */
|
||||
/** A tool call block → its view part, with the live execution layer.
|
||||
* `streamingMessage` — POSITIVE EVIDENCE (owner, 2026-10-05): this call's
|
||||
* own message is streaming THIS turn. The input-streaming shimmer keys on
|
||||
* it, never on session busyness: a call from a settled message can never
|
||||
* render live, whatever else is running. */
|
||||
function toolPartOf(
|
||||
block: { id: string; name: string; arguments?: unknown },
|
||||
live: LiveTurnState | undefined,
|
||||
streamingMessage: boolean,
|
||||
): LhViewPart {
|
||||
const part: LhViewPart = {
|
||||
type: `tool-${block.name}`,
|
||||
@@ -262,10 +274,11 @@ function toolPartOf(
|
||||
part.output = exec.output;
|
||||
}
|
||||
} else {
|
||||
// No execution yet: mid-turn it is input-streaming (the shimmer
|
||||
// activity); in a settled record a dangling call is the dock's
|
||||
// pending question (input-available).
|
||||
part.state = live?.inFlight ? "input-streaming" : "input-available";
|
||||
// No execution yet: live ONLY on positive evidence — the call's own
|
||||
// message is streaming this turn (its arguments may literally be
|
||||
// mid-write). Anything else is a dangling call from a settled
|
||||
// message — the dock's pending state, never a shimmer.
|
||||
part.state = streamingMessage ? "input-streaming" : "input-available";
|
||||
}
|
||||
return part;
|
||||
}
|
||||
@@ -280,7 +293,17 @@ function applyResultTo(part: LhViewPart | undefined, result: ToolResultMessage):
|
||||
// AnsweredCard a null and crash the render (the live crash, 2026-09-26).
|
||||
const tool = part.type.startsWith("tool-") ? part.type.slice("tool-".length) : part.type;
|
||||
const isQuestion = isPlaceholderResultTool(tool);
|
||||
if (isQuestion && !result.isError && result.details == null) return;
|
||||
if (isQuestion && !result.isError && result.details == null) {
|
||||
// The placeholder fork (fork 2) stands: the clean pose/ask ack carries
|
||||
// no card data — NEVER output-available. But the call IS settled
|
||||
// (the practice bug, 2026-10-05: this early-return left the part on
|
||||
// whatever toolPartOf guessed, which mid-turn resurrected a settled
|
||||
// problem as a live spinner). input-available is the settled truth:
|
||||
// the ProblemCard reads the call's input, the dock keys on the
|
||||
// native fold — neither wants the ack's (null) payload.
|
||||
part.state = "input-available";
|
||||
return;
|
||||
}
|
||||
applyResult(part, result);
|
||||
}
|
||||
// A result with no visible part (e.g. a call whose assistant message
|
||||
|
||||
Reference in New Issue
Block a user