From 85022ae143829b6aae252c9f81563a6ee5c2ea56 Mon Sep 17 00:00:00 2001 From: amosblomqvist Date: Sun, 4 Oct 2026 23:57:00 +0300 Subject: [PATCH] practice hardening: weak-model argument heal + parked dismissals mid-turn MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The GLM incident (2026-10-04): glm-5.3 sent mode as a JSON-string-encoded enum ('"recall"') on 19 consecutive problem calls; TypeBox's error never named the expected values, so the model re-emitted the identical payload until the dev server was killed. And mid-turn the learner was trapped: Esc's question/problem rungs only dismissed — the dismiss route 409'd against the turn-owned record and nothing could stop the running turn. - healEncodedArguments (practice.ts, the prepareArguments seam): a STRING where the schema expects a literal union / boolean / null / number / array is JSON.parse-d and kept when it satisfies the schema. Valid args pass through untouched (same reference); unhealable values stay for validation to reject honestly. Wired on BOTH problem and conclude. - dismissal-park.ts: the dismiss route parks a mid-turn dismissal (202); the turn's settle path (onFinish, after persistTurn, before endTurn — the same ordering law as the record write itself) applies it against the persisted record, only if the dismissed thing really is pending. - confirmAbort: question/problem discard while busy now ALSO aborts the in-flight turn; the dialog line says so (busy prop). No error-result cap (owner ruling). 16 new pins (heal incl. pi-ai validateToolArguments end-to-end; park registry, apply paths, the 202 route contract). --- app/api/chats/[id]/dismiss/route.ts | 21 ++- components/abort-turn-dialog.tsx | 13 +- components/chat.tsx | 26 ++-- src/agent/tools/practice.ts | 87 ++++++++++++ src/server/chat-handler.ts | 8 ++ src/server/dismissal-park.ts | 80 +++++++++++ src/server/index.ts | 1 + tests/dismissal-park.test.ts | 202 ++++++++++++++++++++++++++++ tests/practice-heal.test.ts | 123 +++++++++++++++++ 9 files changed, 544 insertions(+), 17 deletions(-) create mode 100644 src/server/dismissal-park.ts create mode 100644 tests/dismissal-park.test.ts create mode 100644 tests/practice-heal.test.ts diff --git a/app/api/chats/[id]/dismiss/route.ts b/app/api/chats/[id]/dismiss/route.ts index 98ee364..a34287f 100644 --- a/app/api/chats/[id]/dismiss/route.ts +++ b/app/api/chats/[id]/dismiss/route.ts @@ -13,11 +13,14 @@ * * Guards mirror the turn route's honesty: 404 unknown chat, 400 for an * input that targets no dangling question call (or isn't a real - * dismissal), 409 when a turn is somehow running — the dismissing client - * heals via the record-changed sync, same as any raced settle. + * dismissal). A RUNNING turn no longer refuses (owner, 2026-10-04 — the + * GLM practice-loop incident): the dismissal is PARKED server-side and + * the turn's settle path applies it against the persisted record, so + * ESC-discard now doubles as the turn abort's bookkeeper (the client + * fires both). */ import { ChatInputError, dismissAppends, emit, parseDismissInput } from "@/src/core"; -import { getChat, isTurnRunning, stores } from "@/src/server"; +import { getChat, isTurnRunning, parkDismissal, stores } from "@/src/server"; export async function POST(req: Request, ctx: { params: Promise<{ id: string }> }): Promise { const { id } = await ctx.params; @@ -35,11 +38,15 @@ export async function POST(req: Request, ctx: { params: Promise<{ id: string }> throw err; } - // A dismissal can only target a settled question (question tools end - // their turn) — a running turn means something raced; refuse, never - // append around a live turn's own persist. + // A running turn owns the chat's record (its messages persist at + // settle) — the dismissal cannot validate against stale truth, so it + // is PARKED (owner, 2026-10-04): onFinish applies it to the settled + // record if the dismissed question/problem really is pending there. + // The dismissing client has also aborted the turn — this 202 is the + // "noted, will land" contract. if (isTurnRunning(id)) { - return Response.json({ error: "A turn is running for this session" }, { status: 409 }); + parkDismissal(id, input); + return Response.json({ ok: true, parked: true }, { status: 202 }); } let appends; diff --git a/components/abort-turn-dialog.tsx b/components/abort-turn-dialog.tsx index b65671b..bfe6e72 100644 --- a/components/abort-turn-dialog.tsx +++ b/components/abort-turn-dialog.tsx @@ -50,10 +50,14 @@ export function AbortTurnBody({ mode, onClose, onConfirm, + busy = false, }: { mode: AbortMode; onClose: () => void; onConfirm: () => void; + /** The dismiss doubles as the abort when a turn is still running + * (owner, 2026-10-04): the line says so. */ + busy?: boolean; }) { const copy = COPY[mode]; return ( @@ -75,7 +79,10 @@ export function AbortTurnBody({

{copy.title}

-

{copy.line}

+

+ {copy.line} + {busy && mode !== "turn" ? " The working turn stops too." : ""} +

diff --git a/src/agent/tools/practice.ts b/src/agent/tools/practice.ts index 56e115b..c35b8bc 100644 --- a/src/agent/tools/practice.ts +++ b/src/agent/tools/practice.ts @@ -20,6 +20,91 @@ import { activeProblemOf } from "../../core/native-messages.ts"; import type { LhMessage } from "../../core/native-messages.ts"; import { concludeResult, invalidInputResult, problemResult } from "../tool-execute.ts"; +// ─── the weak-model argument heal ───────────────────────────────────────────────── + +/** One property schema of a TypeBox object (the shapes this fork emits: + * literal unions carry anyOf[{const}], scalars carry type). */ +type PropSchema = { + type?: string; + const?: unknown; + anyOf?: PropSchema[]; +}; + +/** The literal values a property accepts ([] when it is not a literal + * union / bare literal). */ +function literalsOf(prop: PropSchema): unknown[] { + if (prop.anyOf) return prop.anyOf.flatMap((m) => literalsOf(m)); + return "const" in prop ? [prop.const] : []; +} + +/** Does the property accept null (Type.Null standalone or as a union + * member)? */ +function acceptsNull(prop: PropSchema): boolean { + if (prop.type === "null") return true; + return (prop.anyOf ?? []).some((m) => m.type === "null"); +} + +/** JSON.parse that never throws (undefined for malformed input). */ +function tryParse(raw: string): unknown { + try { + return JSON.parse(raw) as unknown; + } catch { + return undefined; + } +} + +/** THE WEAK-MODEL ARGUMENT HEAL (owner, 2026-10-04 — the GLM incident: + * 19 consecutive rejected `problem` calls, every one carrying + * `"mode": "\"recall\""` — a JSON-string-encoded enum — while the + * TypeBox error text `must be equal to constant` never named the expected + * values, so the model re-emitted the identical payload each time). At + * pi's prepareArguments seam — BEFORE validation — the same discipline as + * healStringifiedArrays (extraction) and parseOptionsIfString (questions): + * a STRING value where the schema expects a literal union / boolean / + * null / number / array is JSON.parse-d and kept when the parsed value + * satisfies the expectation. Well-formed args pass through UNTOUCHED + * (same reference); unhealable values stay for validation to reject + * honestly. */ +export function healEncodedArguments( + args: unknown, + schema: { properties?: Record }, +): unknown { + if (typeof args !== "object" || args === null || Array.isArray(args)) return args; + const properties = schema.properties; + if (!properties) return args; + let healed: Record | null = null; + for (const [key, prop] of Object.entries(properties)) { + const raw = (args as Record)[key]; + if (typeof raw !== "string") continue; + const parsed = tryParse(raw); + let fixed: unknown; + const literals = literalsOf(prop); + if (literals.length > 0) { + if (literals.includes(raw)) continue; // already a valid member + if (literals.includes(parsed)) fixed = parsed; + } + // The literal branch does not cover null (Type.Null carries no + // const) — a union of literals ∪ null must also accept "null". + if (fixed === undefined && acceptsNull(prop) && parsed === null) fixed = null; + if (fixed === undefined && prop.type === "boolean" && typeof parsed === "boolean") { + fixed = parsed; + } + if ( + fixed === undefined && + (prop.type === "number" || prop.type === "integer") && + typeof parsed === "number" + ) { + fixed = parsed; + } + if (fixed === undefined && prop.type === "array" && Array.isArray(parsed)) fixed = parsed; + if (fixed !== undefined) { + healed ??= { ...(args as Record) }; + healed[key] = fixed; + } + } + return healed ?? args; +} + /** The record-reading context the gates need (the plan-tools idiom). */ export interface PracticeToolContext { chatId: string; @@ -70,6 +155,7 @@ export function problemTool(ctx: PracticeToolContext): AgentTool healEncodedArguments(args, problemInputObject) as never, execute: async (_toolCallId, _input) => { const record = ctx.chatStore.getChat(ctx.chatId); if (record && activeProblemOf(record.messages) !== null) { @@ -130,6 +216,7 @@ export function concludeTool(ctx: PracticeToolContext): AgentTool healEncodedArguments(args, concludeInputObject) as never, executionMode: "sequential", execute: async (_toolCallId, _input) => { const record = ctx.chatStore.getChat(ctx.chatId); diff --git a/src/server/chat-handler.ts b/src/server/chat-handler.ts index 62443d7..0630d47 100644 --- a/src/server/chat-handler.ts +++ b/src/server/chat-handler.ts @@ -35,6 +35,7 @@ import { } from "../core/settings-resolution.ts"; import { modelsNotConfigured, providerKeyMissing as providerKeyMissingEnvelope, type ProviderKeyMissing } from "../core/settings.ts"; import { beginTurn, endTurn } from "./turn-liveness.ts"; +import { applyParkedDismissal } from "./dismissal-park.ts"; import { registerTurn, unregisterTurn } from "./turn-runs.ts"; import { persistTurn, triggerExtraction } from "./turn-service.ts"; import { hasProbeDone, danglingQuestionOf } from "../core/native-messages.ts"; @@ -426,6 +427,13 @@ export async function handleChatInput( // against it and the pipeline's batches share it. triggerExtraction(stores, extraction, chatId, effective.graph.extractionChunkTokens); } + // PARKED DISMISSAL (owner, 2026-10-04): a dismissal that arrived + // mid-turn was parked by the dismiss route (202); now that + // persistTurn has landed the turn's messages, apply it against + // real truth — BEFORE endTurn, so "turn-settled" guarantees a + // subsequent fetch reads the dismissal already in the record + // (the same ordering law as persistTurn itself). + applyParkedDismissal(stores.chatStore, chatId); // LAST: a poller that sees not-in-flight must read an already-persisted // record — endTurn only after persistTurn has done its job. endTurn(chatId); diff --git a/src/server/dismissal-park.ts b/src/server/dismissal-park.ts new file mode 100644 index 0000000..b339b16 --- /dev/null +++ b/src/server/dismissal-park.ts @@ -0,0 +1,80 @@ +/** + * PARKED DISMISSALS (owner, 2026-10-04): ESC-discard while the turn is + * still working. Mid-turn the STORED record holds no pending question or + * problem (the turn hasn't persisted — its messages land at settle), so a + * dismissal arriving now CANNOT validate; the old behavior was a hard 409, + * which left the learner trapped with a phantom question until the turn + * died (the GLM practice-loop incident: the only escape was killing the + * dev server). + * + * The park (owner ruling: "server-side park"): the dismiss route, finding + * a live turn, parks the intent and answers 202; the turn's settle path + * (chat-handler onFinish, AFTER persistTurn, BEFORE endTurn — the same + * ordering law that makes turn-settled imply durable truth) applies it + * against the freshly persisted record. Validation runs at APPLY time + * against real state: if the dismissed question/problem really is pending + * there, the dismissal lands exactly as the route would have written it; + * if it never materialized (the call errored, the model re-posed and + * answered), the park is dropped silently — the thing the user dismissed + * no longer exists. + * + * Lives on globalThis, NOT a module-level Map — the same Next.js module + * instance discipline as turn-liveness: the dismiss route and the + * chat-handler entry must see the SAME registry or the park is written in + * one world and read in another. Ephemeral by design; one parked + * dismissal per chat (last write wins — there is one dock, one discard). + */ + +import { ChatInputError, dismissAppends, emit } from "../core"; +import type { ChatStore } from "../core/ports.ts"; + +/** The parked dismissal body — exactly the parsed input the dismiss route + * accepted ({type:"dismiss", toolCallId, answer} or + * {type:"dismiss-problem", problemCallId}). */ +export type ParkedDismissal = unknown; + +interface LhGlobal { + __lhParkedDismissals?: Map; +} + +function registry(): Map { + const g = globalThis as typeof globalThis & LhGlobal; + return (g.__lhParkedDismissals ??= new Map()); +} + +/** Park (or overwrite) the chat's dismissal intent. Called by the dismiss + * route when a turn owns the chat. */ +export function parkDismissal(chatId: string, input: ParkedDismissal): void { + registry().set(chatId, input); +} + +/** Take the chat's parked dismissal (removes it). Returns null when + * nothing is parked. */ +export function takeParkedDismissal(chatId: string): ParkedDismissal | null { + const parked = registry().get(chatId); + if (parked === undefined) return null; + registry().delete(chatId); + return parked; +} + +/** Apply the chat's parked dismissal, if any, against the current record. + * Runs in the turn's onFinish — between persistTurn and endTurn, so a + * client reacting to "turn-settled" reads the dismissal already in the + * record. The append path is the route's own (dismissAppends); a + * ChatInputError means the dismissed thing never materialized — the park + * is dropped, silence is the correct outcome. Emits "record-changed" + * exactly when something was written. */ +export function applyParkedDismissal(chatStore: ChatStore, chatId: string): void { + const input = takeParkedDismissal(chatId); + if (input === null) return; + const record = chatStore.getChat(chatId); + if (!record) return; + try { + const appends = dismissAppends(record, input as Parameters[1]); + chatStore.saveMessages(chatId, [...record.messages, ...appends]); + emit("record-changed", { chatId }); + } catch (err) { + if (err instanceof ChatInputError) return; // never materialized — drop + throw err; + } +} diff --git a/src/server/index.ts b/src/server/index.ts index a1e9640..9979cb8 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -37,6 +37,7 @@ export { authStore } from "../store/auth.ts"; export * from "./settings-body.ts"; export * from "./turn-liveness.ts"; export * from "./turn-runs.ts"; +export * from "./dismissal-park.ts"; export { runPiTurn, piTurnResponse, type PiTurnHandle, type PiTurnSpec } from "./pi/ui-stream.ts"; export * from "./observability.ts"; export * from "./model-catalog.ts"; diff --git a/tests/dismissal-park.test.ts b/tests/dismissal-park.test.ts new file mode 100644 index 0000000..d34cd43 --- /dev/null +++ b/tests/dismissal-park.test.ts @@ -0,0 +1,202 @@ +/** + * PARKED DISMISSALS (owner, 2026-10-04): ESC-discard while the turn is + * still working. Mid-turn the stored record holds no pending question or + * problem (the turn's messages land at settle), so the dismiss route + * cannot validate — it PARKS the intent (202) and the turn's settle path + * applies it against the freshly persisted record, only if the dismissed + * thing really is pending there. + * + * These pins hold both halves: the pure park registry + apply against + * fixture records, and the route's park contract under an isolated + * LH_DATA_DIR (the birth-stamp discipline — the seam is set before any + * config-loading import, every app-module import dynamic). + */ + +import assert from "node:assert/strict"; +import fs from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; +import { after, describe, it } from "node:test"; + +// ── the seam, BEFORE any config-loading import ────────────────────────────── +const TMP_DATA = fs.mkdtempSync(path.join(os.tmpdir(), "lh-dismissal-park-")); +process.env.LH_DATA_DIR = TMP_DATA; + +const { chatStore } = await import("../src/store/chats.ts"); +const { applyParkedDismissal, parkDismissal, takeParkedDismissal } = await import( + "../src/server/dismissal-park.ts" +); +const { beginTurn, endTurn, isTurnRunning } = await import("../src/server/turn-liveness.ts"); +const { subscribe } = await import("../src/core/events.ts"); +const { + activeProblemOf, + answeredCallIds, + danglingQuestionOf, + dismissedAnswer, +} = await import("../src/core/index.ts"); +const newMessageId = (await import("../src/core/native-messages.ts")).newMessageId; + +after(() => { + fs.rmSync(TMP_DATA, { recursive: true, force: true }); +}); + +/** A record whose newest assistant message carries a DANGLING quiz call — + * the settled state a mid-turn dismissal parks against. */ +function chatWithDanglingQuiz(id: string): string { + const chat = chatStore.createChat("park: dangling quiz"); + chatStore.saveMessages(chat.id, [{ + id: newMessageId(), + role: "assistant", + content: [ + { type: "text", text: "Quick check:" }, + { + type: "toolCall", + id: `${id}-q1`, + name: "quiz", + arguments: { question: "What is a cyclic group?" }, + }, + ], + } as never]); + return chat.id; +} + +/** A record with an ACTIVE problem — the settled practice state a + * dismiss-problem park applies against. */ +function chatWithActiveProblem(): string { + const chat = chatStore.createChat("park: active problem"); + const assistant = { + id: newMessageId(), + role: "assistant", + content: [ + { + type: "toolCall", + id: "call-prob-1", + name: "problem", + arguments: { statement: "Prove |G|=p ⇒ cyclic", mode: "recall" }, + }, + ], + } as never; + chatStore.saveMessages(chat.id, [assistant]); + return chat.id; +} + +describe("the park registry", () => { + it("parks and takes (take drains — the second read is null)", () => { + parkDismissal("chat_x", { type: "dismiss-problem", problemCallId: "p" }); + assert.deepEqual(takeParkedDismissal("chat_x"), { type: "dismiss-problem", problemCallId: "p" }); + assert.equal(takeParkedDismissal("chat_x"), null); + }); + + it("apply with nothing parked is a no-op (no write, no emit)", () => { + const chat = chatWithDanglingQuiz("noop"); + const before = chatStore.getChat(chat)!.messages.length; + let emitted = 0; + const off = subscribe((e) => { + if (e.type === "record-changed" && e.chatId === chat) emitted++; + }); + applyParkedDismissal(chatStore, chat); + off(); + assert.equal(emitted, 0); + assert.equal(chatStore.getChat(chat)!.messages.length, before); + }); +}); + +describe("applyParkedDismissal — against real records", () => { + it("applies a parked question dismissal to the dangling quiz call", () => { + const chat = chatWithDanglingQuiz("apply-q"); + const pending = danglingQuestionOf(chatStore.getChat(chat)!.messages); + assert.ok(pending); + parkDismissal(chat, { + type: "dismiss", + toolCallId: pending!.toolCallId, + answer: dismissedAnswer("quiz", "What is a cyclic group?"), + }); + let emitted = 0; + const off = subscribe((e) => { + if (e.type === "record-changed" && e.chatId === chat) emitted++; + }); + applyParkedDismissal(chatStore, chat); + off(); + assert.equal(emitted, 1); + // The call is answered now — nothing dangles. + assert.equal(danglingQuestionOf(chatStore.getChat(chat)!.messages), null); + assert.ok(answeredCallIds(chatStore.getChat(chat)!.messages).has(pending!.toolCallId)); + assert.equal(takeParkedDismissal(chat), null); + }); + + it("applies a parked problem dismissal to the active problem", () => { + const chat = chatWithActiveProblem(); + parkDismissal(chat, { type: "dismiss-problem", problemCallId: "call-prob-1" }); + applyParkedDismissal(chatStore, chat); + // The marker freed the slot — no active problem remains. + assert.equal(activeProblemOf(chatStore.getChat(chat)!.messages), null); + }); + + it("drops a park that targets nothing (never materialized — silence)", () => { + const chat = chatWithDanglingQuiz("drop"); + const before = chatStore.getChat(chat)!.messages.length; + parkDismissal(chat, { type: "dismiss-problem", problemCallId: "call-ghost" }); + let emitted = 0; + const off = subscribe((e) => { + if (e.type === "record-changed" && e.chatId === chat) emitted++; + }); + applyParkedDismissal(chatStore, chat); + off(); + assert.equal(emitted, 0); + assert.equal(chatStore.getChat(chat)!.messages.length, before); + assert.equal(takeParkedDismissal(chat), null); + }); +}); + +describe("the dismiss route — the park contract (202)", () => { + it("parks (202) while a turn runs; the settle apply lands the dismissal", async () => { + const chat = chatWithDanglingQuiz("route"); + const pending = danglingQuestionOf(chatStore.getChat(chat)!.messages); + assert.ok(pending); + const dismissRoute = await import("../app/api/chats/[id]/dismiss/route.ts"); + + // Mid-turn: the route cannot validate against stale truth — park. + assert.ok(!isTurnRunning(chat)); + beginTurn(chat); + const res = await dismissRoute.POST( + new Request(`http://test/api/chats/${chat}/dismiss`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + input: { + type: "dismiss", + toolCallId: pending!.toolCallId, + answer: dismissedAnswer("quiz", "What is a cyclic group?"), + }, + }), + }), + { params: Promise.resolve({ id: chat }) }, + ); + assert.equal(res.status, 202); + assert.deepEqual(await res.json(), { ok: true, parked: true }); + // Nothing landed yet — the record still dangles (the turn owns it). + assert.ok(danglingQuestionOf(chatStore.getChat(chat)!.messages)); + + // The settle: persistTurn (done) → applyParkedDismissal → endTurn. + applyParkedDismissal(chatStore, chat); + endTurn(chat); + assert.equal(danglingQuestionOf(chatStore.getChat(chat)!.messages), null); + assert.equal(takeParkedDismissal(chat), null); + }); + + it("still refuses honestly (400) with no turn and nothing dangling", async () => { + const chat = chatStore.createChat("park: nothing dangling").id; + const dismissRoute = await import("../app/api/chats/[id]/dismiss/route.ts"); + const res = await dismissRoute.POST( + new Request(`http://test/api/chats/${chat}/dismiss`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + input: { type: "dismiss", toolCallId: "call-nowhere", answer: dismissedAnswer("quiz", "?") }, + }), + }), + { params: Promise.resolve({ id: chat }) }, + ); + assert.equal(res.status, 400); + }); +}); diff --git a/tests/practice-heal.test.ts b/tests/practice-heal.test.ts new file mode 100644 index 0000000..b83a559 --- /dev/null +++ b/tests/practice-heal.test.ts @@ -0,0 +1,123 @@ +/** + * THE WEAK-MODEL ARGUMENT HEAL (owner, 2026-10-04 — the GLM practice-loop + * incident): glm-5.3 sent `"mode": "\"recall\""` — a JSON-string-encoded + * enum — on 19 consecutive `problem` calls; TypeBox's "must be equal to + * constant" error never named the expected values, so the model re-emitted + * the identical payload until the dev server was killed. + * + * These pins hold the heal that runs at pi's prepareArguments seam (BEFORE + * validation): string values where the schema expects a literal union / + * boolean / null / number / array are JSON.parse-d and kept when the + * parsed value satisfies the schema. Well-formed args pass through + * untouched (same reference); unhealable values stay for validation to + * reject honestly — the END-TO-END pins drive pi-ai's own + * validateToolArguments: the healed args pass where the raw ones threw. + */ + +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; +import { + concludeInputObject, + healEncodedArguments, + problemInputObject, +} from "../src/agent/tools/practice.ts"; +import { validateToolArguments } from "@earendil-works/pi-ai"; + +/** The incident's exact mode spelling: the string "recall" INCLUDING + * its quote characters (what JSON.parse turns back into recall). */ +const QUOTED = (s: string) => `"${s}"`; + +describe("healEncodedArguments — the GLM incident", () => { + it("heals the double-encoded mode enum (the exact 19-call loop)", () => { + const healed = healEncodedArguments({ mode: QUOTED("recall") }, problemInputObject) as { + mode: unknown; + }; + assert.equal(healed.mode, "recall"); + }); + + it("END-TO-END: the healed args pass pi-ai validation where the raw threw", () => { + const raw = { + statement: "Prove that every finite group of prime order is cyclic.", + mode: QUOTED("recall"), // the incident: every field valid BUT mode + target: ["group-order"], + modelSolution: "Let |G| = p. Any x ≠ e generates a subgroup whose order divides p…", + decisionPoints: ["pick an element and consider ⟨x⟩"], + expectedWrongMoves: [ + { move: "reaching for Sylow", resolution: "Sylow needs p² at least; order p is direct" }, + ], + strategy: "single-cue recall, minimal parse cost", + rationale: "first problem of the session — baseline the cue", + }; + // The raw shape is the incident: validation throws. + assert.throws(() => { + validateToolArguments({ name: "problem", parameters: problemInputObject } as never, { + id: "call_x", + type: "toolCall", + name: "problem", + arguments: raw, + } as never); + }); + const healed = healEncodedArguments(raw, problemInputObject); + // The healed shape is valid — the loop never starts. + const validated = validateToolArguments( + { name: "problem", parameters: problemInputObject } as never, + { id: "call_x", type: "toolCall", name: "problem", arguments: healed } as never, + ); + assert.equal((validated as { mode: unknown }).mode, "recall"); + }); + + it("well-formed args pass through untouched — the SAME reference", () => { + const args = { mode: "recall", target: ["a"], retest: false }; + assert.equal(healEncodedArguments(args, problemInputObject), args); + assert.equal(healEncodedArguments(args, concludeInputObject), args); + }); + + it("unhealable values stay — validation rejects them honestly", () => { + const healed = healEncodedArguments({ mode: QUOTED("bogus") }, problemInputObject) as { + mode: unknown; + }; + assert.equal(healed.mode, QUOTED("bogus")); + }); +}); + +describe("healEncodedArguments — the sibling encodings", () => { + it("heals the conclude enums (outcome) and the null rung", () => { + const healed = healEncodedArguments( + { outcome: QUOTED("solved"), rung: "null" }, + concludeInputObject, + ) as { outcome: unknown; rung: unknown }; + assert.equal(healed.outcome, "solved"); + assert.equal(healed.rung, null); + }); + + it("heals the rung enum and the stringified boolean retest", () => { + const healed = healEncodedArguments( + { rung: QUOTED("R2"), retest: "true" }, + concludeInputObject, + ) as { rung: unknown; retest: unknown }; + assert.equal(healed.rung, "R2"); + assert.equal(healed.retest, true); + }); + + it("heals a stringified array (the extraction-bug sibling)", () => { + const healed = healEncodedArguments({ target: '["a","b"]' }, problemInputObject) as { + target: unknown; + }; + assert.deepEqual(healed.target, ["a", "b"]); + }); + + it("leaves plain string properties alone (statement is a string schema)", () => { + const args = { statement: '"not JSON-healed"' }; + const healed = healEncodedArguments(args, problemInputObject) as { statement: unknown }; + // A Type.String property has no literals and no scalar type match — + // the string passes through as-is (same reference, nothing cloned). + assert.equal(healed, args); + }); + + it("passes non-object shapes through (null, arrays, strings)", () => { + assert.equal(healEncodedArguments(null, problemInputObject), null); + const arr = [1, 2]; + assert.equal(healEncodedArguments(arr, problemInputObject), arr); + assert.equal(healEncodedArguments("mode", problemInputObject), "mode"); + }); +});