practice hardening: weak-model argument heal + parked dismissals mid-turn
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).
This commit is contained in:
@@ -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<Response> {
|
||||
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;
|
||||
|
||||
@@ -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({
|
||||
<h2 className="text-sm font-semibold text-card-foreground" data-testid="abort-turn-title">
|
||||
{copy.title}
|
||||
</h2>
|
||||
<p className="mt-1.5 text-xs text-muted-foreground">{copy.line}</p>
|
||||
<p className="mt-1.5 text-xs text-muted-foreground" data-testid="abort-turn-line">
|
||||
{copy.line}
|
||||
{busy && mode !== "turn" ? " The working turn stops too." : ""}
|
||||
</p>
|
||||
<div className="mt-4 flex justify-end gap-2">
|
||||
<button
|
||||
type="button"
|
||||
@@ -104,11 +111,13 @@ export function AbortTurnDialog({
|
||||
mode,
|
||||
onClose,
|
||||
onConfirm,
|
||||
busy = false,
|
||||
}: {
|
||||
/** What Esc is aborting; null = closed (nothing rendered). */
|
||||
mode: AbortMode | null;
|
||||
onClose: () => void;
|
||||
onConfirm: (mode: AbortMode) => void;
|
||||
busy?: boolean;
|
||||
}) {
|
||||
// Esc cancels the popup (claimed — never a chained abort).
|
||||
useEffect(() => {
|
||||
@@ -125,7 +134,7 @@ export function AbortTurnDialog({
|
||||
|
||||
if (mode === null || typeof document === "undefined") return null;
|
||||
return createPortal(
|
||||
<AbortTurnBody mode={mode} onClose={onClose} onConfirm={() => onConfirm(mode)} />,
|
||||
<AbortTurnBody mode={mode} onClose={onClose} onConfirm={() => onConfirm(mode)} busy={busy} />,
|
||||
document.body,
|
||||
);
|
||||
}
|
||||
|
||||
+18
-8
@@ -358,24 +358,34 @@ export function Chat({
|
||||
abortInFlightTurn();
|
||||
return;
|
||||
}
|
||||
// From a pending question there is no turn to kill — abort IS
|
||||
// dismissal (A2, 2026-10-03): the skipped toolResult lands via the
|
||||
// SILENT route — no turn, no wake, nothing generates. The session
|
||||
// pauses at the user's choice; the next plain send is the wake.
|
||||
// From a pending question the dismiss is silent (A2, 2026-10-03):
|
||||
// the skipped toolResult lands via the SILENT route, no turn, no
|
||||
// wake — the next plain send is the wake. MID-TURN (owner,
|
||||
// 2026-10-04 — the GLM loop): the question can only dangle because a
|
||||
// turn is STILL RUNNING, so the discard ALSO kills that turn; the
|
||||
// route parks the dismissal (202) and the settle applies it.
|
||||
const p = pendingRef.current;
|
||||
if (p) {
|
||||
const question = (p.input as { question?: string } | null)?.question ?? "";
|
||||
const reading = readingThinkingRef.current.trim() || undefined;
|
||||
clearReadingThinking();
|
||||
const answer = dismissedAnswer(toolQuestionKind(p.tool), question);
|
||||
void dismissQuestion(p.toolCallId, reading ? { ...answer, readingThinking: reading } : answer);
|
||||
void dismissQuestion(
|
||||
p.toolCallId,
|
||||
reading ? { ...answer, readingThinking: reading } : answer,
|
||||
);
|
||||
if (busyRef.current) abortInFlightTurn();
|
||||
return;
|
||||
}
|
||||
// The practice discard (owner, 2026-10-04): ask-style — the
|
||||
// silent marker route; the conclude synthesis rides the user's
|
||||
// next input. The composer returns immediately.
|
||||
// next input. The composer returns immediately. Mid-turn the
|
||||
// discard also kills the running turn (same park + settle law).
|
||||
const problem = activeProblemRef.current;
|
||||
if (problem) void dismissProblem(problem.toolCallId);
|
||||
if (problem) {
|
||||
void dismissProblem(problem.toolCallId);
|
||||
if (busyRef.current) abortInFlightTurn();
|
||||
}
|
||||
},
|
||||
[abortInFlightTurn, dismissQuestion, dismissProblem],
|
||||
);
|
||||
@@ -1444,7 +1454,7 @@ export function Chat({
|
||||
<MermaidOverlay chart={mermaidFocus} onClose={() => setMermaidFocus(null)} />
|
||||
)}
|
||||
{planOpen && <PlanPopup messages={messages} onClose={() => setPlanOpen(false)} />}
|
||||
<AbortTurnDialog mode={abortAsk} onClose={() => setAbortAsk(null)} onConfirm={confirmAbort} />
|
||||
<AbortTurnDialog mode={abortAsk} onClose={() => setAbortAsk(null)} onConfirm={confirmAbort} busy={busy} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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<string, PropSchema> },
|
||||
): unknown {
|
||||
if (typeof args !== "object" || args === null || Array.isArray(args)) return args;
|
||||
const properties = schema.properties;
|
||||
if (!properties) return args;
|
||||
let healed: Record<string, unknown> | null = null;
|
||||
for (const [key, prop] of Object.entries(properties)) {
|
||||
const raw = (args as Record<string, unknown>)[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<string, unknown>) };
|
||||
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<typeof problemI
|
||||
"Only one problem may be active — conclude it before posing the next.",
|
||||
parameters: problemInputObject,
|
||||
executionMode: "sequential",
|
||||
prepareArguments: (args: unknown) => 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<typeof conclud
|
||||
"the learner's composer. The turn CONTINUES after conclude — reveal, then pose the next problem (or wrap " +
|
||||
"up in text). Nothing may conclude unless a problem is active.",
|
||||
parameters: concludeInputObject,
|
||||
prepareArguments: (args: unknown) => healEncodedArguments(args, concludeInputObject) as never,
|
||||
executionMode: "sequential",
|
||||
execute: async (_toolCallId, _input) => {
|
||||
const record = ctx.chatStore.getChat(ctx.chatId);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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<string, ParkedDismissal>;
|
||||
}
|
||||
|
||||
function registry(): Map<string, ParkedDismissal> {
|
||||
const g = globalThis as typeof globalThis & LhGlobal;
|
||||
return (g.__lhParkedDismissals ??= new Map<string, ParkedDismissal>());
|
||||
}
|
||||
|
||||
/** 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<typeof dismissAppends>[1]);
|
||||
chatStore.saveMessages(chatId, [...record.messages, ...appends]);
|
||||
emit("record-changed", { chatId });
|
||||
} catch (err) {
|
||||
if (err instanceof ChatInputError) return; // never materialized — drop
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
@@ -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";
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user