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:
+38
-18
@@ -212,14 +212,15 @@ export function Chat({
|
|||||||
// The reattach settled terminal → the sync-and-continue seam (below).
|
// The reattach settled terminal → the sync-and-continue seam (below).
|
||||||
setReattachSettled(() => reattachSettledRef.current());
|
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);
|
const sentInitial = useRef(false);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!sentInitial.current && initialPrompt && initialMessages.length === 0) {
|
if (!sentInitial.current && initialPromptOwed(initialMessages, initialPrompt)) {
|
||||||
sentInitial.current = true;
|
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
|
// 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
|
// 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
|
// assistant group — is gone). Never a user bubble: the wake is
|
||||||
// scaffolding; the model-facing instruction lives in the adapter.
|
// scaffolding; the model-facing instruction lives in the adapter.
|
||||||
<PhaseDivider key={m.id || `m-${mi}`} phase="plan" />
|
<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}`}>
|
<Fragment key={m.id || `m-${mi}`}>
|
||||||
{/* THE INFERRED TEACH SEAM (owner ruling 2026-09-26, the verdict
|
{/* THE INFERRED TEACH SEAM (owner ruling 2026-09-26, the verdict
|
||||||
@@ -1259,20 +1280,6 @@ export function Chat({
|
|||||||
);
|
);
|
||||||
case "step-start":
|
case "step-start":
|
||||||
return pi > 0 ? <Separator key={pi} className="my-3" /> : null;
|
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":
|
case "data-plan-block":
|
||||||
// The plan in the chat (wave 4): a frozen snapshot
|
// The plan in the chat (wave 4): a frozen snapshot
|
||||||
// block — rendered as carried, never re-derived.
|
// 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
|
* then setup (self-closing — the gate only must not fire), then the
|
||||||
* 2026-10-02 abort chain proper (question before turn). */
|
* 2026-10-02 abort chain proper (question before turn). */
|
||||||
export type EscAbortTarget = "mermaid" | "plan" | "setup" | "question" | "problem" | "turn" | null;
|
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: {
|
export function escAbortTarget(s: {
|
||||||
abortAsk: boolean;
|
abortAsk: boolean;
|
||||||
mermaidFocus: boolean;
|
mermaidFocus: boolean;
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ const { toLlmMessages } = await import("../src/core/native-messages.ts");
|
|||||||
import type { LhMessage } from "../src/core/native-messages.ts";
|
import type { LhMessage } from "../src/core/native-messages.ts";
|
||||||
const { projectView } = await import("../src/core/view.ts");
|
const { projectView } = await import("../src/core/view.ts");
|
||||||
const { POST } = await import("../app/api/chats/route.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 { chatStore } = await import("../src/store/chats.ts");
|
||||||
const { graphStore } = await import("../src/store/graph.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) ─────────────────────────────────────
|
// ── the route (picks ride the creation) ─────────────────────────────────────
|
||||||
|
|
||||||
describe("POST /api/chats — graphContext becomes message[0]", () => {
|
describe("POST /api/chats — graphContext becomes message[0]", () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user