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:
+60
-1
@@ -404,6 +404,10 @@ describe("teacher chat flow (scripted model)", () => {
|
||||
// the reaction AFTER the boundary injection (records its prompt).
|
||||
let release!: () => void;
|
||||
const gate = new Promise<void>((r) => (release = r));
|
||||
let release2!: () => void;
|
||||
const gate2 = new Promise<void>((r) => (release2 = r));
|
||||
let jobIdMid = "";
|
||||
let jobFlippedMidTurn: string | undefined;
|
||||
const seen: string[] = [];
|
||||
const faux = createFauxCore({});
|
||||
faux.setResponses([
|
||||
@@ -414,8 +418,14 @@ describe("teacher chat flow (scripted model)", () => {
|
||||
{ stopReason: "toolUse" },
|
||||
);
|
||||
},
|
||||
(context) => {
|
||||
async (context) => {
|
||||
seen.push(context.messages.map((m) => JSON.stringify((m as { content: unknown }).content)).join("\n"));
|
||||
// HOLD the turn open past the injection: the marker's
|
||||
// message_end already fired, so the job must be flipped
|
||||
// done NOW (dock fix, owner 2026-09-26) — not at this
|
||||
// turn's settle.
|
||||
jobFlippedMidTurn = jobStore.getJob(jobIdMid)?.status;
|
||||
await gate2;
|
||||
return fauxAssistantMessage([fauxText("Folded the marker in.")]);
|
||||
},
|
||||
]);
|
||||
@@ -437,14 +447,21 @@ describe("teacher chat flow (scripted model)", () => {
|
||||
// steering queue (vehicle 1) — the queued marker waits for the
|
||||
// in-flight turn to complete so the injection lands at its boundary.
|
||||
const job = jobStore.createJob({ kind: "research", chatId: chat.id, toolCallId: "call-mid" });
|
||||
jobIdMid = job.id;
|
||||
// The runner stores the result BEFORE delivery (finalizeJob reads it).
|
||||
jobStore.updateJob(job.id, { result: { brief: "Mid-run brief.", sources: [] } });
|
||||
const steer = buildSubagentSteer(job, { brief: "Mid-run brief.", sources: [] });
|
||||
const delivery = deliverJobResult(steer);
|
||||
await new Promise((r) => setTimeout(r, 300)); // vehicle 1 hands off
|
||||
assert.equal(wakes, 0, "no wake — the live run owns the delivery");
|
||||
|
||||
release(); // step 1 completes → tool result → TURN BOUNDARY → inject → step 2
|
||||
// Step 2 has pended briefly — give the wire seam a beat, then release.
|
||||
await new Promise((r) => setTimeout(r, 150));
|
||||
release2();
|
||||
await drain(await res);
|
||||
await delivery; // resolves at settleSteers (the run persisted the marker)
|
||||
assert.equal(jobFlippedMidTurn, "done", "the job flipped at the marker's message_end — WHILE the turn still streamed");
|
||||
|
||||
// Record shape: the marker sits BETWEEN completed turn units — after
|
||||
// the toolResult it must never split, adjacent to it.
|
||||
@@ -467,6 +484,48 @@ describe("teacher chat flow (scripted model)", () => {
|
||||
assert.equal(wakes, 0, "delivery rode the live run end to end");
|
||||
});
|
||||
|
||||
it("the view canon HOISTS reasoning above the message text (owner ruling 2026-09-26)", () => {
|
||||
// GLM emits trailing reasoning tokens — the persisted content lands
|
||||
// [text, thinking] (and [text, thinking, toolCall]), which arrival-order
|
||||
// presentation rendered as reasoning UNDER its message. The projection
|
||||
// stable-partitions: reasoning parts first (their own order), then the
|
||||
// rest — live partials and settled records alike.
|
||||
const baseMessage = {
|
||||
role: "assistant",
|
||||
api: "openai-completions",
|
||||
provider: "openrouter",
|
||||
model: "test",
|
||||
usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
||||
timestamp: 1,
|
||||
};
|
||||
const trailingThink: LhMessage = {
|
||||
...baseMessage,
|
||||
id: "a_trail",
|
||||
content: [
|
||||
{ type: "text", text: "The answer text." },
|
||||
{ type: "thinking", thinking: "Checked the map afterwards." },
|
||||
{ type: "toolCall", id: "call-tr", name: "quiz", arguments: { question: "q", options: [{ label: "a" }], correctAnswer: "a", explanation: "" } },
|
||||
],
|
||||
stopReason: "toolUse",
|
||||
} as never;
|
||||
const view = projectView([{ id: "u0", role: "user", content: "hi", timestamp: 1 } as never, trailingThink]);
|
||||
assert.equal(view.length, 2);
|
||||
const parts = view[1]!.parts.filter((p) => p.type !== "step-start");
|
||||
const order = parts.map((p) => p.type);
|
||||
assert.equal(order[0], "reasoning", "reasoning renders FIRST regardless of content order");
|
||||
assert.ok(order.indexOf("text") > order.indexOf("reasoning"), "text follows the reasoning");
|
||||
assert.equal(order[order.length - 1], "tool-quiz", "the tool call stays last");
|
||||
// The canonical think-first shape is unchanged.
|
||||
const leadingThink: LhMessage = {
|
||||
...baseMessage,
|
||||
id: "a_lead",
|
||||
stopReason: "stop",
|
||||
content: [{ type: "thinking", thinking: "First." }, { type: "text", text: "The answer text." }],
|
||||
} as never;
|
||||
const view2 = projectView([{ id: "u1", role: "user", content: "hi", timestamp: 1 } as never, leadingThink]);
|
||||
assert.deepEqual(view2[1]!.parts.filter((p) => p.type !== "step-start").map((p) => p.type), ["reasoning", "text"], "think-first messages project identically");
|
||||
});
|
||||
|
||||
it("a cancelled view NEVER aborts the turn — it runs to completion (owner ruling 2026-09-25)", async () => {
|
||||
let release!: () => void;
|
||||
const gate = new Promise<void>((r) => (release = r));
|
||||
|
||||
Reference in New Issue
Block a user