Merge accepted main probe baseline before maintenance checks

This commit is contained in:
amosblomqvist
2026-10-27 21:13:59 +02:00
7 changed files with 178 additions and 32 deletions
+9 -2
View File
@@ -21,7 +21,8 @@ philosophy so they share a view of the whole process).
field it must not use. No plan tools, no jobs, no demos. field it must not use. No plan tools, no jobs, no demos.
- **The teacher** runs everything from planning onward: full tool set - **The teacher** runs everything from planning onward: full tool set
(teach quiz with nodeId, plan_init, jobs, demos). Constructed the moment the (teach quiz with nodeId, plan_init, jobs, demos). Constructed the moment the
transcript carries a `probe_done`. transcript carries a concluded `probe_done`: a non-failed/non-aborted
assistant call followed by a matching successful tool result.
- **The swap happens between probing and planning** — the planning phase - **The swap happens between probing and planning** — the planning phase
needs full knowledge of how teaching is done, so the teacher agent starts needs full knowledge of how teaching is done, so the teacher agent starts
at planning. at planning.
@@ -43,6 +44,12 @@ The prober concludes explicitly:
- **Refuses while a question is open** ("resolve the open question first") — - **Refuses while a question is open** ("resolve the open question first") —
same doctrine as nodeId validation. same doctrine as nodeId validation.
- **Refuses a second conclusion** (idempotent: the probe concludes once). - **Refuses a second conclusion** (idempotent: the probe concludes once).
Conclusion requires a later `probe_done` result matching the call's ID
and tool name. The calling generation must not have failed or aborted;
the result must not be `isError` or explicitly contain `details.ok:false`.
Clean acknowledgments without an explicit `ok:true` remain valid. Bare,
unmatched, errored, or refused calls keep probing open; saved attempts
are never deleted or replay-executed by this detection rule.
- The assessment is the tool call's input, so it streams to the learner live - The assessment is the tool call's input, so it streams to the learner live
and is durable in the transcript by the tool result itself. A dedicated and is durable in the transcript by the tool result itself. A dedicated
assessment card/viewer comes later (UI). assessment card/viewer comes later (UI).
@@ -53,7 +60,7 @@ The prober concludes explicitly:
- **Side effect: it queues the handoff.** When the prober's turn ends, the - **Side effect: it queues the handoff.** When the prober's turn ends, the
wake pipeline (the same machinery that delivers sub-agent results and wake pipeline (the same machinery that delivers sub-agent results and
abort steers) fires a server-side turn with a handoff steer — the teacher abort steers) fires a server-side turn with a handoff steer — the teacher
is constructed (probe_done is in the transcript) and authors the plan. is constructed (probe_done has successfully concluded) and authors the plan.
The learner experiences assessment → plan as one continuous flow. If the The learner experiences assessment → plan as one continuous flow. If the
wake is lost (crash window), the next client message still constructs the wake is lost (crash window), the next client message still constructs the
teacher — the transcript is the source of truth. teacher — the transcript is the source of truth.
+3 -2
View File
@@ -42,8 +42,9 @@ function probeConcluded(messages: readonly LhMessage[]): boolean {
* live), so the agent sees the fixed PROBE_DONE_ACK_TEXT — never an * live), so the agent sees the fixed PROBE_DONE_ACK_TEXT — never an
* echo. details = {ok:true} only. * echo. details = {ok:true} only.
* *
* The first probe_done in the transcript is also the construction seam: * The first concluded probe_done (non-failed call plus matching successful
* from that message on, every request builds the TEACHER agent. * result) is also the construction seam: from that result on, every request
* builds the TEACHER agent. Bare calls and refused results keep probing.
*/ */
export function probeDoneTool(ctx: ProbeDoneContext): Record<string, AgentTool<any>> { export function probeDoneTool(ctx: ProbeDoneContext): Record<string, AgentTool<any>> {
return { return {
+27 -7
View File
@@ -669,15 +669,35 @@ export function danglingQuestionOf(
return null; return null;
} }
/** The probe→teach construction seam (chat-handler): a probe_done call in /** Successful probe_done result MESSAGE ids, in transcript order. A call
* the transcript — any state — means the probe is concluded: teach. */ * from a failed/aborted generation grants no completion authority. Its
export function hasProbeDone(messages: readonly LhMessage[]): boolean { * later result must match both call id and tool name, must not be an
* error, and must not explicitly refuse with details.ok:false. Older
* clean acknowledgments without ok:true still count. No history changes. */
export function probeDoneResultIds(messages: readonly LhMessage[]): Set<string> {
const calls = new Set<string>();
const results = new Set<string>();
for (const m of messages) { for (const m of messages) {
if ((m as { role?: string }).role !== "assistant") continue; if (m.role === "assistant") {
const assistant = m as AssistantMessage; if (m.stopReason === "error" || m.stopReason === "aborted") continue;
if (assistant.content.some((b) => b.type === "toolCall" && b.name === "probe_done")) return true; for (const block of m.content) {
if (block.type === "toolCall" && block.name === "probe_done") calls.add(block.id);
}
} else if (
m.role === "toolResult" && m.toolName === "probe_done" &&
calls.has(m.toolCallId) && !m.isError &&
(m.details as { ok?: unknown } | null | undefined)?.ok !== false
) {
results.add(m.id);
}
} }
return false; return results;
}
/** The probe→teach construction seam (chat-handler and the tool guard):
* only a concluded call plus a matching successful result means teach. */
export function hasProbeDone(messages: readonly LhMessage[]): boolean {
return probeDoneResultIds(messages).size > 0;
} }
/** Every toolResult id in the record — answer-event validation (the call /** Every toolResult id in the record — answer-event validation (the call
+8 -11
View File
@@ -33,7 +33,7 @@
* Both are strictly sequential; the two derivations agree on every settled * Both are strictly sequential; the two derivations agree on every settled
* record (a call's part projects into the same view boundary). * record (a call's part projects into the same view boundary).
* *
* Pure: no imports, no runtime deps — safe on server, client, and tests. * Pure: only browser-safe core helpers — safe on server, client, and tests.
*/ */
/** The three phases of a session, as the picker / header / dividers see them. */ /** The three phases of a session, as the picker / header / dividers see them. */
@@ -80,7 +80,10 @@ function advance(
// plan → teach only via the first nodeId-tagged quiz CALL (the call, // plan → teach only via the first nodeId-tagged quiz CALL (the call,
// not its answer — an errored-and-retried call still proves teaching // not its answer — an errored-and-retried call still proves teaching
// began, and the retry lands in the same group anyway). // began, and the retry lands in the same group anyway).
if (phase === "probe" && part.type === "tool-probe_done" && part.state === "output-available") { if (
phase === "probe" && part.type === "tool-probe_done" && part.state === "output-available" &&
(part.output as { ok?: unknown } | null | undefined)?.ok !== false
) {
phase = "plan"; phase = "plan";
} }
// plan → teach via the first nodeId-tagged quizzing CALL — quiz OR // plan → teach via the first nodeId-tagged quizzing CALL — quiz OR
@@ -153,7 +156,7 @@ export function phaseTimeline(messages: readonly PhaseMessage[]): Phase3[] {
// ─── the native fold (Wave D: record-side phase over LhMessage[]) ─────────── // ─── the native fold (Wave D: record-side phase over LhMessage[]) ───────────
import type { LhMessage } from "./native-messages.ts"; import { probeDoneResultIds, type LhMessage } from "./native-messages.ts";
/** The phase at `upToIndex` (inclusive; default: the last message) of a /** The phase at `upToIndex` (inclusive; default: the last message) of a
* NATIVE record. probe→plan advances at the probe_done call's clean * NATIVE record. probe→plan advances at the probe_done call's clean
@@ -163,18 +166,12 @@ import type { LhMessage } from "./native-messages.ts";
export function phaseAt3Lh(messages: readonly LhMessage[], upToIndex?: number): Phase3 { export function phaseAt3Lh(messages: readonly LhMessage[], upToIndex?: number): Phase3 {
let phase: Phase3 = "probe"; let phase: Phase3 = "probe";
const last = Math.min(upToIndex ?? messages.length - 1, messages.length - 1); const last = Math.min(upToIndex ?? messages.length - 1, messages.length - 1);
const concluded = probeDoneResultIds(messages.slice(0, last + 1));
for (let i = 0; i <= last; i++) { for (let i = 0; i <= last; i++) {
const m = messages[i]!; const m = messages[i]!;
const role = (m as { role?: string }).role; const role = (m as { role?: string }).role;
if (role === "toolResult") { if (role === "toolResult") {
const result = m as { toolName?: string; isError?: boolean }; if (phase === "probe" && concluded.has(m.id)) phase = "plan";
if (
phase === "probe" &&
result.toolName === "probe_done" &&
!result.isError
) {
phase = "plan";
}
} else if (role === "assistant") { } else if (role === "assistant") {
const content = (m as { content?: ReadonlyArray<{ type?: string; name?: string; arguments?: unknown }> }).content ?? []; const content = (m as { content?: ReadonlyArray<{ type?: string; name?: string; arguments?: unknown }> }).content ?? [];
if (phase === "plan") { if (phase === "plan") {
+26 -7
View File
@@ -103,6 +103,16 @@ test("phaseAt3: a mere probe_done CALL (input-available, not yet concluded) keep
assert.equal(still, "probe"); assert.equal(still, "probe");
}); });
test("phaseAt3 and phaseTimeline: explicit probe refusals never advance the UI phase", () => {
const refused = assistantTool("probe_done", "output-available", { ok: false, error: "question open" });
const errored = assistantTool("probe_done", "output-error", { ok: true });
const tagged = assistantQuiz({ nodeId: "n1" });
const clean = assistantTool("probe_done", "output-available", { ok: true });
const messages = [refused, errored, tagged, clean, tagged];
assert.equal(phaseAt3(messages, 2), "probe");
assert.deepEqual(phaseTimeline(messages), ["probe", "probe", "probe", "plan", "teach"]);
});
test("phaseAt3: legacy transcripts without probe_done read as probe (no backwards compat — data wiped)", () => { test("phaseAt3: legacy transcripts without probe_done read as probe (no backwards compat — data wiped)", () => {
const legacy = phaseAt3([user("teach me lacan"), assistantTool("plan_init", "output-available", PLAN_OK)]); const legacy = phaseAt3([user("teach me lacan"), assistantTool("plan_init", "output-available", PLAN_OK)]);
assert.equal(legacy, "probe", "plan_init alone never flips a phase post-split"); assert.equal(legacy, "probe", "plan_init alone never flips a phase post-split");
@@ -158,23 +168,31 @@ function assistantQuizCall(arguments_: Record<string, unknown>): LhMessage {
} as unknown as LhMessage; } as unknown as LhMessage;
} }
function assistantProbeCall(): LhMessage {
return {
...assistantQuizCall({}),
content: [{ type: "toolCall", id: "call_probe_done", name: "probe_done", arguments: {} }],
} as LhMessage;
}
test("phaseAt3Lh (native fold): probe→plan at the clean probe_done result; plan→teach at the first tagged quiz CALL", () => { test("phaseAt3Lh (native fold): probe→plan at the clean probe_done result; plan→teach at the first tagged quiz CALL", () => {
const done = toolResult("probe_done"); const done = toolResult("probe_done");
const call = assistantProbeCall();
const tagged = assistantQuizCall({ question: "q", nodeId: "gravity-basics", correctAnswer: "a" }); const tagged = assistantQuizCall({ question: "q", nodeId: "gravity-basics", correctAnswer: "a" });
const untagged = assistantQuizCall({ question: "q", correctAnswer: "a" }); const untagged = assistantQuizCall({ question: "q", correctAnswer: "a" });
assert.equal(phaseAt3Lh([done, untagged]), "plan", "an untagged quiz call never starts teaching"); assert.equal(phaseAt3Lh([call, done, untagged]), "plan", "an untagged quiz call never starts teaching");
assert.equal(phaseAt3Lh([done, tagged]), "teach", "the tagged quiz CALL is the seam"); assert.equal(phaseAt3Lh([call, done, tagged]), "teach", "the tagged quiz CALL is the seam");
assert.equal(phaseAt3Lh([tagged]), "probe", "the chain never skips (tag before probe_done)"); assert.equal(phaseAt3Lh([tagged]), "probe", "the chain never skips (tag before probe_done)");
assert.equal( assert.equal(
phaseAt3Lh([toolResult("probe_done", true), tagged]), phaseAt3Lh([call, toolResult("probe_done", true), tagged]),
"probe", "probe",
"an ERRORED probe_done concludes nothing", "an ERRORED probe_done concludes nothing",
); );
// The two folds agree on the same settled transcript (the view projection // The two folds agree on the same settled transcript (the view projection
// of the tagged call carries its arguments as the part input). // of the tagged call carries its arguments as the part input).
assert.equal( assert.equal(
phaseAt3Lh([done, tagged]), phaseAt3Lh([call, done, tagged]),
phaseAt3([ phaseAt3([
{ parts: [{ type: "tool-probe_done", state: "output-available" } as never] }, { parts: [{ type: "tool-probe_done", state: "output-available" } as never] },
{ parts: [{ type: "tool-quiz", input: { nodeId: "gravity-basics" } } as never] }, { parts: [{ type: "tool-quiz", input: { nodeId: "gravity-basics" } } as never] },
@@ -188,6 +206,7 @@ test("phaseAt3Lh (native fold): probe→plan at the clean probe_done result; pla
test("phaseAt3 + phaseAt3Lh: a tagged explain CALL is the plan→teach seam too", () => { test("phaseAt3 + phaseAt3Lh: a tagged explain CALL is the plan→teach seam too", () => {
const done = toolResult("probe_done"); const done = toolResult("probe_done");
const call = assistantProbeCall();
const explainCall = (args: Record<string, unknown>): LhMessage => const explainCall = (args: Record<string, unknown>): LhMessage =>
({ ({
id: "a_explain", id: "a_explain",
@@ -199,10 +218,10 @@ test("phaseAt3 + phaseAt3Lh: a tagged explain CALL is the plan→teach seam too"
const tagged = explainCall({ question: "q", correctAnswer: "a", explanation: "e", nodeId: "gravity-basics" }); const tagged = explainCall({ question: "q", correctAnswer: "a", explanation: "e", nodeId: "gravity-basics" });
const untagged = explainCall({ question: "q", correctAnswer: "a", explanation: "e" }); const untagged = explainCall({ question: "q", correctAnswer: "a", explanation: "e" });
assert.equal(phaseAt3Lh([done, untagged]), "plan", "an untagged explain never starts teaching"); assert.equal(phaseAt3Lh([call, done, untagged]), "plan", "an untagged explain never starts teaching");
assert.equal(phaseAt3Lh([done, tagged]), "teach", "the tagged explain CALL is the seam"); assert.equal(phaseAt3Lh([call, done, tagged]), "teach", "the tagged explain CALL is the seam");
assert.equal( assert.equal(
phaseAt3Lh([done, tagged]), phaseAt3Lh([call, done, tagged]),
phaseAt3([ phaseAt3([
{ parts: [{ type: "tool-probe_done", state: "output-available" } as never] }, { parts: [{ type: "tool-probe_done", state: "output-available" } as never] },
{ parts: [{ type: "tool-explain", input: { nodeId: "gravity-basics" } } as never] }, { parts: [{ type: "tool-explain", input: { nodeId: "gravity-basics" } } as never] },
+61 -2
View File
@@ -13,12 +13,13 @@ import * as path from "node:path";
import { after, describe, it } from "node:test"; import { after, describe, it } from "node:test";
import { probeDoneTool } from "../src/agent/tools/probe.ts"; import { probeDoneTool } from "../src/agent/tools/probe.ts";
import type { SteerMessage } from "../src/core/contracts.ts"; import type { SteerMessage } from "../src/core/contracts.ts";
import type { LhMessage } from "../src/core/native-messages.ts"; import { hasProbeDone, type LhMessage } from "../src/core/native-messages.ts";
import { phaseAt3Lh } from "../src/core/phases.ts";
import { HANDOFF_TEXT } from "../src/core/contracts.ts"; import { HANDOFF_TEXT } from "../src/core/contracts.ts";
import { chatStore } from "../src/store/chats.ts"; import { chatStore } from "../src/store/chats.ts";
import { PATHS } from "../config.ts"; import { PATHS } from "../config.ts";
const dataDir = path.join(PATHS.root, "data"); const dataDir = PATHS.data;
fs.mkdirSync(dataDir, { recursive: true }); fs.mkdirSync(dataDir, { recursive: true });
const createdChats: string[] = []; const createdChats: string[] = [];
@@ -142,3 +143,61 @@ describe("probe_done: the probe→planning seam", () => {
assert.equal(res.ok, true, "ok:true even when the queue is lost — the next client message constructs the teacher off the transcript seam"); assert.equal(res.ok, true, "ok:true even when the queue is lost — the next client message constructs the teacher off the transcript seam");
}); });
}); });
/** A real record result, distinct from the calling assistant message. */
function probeResult(overrides: Partial<Extract<LhMessage, { role: "toolResult" }>> = {}): LhMessage {
return {
id: "probe-result", role: "toolResult", toolCallId: "call_probe", toolName: "probe_done",
content: [{ type: "text", text: "ok" }], isError: false, timestamp: 1,
...overrides,
};
}
const probeCall = () => assistantWithCall("probe", "probe_done", { assessment: ASSESSMENT });
const failedProbeCall = (stopReason: "error" | "aborted"): LhMessage => ({ ...probeCall(), stopReason } as LhMessage);
const incompleteProbes: Array<[string, () => LhMessage[]]> = [
["unmatched call", () => [probeCall()]],
["errored result", () => [probeCall(), probeResult({ isError: true })]],
["explicit refusal without isError", () => [probeCall(), probeResult({ details: { ok: false, error: "refused" } })]],
["result without a call", () => [probeResult()]],
["wrong call id", () => [probeCall(), probeResult({ toolCallId: "other" })]],
["wrong result tool name", () => [probeCall(), probeResult({ toolName: "plan_init" })]],
["wrong call tool name", () => [assistantWithCall("probe", "plan_init", {}), probeResult()]],
["result before the call", () => [probeResult(), probeCall()]],
["failed calling generation", () => [failedProbeCall("error"), probeResult({ details: { ok: true } })]],
["aborted calling generation", () => [failedProbeCall("aborted"), probeResult({ details: { ok: true } })]],
];
describe("probe completion: matched successful results only (PR130 foundation)", () => {
for (const [name, messages] of incompleteProbes) {
it(`${name} keeps probing open and allows a fresh conclusion`, async () => {
const chatId = newChat(`Probe recovery: ${name}`);
const before = messages();
const snapshot = structuredClone(before);
chatStore.saveMessages(chatId, before);
assert.equal(hasProbeDone(before), false);
assert.equal(phaseAt3Lh(before), "probe");
const delivered: SteerMessage[] = [];
const tools = probeDoneTool({ chatId, chatStore, deliver: (s) => delivered.push(s) });
assert.equal((await execute(tools, { assessment: ASSESSMENT })).ok, true);
assert.equal(delivered.length, 1, "only the fresh invocation queues a handoff");
assert.deepEqual(before, snapshot, "the scans never mutate history");
assert.deepEqual(chatStore.getChat(chatId)!.messages, snapshot, "the tool does not delete or replay saved attempts");
});
}
for (const [name, result] of [
["current acknowledgment", probeResult({ details: { ok: true } })],
["older clean acknowledgment without details", probeResult()],
["older clean acknowledgment without ok", probeResult({ details: { assessment: ASSESSMENT } })],
] as const) {
it(`${name} concludes exactly at the matching result`, () => {
const messages = [probeCall(), result];
const before = structuredClone(messages);
assert.equal(hasProbeDone(messages), true);
assert.equal(phaseAt3Lh(messages, 0), "probe", "a future result cannot conclude an earlier prefix");
assert.equal(phaseAt3Lh(messages, 1), "plan");
assert.deepEqual(messages, before);
});
}
});
+44 -1
View File
@@ -17,6 +17,7 @@ import type { AgentBuildOptions, TeachingAgent } from "../src/agent/teacher-agen
import type { PiEngine } from "../src/agent/models.ts"; import type { PiEngine } from "../src/agent/models.ts";
import type { LhMessage } from "../src/core/native-messages.ts"; import type { LhMessage } from "../src/core/native-messages.ts";
import type { QuestionData } from "../src/core/question-core.ts"; import type { QuestionData } from "../src/core/question-core.ts";
import type { SteerMessage } from "../src/core/contracts.ts";
// Set the storage/auth seams BEFORE any app/config-loading imports. // Set the storage/auth seams BEFORE any app/config-loading imports.
assert.equal(process.env.NODE_ENV, "test", "Run with NODE_ENV=test; these tests must not invoke real providers."); assert.equal(process.env.NODE_ENV, "test", "Run with NODE_ENV=test; these tests must not invoke real providers.");
@@ -36,7 +37,8 @@ const { jobStore } = await import("../src/store/jobs.ts");
const { subscribe } = await import("../src/core/events.ts"); const { subscribe } = await import("../src/core/events.ts");
const { buildAnswer, normalizeOptions, resolveCorrect } = await import("../src/core/question-core.ts"); const { buildAnswer, normalizeOptions, resolveCorrect } = await import("../src/core/question-core.ts");
const { ChatInputError, endsInFailedTurn, failedResponseAtEnd, inputAppends, parseChatInput, retryDrops } = await import("../src/core/input-events.ts"); const { ChatInputError, endsInFailedTurn, failedResponseAtEnd, inputAppends, parseChatInput, retryDrops } = await import("../src/core/input-events.ts");
const { INTERRUPTED_TEXT, asAgentMessages, newHandoffMessage, newPlanBlockMessage, newUserMessage, reconstructRequestCause, toLlmMessages } = await import("../src/core/native-messages.ts"); const { INTERRUPTED_TEXT, asAgentMessages, hasProbeDone, newHandoffMessage, newPlanBlockMessage, newUserMessage, reconstructRequestCause, toLlmMessages } = await import("../src/core/native-messages.ts");
const { phaseAt3Lh } = await import("../src/core/phases.ts");
const { newCompactionMarker } = await import("../src/core/compaction.ts"); const { newCompactionMarker } = await import("../src/core/compaction.ts");
after(() => { after(() => {
@@ -287,6 +289,47 @@ describe("failed-response Retry (issue #110)", () => {
assert.deepEqual(contexts.at(-1), ["user"]); assert.deepEqual(contexts.at(-1), ["user"]);
}); });
it("a retained failed probe retries with the prober, concludes once, then selects the teacher", async () => {
const input = { assessment: { strands: [{ strand: "planet order", assessment: "Names known; ordering needs work." }] } };
const { engine, histories } = scripted([
() => fauxAssistantMessage([fauxToolCall("probe_done", input, { id: "failed-probe" })], { stopReason: "error", errorMessage: "cut off" }),
() => fauxAssistantMessage([fauxToolCall("probe_done", input, { id: "fresh-probe" })], { stopReason: "toolUse" }),
() => fauxAssistantMessage("Assessment ready."),
() => fauxAssistantMessage("Now planning the lesson."),
]);
const chat = createConfiguredChat("Test: failed probe recovery");
const delivered: SteerMessage[] = [];
const selections: boolean[] = [];
const factory: AgentFactory = (opts) => {
selections.push(opts.teaching);
if (opts.teaching) return createTeacherAgent(undefined, { ...opts, engine });
return createProberAgent({
chatId: chat.id, chatStore, jobStore, deliver: (s) => delivered.push(s), spawn: () => {},
}, { ...opts, engine });
};
await turn(engine, chat.id, { type: "text", text: "teach me planet order" }, factory);
const before = chatStore.getChat(chat.id)!.messages;
assert.ok(endsInFailedTurn(before));
assert.equal(hasProbeDone(before), false);
assert.equal(phaseAt3Lh(before), "probe");
assert.equal(delivered.length, 0, "the stored failed call never ran");
assert.equal((await turn(engine, chat.id, { type: "retry" }, factory)).status, 200);
const recovered = chatStore.getChat(chat.id)!.messages;
assertPreserved(before, recovered);
assert.deepEqual(selections, [false, false], "Retry must still build the prober");
assert.equal(hasProbeDone(recovered), true);
assert.equal(phaseAt3Lh(recovered), "plan");
assert.equal(delivered.length, 1, "the full retained-store guard accepts the fresh conclusion");
assert.deepEqual(recovered.filter((m) => m.role === "toolResult").map((m) => m.role === "toolResult" && m.toolCallId), ["fresh-probe"]);
assert.doesNotMatch(histories[1]!, /failed-probe/, "the failed call is omitted only from temporary Retry history");
assert.equal((await turn(engine, chat.id, { type: "text", text: "go on" }, factory)).status, 200);
assert.deepEqual(selections, [false, false, true]);
assert.equal(delivered.length, 1, "later turns do not replay either saved call");
assertPreserved(recovered, chatStore.getChat(chat.id)!.messages);
});
for (const kind of ["prober", "teacher", "practice"] as const) { for (const kind of ["prober", "teacher", "practice"] as const) {
it(`uses the same retained-history retry boundary for the ${kind}`, async () => { it(`uses the same retained-history retry boundary for the ${kind}`, async () => {
const { engine, contexts } = scripted([() => fauxAssistantMessage("Recovered response.")]); const { engine, contexts } = scripted([() => fauxAssistantMessage("Recovered response.")]);