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
+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]", () => {