From eb7454900cbf9350a083627df93ce47114b33f31 Mon Sep 17 00:00:00 2001 From: amosblomqvist Date: Sun, 4 Oct 2026 21:41:29 +0300 Subject: [PATCH] practice record mechanics: active-problem fold, thinking channel, deferred conclude MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The native shapes beneath the practice tools (owner design 2026-10-04): - activeProblemOf — the conclude-before-next-problem gate's fold (a conclude call OR a dismissal marker deactivates; the pose-time ack never does — nudges keep the card up). - The thinking input: {problemCallId, commit, text} → practice-thinking marker; the label (THINKING_HINT/COMMIT_LABEL, single-sourced in contracts) is the only register difference the model sees. - Ask-style problem dismissal: silent route appends the UI-only marker; the user's NEXT input prepends the synthesized conclude pair (call outcome:'dismissed' + shared ack) so the agent reads the discard in its own vocabulary and the slot frees. - toLlmMessages: dismissal marker filtered; pair passes natively. --- src/core/contracts/chat.ts | 16 +++ src/core/input-events.ts | 67 ++++++++- src/core/native-messages.ts | 173 +++++++++++++++++++++- tests/practice-record.test.ts | 264 ++++++++++++++++++++++++++++++++++ 4 files changed, 512 insertions(+), 8 deletions(-) create mode 100644 tests/practice-record.test.ts diff --git a/src/core/contracts/chat.ts b/src/core/contracts/chat.ts index 64bb29b..a3a4007 100644 --- a/src/core/contracts/chat.ts +++ b/src/core/contracts/chat.ts @@ -179,6 +179,22 @@ export const HANDOFF_TEXT = "Take your time here and really think this through. " + "When it's genuinely reasoned out, call plan_init and present the plan; I'll accept it or ask for changes."; +// ─── Practice sessions (owner, 2026-10-04) — marker + ack texts ────────────── +// Single-sourced here per the marker law: the model-facing text of every +// custom marker lives in core/contracts, never in the flattening seam. + +/** The model-facing register of a thinking submission (newThinkingMessage + * prefixes the learner's text with this label). Hints and commits ride + * one channel — the label is the ONLY difference the model sees. */ +export const THINKING_HINT_LABEL = "Thinking trace (working, not committed)"; +export const THINKING_COMMIT_LABEL = "Committed answer"; + +/** The conclude tool's ack — shared by the executed call (agent layer) and + * the dismissal-synthesized pair (input-events), so a concluded problem + * reads identically to the model however it concluded. */ +export const CONCLUDE_ACK_TEXT = + "Problem concluded — the composer is free; pose the next problem or wrap up."; + export interface PlanBlockData { /** Only "full" survives (the 2026-09-11 pivot): the plan renders IN diff --git a/src/core/input-events.ts b/src/core/input-events.ts index 318e811..52f2c4e 100644 --- a/src/core/input-events.ts +++ b/src/core/input-events.ts @@ -28,9 +28,14 @@ import type { QuestionAnswerData } from "./question-core.ts"; import type { LhMessage } from "./native-messages.ts"; import { + activeProblemOf, + concludedByDismissalPair, danglingQuestionOf, newAnswerResult, + newProblemDismissedMarker, + newThinkingMessage, newUserMessage, + pendingProblemDismissalOf, } from "./native-messages.ts"; import { QUESTION_TOOLS, toolQuestionKind } from "./pending.ts"; @@ -38,7 +43,12 @@ import { QUESTION_TOOLS, toolQuestionKind } from "./pending.ts"; export type ChatInput = | { type: "text"; text: string } | { type: "answer"; toolCallId: string; answer: QuestionAnswerData } - | { type: "continue" }; + | { type: "continue" } + /** The practice dock's two buttons (owner, 2026-10-04): the learner's + * thinking on the ACTIVE problem — commit=false is the hint request + * (working, not committed), commit=true the answer commit. One + * problem may carry many submissions; each is its own turn. */ + | { type: "thinking"; problemCallId: string; commit: boolean; text: string }; /** A wake-pipeline steer delivery — same turn composition, server-origin. * Wave C (2026-09-26): the idle wake carries EVERY queued marker as its @@ -56,12 +66,23 @@ export type TurnInput = ChatInput | SteerInput; * BE a dismissal: the silent path must never inject a live answer * wake-free. Parallel question calls each get their own dismiss event; * results reference their calls by id, so the appends stay independent. */ -export type DismissInput = { type: "dismiss"; toolCallId: string; answer: QuestionAnswerData }; +export type DismissInput = + | { type: "dismiss"; toolCallId: string; answer: QuestionAnswerData } + /** The problem discard (practice, owner 2026-10-04): ask-style — the + * silent route appends the UI-only marker and wakes nothing; the + * conclude pair rides the user's NEXT input (inputAppends). */ + | { type: "dismiss-problem"; problemCallId: string }; /** Parse and validate a dismissal event off a request body. */ export function parseDismissInput(input: unknown): DismissInput { if (typeof input !== "object" || input === null) throw new ChatInputError("input must be an object"); const i = input as Record; + if (i.type === "dismiss-problem") { + if (typeof i.problemCallId !== "string" || i.problemCallId.length === 0) { + throw new ChatInputError("dismiss-problem input requires problemCallId"); + } + return { type: "dismiss-problem", problemCallId: i.problemCallId }; + } if (i.type !== "dismiss") throw new ChatInputError(`unknown input type: ${String(i.type)}`); if (typeof i.toolCallId !== "string" || i.toolCallId.length === 0) { throw new ChatInputError("dismiss input requires toolCallId"); @@ -100,8 +121,16 @@ export function parseUserSteerInput(input: unknown): UserSteerInput { } /** The native appends a dismissal makes (pure — same orphan discipline - * as answers: the call must be a DANGLING question call). */ + * as answers: the call must be a DANGLING question call, or — the + * practice variant — the ACTIVE problem). */ export function dismissAppends(record: { messages: LhMessage[] }, input: DismissInput): LhMessage[] { + if (input.type === "dismiss-problem") { + const active = activeProblemOf(record.messages); + if (!active || active.toolCallId !== input.problemCallId) { + throw new ChatInputError("dismiss-problem input targets no active problem"); + } + return [newProblemDismissedMarker(input.problemCallId)]; + } const pending = danglingQuestionOf(record.messages); if (!pending || pending.toolCallId !== input.toolCallId) { throw new ChatInputError("dismiss input targets no pending question call"); @@ -135,6 +164,17 @@ export function parseChatInput(input: unknown): ChatInput { return { type: "answer", toolCallId: i.toolCallId, answer: i.answer as QuestionAnswerData }; case "continue": return { type: "continue" }; + case "thinking": + if (typeof i.problemCallId !== "string" || i.problemCallId.length === 0) { + throw new ChatInputError("thinking input requires problemCallId"); + } + if (typeof i.commit !== "boolean") { + throw new ChatInputError("thinking input requires commit (boolean)"); + } + if (typeof i.text !== "string" || i.text.trim().length === 0) { + throw new ChatInputError("thinking input requires non-empty text"); + } + return { type: "thinking", problemCallId: i.problemCallId, commit: i.commit, text: i.text }; default: throw new ChatInputError(`unknown input type: ${String(i.type)}`); } @@ -152,7 +192,7 @@ export function parseChatInput(input: unknown): ChatInput { export function inputAppends(record: { messages: LhMessage[] }, input: ChatInput): LhMessage[] { switch (input.type) { case "text": - return [newUserMessage(input.text)]; + return [...dismissalConclusion(record), newUserMessage(input.text)]; case "answer": { const pending = danglingQuestionOf(record.messages); if (!pending || pending.toolCallId !== input.toolCallId) { @@ -161,10 +201,27 @@ export function inputAppends(record: { messages: LhMessage[] }, input: ChatInput return [newAnswerResult(input.toolCallId, pending.tool, input.answer)]; } case "continue": - return []; + return dismissalConclusion(record); + case "thinking": { + const active = activeProblemOf(record.messages); + if (!active || active.toolCallId !== input.problemCallId) { + throw new ChatInputError("thinking input targets no active problem"); + } + return [...dismissalConclusion(record), newThinkingMessage(input.problemCallId, input.commit, input.text)]; + } } } +/** The deferred conclude synthesis (owner ruling, 2026-10-04): a + * problem-dismissal marker waits for the user's next input — the pair + * (synthetic conclude call + ack) rides ahead of it, so the agent reads + * the dismissal through its own vocabulary and the slot frees. Pure; + * empty when nothing dismissals pend. */ +function dismissalConclusion(record: { messages: LhMessage[] }): LhMessage[] { + const problemCallId = pendingProblemDismissalOf(record.messages); + return problemCallId ? concludedByDismissalPair(problemCallId) : []; +} + /** Is this tool name a question tool (dock-owned, answer-gated)? */ export function isQuestionTool(name: string): boolean { return (QUESTION_TOOLS as readonly string[]).includes(name); diff --git a/src/core/native-messages.ts b/src/core/native-messages.ts index d15aaf2..d3cebee 100644 --- a/src/core/native-messages.ts +++ b/src/core/native-messages.ts @@ -36,7 +36,12 @@ import type { AgentMessage } from "@earendil-works/pi-agent-core"; import type { AssistantMessage, Message, ToolResultMessage, UserMessage } from "@earendil-works/pi-ai"; import { QUESTION_TOOLS } from "./pending.ts"; -import { HANDOFF_TEXT } from "./contracts/chat.ts"; +import { + CONCLUDE_ACK_TEXT, + HANDOFF_TEXT, + THINKING_COMMIT_LABEL, + THINKING_HINT_LABEL, +} from "./contracts/chat.ts"; import { compactAnswerForModel, type QuestionAnswerData } from "./question-core.ts"; /** The hard-invariant wording for an aborted turn's unanswered calls — @@ -59,7 +64,13 @@ export interface LhMarkerMessage { /** Record identity (pi messages carry no id — see the file header). */ id: string; role: "custom"; - customType: "probe-handoff" | "subagent-steer" | "subagent-abort" | "plan-block"; + customType: + | "probe-handoff" + | "subagent-steer" + | "subagent-abort" + | "plan-block" + | "practice-thinking" + | "problem-dismissed"; /** The model-facing text ("" for plan blocks — UI-only). */ content: string; display: boolean; @@ -169,6 +180,160 @@ export function newPlanBlockMessage( }; } +// ─── practice sessions (owner, 2026-10-04) ─────────────────────────────────── + +/** The practice agent's two tools (the conclude-before-next-problem gate's + * vocabulary — the tool-execute layer owns the tools themselves). */ +export const PRACTICE_PROBLEM_TOOL = "problem"; +export const PRACTICE_CONCLUDE_TOOL = "conclude"; + +/** The learner's thinking submission (the practice dock's two buttons): + * a user-register message the model reads WITH its label (hint vs + * commit — the register is the whole difference), while `details` keeps + * the raw payload for the transcript card. One problem may carry many + * submissions. */ +export function newThinkingMessage(problemCallId: string, commit: boolean, text: string): LhMarkerMessage { + const label = commit ? THINKING_COMMIT_LABEL : THINKING_HINT_LABEL; + return { + id: newMessageId(), + role: "custom", + customType: "practice-thinking", + content: `${label}:\n${text}`, + display: true, + details: { problemCallId, commit, text }, + timestamp: Date.now(), + }; +} + +/** The problem-dismissal marker (ask-style discard, owner 2026-10-04): + * UI-ONLY — toLlmMessages filters it. The model's face arrives later, at + * the user's next input, as the synthesized conclude pair + * (concludedByDismissalPair below) — the agent sees the dismissal as a + * conclude call it "made" with outcome "dismissed", so the slot-freed + * state and the dismissal read through the one vocabulary it owns. */ +export function newProblemDismissedMarker(problemCallId: string): LhMarkerMessage { + return { + id: newMessageId(), + role: "custom", + customType: "problem-dismissed", + content: "", + display: true, + details: { problemCallId }, + timestamp: Date.now(), + }; +} + +/** The dismissal's deferred conclude (owner ruling 2026-10-04): the pair + * prepended to the user's NEXT input — a synthetic assistant message + * carrying a conclude call (outcome "dismissed") plus its ack result, so + * both the record and the model context read the problem as concluded + * by dismissal, slot freed. Pure; ids are deterministic off the problem + * call id (idempotent shape — the pending-dismissal gate guarantees the + * pair appends at most once per marker). */ +export function concludedByDismissalPair( + problemCallId: string, +): [LhMessage & AssistantMessage, LhMessage & ToolResultMessage] { + const callId = `call_conc_${problemCallId}`; + const now = Date.now(); + // The stamps are placeholders by design: history serialization reads + // ONLY the content blocks (verified in pi-ai's converters), and the + // app's money channel is the parallel turns log, never message + // objects — so "synthetic" never corrupts a wire view or a cost fold. + const syntheticStamps = { + api: "faux", + provider: "synthetic", + model: "synthetic-conclude", + usage: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + totalTokens: 0, + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, + }, + stopReason: "toolUse", + } as const; + return [ + { + id: newMessageId(), + role: "assistant", + content: [{ type: "toolCall", id: callId, name: PRACTICE_CONCLUDE_TOOL, arguments: { outcome: "dismissed" } }], + timestamp: now, + ...syntheticStamps, + }, + { + id: newMessageId(), + role: "toolResult", + toolCallId: callId, + toolName: PRACTICE_CONCLUDE_TOOL, + content: [{ type: "text", text: CONCLUDE_ACK_TEXT }], + details: { outcome: "dismissed", problemCallId }, + isError: false, + timestamp: now, + }, + ]; +} + +/** The active-problem fold (the conclude-before-next-problem gate): the + * NEWEST problem tool call with nothing concluding after it — either a + * conclude call or a dismissal marker deactivates. The ack result at pose + * time does NOT (the problem stays on screen through nudges; the dock's + * subject is the call, not a dangling result). */ +export function activeProblemOf( + messages: readonly LhMessage[], +): { toolCallId: string; input: unknown } | null { + for (let mi = messages.length - 1; mi >= 0; mi--) { + const m = messages[mi]!; + const role = (m as { role?: string }).role; + if (role === "custom") { + const marker = m as LhMarkerMessage; + if (marker.customType === "problem-dismissed") return null; + continue; + } + if (role === "assistant") { + const assistant = m as AssistantMessage & { id: string }; + for (let bi = assistant.content.length - 1; bi >= 0; bi--) { + const block = assistant.content[bi]!; + if (block.type !== "toolCall") continue; + if (block.name === PRACTICE_CONCLUDE_TOOL) return null; + if (block.name !== PRACTICE_PROBLEM_TOOL) continue; + return { toolCallId: block.id, input: block.arguments }; + } + continue; + } + } + return null; +} + +/** Is a problem-dismissal marker waiting for its conclude synthesis? The + * text/continue appends read this: the pair rides the user's next input + * (input-events). Newest-marker-wins scan: the first dismissal marker or + * conclude call from the end decides. */ +export function pendingProblemDismissalOf(messages: readonly LhMessage[]): string | null { + for (let mi = messages.length - 1; mi >= 0; mi--) { + const m = messages[mi]!; + const role = (m as { role?: string }).role; + if (role === "custom") { + const marker = m as LhMarkerMessage; + if (marker.customType === "problem-dismissed") { + return (marker.details as { problemCallId?: unknown }).problemCallId as string; + } + continue; + } + if (role === "assistant") { + const assistant = m as AssistantMessage; + for (let bi = assistant.content.length - 1; bi >= 0; bi--) { + const block = assistant.content[bi]!; + if (block.type !== "toolCall" && block.type !== "text") continue; + if (block.type === "toolCall" && block.name === PRACTICE_CONCLUDE_TOOL) return null; + if (block.type === "toolCall" && block.name === PRACTICE_PROBLEM_TOOL) return null; + } + continue; + } + } + return null; +} + // ─── the one model-facing seam (the agent's convertToLlm) ──────────────────── /** The ids of every toolResult in the tail (for the dangling-call scan). */ @@ -206,7 +371,9 @@ export function toLlmMessages(messages: readonly AgentMessage[]): Message[] { const role = (m as { role?: string }).role; if (role === "custom") { const marker = m as LhMarkerMessage; - if (marker.customType === "plan-block") continue; // UI-only + // UI-only markers: plan blocks, and problem dismissals (the + // synthesized conclude pair carries the model's face). + if (marker.customType === "plan-block" || marker.customType === "problem-dismissed") continue; out.push({ role: "user", content: [{ type: "text", text: marker.content }], diff --git a/tests/practice-record.test.ts b/tests/practice-record.test.ts new file mode 100644 index 0000000..629161c --- /dev/null +++ b/tests/practice-record.test.ts @@ -0,0 +1,264 @@ +/** + * PRACTICE RECORD MECHANICS (practice sessions, owner 2026-10-04): the + * native shapes beneath the two practice tools — the active-problem fold + * (conclude-before-next-problem), the thinking marker (label registers), + * the ask-style dismissal (UI-only marker; the conclude pair rides the + * NEXT input), and the model-facing flattening (markers labeled, the + * dismissal filtered, the synthesized pair native). + */ + +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; + +import type { AssistantMessage, ToolResultMessage } from "@earendil-works/pi-ai"; +import { + activeProblemOf, + asAgentMessages, + concludedByDismissalPair, + newProblemDismissedMarker, + newThinkingMessage, + newUserMessage, + pendingProblemDismissalOf, + toLlmMessages, + PRACTICE_CONCLUDE_TOOL, + PRACTICE_PROBLEM_TOOL, + type LhMessage, +} from "../src/core/native-messages.ts"; +import { CONCLUDE_ACK_TEXT } from "../src/core/contracts/chat.ts"; +import { dismissAppends, inputAppends, parseChatInput, parseDismissInput } from "../src/core/input-events.ts"; + +// ── builders (native-shaped test fixtures) ─────────────────────────────────── + +const STAMPS = { + api: "faux", + provider: "test", + model: "test-model", + usage: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + totalTokens: 0, + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, + }, + stopReason: "toolUse", +} as const; + +function problemPose(callId = "call_p1", statement = "Show that…"): LhMessage[] { + const assistant: LhMessage & AssistantMessage = { + id: `msg_a_${callId}`, + role: "assistant", + content: [ + { type: "toolCall", id: callId, name: PRACTICE_PROBLEM_TOOL, arguments: { statement, mode: "recall" } }, + ], + timestamp: 1, + ...STAMPS, + }; + const ack: LhMessage & ToolResultMessage = { + id: `msg_r_${callId}`, + role: "toolResult", + toolCallId: callId, + toolName: PRACTICE_PROBLEM_TOOL, + content: [{ type: "text", text: "Problem posed — the turn ends here." }], + details: null, + isError: false, + timestamp: 2, + }; + return [assistant, ack]; +} + +function concludeCall(outcome: string): LhMessage & AssistantMessage { + return { + id: `msg_c_${outcome}`, + role: "assistant", + content: [ + { + type: "toolCall", + id: `call_conclude_${outcome}`, + name: PRACTICE_CONCLUDE_TOOL, + arguments: { outcome }, + }, + ], + timestamp: 3, + ...STAMPS, + }; +} + +// ── the active-problem fold ────────────────────────────────────────────────── + +describe("activeProblemOf (the conclude-before-next-problem gate's fold)", () => { + it("no messages → nothing active", () => { + assert.equal(activeProblemOf([]), null); + }); + + it("a posed problem (call + ack) is ACTIVE — the ack does not conclude", () => { + const messages = problemPose(); + const active = activeProblemOf(messages); + assert.equal(active?.toolCallId, "call_p1"); + assert.deepEqual(active?.input, { statement: "Show that…", mode: "recall" }); + }); + + it("thinking submissions keep it active (nudges never conclude)", () => { + const messages = [ + ...problemPose(), + newThinkingMessage("call_p1", false, "stuck at the second step"), + ]; + assert.equal(activeProblemOf(messages)?.toolCallId, "call_p1"); + }); + + it("a conclude call deactivates", () => { + const messages = [...problemPose(), concludeCall("solved")]; + assert.equal(activeProblemOf(messages), null); + }); + + it("a dismissal marker deactivates immediately (the card closes at Esc)", () => { + const messages = [...problemPose(), newProblemDismissedMarker("call_p1")]; + assert.equal(activeProblemOf(messages), null); + }); + + it("a NEWER problem after a conclude is the active one", () => { + const messages = [ + ...problemPose("call_p1"), + concludeCall("solved"), + ...problemPose("call_p2", "second"), + ]; + assert.equal(activeProblemOf(messages)?.toolCallId, "call_p2"); + }); +}); + +// ── the dismissal's deferred conclude ──────────────────────────────────────── + +describe("pendingProblemDismissalOf (the marker waiting for its synthesis)", () => { + it("a trailing dismissal marker pends", () => { + const messages = [...problemPose(), newProblemDismissedMarker("call_p1")]; + assert.equal(pendingProblemDismissalOf(messages), "call_p1"); + }); + + it("the synthesized pair clears it", () => { + const messages = [...problemPose(), newProblemDismissedMarker("call_p1"), ...concludedByDismissalPair("call_p1")]; + assert.equal(pendingProblemDismissalOf(messages), null); + }); + + it("a plain record pends nothing", () => { + assert.equal(pendingProblemDismissalOf(problemPose()), null); + }); +}); + +// ── the input channel ──────────────────────────────────────────────────────── + +describe("the thinking input", () => { + it("parses and appends the marker against the active problem", () => { + const input = parseChatInput({ + type: "thinking", + problemCallId: "call_p1", + commit: false, + text: "I think the inactivity constraint applies here", + }); + assert.deepEqual(input, { + type: "thinking", + problemCallId: "call_p1", + commit: false, + text: "I think the inactivity constraint applies here", + }); + const appends = inputAppends({ messages: problemPose() }, input); + assert.equal(appends.length, 1); + const marker = appends[0] as { customType: string; content: string; details: unknown }; + assert.equal(marker.customType, "practice-thinking"); + assert.match(marker.content, /Thinking trace \(working, not committed\):\n/); + assert.deepEqual(marker.details, { + problemCallId: "call_p1", + commit: false, + text: "I think the inactivity constraint applies here", + }); + }); + + it("commit=true carries the committed register", () => { + const appends = inputAppends( + { messages: problemPose() }, + { type: "thinking", problemCallId: "call_p1", commit: true, text: "The answer is 42 because…" }, + ); + assert.match((appends[0] as { content: string }).content, /Committed answer:\n/); + }); + + it("400s against a stale or unknown problem call", () => { + assert.throws( + () => inputAppends({ messages: problemPose() }, { type: "thinking", problemCallId: "call_ghost", commit: true, text: "x" }), + /targets no active problem/, + ); + assert.throws( + () => inputAppends({ messages: [] }, { type: "thinking", problemCallId: "call_p1", commit: true, text: "x" }), + /targets no active problem/, + ); + }); + + it("empty text 400s at parse", () => { + assert.throws(() => parseChatInput({ type: "thinking", problemCallId: "c", commit: false, text: " " }), /non-empty/); + }); +}); + +describe("the dismissal's conclude synthesis rides the next input", () => { + it("text after a dismissal prepends the conclude pair before the user message", () => { + const record = { messages: [...problemPose(), newProblemDismissedMarker("call_p1")] }; + const appends = inputAppends(record, { type: "text", text: "that one was over my head" }); + assert.equal(appends.length, 3); + const [call, ack, user] = appends as [AssistantMessage & { id: string }, ToolResultMessage & { id: string }, { role: string; content: string }]; + assert.equal(call.content[0]!.type, "toolCall"); + assert.equal((call.content[0] as { name: string }).name, PRACTICE_CONCLUDE_TOOL); + assert.deepEqual((call.content[0] as { arguments: unknown }).arguments, { outcome: "dismissed" }); + assert.equal(ack.toolCallId, (call.content[0] as { id: string }).id); + assert.equal((ack.content[0] as { text: string }).text, CONCLUDE_ACK_TEXT); + assert.equal(user.role, "user"); + assert.equal(user.content, "that one was over my head"); + }); + + it("continue after a dismissal carries the pair alone", () => { + const record = { messages: [...problemPose(), newProblemDismissedMarker("call_p1")] }; + const appends = inputAppends(record, { type: "continue" }); + assert.equal(appends.length, 2); + }); + + it("with nothing pending, text appends only the user message", () => { + const appends = inputAppends({ messages: problemPose() }, { type: "text", text: "hello" }); + assert.equal(appends.length, 1); + }); +}); + +describe("the dismiss-problem input (ask-style discard)", () => { + it("parses and appends the UI-only marker against the active problem", () => { + const input = parseDismissInput({ type: "dismiss-problem", problemCallId: "call_p1" }); + assert.deepEqual(input, { type: "dismiss-problem", problemCallId: "call_p1" }); + const appends = dismissAppends({ messages: problemPose() }, input); + assert.equal(appends.length, 1); + assert.equal((appends[0] as { customType: string }).customType, "problem-dismissed"); + }); + + it("400s when nothing active or a stale id", () => { + assert.throws(() => dismissAppends({ messages: [] }, { type: "dismiss-problem", problemCallId: "x" }), /no active problem/); + const concluded = [...problemPose(), concludeCall("solved")]; + assert.throws(() => dismissAppends({ messages: concluded }, { type: "dismiss-problem", problemCallId: "call_p1" }), /no active problem/); + }); +}); + +// ── the model-facing seam ──────────────────────────────────────────────────── + +describe("toLlmMessages (practice shapes)", () => { + it("the thinking marker flattens to a labeled user message", () => { + const out = toLlmMessages(asAgentMessages([newThinkingMessage("call_p1", true, "It is 42")])); + assert.equal(out.length, 1); + const user = out[0] as { role: string; content: Array<{ type: string; text: string }> }; + assert.equal(user.role, "user"); + assert.match(user.content[0]!.text, /^Committed answer:\nIt is 42$/); + }); + + it("the dismissal marker is UI-only (the synthesized pair carries the face)", () => { + const out = toLlmMessages(asAgentMessages([newProblemDismissedMarker("call_p1")])); + assert.equal(out.length, 0); + }); + + it("the synthesized pair passes through natively", () => { + const out = toLlmMessages(asAgentMessages(concludedByDismissalPair("call_p1"))); + assert.equal(out.length, 2); + assert.equal(out[0]!.role, "assistant"); + assert.equal(out[1]!.role, "toolResult"); + }); +});