fix: the marker-only first turn — dead send + empty bubble

Owner report, 2026-10-04: starting a session with map picks showed ONE
empty user bubble, no agent turn; a follow-up woke the agent with the
right material but the marker never rendered. Two breaks, one root —
the record no longer starts empty and user-side data parts had no
render lane:

- The first-send gate checked initialMessages.length === 0; the
  graph-context marker at message[0] made that false, so the ?prompt
  turn NEVER fired. New pure helper initialPromptOwed (the escAbortTarget
  pattern): the prompt is owed while no user/assistant message exists —
  scaffolding is not conversation. Pinned in tests/graph-context.test.ts.
- The transcript's user branch rendered text parts only; a data-only
  message (graph-context, practice-thinking) fell through as an EMPTY
  bubble — the data-practice-thinking case I anchored to lived in the
  ASSISTANT switch, dead on arrival. Data-only user messages now hoist
  to a bubble-free card lane (the subagent-steer precedent); the two
  dead assistant-switch cases are gone.

Gates: 1296 pass / 0 fail; typecheck, lint, build clean.
This commit is contained in:
amosblomqvist
2026-10-04 23:15:40 +03:00
parent edfac20bf3
commit 4640117ba7
2 changed files with 59 additions and 18 deletions
+38 -18
View File
@@ -212,14 +212,15 @@ export function Chat({
// The reattach settled terminal → the sync-and-continue seam (below).
setReattachSettled(() => reattachSettledRef.current());
// New chat with a topic: send the first message exactly once.
// New chat with a topic: send the first message exactly once
// (initialPromptOwed — the pure gate, pinned in the test suite).
const sentInitial = useRef(false);
useEffect(() => {
if (!sentInitial.current && initialPrompt && initialMessages.length === 0) {
if (!sentInitial.current && initialPromptOwed(initialMessages, initialPrompt)) {
sentInitial.current = true;
void send({ type: "text", text: initialPrompt });
void send({ type: "text", text: initialPrompt! });
}
}, [initialPrompt, initialMessages.length, send]);
}, [initialPrompt, initialMessages, send]);
// A turn running server-side, attached to no live view of ours (navigate
// away mid-turn, or a wake turn discovered mid-session): the reattach
@@ -1202,6 +1203,26 @@ export function Chat({
// assistant group — is gone). Never a user bubble: the wake is
// scaffolding; the model-facing instruction lives in the adapter.
<PhaseDivider key={m.id || `m-${mi}`} phase="plan" />
) : m.role === "user" && m.parts.length > 0 && m.parts.every((p) => p.type.startsWith("data-")) ? (
// Marker cards (custom messages, 2026-10-04): user-side
// scaffolding — the graph-context pick, the practice
// thinking trace — render as cards in the transcript
// column directly, NO chat bubble (the subagent-steer
// precedent). The user branch below renders text only;
// without this hoist a data-only message read as an
// EMPTY bubble (owner report, 2026-10-04).
<Fragment key={m.id || `m-${mi}`}>
{m.parts.map((part, pi) => {
switch (part.type) {
case "data-graph-context":
return <GraphContextCard key={pi} data={part.data as GraphContextData} />;
case "data-practice-thinking":
return <ThinkingCard key={pi} data={part.data as PracticeThinkingData} />;
default:
return null;
}
})}
</Fragment>
) : (
<Fragment key={m.id || `m-${mi}`}>
{/* THE INFERRED TEACH SEAM (owner ruling 2026-09-26, the verdict
@@ -1259,20 +1280,6 @@ export function Chat({
);
case "step-start":
return pi > 0 ? <Separator key={pi} className="my-3" /> : null;
case "data-graph-context":
// The session's map pick (2026-10-04): a minimal
// collapsible card — scaffolding above the first
// user message, never a bubble.
return (
<GraphContextCard key={pi} data={part.data as GraphContextData} />
);
case "data-practice-thinking":
// The learner's practice submission (2026-10-04):
// user-side — the register (thinking trace /
// committed answer) is the card's title.
return (
<ThinkingCard key={pi} data={part.data as PracticeThinkingData} />
);
case "data-plan-block":
// The plan in the chat (wave 4): a frozen snapshot
// block — rendered as carried, never re-derived.
@@ -1508,6 +1515,19 @@ export function Chat({
* then setup (self-closing — the gate only must not fire), then the
* 2026-10-02 abort chain proper (question before turn). */
export type EscAbortTarget = "mermaid" | "plan" | "setup" | "question" | "problem" | "turn" | null;
/**
* Does the ?prompt topic still owe its first send? The gate is "no real
* conversation yet" (no user/assistant message) — NOT emptiness: a
* session started with map picks carries a graph-context MARKER as
* message[0], and that scaffolding must never swallow the first send
* (owner report, 2026-10-04: the turn went dead behind it — the old
* `length === 0` check saw the marker and refused to fire).
*/
export function initialPromptOwed(messages: readonly { role: string }[], prompt: string | undefined): boolean {
if (!prompt) return false;
return !messages.some((m) => m.role === "user" || m.role === "assistant");
}
export function escAbortTarget(s: {
abortAsk: boolean;
mermaidFocus: boolean;
+21
View File
@@ -29,6 +29,7 @@ const { toLlmMessages } = await import("../src/core/native-messages.ts");
import type { LhMessage } from "../src/core/native-messages.ts";
const { projectView } = await import("../src/core/view.ts");
const { POST } = await import("../app/api/chats/route.ts");
const { initialPromptOwed } = await import("../components/chat.tsx");
const { chatStore } = await import("../src/store/chats.ts");
const { graphStore } = await import("../src/store/graph.ts");
@@ -80,6 +81,26 @@ describe("newGraphContextMessage", () => {
});
});
// ── the first-send gate (the dead first turn, owner report 2026-10-04) ──────
describe("initialPromptOwed", () => {
it("a marker-only record still owes the prompt — scaffolding is not conversation", () => {
const marker = newGraphContextMessage(PICKS, []);
assert.equal(initialPromptOwed([marker as never], "drill me on logic"), true);
});
it("once a user or assistant message exists, the prompt is spent", () => {
const marker = newGraphContextMessage(PICKS, []);
const user = { role: "user" } as never;
assert.equal(initialPromptOwed([marker as never, user], "drill me"), false);
assert.equal(initialPromptOwed([{ role: "assistant" } as never], "drill me"), false);
});
it("no prompt → nothing owed (the empty-draft case)", () => {
assert.equal(initialPromptOwed([], undefined), false);
});
});
// ── the route (picks ride the creation) ─────────────────────────────────────
describe("POST /api/chats — graphContext becomes message[0]", () => {