fix: reasoning always renders above its message; dock drops delivered subagents at delivery

TWO owner-reported bugs from the live red-circle session
(chat_muhpyoll_27305e23), both investigated to root cause first:

1. THINKING BLOCKS UNDER THE MESSAGE. GLM emits trailing reasoning
   tokens, so the persisted content lands [text, thinking] (4 of the 6
   thinking-bearing messages from [21] on in that session) - and the
   view canon presented arrival order verbatim, rendering the reasoning
   UNDER its own message. Fix: projectView stable-partitions each
   assistant message - reasoning parts first (their own order kept),
   then text/tool parts. Owner ruling 2026-09-26: the reasoning belongs
   above, always. Covers live partials and settled records alike (one
   projection canon). NOT addressed (owner ruling): the plan_tools
   "missing" claim in the same session - wire-verified 11 tools incl.
   plan_init; GLM confabulation, no mitigation wanted.

2. DOCK CARDS LINGERING AFTER DELIVERY. /api/activity lists jobs with
   status "running", and the status flipped only in settleSteers at the
   DELIVERING TURN'S onFinish - so a subagent whose card was already
   visible in the transcript (marker injected mid-run) stayed in the
   background dock until the teacher finished teaching. Fix: flip the
   job at the moment the result reaches the learner - noteSteerVisible
   (jobs.ts) called from BOTH visibility seams: the marker's message_end
   wire event (live steer injection, ui-stream) and the write-ahead
   persist (the wake's input appends, chat-handler). finalizeJob is
   idempotent; settleSteers/runner-200 re-run it as a no-op, and the
   registry's delivery promise still settles exactly as before.

Tests: 1001/1001 - view-canon hoist pin (trailing [text, think, call]
projects reasoning-first; think-first unchanged), noteSteerVisible unit
pin (flip at the seam, idempotent, handoff no-op), and the mid-run
steering e2e now holds the turn open past the injection and pins the
job flipping done at the marker's message_end WHILE the turn still
streams. Live-verified read-only on the owner's server: the original
session reloads with every reasoning trigger above its message.
This commit is contained in:
amosblomqvist
2026-09-26 05:46:33 +03:00
parent ad0a6e5201
commit b84ab1cdc0
6 changed files with 129 additions and 8 deletions
+14 -4
View File
@@ -156,18 +156,28 @@ export function projectView(messages: readonly LhMessage[], live?: LiveTurnState
if (role === "assistant") {
const assistant = m as AssistantMessage;
const parts: LhViewPart[] = [];
// OWNER RULING (2026-09-26): reasoning ALWAYS renders above the
// message it belongs to. Providers emit trailing reasoning tokens
// (GLM fires a final reasoning burst after the answer), which
// lands the thinking block AFTER the text in the persisted
// content — presenting arrival order verbatim put the reasoning
// UNDER the message. The view canon owns presentation order:
// stable-partition each assistant message, reasoning parts first
// (their own order kept), then everything else.
const rest: LhViewPart[] = [];
const reasoning: LhViewPart[] = [];
let textN = 0;
let reasoningN = 0;
for (const block of assistant.content) {
if (block.type === "text") {
parts.push({ type: "text", id: `${m.id}-t${textN++}`, text: block.text, state: "done" });
rest.push({ type: "text", id: `${m.id}-t${textN++}`, text: block.text, state: "done" });
} else if (block.type === "thinking") {
parts.push({ type: "reasoning", id: `${m.id}-r${reasoningN++}`, text: block.thinking, state: "done" });
reasoning.push({ type: "reasoning", id: `${m.id}-r${reasoningN++}`, text: block.thinking, state: "done" });
} else if (block.type === "toolCall") {
parts.push(toolPartOf(block, live));
rest.push(toolPartOf(block, live));
}
}
const parts: LhViewPart[] = [...reasoning, ...rest];
if (parts.length === 0) continue; // presentation-only (nothing renderable)
const group = lastAssistant();
if (group) {