Wave B: the native message record — input events, projection, adapter death
The chat record is now pi-native agent history (LhMessage[]), the AI SDK's
UIMessage transcript contract is gone from the server, and every client turn
arrives as ONE explicit input event.
Core (new):
- src/core/native-messages.ts — the LhMessage taxonomy (user/assistant/
toolResult + lhMarker customs: verdict, probe-handoff, subagent-steer,
subagent-abort, plan-block), constructors, and toLlmMessages — the single
model-facing seam (convertToLlm): strips ids, flattens markers to user
text, drops UI-only plan-blocks, synthesizes INTERRUPTED results for
dangling aborted calls, never throws.
- src/core/projection.ts — projectNative: the one-way native → UIMessage
projection (consecutive assistant+toolResult runs group into one
assistant view message; toolResults attach backward by toolCallId;
markers project to data parts; plan-blocks splice after the last
successful plan_init). Every existing consumer reads through it.
- src/core/input-events.ts — the ChatInput union (text/answer/verdict/
continue), parseChatInput validation, and inputAppends (answer targets
the dangling question call or 400s; verdict = optional note user
message + native verdict marker).
Server:
- chat-handler.ts — the turn root takes {chatId, input}; write-ahead
persists input appends + claimed steers before the turn; claimSteers
coalesces the whole queue with the handler's steerDelivered filter as
the dedupe seam; teacher-vs-prober branch on hasProbeDone.
- turn-service.ts — persistTurn stamps ids, drops clean question
placeholder toolResults (the record ends at the unanswered call),
emits plan-block markers idempotently (plan-blocks.ts rewrite), and
persists abort partials.
- ui-stream.ts — PiTurnSpec takes the native base; the wire dialect is
unchanged. jobs.ts steers are native markers (steer_<jobId> dedup ids);
waitForIdle reads danglingQuestionOf. teacher-agent.ts wires
convertToLlm: toLlmMessages. record-adapter.ts DELETED wholesale.
- GET /api/chats/[id] serves messages: projectNative(record.messages).
Client:
- chat-transport.ts — the 409 retry is IDENTITY (re-POST the same event
body after the turn settles — the event was never persisted
pre-acceptance; the merge logic is gone). The transport consumes the
stashed input event via prepareSendMessagesRequest and throws loudly on
a missing stash.
- chat.tsx — every send call site stashes exactly one ChatInput event
(text, answer, verdict, continue) before invoking the SDK send.
Tests: full native migration (chat-flow rewritten to input events and
native record pins; jobs/extraction/demos/graph/usage/plan-blocks/
probe-done/reattach-transport converted; record-adapter.test.ts deleted;
chat-transport tests pin the identity retry). 982/982, typecheck, lint,
build all green.
This commit is contained in:
+151
-165
@@ -2,13 +2,15 @@
|
||||
* Scripted integration test — the full teacher loop with zero tokens:
|
||||
*
|
||||
* learner opens chat → faux model calls `quiz` → server streams + persists
|
||||
* → client grades and sends the answer back as the tool output
|
||||
* → client grades and sends the ANSWER EVENT back
|
||||
* → faux model reacts → transcript persisted.
|
||||
*
|
||||
* Drives handleChatRequest directly (plain Request/Response — no Next server
|
||||
* needed) with a scripted pi engine (pi-ai's faux provider, docs/
|
||||
* pi-engine-swap.md §7): the quiz bridge is gone, the tool part IS the
|
||||
* pending state, and the persisted transcript is the resume record.
|
||||
* pi-engine-swap.md §7). Wave B: POSTs carry ONE INPUT EVENT (never a client
|
||||
* transcript), the record is pi-native agent history (read through the
|
||||
* projection where a part shape is pinned), and the answer arrives as the
|
||||
* question call's real tool result (fork 1, owner 2026-09-26).
|
||||
*/
|
||||
|
||||
import assert from "node:assert/strict";
|
||||
@@ -28,13 +30,12 @@ import { handleChatRequest } from "../src/server/chat-handler.ts";
|
||||
import { effectiveSettings } from "../src/server/stores.ts";
|
||||
import { isTurnRunning } from "../src/server/turn-liveness.ts";
|
||||
import { abortTurn, runningTurn, settledTurnResponse } from "../src/server/turn-runs.ts";
|
||||
import { recordToPiMessages } from "../src/server/pi/record-adapter.ts";
|
||||
import { chatStore } from "../src/store/chats.ts";
|
||||
import { buildAnswer, coerceCorrectAnswer, normalizeOptions, resolveCorrect, type QuestionData } from "../src/core/question-core.ts";
|
||||
import { findPendingToolCall } from "../src/core/pending.ts";
|
||||
import { danglingQuestionOf, newAnswerResult } from "../src/core/native-messages.ts";
|
||||
import { projectNative } from "../src/core/projection.ts";
|
||||
import type { PiEngine } from "../src/agent/models.ts";
|
||||
import type { TeachingAgent } from "../src/agent/teacher-agent.ts";
|
||||
import type { UIMessage } from "ai";
|
||||
import type { LhMessage } from "../src/core/native-messages.ts";
|
||||
import { PATHS } from "../config.ts";
|
||||
|
||||
// The key-presence turn gate (provider-key interlude, 2026-09-25): scripted
|
||||
@@ -84,10 +85,27 @@ function defaultLesson(calls: string[][]): FauxResponseStep[] {
|
||||
}
|
||||
|
||||
/** The handler seam (stateless per turn): each request builds a fresh agent
|
||||
* off the transcript it is about to run — the factory tests inject. */
|
||||
function agentFactory(engine: PiEngine) {
|
||||
return (outgoing: UIMessage[]): TeachingAgent =>
|
||||
createTeacherAgent(undefined, { engine, messages: recordToPiMessages(outgoing) });
|
||||
* off the record it is about to run — the factory tests inject. Wave B: the
|
||||
* handler hands the factory the NATIVE build options (the record IS the
|
||||
* agent state — no adapter conversion). */
|
||||
function agentFactory(engine: PiEngine): Parameters<typeof handleChatRequest>[1] {
|
||||
return (opts) => createTeacherAgent(undefined, { engine, messages: opts.messages });
|
||||
}
|
||||
|
||||
/** The graded answer a client sends for a quiz (the shared grading seam). */
|
||||
function gradedAnswer(input: typeof QUIZ_INPUT, values: string[]) {
|
||||
const options = normalizeOptions(input.options);
|
||||
const qd: QuestionData = {
|
||||
id: "call-1",
|
||||
kind: "quiz",
|
||||
question: input.question,
|
||||
mode: "single-select",
|
||||
options,
|
||||
correctIndices: resolveCorrect(coerceCorrectAnswer(input.correctAnswer), options).indices,
|
||||
explanation: input.explanation,
|
||||
createdAt: new Date().toISOString(),
|
||||
};
|
||||
return buildAnswer(qd, { values });
|
||||
}
|
||||
|
||||
async function drain(res: Response): Promise<string> {
|
||||
@@ -137,15 +155,16 @@ function abortableTeacher(): PiEngine {
|
||||
return { model: faux.getModel(), streamFn: faux.streamSimple };
|
||||
}
|
||||
|
||||
function post(chatId: string, messages: unknown[]): Request {
|
||||
function post(chatId: string, input: unknown): Request {
|
||||
return new Request("http://test/api/chat", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ chatId, messages }),
|
||||
// Wave B: the input-events contract — one explicit event per POST,
|
||||
// never a client transcript.
|
||||
body: JSON.stringify({ chatId, input }),
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
/** BYOK turn gate (owner, 2026-09-22): no factory floor — a chat needs a
|
||||
* conversation model before any turn streams. The scripted tests give
|
||||
* each chat a per-chat pin exactly like the setup flow does in
|
||||
@@ -174,68 +193,69 @@ describe("teacher chat flow (scripted model)", () => {
|
||||
createdChats.push(chat.id);
|
||||
|
||||
// ── Turn 1: learner opens with a topic; teacher probes with a quiz ──
|
||||
const firstUser = { id: "u1", role: "user", parts: [{ type: "text", text: "teach me the solar system" }] };
|
||||
await drain(await handleChatRequest(post(chat.id, [firstUser]), agentFactory(engine)));
|
||||
await drain(await handleChatRequest(post(chat.id, { type: "text", text: "teach me the solar system" }), agentFactory(engine)));
|
||||
|
||||
const saved1 = chatStore.getChat(chat.id)!;
|
||||
// Native record: user + assistant (fork 1 — the clean question's
|
||||
// placeholder result never persists, so the record ends at the call).
|
||||
assert.equal(saved1.messages.length, 2, "user message + assistant response persisted");
|
||||
const assistantMsg = saved1.messages[1]!;
|
||||
assert.equal(assistantMsg.role, "assistant");
|
||||
|
||||
const reasoningPart = assistantMsg.parts.find((p) => p.type === "reasoning");
|
||||
assert.ok(reasoningPart && reasoningPart.type === "reasoning", "reasoning part streamed + persisted");
|
||||
assert.match((reasoningPart as { text?: string }).text!, /Probe the basics/);
|
||||
const thinking = assistantMsg.content.find((b) => b.type === "thinking");
|
||||
assert.ok(thinking && thinking.type === "thinking", "thinking block streamed + persisted");
|
||||
assert.match((thinking as { thinking?: string }).thinking!, /Probe the basics/);
|
||||
|
||||
const quizPart = assistantMsg.parts.find((p) => p.type === "tool-quiz");
|
||||
assert.ok(quizPart && quizPart.type === "tool-quiz", "quiz tool part persisted");
|
||||
const quizCall = assistantMsg.content.find((b) => b.type === "toolCall" && b.name === "quiz");
|
||||
assert.ok(quizCall && quizCall.type === "toolCall", "quiz tool call persisted");
|
||||
assert.equal((quizCall as { id?: string }).id, "call-1");
|
||||
assert.deepEqual((quizCall as { arguments?: unknown }).arguments, QUIZ_INPUT);
|
||||
|
||||
// The projection pins the client view: reasoning part + a pending
|
||||
// (input-available) quiz part — the dock's mount state.
|
||||
const view1 = projectNative(saved1.messages);
|
||||
const reasoningPart = view1[1]!.parts.find((p) => p.type === "reasoning");
|
||||
assert.ok(reasoningPart && reasoningPart.type === "reasoning", "reasoning part projected");
|
||||
const quizPart = view1[1]!.parts.find((p) => p.type === "tool-quiz");
|
||||
assert.ok(quizPart && quizPart.type === "tool-quiz", "quiz tool part projected");
|
||||
assert.equal((quizPart as { state?: string }).state, "input-available", "unanswered quiz = pending state (resume record)");
|
||||
assert.deepEqual((quizPart as { input?: unknown }).input, QUIZ_INPUT);
|
||||
|
||||
// The pending-question gate sees it.
|
||||
const pending = findPendingToolCall(saved1.messages);
|
||||
// The pending-question gate sees it (the native seam).
|
||||
const pending = danglingQuestionOf(saved1.messages);
|
||||
assert.equal(pending?.toolCallId, "call-1");
|
||||
assert.equal(pending?.tool, "quiz");
|
||||
assert.deepEqual(pending?.input, QUIZ_INPUT);
|
||||
|
||||
// ── The learner answers: client grades, output goes back on the part ──
|
||||
const options = normalizeOptions(QUIZ_INPUT.options);
|
||||
const qd: QuestionData = {
|
||||
id: "call-1",
|
||||
kind: "quiz",
|
||||
question: QUIZ_INPUT.question,
|
||||
mode: "single-select",
|
||||
options,
|
||||
correctIndices: resolveCorrect(coerceCorrectAnswer(QUIZ_INPUT.correctAnswer), options).indices,
|
||||
explanation: QUIZ_INPUT.explanation,
|
||||
createdAt: new Date().toISOString(),
|
||||
};
|
||||
const answer = buildAnswer(qd, { values: ["Mercury"] });
|
||||
// ── The learner answers: the client grades and posts the ANSWER
|
||||
// EVENT — the answer lands as the call's real tool result. ──
|
||||
const answer = gradedAnswer(QUIZ_INPUT, ["Mercury"]);
|
||||
assert.equal(answer.correct, true);
|
||||
await drain(await handleChatRequest(post(chat.id, { type: "answer", toolCallId: "call-1", answer }), agentFactory(engine)));
|
||||
|
||||
const messages2 = saved1.messages.map((m) =>
|
||||
m.id === assistantMsg.id
|
||||
? {
|
||||
...m,
|
||||
parts: m.parts.map((p) =>
|
||||
p.type === "tool-quiz" ? { ...p, state: "output-available" as const, output: answer } : p,
|
||||
),
|
||||
}
|
||||
: m,
|
||||
);
|
||||
await drain(await handleChatRequest(post(chat.id, messages2), agentFactory(engine)));
|
||||
|
||||
// ── The teacher reacts — as a CONTINUATION of the same assistant message
|
||||
// (the last client message is the assistant message carrying the tool
|
||||
// output, so the new text appends to that message's parts). ──
|
||||
// ── The teacher reacts — the projection groups the reaction into the
|
||||
// SAME assistant view message as the answered quiz (the native
|
||||
// grouping canon: consecutive assistant/toolResult messages bounded
|
||||
// by user messages form one group). ──
|
||||
const saved2 = chatStore.getChat(chat.id)!;
|
||||
assert.equal(saved2.messages.length, 2, "the reaction continues the same assistant message");
|
||||
const finalParts = saved2.messages[1]!.parts;
|
||||
// Native: user, assistant, answer toolResult, reaction assistant.
|
||||
assert.equal(saved2.messages.length, 4, "the answer + reaction append natively");
|
||||
assert.equal((saved2.messages[2] as { role?: string }).role, "toolResult", "the graded answer is the call's tool result");
|
||||
const view2 = projectNative(saved2.messages);
|
||||
assert.equal(view2.length, 2, "the reaction continues the same assistant message (one group)");
|
||||
const finalParts = view2[1]!.parts;
|
||||
const textPart = finalParts.find((p) => p.type === "text");
|
||||
assert.ok(textPart && textPart.type === "text", "reaction text appended after the tool part");
|
||||
assert.match((textPart as { text?: string }).text!, /Mercury it is/);
|
||||
// The answered quiz part is still there, now with its output.
|
||||
const answeredQuiz = finalParts.find((p) => p.type === "tool-quiz");
|
||||
assert.ok(answeredQuiz && answeredQuiz.type === "tool-quiz" && (answeredQuiz as { state?: string }).state === "output-available");
|
||||
// The graded answer rode the record as the call's tool result and
|
||||
// round-trips through JSON (undefined keys drop) — compare its
|
||||
// SUBSTANCE, not key-for-key against the in-memory build.
|
||||
const out = (answeredQuiz as { output?: { questionId?: string; correct?: boolean } }).output!;
|
||||
assert.equal(out.questionId, "call-1");
|
||||
assert.equal(out.correct, true);
|
||||
|
||||
// The model actually received the graded answer as a tool result.
|
||||
assert.equal(calls.length, 2, "model saw exactly two prompts");
|
||||
@@ -243,14 +263,41 @@ describe("teacher chat flow (scripted model)", () => {
|
||||
assert.ok(toolResultSent, "continuation prompt carries the tool result to the model");
|
||||
|
||||
// No pending question remains.
|
||||
assert.equal(findPendingToolCall(saved2.messages), null);
|
||||
assert.equal(danglingQuestionOf(saved2.messages), null);
|
||||
});
|
||||
|
||||
it("rejects unknown chats", async () => {
|
||||
const res = await handleChatRequest(post("chat_nonexistent", []));
|
||||
const res = await handleChatRequest(post("chat_nonexistent", { type: "text", text: "hi" }));
|
||||
assert.equal(res.status, 404);
|
||||
});
|
||||
|
||||
it("refuses malformed inputs with a clean 400 — no liveness effects, no lockout", async () => {
|
||||
const chat = createConfiguredChat("Test: malformed input");
|
||||
createdChats.push(chat.id);
|
||||
|
||||
// A text event with no text: refused at the parse boundary.
|
||||
const res = await handleChatRequest(post(chat.id, { type: "text" }), agentFactory(scriptedTeacher(defaultLesson).engine));
|
||||
assert.equal(res.status, 400, "malformed input is a clean 400");
|
||||
assert.equal(isTurnRunning(chat.id), false, "the refusal never registered a turn");
|
||||
|
||||
// An ANSWER with no dangling question call: the orphan-400 discipline
|
||||
// (the server owns the record — it validates the target call).
|
||||
const res2 = await handleChatRequest(
|
||||
post(chat.id, { type: "answer", toolCallId: "call-none", answer: { correct: true } }),
|
||||
agentFactory(scriptedTeacher(defaultLesson).engine),
|
||||
);
|
||||
assert.equal(res2.status, 400, "an answer with no pending question call is refused");
|
||||
assert.equal(isTurnRunning(chat.id), false);
|
||||
|
||||
// And the chat is NOT locked: the next (valid) turn is accepted.
|
||||
const ok = await handleChatRequest(
|
||||
post(chat.id, { type: "text", text: "teach me" }),
|
||||
agentFactory(scriptedTeacher(defaultLesson).engine),
|
||||
);
|
||||
assert.equal(ok.status, 200);
|
||||
await drain(ok);
|
||||
});
|
||||
|
||||
it("streams tool-input-start before tool-input-available (protocol pin for the 'Writing a question…' indicator)", async () => {
|
||||
// The client's writing indicator keys off tool parts in state
|
||||
// 'input-streaming', which the stream processor sets from the
|
||||
@@ -259,8 +306,7 @@ describe("teacher chat flow (scripted model)", () => {
|
||||
const { engine } = scriptedTeacher(defaultLesson);
|
||||
const chat = createConfiguredChat("Test: protocol pin");
|
||||
createdChats.push(chat.id);
|
||||
const firstUser = { id: "u1", role: "user", parts: [{ type: "text", text: "quiz me" }] };
|
||||
const body = await drain(await handleChatRequest(post(chat.id, [firstUser]), agentFactory(engine)));
|
||||
const body = await drain(await handleChatRequest(post(chat.id, { type: "text", text: "quiz me" }), agentFactory(engine)));
|
||||
const startIdx = body.indexOf('"type":"tool-input-start"');
|
||||
const availIdx = body.indexOf('"type":"tool-input-available"');
|
||||
assert.ok(startIdx !== -1, "tool-input-start chunk present in the stream");
|
||||
@@ -275,8 +321,7 @@ describe("teacher chat flow (scripted model)", () => {
|
||||
const chat = createConfiguredChat("Test: view cancel");
|
||||
createdChats.push(chat.id);
|
||||
|
||||
const req = post(chat.id, [{ id: "u1", role: "user", parts: [{ type: "text", text: "hold my turn" }] }]);
|
||||
const res = await handleChatRequest(req, agentFactory(engine));
|
||||
const res = await handleChatRequest(post(chat.id, { type: "text", text: "hold my turn" }), agentFactory(engine));
|
||||
assert.equal(res.status, 200);
|
||||
// Read one chunk then cancel — the browser view leaves mid-stream.
|
||||
const reader = res.body!.getReader();
|
||||
@@ -297,7 +342,7 @@ describe("teacher chat flow (scripted model)", () => {
|
||||
it("an ungradeable quiz is rejected at the call boundary, the error reaches the model, and the model retries", async () => {
|
||||
// The authoring contract (question-core superRefine): a quiz whose
|
||||
// correctAnswer matches no option value must NEVER reach the dock —
|
||||
// toPiTool re-runs the zod schema at the call boundary, marks the
|
||||
// the boundary re-runs the schema at the call boundary, marks the
|
||||
// call invalid, and returns the validation error to the teacher as
|
||||
// a tool result. The loop re-prompts the model, which retries in the
|
||||
// same turn. This pins that whole mechanism (the live Nietzsche bug).
|
||||
@@ -328,14 +373,12 @@ describe("teacher chat flow (scripted model)", () => {
|
||||
|
||||
const chat = createConfiguredChat("Test: bad quiz retry");
|
||||
createdChats.push(chat.id);
|
||||
const firstUser = { id: "u1", role: "user", parts: [{ type: "text", text: "quiz me" }] };
|
||||
const body = await drain(await handleChatRequest(post(chat.id, [firstUser]), agentFactory(engine)));
|
||||
const body = await drain(await handleChatRequest(post(chat.id, { type: "text", text: "quiz me" }), agentFactory(engine)));
|
||||
|
||||
// WIRE pin (the live modal-realism crash): the invalid call must NEVER
|
||||
// become input-available — the dock mounts on input-available parts and
|
||||
// crashed on a malformed quiz before tool-input-error could replace it.
|
||||
// The SDK gated this with InvalidToolInputError; the bridge gates it on
|
||||
// clean tool_execution_end.
|
||||
// The boundary gates this on clean tool_execution_end.
|
||||
assert.ok(
|
||||
!body.includes('"type":"tool-input-available","toolCallId":"call-bad"'),
|
||||
"the invalid call never reached input-available on the wire",
|
||||
@@ -358,11 +401,12 @@ describe("teacher chat flow (scripted model)", () => {
|
||||
"the validation error reached the model",
|
||||
);
|
||||
|
||||
// The transcript: the failed call persisted as an error part (renders
|
||||
// nothing client-side), the retry as the pending quiz.
|
||||
// The transcript: the failed call persisted as an ERROR RESULT (the
|
||||
// model's retry context — errored question calls DO persist), the
|
||||
// retry as the dangling quiz. The projection renders the error part.
|
||||
const saved = chatStore.getChat(chat.id)!;
|
||||
const assistantMsg = saved.messages[1]!;
|
||||
const quizParts = assistantMsg.parts.filter((p) => p.type === "tool-quiz");
|
||||
const view = projectNative(saved.messages);
|
||||
const quizParts = view[1]!.parts.filter((p) => p.type === "tool-quiz");
|
||||
const failed = quizParts.find(
|
||||
(p) => p.type === "tool-quiz" && (p as { state?: string }).state === "output-error",
|
||||
);
|
||||
@@ -371,7 +415,7 @@ describe("teacher chat flow (scripted model)", () => {
|
||||
String((failed as { errorText?: string }).errorText),
|
||||
/does not match any option value/,
|
||||
);
|
||||
const pending = findPendingToolCall(saved.messages);
|
||||
const pending = danglingQuestionOf(saved.messages);
|
||||
assert.equal(pending?.toolCallId, "call-good", "the RETRIED quiz is the pending one");
|
||||
assert.deepEqual(pending?.input, QUIZ_INPUT);
|
||||
});
|
||||
@@ -400,8 +444,7 @@ describe("teacher chat flow (scripted model)", () => {
|
||||
|
||||
const chat = createConfiguredChat("Test: string options");
|
||||
createdChats.push(chat.id);
|
||||
const firstUser = { id: "u1", role: "user", parts: [{ type: "text", text: "quiz me" }] };
|
||||
const body = await drain(await handleChatRequest(post(chat.id, [firstUser]), agentFactory(engine)));
|
||||
const body = await drain(await handleChatRequest(post(chat.id, { type: "text", text: "quiz me" }), agentFactory(engine)));
|
||||
|
||||
assert.ok(
|
||||
!body.includes('"type":"tool-input-available","toolCallId":"call-stringy"'),
|
||||
@@ -413,12 +456,13 @@ describe("teacher chat flow (scripted model)", () => {
|
||||
);
|
||||
|
||||
const saved = chatStore.getChat(chat.id)!;
|
||||
const failed = saved.messages[1]!.parts.find(
|
||||
const view = projectNative(saved.messages);
|
||||
const failed = view[1]!.parts.find(
|
||||
(p) => (p as { toolCallId?: string }).toolCallId === "call-stringy",
|
||||
);
|
||||
assert.equal((failed as { state?: string }).state, "output-error", "persisted as an error part");
|
||||
assert.match(String((failed as { errorText?: string }).errorText), /options/);
|
||||
const pending = findPendingToolCall(saved.messages);
|
||||
const pending = danglingQuestionOf(saved.messages);
|
||||
assert.equal(pending?.toolCallId, "call-clean", "the retried quiz is the pending one");
|
||||
});
|
||||
|
||||
@@ -426,8 +470,8 @@ describe("teacher chat flow (scripted model)", () => {
|
||||
// The live modal-realism observation: the teacher batched two quizzes
|
||||
// in one turn, and the dock showed the SECOND one first (the backward
|
||||
// part scan), so the interaction order was reversed vs the record.
|
||||
// Pins: (A) findPendingToolCall picks the FIRST unanswered question in
|
||||
// part order; (B) question tools execute sequentially, so their
|
||||
// Pins: (A) danglingQuestionOf picks the FIRST unanswered question in
|
||||
// call order; (B) question tools execute sequentially, so their
|
||||
// tool-input-available chunks arrive in call order on the wire.
|
||||
const QUIZ_A = { ...QUIZ_INPUT, question: "Quiz A: which planet is closest to the sun?" };
|
||||
const QUIZ_B = { ...QUIZ_INPUT, question: "Quiz B: which planet is largest?" };
|
||||
@@ -442,8 +486,7 @@ describe("teacher chat flow (scripted model)", () => {
|
||||
|
||||
const chat = createConfiguredChat("Test: parallel quizzes");
|
||||
createdChats.push(chat.id);
|
||||
const firstUser = { id: "u1", role: "user", parts: [{ type: "text", text: "quiz me twice" }] };
|
||||
const body = await drain(await handleChatRequest(post(chat.id, [firstUser]), agentFactory(engine)));
|
||||
const body = await drain(await handleChatRequest(post(chat.id, { type: "text", text: "quiz me twice" }), agentFactory(engine)));
|
||||
|
||||
// (B) wire determinism: the batches' input-available chunks in call order.
|
||||
const availA = body.indexOf('"type":"tool-input-available","toolCallId":"call-a"');
|
||||
@@ -451,30 +494,20 @@ describe("teacher chat flow (scripted model)", () => {
|
||||
assert.ok(availA !== -1 && availB !== -1, "both quizzes became available");
|
||||
assert.ok(availA < availB, "call A's input became available before call B's (sequential execution)");
|
||||
|
||||
// Saved in call order.
|
||||
// Saved in call order (the projection preserves the content order).
|
||||
const saved = chatStore.getChat(chat.id)!;
|
||||
const quizIds = saved.messages[1]!.parts
|
||||
const quizIds = projectNative(saved.messages)[1]!.parts
|
||||
.filter((p) => p.type === "tool-quiz")
|
||||
.map((p) => (p as { toolCallId: string }).toolCallId);
|
||||
assert.deepEqual(quizIds, ["call-a", "call-b"], "parts persisted in call order");
|
||||
|
||||
// (A) the dock serializes answering in the same order: A first, then B.
|
||||
const pending = findPendingToolCall(saved.messages);
|
||||
const pending = danglingQuestionOf(saved.messages);
|
||||
assert.equal(pending?.toolCallId, "call-a", "the FIRST asked question is the dock subject");
|
||||
// …the learner answers A (client-side grading seam — state flip only here)…
|
||||
const answered = saved.messages.map((m) =>
|
||||
(m as { id?: string }).id === saved.messages[1]!.id
|
||||
? {
|
||||
...m,
|
||||
parts: (m as { parts: unknown[] }).parts.map((p) =>
|
||||
(p as { toolCallId?: string }).toolCallId === "call-a"
|
||||
? { ...(p as object), state: "output-available", output: { correct: true } }
|
||||
: p,
|
||||
),
|
||||
}
|
||||
: m,
|
||||
);
|
||||
const next = findPendingToolCall(answered as UIMessage[]);
|
||||
// …the learner answers A: the answer lands as call-a's tool result —
|
||||
// the SECOND question becomes the dock subject only after that.
|
||||
const answered = [...saved.messages, newAnswerResult("call-a", "quiz", gradedAnswer(QUIZ_A, ["Mercury"]))];
|
||||
const next = danglingQuestionOf(answered);
|
||||
assert.equal(next?.toolCallId, "call-b", "the SECOND question becomes the dock subject only after the first is answered");
|
||||
});
|
||||
});
|
||||
@@ -483,7 +516,7 @@ describe("chat store", () => {
|
||||
it("round-trips and lists chats", async () => {
|
||||
const chat = chatStore.createChat("Store test");
|
||||
createdChats.push(chat.id);
|
||||
chatStore.saveMessages(chat.id, [{ id: "m1", role: "user", parts: [{ type: "text", text: "hi" }] }]);
|
||||
chatStore.saveMessages(chat.id, [{ id: "m1", role: "user", content: "hi", timestamp: 1 } as never]);
|
||||
const loaded = chatStore.getChat(chat.id)!;
|
||||
assert.equal(loaded.messages.length, 1);
|
||||
assert.equal(loaded.title, "Store test");
|
||||
@@ -510,18 +543,17 @@ describe("mid-turn navigation (turn liveness + write-ahead)", () => {
|
||||
const chat = createConfiguredChat("Test: turn guard");
|
||||
createdChats.push(chat.id);
|
||||
|
||||
const firstUser = { id: "u1", role: "user", parts: [{ type: "text", text: "teach me the solar system" }] };
|
||||
const res1 = await handleChatRequest(post(chat.id, [firstUser]), agentFactory(engine)); // NOT drained → in flight
|
||||
const res1 = await handleChatRequest(post(chat.id, { type: "text", text: "teach me the solar system" }), agentFactory(engine)); // NOT drained → in flight
|
||||
assert.equal(isTurnRunning(chat.id), true, "turn registered as in flight");
|
||||
|
||||
// A stale client (remount racing a live turn) tries to start a second turn.
|
||||
const res2 = await handleChatRequest(post(chat.id, [firstUser]), agentFactory(engine));
|
||||
const res2 = await handleChatRequest(post(chat.id, { type: "text", text: "teach me the solar system" }), agentFactory(engine));
|
||||
assert.equal(res2.status, 409, "concurrent turn rejected — last-writer-wins persist would clobber it");
|
||||
|
||||
await drain(res1); // finish → onFinish → endTurn
|
||||
assert.equal(isTurnRunning(chat.id), false, "registry cleared at finish");
|
||||
|
||||
const res3 = await handleChatRequest(post(chat.id, [firstUser]), agentFactory(engine));
|
||||
const res3 = await handleChatRequest(post(chat.id, { type: "text", text: "teach me the solar system" }), agentFactory(engine));
|
||||
assert.equal(res3.status, 200, "the next turn is accepted after the previous finished");
|
||||
await drain(res3);
|
||||
});
|
||||
@@ -531,69 +563,31 @@ describe("mid-turn navigation (turn liveness + write-ahead)", () => {
|
||||
const chat = createConfiguredChat("Test: write-ahead");
|
||||
createdChats.push(chat.id);
|
||||
|
||||
// Turn 1: the teacher asks the quiz (pending part in the record).
|
||||
const firstUser = { id: "u1", role: "user", parts: [{ type: "text", text: "teach me the solar system" }] };
|
||||
await drain(await handleChatRequest(post(chat.id, [firstUser]), agentFactory(engine)));
|
||||
const saved1 = chatStore.getChat(chat.id)!;
|
||||
const assistantMsg = saved1.messages[1]!;
|
||||
// Turn 1: the teacher asks the quiz (dangling call in the record).
|
||||
await drain(await handleChatRequest(post(chat.id, { type: "text", text: "teach me the solar system" }), agentFactory(engine)));
|
||||
|
||||
// The learner answers; the client posts the graded transcript back.
|
||||
const options = normalizeOptions(QUIZ_INPUT.options);
|
||||
const qd: QuestionData = {
|
||||
id: "call-1",
|
||||
kind: "quiz",
|
||||
question: QUIZ_INPUT.question,
|
||||
mode: "single-select",
|
||||
options,
|
||||
correctIndices: resolveCorrect(coerceCorrectAnswer(QUIZ_INPUT.correctAnswer), options).indices,
|
||||
explanation: QUIZ_INPUT.explanation,
|
||||
createdAt: new Date().toISOString(),
|
||||
};
|
||||
const answer = buildAnswer(qd, { values: ["Mercury"] });
|
||||
const messages2 = saved1.messages.map((m) =>
|
||||
m.id === assistantMsg.id
|
||||
? {
|
||||
...m,
|
||||
parts: m.parts.map((p) =>
|
||||
p.type === "tool-quiz" ? { ...p, state: "output-available" as const, output: answer } : p,
|
||||
),
|
||||
}
|
||||
: m,
|
||||
);
|
||||
// The learner answers; the client posts the ANSWER EVENT.
|
||||
const answer = gradedAnswer(QUIZ_INPUT, ["Mercury"]);
|
||||
|
||||
// NOT drained: the teacher turn is streaming, but the answer is already
|
||||
// on disk — a remount (or crash) mid-turn sees the answered quiz, never
|
||||
// the stale pre-answer question.
|
||||
const res = await handleChatRequest(post(chat.id, messages2), agentFactory(engine));
|
||||
const res = await handleChatRequest(post(chat.id, { type: "answer", toolCallId: "call-1", answer }), agentFactory(engine));
|
||||
const midTurn = chatStore.getChat(chat.id)!;
|
||||
const midQuiz = midTurn.messages[1]!.parts.find((p) => p.type === "tool-quiz");
|
||||
assert.equal(midTurn.messages.length, 3, "write-ahead appended the answer tool result before finish");
|
||||
assert.equal((midTurn.messages[2] as { role?: string }).role, "toolResult");
|
||||
const midQuiz = projectNative(midTurn.messages)[1]!.parts.find((p) => p.type === "tool-quiz");
|
||||
assert.ok(midQuiz && midQuiz.type === "tool-quiz");
|
||||
assert.equal((midQuiz as { state?: string }).state, "output-available", "write-ahead persisted the answer before finish");
|
||||
|
||||
await drain(res); // finish → persist the full turn → endTurn
|
||||
const saved2 = chatStore.getChat(chat.id)!;
|
||||
assert.equal(saved2.messages.length, 2, "full turn persisted as usual at finish");
|
||||
// Native: user, assistant, answer result, reaction — one append-only
|
||||
// history; the projection still renders ONE assistant group.
|
||||
assert.equal(saved2.messages.length, 4, "full turn persisted as usual at finish");
|
||||
assert.equal(projectNative(saved2.messages).length, 2, "the projection groups the answered quiz + reaction");
|
||||
assert.equal(isTurnRunning(chat.id), false, "registry cleared at finish");
|
||||
});
|
||||
|
||||
it("a turn that fails to start (malformed messages) clears the registry — no lockout", async () => {
|
||||
const chat = createConfiguredChat("Test: turn failed to start");
|
||||
createdChats.push(chat.id);
|
||||
// A text part with no text: fails message validation before the stream
|
||||
// exists — the path where no onFinish will ever run.
|
||||
const bad = [{ id: "m1", role: "user", parts: [{ type: "text" }] }];
|
||||
const res = await handleChatRequest(post(chat.id, bad), agentFactory(scriptedTeacher(defaultLesson).engine));
|
||||
assert.equal(res.status, 500, "turn-start failure is a 500, not a hang");
|
||||
assert.equal(isTurnRunning(chat.id), false, "registry cleared when the stream never started");
|
||||
|
||||
// And the chat is NOT locked: the next (valid) turn is accepted.
|
||||
const ok = await handleChatRequest(
|
||||
post(chat.id, [{ id: "m1", role: "user", parts: [{ type: "text", text: "teach me" }] }]),
|
||||
agentFactory(scriptedTeacher(defaultLesson).engine),
|
||||
);
|
||||
assert.equal(ok.status, 200);
|
||||
await drain(ok);
|
||||
});
|
||||
});
|
||||
|
||||
describe("renameChat (the header's click-to-edit title)", () => {
|
||||
@@ -635,8 +629,7 @@ describe("the BYOK turn gate (no factory floor — owner, 2026-09-22)", () => {
|
||||
// slot legitimately opens the gate (the chain is honest both ways).
|
||||
const expectRefusal = effectiveSettings().models.strong === null;
|
||||
|
||||
const firstUser = { id: "u1", role: "user", parts: [{ type: "text", text: "teach me the solar system" }] };
|
||||
const res = await handleChatRequest(post(chat.id, [firstUser]), agentFactory(scriptedTeacher(defaultLesson).engine));
|
||||
const res = await handleChatRequest(post(chat.id, { type: "text", text: "teach me the solar system" }), agentFactory(scriptedTeacher(defaultLesson).engine));
|
||||
|
||||
if (expectRefusal) {
|
||||
assert.equal(res.status, 503, "the universal turn gate refuses before beginTurn");
|
||||
@@ -660,8 +653,7 @@ describe("the BYOK turn gate (no factory floor — owner, 2026-09-22)", () => {
|
||||
it("a per-chat model alone opens the gate (the setup flow's exact write)", async () => {
|
||||
const chat = createConfiguredChat("Test: per-chat pin only");
|
||||
createdChats.push(chat.id);
|
||||
const firstUser = { id: "u1", role: "user", parts: [{ type: "text", text: "teach me the solar system" }] };
|
||||
const res = await handleChatRequest(post(chat.id, [firstUser]), agentFactory(scriptedTeacher(defaultLesson).engine));
|
||||
const res = await handleChatRequest(post(chat.id, { type: "text", text: "teach me the solar system" }), agentFactory(scriptedTeacher(defaultLesson).engine));
|
||||
assert.equal(res.status, 200, "chat.settings.model satisfies the chain without any record slot");
|
||||
await drain(res);
|
||||
});
|
||||
@@ -683,8 +675,7 @@ describe("the key-presence turn gate (provider-key interlude, 2026-09-25)", () =
|
||||
// chain the strong slot rides. The gate fires BEFORE the engine
|
||||
// build, so the fake model id never hits the catalog.
|
||||
chatStore.saveSettings(chat.id, { model: { provider: "together", id: "no-such-model" } });
|
||||
const firstUser = { id: "u1", role: "user", parts: [{ type: "text", text: "teach me something" }] };
|
||||
const res = await handleChatRequest(post(chat.id, [firstUser]), agentFactory(scriptedTeacher(defaultLesson).engine));
|
||||
const res = await handleChatRequest(post(chat.id, { type: "text", text: "teach me something" }), agentFactory(scriptedTeacher(defaultLesson).engine));
|
||||
|
||||
assert.equal(res.status, 503, "the key-presence gate refuses before beginTurn");
|
||||
const body = (await res.json()) as { error: string; code: string; provider: string; slots: string[] };
|
||||
@@ -708,8 +699,7 @@ describe("the key-presence turn gate (provider-key interlude, 2026-09-25)", () =
|
||||
// A REAL catalog model: the engine build AFTER the gate must
|
||||
// resolve — the injected agent replaces the loop, not the build.
|
||||
chatStore.saveSettings(chat.id, { model: { provider: "openai", id: "gpt-4" } });
|
||||
const firstUser = { id: "u1", role: "user", parts: [{ type: "text", text: "teach me something" }] };
|
||||
const res = await handleChatRequest(post(chat.id, [firstUser]), agentFactory(scriptedTeacher(defaultLesson).engine));
|
||||
const res = await handleChatRequest(post(chat.id, { type: "text", text: "teach me something" }), agentFactory(scriptedTeacher(defaultLesson).engine));
|
||||
|
||||
assert.equal(res.status, 200, "the env-shadowed key counts as present — the turn runs");
|
||||
await drain(res);
|
||||
@@ -728,8 +718,7 @@ describe("background-turn runner (owner ruling 2026-09-25: every turn is a serve
|
||||
const chat = createConfiguredChat("Test: reattach replay");
|
||||
createdChats.push(chat.id);
|
||||
|
||||
const req = post(chat.id, [{ id: "u1", role: "user", parts: [{ type: "text", text: "hold my turn" }] }]);
|
||||
const first = await handleChatRequest(req, agentFactory(engine));
|
||||
const first = await handleChatRequest(post(chat.id, { type: "text", text: "hold my turn" }), agentFactory(engine));
|
||||
assert.equal(first.status, 200);
|
||||
// Consume the first view a little (the start chunk is out).
|
||||
const reader = first.body!.getReader();
|
||||
@@ -768,8 +757,7 @@ describe("background-turn runner (owner ruling 2026-09-25: every turn is a serve
|
||||
const chat = createConfiguredChat("Test: remote abort");
|
||||
createdChats.push(chat.id);
|
||||
|
||||
const req = post(chat.id, [{ id: "u1", role: "user", parts: [{ type: "text", text: "wait forever" }] }]);
|
||||
const res = await handleChatRequest(req, agentFactory(engine));
|
||||
const res = await handleChatRequest(post(chat.id, { type: "text", text: "wait forever" }), agentFactory(engine));
|
||||
assert.equal(res.status, 200);
|
||||
assert.ok(runningTurn(chat.id), "turn registered while in flight");
|
||||
const viewText = res.text(); // the first view, reading to settle
|
||||
@@ -802,8 +790,7 @@ describe("background-turn runner (owner ruling 2026-09-25: every turn is a serve
|
||||
|
||||
// And after a settled turn, the registry is empty — the GET would
|
||||
// serve the same terminal, not a stale handle.
|
||||
const req = post(chat.id, [{ id: "u1", role: "user", parts: [{ type: "text", text: "quiz me" }] }]);
|
||||
await drain(await handleChatRequest(req, agentFactory(engine)));
|
||||
await drain(await handleChatRequest(post(chat.id, { type: "text", text: "quiz me" }), agentFactory(engine)));
|
||||
assert.equal(runningTurn(chat.id), undefined, "no handle survives settle");
|
||||
});
|
||||
|
||||
@@ -819,8 +806,7 @@ describe("background-turn runner (owner ruling 2026-09-25: every turn is a serve
|
||||
|
||||
const views = new Map<string, Promise<string>>();
|
||||
for (const { chat, engine } of chats) {
|
||||
const req = post(chat.id, [{ id: "u1", role: "user", parts: [{ type: "text", text: "hold my turn" }] }]);
|
||||
const res = await handleChatRequest(req, agentFactory(engine));
|
||||
const res = await handleChatRequest(post(chat.id, { type: "text", text: "hold my turn" }), agentFactory(engine));
|
||||
assert.equal(res.status, 200);
|
||||
const handle = runningTurn(chat.id)!;
|
||||
assert.ok(handle, `chat ${chat.id} has its own handle`);
|
||||
|
||||
@@ -1,25 +1,20 @@
|
||||
/**
|
||||
* 409 merge-retry transport tests (components/chat-transport.ts).
|
||||
* 409 identity-retry transport tests (components/chat-transport.ts).
|
||||
*
|
||||
* Every 409 means the server record is AHEAD (a wake turn persisted the
|
||||
* steer): the retry must wait out the turn, merge the outgoing messages onto
|
||||
* the fresh record, and re-POST the merged list — a bare retry would clobber
|
||||
* the steer/wake messages via the write-ahead persist.
|
||||
* Wave B (input events): a 409 means a turn is in flight — the event was
|
||||
* NEVER persisted (write-ahead happens only on accept), so the retry waits
|
||||
* out the turn and re-POSTs the SAME event body. Nothing merges, nothing
|
||||
* derives: the event is the unit, and re-POSTing it is idempotent-safe.
|
||||
*/
|
||||
import assert from "node:assert/strict";
|
||||
import { afterEach, describe, it } from "node:test";
|
||||
import { retryingFetch } from "../components/chat-transport.ts";
|
||||
import type { TeacherUIMessage } from "../src/core/ui-message.ts";
|
||||
|
||||
const realFetch = globalThis.fetch;
|
||||
afterEach(() => {
|
||||
globalThis.fetch = realFetch;
|
||||
});
|
||||
|
||||
function msg(id: string, role: "user" | "assistant", text: string): TeacherUIMessage {
|
||||
return { id, role, metadata: {}, parts: [{ type: "text", text }] };
|
||||
}
|
||||
|
||||
interface Call {
|
||||
url: string;
|
||||
body: string | undefined;
|
||||
@@ -30,8 +25,6 @@ function installFetch(routes: {
|
||||
chatPOST: (call: Call, attempt: number) => Response;
|
||||
/** GET /api/jobs?chatId=… → turnInFlight value per poll (poll index). */
|
||||
jobsGET?: (poll: number) => boolean;
|
||||
/** GET /api/chats/<id> → messages array. */
|
||||
chatGET?: () => TeacherUIMessage[];
|
||||
}): Call[] {
|
||||
const calls: Call[] = [];
|
||||
let poll = 0;
|
||||
@@ -42,14 +35,14 @@ function installFetch(routes: {
|
||||
return routes.chatPOST(calls.at(-1)!, calls.filter((c) => c.url === "/api/chat").length);
|
||||
}
|
||||
if (url.startsWith("/api/jobs")) return Response.json({ turnInFlight: routes.jobsGET?.(++poll) ?? false });
|
||||
if (url.startsWith("/api/chats/")) return Response.json({ messages: routes.chatGET?.() ?? [] });
|
||||
throw new Error(`unexpected fetch: ${url}`);
|
||||
}) as typeof globalThis.fetch;
|
||||
return calls;
|
||||
}
|
||||
|
||||
function outgoing(clientOnly: TeacherUIMessage[]): string {
|
||||
return JSON.stringify({ chatId: "chat_x", messages: [...clientOnly] });
|
||||
/** One input event — the POST body's whole unit. */
|
||||
function outgoing(input: Record<string, unknown>): string {
|
||||
return JSON.stringify({ chatId: "chat_x", input });
|
||||
}
|
||||
|
||||
describe("retryingFetch (409 merge-retry)", { skip: process.env.NO_SLOW ? true : undefined }, () => {
|
||||
@@ -58,66 +51,54 @@ describe("retryingFetch (409 merge-retry)", { skip: process.env.NO_SLOW ? true :
|
||||
chatPOST: () => new Response("stream", { status: 200 }),
|
||||
});
|
||||
const fetch = retryingFetch("chat_x", { pollMs: 10, waitMaxMs: 100 });
|
||||
const res = await fetch("/api/chat", { method: "POST", body: outgoing([msg("c1", "user", "hi")]) });
|
||||
const res = await fetch("/api/chat", { method: "POST", body: outgoing({ type: "text", text: "hi" }) });
|
||||
assert.equal(res.status, 200);
|
||||
assert.equal(calls.length, 1, "no polling, no merge, single call");
|
||||
});
|
||||
|
||||
it("waits out the turn, merges onto the fresh record, re-POSTs the merged list", async () => {
|
||||
const server = [msg("steer_1", "user", "Research complete"), msg("s1", "assistant", "wake turn")];
|
||||
const fresh = [msg("c1", "user", "my question while the wake turn ran")];
|
||||
it("waits out the turn, then re-POSTs the SAME event body (identity retry)", async () => {
|
||||
const event = { type: "text", text: "my question while the wake turn ran" };
|
||||
const calls = installFetch({
|
||||
chatPOST: (_call, n) => (n === 1 ? new Response("busy", { status: 409 }) : new Response("stream", { status: 200 })),
|
||||
jobsGET: (poll) => poll <= 1, // first poll: still running; second: done
|
||||
chatGET: () => server,
|
||||
});
|
||||
const fetch = retryingFetch("chat_x", { pollMs: 10, waitMaxMs: 100 });
|
||||
const res = await fetch("/api/chat", { method: "POST", body: outgoing(fresh) });
|
||||
const res = await fetch("/api/chat", { method: "POST", body: outgoing(event) });
|
||||
assert.equal(res.status, 200);
|
||||
|
||||
// second POST carries server truth + the client's unsent message, in order
|
||||
const retry = JSON.parse(calls.filter((c) => c.url === "/api/chat")[1]!.body!);
|
||||
assert.deepEqual(
|
||||
retry.messages.map((m: { id: string }) => m.id),
|
||||
["steer_1", "s1", "c1"],
|
||||
);
|
||||
// The retry carries the IDENTICAL body — the event was never
|
||||
// persisted pre-accept, so re-sending it is the whole protocol.
|
||||
const posts = calls.filter((c) => c.url === "/api/chat");
|
||||
assert.equal(posts.length, 2, "one 409, one retry");
|
||||
assert.equal(posts[1]!.body, posts[0]!.body, "the event body is byte-identical");
|
||||
assert.deepEqual(JSON.parse(posts[1]!.body!), { chatId: "chat_x", input: event });
|
||||
});
|
||||
|
||||
it("drops client messages the server already has (no duplicates in the merge)", async () => {
|
||||
const server = [msg("steer_1", "user", "Research complete"), msg("c1", "user", "my question")];
|
||||
it("does not poll the record or merge — the event is the only payload", async () => {
|
||||
const calls = installFetch({
|
||||
chatPOST: (_call, n) => (n === 1 ? new Response("busy", { status: 409 }) : new Response("stream", { status: 200 })),
|
||||
chatGET: () => server,
|
||||
});
|
||||
const fetch = retryingFetch("chat_x", { pollMs: 10, waitMaxMs: 100 });
|
||||
const res = await fetch("/api/chat", {
|
||||
method: "POST",
|
||||
body: outgoing([msg("steer_1", "user", "Research complete"), msg("c1", "user", "my question")]),
|
||||
});
|
||||
const res = await fetch("/api/chat", { method: "POST", body: outgoing({ type: "text", text: "q" }) });
|
||||
assert.equal(res.status, 200);
|
||||
const retry = JSON.parse(calls.filter((c) => c.url === "/api/chat")[1]!.body!);
|
||||
assert.deepEqual(
|
||||
retry.messages.map((m: { id: string }) => m.id),
|
||||
["steer_1", "c1"],
|
||||
assert.equal(
|
||||
calls.filter((c) => c.url.startsWith("/api/chats/")).length,
|
||||
0,
|
||||
"the retry never reads the record — the server owns it",
|
||||
);
|
||||
});
|
||||
|
||||
it("loops on repeated 409s, re-merging fresh each time", async () => {
|
||||
const server = [msg("steer_1", "user", "Research complete")];
|
||||
it("loops on repeated 409s, re-POSTing the same event each time", async () => {
|
||||
const event = { type: "answer", toolCallId: "call-1", answer: { correct: true } };
|
||||
const calls = installFetch({
|
||||
chatPOST: (_call, n) => (n <= 2 ? new Response("busy", { status: 409 }) : new Response("stream", { status: 200 })),
|
||||
chatGET: () => server,
|
||||
});
|
||||
const fetch = retryingFetch("chat_x", { pollMs: 10, waitMaxMs: 100 });
|
||||
const res = await fetch("/api/chat", { method: "POST", body: outgoing([msg("c1", "user", "q")]) });
|
||||
const res = await fetch("/api/chat", { method: "POST", body: outgoing(event) });
|
||||
assert.equal(res.status, 200);
|
||||
const posts = calls.filter((c) => c.url === "/api/chat");
|
||||
assert.equal(posts.length, 3);
|
||||
const last = JSON.parse(posts[2]!.body!);
|
||||
assert.deepEqual(
|
||||
last.messages.map((m: { id: string }) => m.id),
|
||||
["steer_1", "c1"],
|
||||
);
|
||||
assert.ok(posts.every((c) => c.body === posts[0]!.body), "every attempt carries the same event");
|
||||
});
|
||||
|
||||
it("gives up after TURN_WAIT_MAX_MS and returns the last 409 (banner as before)", async () => {
|
||||
@@ -129,7 +110,7 @@ describe("retryingFetch (409 merge-retry)", { skip: process.env.NO_SLOW ? true :
|
||||
jobsGET: () => true,
|
||||
});
|
||||
const fetch = retryingFetch("chat_x", { pollMs: 10, waitMaxMs: 100 });
|
||||
const res = await fetch("/api/chat", { method: "POST", body: outgoing([msg("c1", "user", "q")]) });
|
||||
const res = await fetch("/api/chat", { method: "POST", body: outgoing({ type: "text", text: "q" }) });
|
||||
assert.equal(res.status, 409);
|
||||
polls = calls.filter((c) => c.url.startsWith("/api/jobs")).length;
|
||||
assert.ok(polls <= 15, `expected bounded polling (~10×10ms), got ${polls}`);
|
||||
|
||||
+78
-40
@@ -50,16 +50,15 @@ import { runDemoBuilder } from "../src/agent/subagents/demo-builder.ts";
|
||||
import { headlessOverride, type DemoRunResult, type HeadlessApi } from "../src/agent/subagents/headless.ts";
|
||||
import { createJobRunner, demoModelOverride, visualModelOverride } from "../src/agent/subagents/runner.ts";
|
||||
import { runVisualMaker } from "../src/agent/subagents/visual-maker.ts";
|
||||
import { buildAbortSteer, buildSubagentSteer, isSubagentFailure, type SubagentSteerMessage } from "../src/core/contracts.ts";
|
||||
import { buildAbortSteer, buildSubagentSteer, isSubagentFailure, type SteerMessage } from "../src/core/contracts.ts";
|
||||
import type { JobRecord } from "../src/core/contracts.ts";
|
||||
import { createTeacherTools } from "../src/agent/tools.ts";
|
||||
import { chatStore } from "../src/store/chats.ts";
|
||||
import { jobStore } from "../src/store/jobs.ts";
|
||||
import { handleChatRequest } from "../src/server/chat-handler.ts";
|
||||
import { createTeacherAgent, type TeachingAgent } from "../src/agent/teacher-agent.ts";
|
||||
import { recordToPiMessages } from "../src/server/pi/record-adapter.ts";
|
||||
import { createTeacherAgent } from "../src/agent/teacher-agent.ts";
|
||||
import { projectNative } from "../src/core/projection.ts";
|
||||
import type { PiEngine } from "../src/agent/models.ts";
|
||||
import type { UIMessage } from "ai";
|
||||
|
||||
/**
|
||||
* A fake deliver seam + a runner wired like the chat-handler's, per test:
|
||||
@@ -67,13 +66,13 @@ import type { UIMessage } from "ai";
|
||||
* record reads exactly as production does after the wake turn completes.
|
||||
*/
|
||||
function testRunner() {
|
||||
const steers: SubagentSteerMessage[] = [];
|
||||
const steers: SteerMessage[] = [];
|
||||
const runner = createJobRunner({
|
||||
chatStore,
|
||||
jobStore,
|
||||
deliver: async (steer) => {
|
||||
steers.push(steer);
|
||||
const jobId = steer.metadata.subagentResult.jobId;
|
||||
const jobId = (steer.details as { jobId: string }).jobId;
|
||||
const job = jobStore.getJob(jobId);
|
||||
if (job && job.status === "running") {
|
||||
const failed = job.result ? isSubagentFailure(job.kind, job.result as never) : true;
|
||||
@@ -349,7 +348,7 @@ describe("steer card rendering (the delivered artifact)", () => {
|
||||
{ id: "jd", kind: "demo", toolCallId: "cd" },
|
||||
{ title: "Damped oscillator", html: DEMO_HTML, tested: true },
|
||||
);
|
||||
const html = renderToStaticMarkup(<SubagentSteerCard message={steer as never} />);
|
||||
const html = renderToStaticMarkup(<SubagentSteerCard message={projectNative([steer])[0] as never} />);
|
||||
assert.match(html, /sandbox="allow-scripts"/);
|
||||
assert.ok(!html.includes("allow-same-origin"), "never allow-same-origin");
|
||||
assert.match(html, /demo-resize/);
|
||||
@@ -365,7 +364,7 @@ describe("steer card rendering (the delivered artifact)", () => {
|
||||
{ id: "jd", kind: "demo", toolCallId: "cd" },
|
||||
{ title: "T", html: "", tested: true, notes: "FAILED: never passed verification" },
|
||||
);
|
||||
const html = renderToStaticMarkup(<SubagentSteerCard message={steer as never} />);
|
||||
const html = renderToStaticMarkup(<SubagentSteerCard message={projectNative([steer])[0] as never} />);
|
||||
assert.match(html, /FAILED: never passed verification/);
|
||||
assert.ok(!html.includes("<iframe"), "no iframe for a failed build");
|
||||
});
|
||||
@@ -376,7 +375,7 @@ describe("steer card rendering (the delivered artifact)", () => {
|
||||
{ id: "jv", kind: "visual", toolCallId: "cv" },
|
||||
{ svg, notes: "Looking at the render, the diagram is clean and accurate." },
|
||||
);
|
||||
const html = renderToStaticMarkup(<SubagentSteerCard message={steer as never} />);
|
||||
const html = renderToStaticMarkup(<SubagentSteerCard message={projectNative([steer])[0] as never} />);
|
||||
assert.ok(html.includes("circle"), "svg content rendered inline");
|
||||
assert.match(html, /Figure/);
|
||||
assert.match(html, /svg\]:w-full/, "svg scales to the display area's full width");
|
||||
@@ -385,16 +384,19 @@ describe("steer card rendering (the delivered artifact)", () => {
|
||||
|
||||
it("renders the abort chip for an aborted job's steer (2026-09 dock semantics; the message type is the marker PART)", () => {
|
||||
const steer = buildAbortSteer({ id: "jc", kind: "research", toolCallId: "cc" });
|
||||
assert.equal(steer.metadata.subagentResult.aborted, true);
|
||||
assert.equal(steer.parts.length, 2, "text (teacher-facing) + the data-subagent-aborted marker part");
|
||||
const html = renderToStaticMarkup(<SubagentSteerCard message={steer as never} />);
|
||||
// Wave B: the steer is a native marker — the card renders its
|
||||
// PROJECTION (the same view the transcript renderer hands it).
|
||||
const view = projectNative([steer])[0]!;
|
||||
assert.equal((steer.details as { aborted: boolean }).aborted, true);
|
||||
assert.equal(view.parts.length, 2, "text (teacher-facing) + the data-subagent-aborted marker part");
|
||||
const html = renderToStaticMarkup(<SubagentSteerCard message={view as never} />);
|
||||
assert.match(html, /job-aborted-badge/);
|
||||
assert.match(html, /research/, "the job's kind, lower-cased like the complete-badge idiom");
|
||||
assert.match(html, /aborted by the learner/);
|
||||
// the teacher-facing protocol text never renders for the learner
|
||||
assert.ok(!html.includes("no brief is coming"), "no protocol glue in the chip");
|
||||
// the badge renders off the PART, not metadata (the custom-message scheme)
|
||||
const noMeta = { ...steer, metadata: {} } as typeof steer;
|
||||
const noMeta = { ...view, metadata: {} };
|
||||
const htmlNoMeta = renderToStaticMarkup(<SubagentSteerCard message={noMeta as never} />);
|
||||
assert.match(htmlNoMeta, /job-aborted-badge/, "metadata is pump mechanics, never the render seam");
|
||||
});
|
||||
@@ -410,7 +412,7 @@ describe("steer card rendering (the delivered artifact)", () => {
|
||||
],
|
||||
},
|
||||
);
|
||||
const html = renderToStaticMarkup(<SubagentSteerCard message={steer as never} />);
|
||||
const html = renderToStaticMarkup(<SubagentSteerCard message={projectNative([steer])[0] as never} />);
|
||||
assert.match(html, /Research/);
|
||||
assert.match(html, /complete/);
|
||||
assert.match(html, /2 sources/);
|
||||
@@ -435,7 +437,7 @@ describe("steer card rendering (the delivered artifact)", () => {
|
||||
pagesConsulted: [1, 2],
|
||||
},
|
||||
);
|
||||
const html = renderToStaticMarkup(<SubagentSteerCard message={steer as never} />);
|
||||
const html = renderToStaticMarkup(<SubagentSteerCard message={projectNative([steer])[0] as never} />);
|
||||
assert.match(html, /PDF/);
|
||||
assert.match(html, /reading complete/);
|
||||
assert.match(html, /read 1 page/, "the badge counts READ pages, not search hits");
|
||||
@@ -451,7 +453,7 @@ describe("steer card rendering (the delivered artifact)", () => {
|
||||
{ id: "jpf2", kind: "pdf", toolCallId: "cpf2" },
|
||||
{ path: "/tmp/doc.pdf", question: "q", brief: "PDF failed: the file could not be opened", pagesRead: [], pagesConsulted: [] },
|
||||
);
|
||||
const html = renderToStaticMarkup(<SubagentSteerCard message={steer as never} />);
|
||||
const html = renderToStaticMarkup(<SubagentSteerCard message={projectNative([steer])[0] as never} />);
|
||||
assert.match(html, /pdf-brief-card/);
|
||||
assert.match(html, /failed/);
|
||||
assert.match(html, /could not be opened/);
|
||||
@@ -460,7 +462,7 @@ describe("steer card rendering (the delivered artifact)", () => {
|
||||
|
||||
it("renders the visual failure note with no svg when the build FAILED", () => {
|
||||
const steer = buildSubagentSteer({ id: "jv", kind: "visual", toolCallId: "cv" }, { svg: "", notes: "FAILED: iteration limit" });
|
||||
const html = renderToStaticMarkup(<SubagentSteerCard message={steer as never} />);
|
||||
const html = renderToStaticMarkup(<SubagentSteerCard message={projectNative([steer])[0] as never} />);
|
||||
assert.match(html, /FAILED: iteration limit/);
|
||||
assert.ok(!html.includes("circle") && !html.includes("<svg"), "no svg for a failed build");
|
||||
});
|
||||
@@ -675,14 +677,32 @@ describe("demo/visual job runners", () => {
|
||||
it("runJob completes a demo: result on the job, steer via deliver, ack stays on the part", async () => {
|
||||
const chat = chatStore.createChat("Test: demo runner");
|
||||
createdChats.push(chat.id);
|
||||
// Wave B: a NATIVE fixture (the record holds agent history) — the
|
||||
// runner's transcript scan reads it through the projection.
|
||||
chatStore.saveMessages(chat.id, [
|
||||
{ id: "u1", role: "user", parts: [{ type: "text", text: "show me compounding" }] },
|
||||
{ id: "u1", role: "user", content: "show me compounding", timestamp: 1 } as never,
|
||||
{
|
||||
id: "a1",
|
||||
role: "assistant",
|
||||
parts: [{ type: "tool-demo", toolCallId: "call-d1", state: "output-available", input: DEMO_INPUT, output: "Dispatched." }],
|
||||
},
|
||||
] as never);
|
||||
content: [{ type: "toolCall", id: "call-d1", name: "demo", arguments: DEMO_INPUT as never }],
|
||||
api: "openai-completions",
|
||||
provider: "openrouter",
|
||||
model: "test",
|
||||
usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
||||
stopReason: "stop",
|
||||
timestamp: 1,
|
||||
} as never,
|
||||
{
|
||||
id: "r1",
|
||||
role: "toolResult",
|
||||
toolCallId: "call-d1",
|
||||
toolName: "demo",
|
||||
content: [{ type: "text", text: "Dispatched." }],
|
||||
details: "Dispatched.",
|
||||
isError: false,
|
||||
timestamp: 1,
|
||||
} as never,
|
||||
]);
|
||||
|
||||
const job = jobStore.createJob({ kind: "demo", chatId: chat.id, toolCallId: "call-d1" });
|
||||
createdJobs.push(job.id);
|
||||
@@ -712,15 +732,14 @@ describe("demo/visual job runners", () => {
|
||||
|
||||
// The steer carries the artifact for the renderer.
|
||||
assert.equal(steers.length, 1, "exactly one steer");
|
||||
const data = steers[0]!.parts[1];
|
||||
assert.ok(data.type === "data-subagent-artifact");
|
||||
assert.equal(data.data.kind, "demo");
|
||||
assert.deepEqual(data.data.result, result);
|
||||
const data = steers[0]!.details as { kind: string; result: unknown };
|
||||
assert.equal(data.kind, "demo");
|
||||
assert.deepEqual(data.result, result);
|
||||
|
||||
// The tool part keeps the DISPATCH ACK forever — the result never
|
||||
// rewrites it (the teacher gets the result once, via the steer).
|
||||
const saved = chatStore.getChat(chat.id)!;
|
||||
const part = saved.messages[1]!.parts.find((p) => p.type === "tool-demo");
|
||||
const saved = projectNative(chatStore.getChat(chat.id)!.messages);
|
||||
const part = saved[1]!.parts.find((p) => p.type === "tool-demo");
|
||||
assert.ok(part && part.type === "tool-demo", "demo tool part persisted");
|
||||
assert.equal(typeof (part as { output?: unknown }).output, "string", "part output is the ack, not the result");
|
||||
});
|
||||
@@ -729,13 +748,29 @@ describe("demo/visual job runners", () => {
|
||||
const chat = chatStore.createChat("Test: visual runner failure");
|
||||
createdChats.push(chat.id);
|
||||
chatStore.saveMessages(chat.id, [
|
||||
{ id: "u1", role: "user", parts: [{ type: "text", text: "draw it" }] },
|
||||
{ id: "u1", role: "user", content: "draw it", timestamp: 1 } as never,
|
||||
{
|
||||
id: "a1",
|
||||
role: "assistant",
|
||||
parts: [{ type: "tool-visual", toolCallId: "call-v1", state: "output-available", input: { brief: "b" }, output: "Dispatched." }],
|
||||
},
|
||||
] as never);
|
||||
content: [{ type: "toolCall", id: "call-v1", name: "visual", arguments: { brief: "b" } as never }],
|
||||
api: "openai-completions",
|
||||
provider: "openrouter",
|
||||
model: "test",
|
||||
usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
||||
stopReason: "stop",
|
||||
timestamp: 1,
|
||||
} as never,
|
||||
{
|
||||
id: "r1",
|
||||
role: "toolResult",
|
||||
toolCallId: "call-v1",
|
||||
toolName: "visual",
|
||||
content: [{ type: "text", text: "Dispatched." }],
|
||||
details: "Dispatched.",
|
||||
isError: false,
|
||||
timestamp: 1,
|
||||
} as never,
|
||||
]);
|
||||
|
||||
const job = jobStore.createJob({ kind: "visual", chatId: chat.id, toolCallId: "call-v1" });
|
||||
createdJobs.push(job.id);
|
||||
@@ -753,9 +788,7 @@ describe("demo/visual job runners", () => {
|
||||
assert.match(errored.error ?? "", /never wrote an SVG/);
|
||||
|
||||
assert.ok(steers[0], "failure still delivers a steer — the teacher learns the outcome");
|
||||
const data = steers[0]!.parts[1];
|
||||
assert.ok(data.type === "data-subagent-artifact");
|
||||
const result = data.data.result as { svg: string; notes?: string };
|
||||
const result = (steers[0]!.details as { result: unknown }).result as { svg: string; notes?: string };
|
||||
assert.equal(result.svg, "");
|
||||
assert.match(result.notes ?? "", /^FAILED:/);
|
||||
});
|
||||
@@ -791,13 +824,18 @@ describe("demo/visual job runners", () => {
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
chatId: chat.id,
|
||||
messages: [{ id: "u1", role: "user", parts: [{ type: "text", text: "teach me compounding" }] }],
|
||||
// Wave B: the input-events contract — one event,
|
||||
// never a client transcript.
|
||||
input: { type: "text", text: "teach me compounding" },
|
||||
}),
|
||||
}),
|
||||
(outgoing: UIMessage[]): TeachingAgent =>
|
||||
// The handler hands the factory the native build options
|
||||
// (messages are the merged record; the record IS the agent
|
||||
// state — no adapter conversion).
|
||||
(opts) =>
|
||||
createTeacherAgent(undefined, {
|
||||
engine: teacher,
|
||||
messages: recordToPiMessages(outgoing),
|
||||
messages: opts.messages,
|
||||
tools: createTeacherTools({
|
||||
chatId: chat.id,
|
||||
chatStore,
|
||||
@@ -818,14 +856,14 @@ describe("demo/visual job runners", () => {
|
||||
const done = jobStore.getJob(job!.id)!;
|
||||
assert.equal(done.status, "done");
|
||||
|
||||
const saved = chatStore.getChat(chat.id)!;
|
||||
const part = saved.messages[1]!.parts.find((p) => p.type === "tool-demo");
|
||||
const saved = projectNative(chatStore.getChat(chat.id)!.messages);
|
||||
const part = saved[1]!.parts.find((p) => p.type === "tool-demo");
|
||||
assert.ok(part && part.type === "tool-demo", "demo tool part persisted");
|
||||
assert.equal(part.state, "output-available", "the part resolved with the ack at dispatch time");
|
||||
assert.match(String((part as { output?: unknown }).output), /fire-and-forget/);
|
||||
|
||||
assert.equal(steers.length, 1, "one job, one steer");
|
||||
assert.equal(steers[0]!.metadata.subagentResult.jobId, job!.id);
|
||||
assert.equal((steers[0]!.details as { jobId: string }).jobId, job!.id);
|
||||
|
||||
// Idempotency: dispatch dedupes by toolCallId — no second job.
|
||||
assert.equal(jobStore.listJobs(chat.id).length, 1, "no duplicate job for the same tool part");
|
||||
|
||||
+93
-41
@@ -23,8 +23,10 @@ import assert from "node:assert/strict";
|
||||
import * as fs from "node:fs";
|
||||
import { after, describe, it } from "node:test";
|
||||
import { createFauxCore, fauxAssistantMessage, fauxToolCall, type FauxResponseStep } from "@earendil-works/pi-ai";
|
||||
import type { UIMessage } from "ai";
|
||||
import type { PiEngine } from "../src/agent/models.ts";
|
||||
import type { LhMessage } from "../src/core/native-messages.ts";
|
||||
import { newPlanBlockMessage } from "../src/core/native-messages.ts";
|
||||
import { projectNative } from "../src/core/projection.ts";
|
||||
import type {
|
||||
AuditResult,
|
||||
CoverageEntry,
|
||||
@@ -116,26 +118,67 @@ function memoryLedgers() {
|
||||
} satisfies GraphStore;
|
||||
}
|
||||
|
||||
function assistant(id: string, parts: unknown[]): UIMessage {
|
||||
return { id, role: "assistant", parts } as unknown as UIMessage;
|
||||
// Native fixture builders (Wave B): the record holds pi-native history —
|
||||
// an assistant message with its tool calls + one result message per call;
|
||||
// the projection reproduces the part shapes these view tests pin.
|
||||
interface NativeCallDesc {
|
||||
name: string;
|
||||
toolCallId: string;
|
||||
input: Record<string, unknown>;
|
||||
output: unknown;
|
||||
}
|
||||
|
||||
function user(id: string, text: string): UIMessage {
|
||||
return { id, role: "user", parts: [{ type: "text", text }] } as unknown as UIMessage;
|
||||
function user(id: string, text: string): LhMessage {
|
||||
return { id, role: "user", content: text, timestamp: Date.now() } as never;
|
||||
}
|
||||
|
||||
function quizPart(toolCallId: string, input: Record<string, unknown>, output: Record<string, unknown>): unknown {
|
||||
return { type: "tool-quiz", toolCallId, state: "output-available", input, output };
|
||||
function assistant(id: string, items: unknown[]): LhMessage[] {
|
||||
const content: unknown[] = [];
|
||||
const calls: NativeCallDesc[] = [];
|
||||
for (const item of items) {
|
||||
const it = item as { type?: string; text?: string };
|
||||
if (it && it.type === "text") {
|
||||
content.push({ type: "text", text: it.text });
|
||||
} else {
|
||||
const call = item as NativeCallDesc;
|
||||
calls.push(call);
|
||||
content.push({ type: "toolCall", id: call.toolCallId, name: call.name, arguments: call.input as never });
|
||||
}
|
||||
}
|
||||
const out: LhMessage[] = [
|
||||
{
|
||||
id,
|
||||
role: "assistant",
|
||||
content,
|
||||
api: "openai-completions",
|
||||
provider: "openrouter",
|
||||
model: "test",
|
||||
usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
||||
stopReason: "stop",
|
||||
timestamp: Date.now(),
|
||||
} as never,
|
||||
];
|
||||
for (const call of calls) {
|
||||
out.push({
|
||||
id: `r_${call.toolCallId}`,
|
||||
role: "toolResult",
|
||||
toolCallId: call.toolCallId,
|
||||
toolName: call.name,
|
||||
content: [{ type: "text", text: "ok" }],
|
||||
details: call.output,
|
||||
isError: false,
|
||||
timestamp: Date.now(),
|
||||
} as never);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function planPart(toolCallId: string, name: string, input: Record<string, unknown>, tree: unknown): unknown {
|
||||
return {
|
||||
type: `tool-${name}`,
|
||||
toolCallId,
|
||||
state: "output-available",
|
||||
input,
|
||||
output: { ok: true, tree },
|
||||
};
|
||||
function quizPart(toolCallId: string, input: Record<string, unknown>, output: Record<string, unknown>): NativeCallDesc {
|
||||
return { name: "quiz", toolCallId, input, output };
|
||||
}
|
||||
|
||||
function planPart(toolCallId: string, name: string, input: Record<string, unknown>, tree: unknown): NativeCallDesc {
|
||||
return { name, toolCallId, input, output: { ok: true, tree } };
|
||||
}
|
||||
|
||||
/** A session-shaped fixture chat: probe → teaching text → check. Tree-blind
|
||||
@@ -145,23 +188,28 @@ function fixtureChat() {
|
||||
createdChats.push(chat.id);
|
||||
chatStore.saveMessages(chat.id, [
|
||||
user("u1", "teach me the chain rule"),
|
||||
assistant("a1", [
|
||||
...assistant("a1", [
|
||||
quizPart("probe-1", { question: "What is a derivative, roughly?" }, {
|
||||
questionId: "probe-1", kind: "quiz", questionText: "What is a derivative, roughly?",
|
||||
correct: false, dontKnow: false, answeredAt: "2026-08-22T01:00:00.000Z",
|
||||
}),
|
||||
]),
|
||||
assistant("a2", [
|
||||
// Distinct TURNS: the native grouping canon merges consecutive
|
||||
// assistant messages into one view group, so the fixture separates
|
||||
// its turns with learner messages (the real record's shape).
|
||||
user("u2", "go on"),
|
||||
...assistant("a2", [
|
||||
{ type: "text", text: "The derivative measures how fast a function changes." },
|
||||
planPart("p1", "plan_init", { nodes: [], edges: [] }, { nodes: [], edges: [], currentNodeId: null, doneNodeIds: [], version: 1 }),
|
||||
]),
|
||||
assistant("a3", [
|
||||
user("u3", "check me"),
|
||||
...assistant("a3", [
|
||||
quizPart("check-1", { question: "Check" }, {
|
||||
questionId: "check-1", kind: "quiz", questionText: "Check", correct: true, dontKnow: false,
|
||||
answeredAt: "2026-08-22T02:00:00.000Z",
|
||||
}),
|
||||
]),
|
||||
]);
|
||||
].flat());
|
||||
return { chatId: chat.id, record: chatStore.getChat(chat.id)! };
|
||||
}
|
||||
|
||||
@@ -282,17 +330,16 @@ describe("transcriptView (pure)", () => {
|
||||
createdChats.push(chat.id);
|
||||
chatStore.saveMessages(chat.id, [
|
||||
user("u1", "go"),
|
||||
assistant("a1", [
|
||||
...assistant("a1", [
|
||||
{
|
||||
type: "tool-research",
|
||||
name: "research",
|
||||
toolCallId: "r1",
|
||||
state: "output-available",
|
||||
input: { question: "q" },
|
||||
output: { brief: "long brief", sources: [] },
|
||||
},
|
||||
{ type: "text", text: " " },
|
||||
]),
|
||||
]);
|
||||
].flat());
|
||||
const view = transcriptView(chatStore.getChat(chat.id)!);
|
||||
assert.match(view, /\(research tool result delivered\)/);
|
||||
assert.doesNotMatch(view, /long brief/);
|
||||
@@ -352,7 +399,7 @@ describe("the extraction pipeline (extractor → auditor → merger)", () => {
|
||||
// a new event (vision-extraction-coverage.md).
|
||||
const events = ledgers.readEvents();
|
||||
const record = chatStore.getChat(chatId)!;
|
||||
const expectedTag = batchTag(coverageSlots(record.messages as UIMessage[]));
|
||||
const expectedTag = batchTag(coverageSlots(projectNative(record.messages)));
|
||||
assert.deepEqual(
|
||||
events.map((e) => [e.kind, e.ref, e.nodeId]),
|
||||
[
|
||||
@@ -368,12 +415,12 @@ describe("the extraction pipeline (extractor → auditor → merger)", () => {
|
||||
// one entry per transcript message, fingerprinted content each —
|
||||
// the same turns are never extracted twice.
|
||||
const covered = ledgers.readCoverage();
|
||||
assert.equal(covered.length, record.messages.length);
|
||||
assert.equal(covered.length, projectNative(record.messages).length);
|
||||
assert.ok(covered.every((c) => c.chatId === chatId && c.fp.length === 64 && c.tokens > 0));
|
||||
assert.equal(ledgers.readCheckpoints().length, 0, "the chat path writes coverage, not the legacy checkpoint");
|
||||
|
||||
assert.equal(
|
||||
coverageOf(record.messages as UIMessage[], covered, chatId).uncoveredCount,
|
||||
coverageOf(projectNative(record.messages), covered, chatId).uncoveredCount,
|
||||
0,
|
||||
"every message is now covered",
|
||||
);
|
||||
@@ -973,9 +1020,9 @@ describe("the chunked pipeline (extraction coverage)", () => {
|
||||
|
||||
// Per-batch coverage: every message checkpointed, nothing legacy.
|
||||
const coverage = ledgers.readCoverage();
|
||||
assert.equal(coverage.length, record.messages.length);
|
||||
assert.equal(coverage.length, projectNative(record.messages).length);
|
||||
assert.equal(ledgers.readCheckpoints().length, 0);
|
||||
assert.equal(coverageOf(record.messages as UIMessage[], coverage, chatId).uncoveredCount, 0);
|
||||
assert.equal(coverageOf(projectNative(record.messages), coverage, chatId).uncoveredCount, 0);
|
||||
|
||||
// A second run is a NO-OP: nothing uncovered → no model calls, no
|
||||
// new usage entry (no money spent on covered ground).
|
||||
@@ -1028,7 +1075,7 @@ describe("the chunked pipeline (extraction coverage)", () => {
|
||||
// Batch 1's emissions are kept and its messages stay covered.
|
||||
assert.deepEqual(stats, { nodes: 1, edges: 0, events: 1 });
|
||||
assert.equal(ledgers.readCoverage().length, 3, "batch 1 checkpointed its three messages");
|
||||
const cov = coverageOf(record.messages as UIMessage[], ledgers.readCoverage(), chatId);
|
||||
const cov = coverageOf(projectNative(record.messages), ledgers.readCoverage(), chatId);
|
||||
assert.equal(cov.uncoveredCount, 2, "the in-flight batch's messages stay uncovered");
|
||||
|
||||
// The re-run — the manual extract button — picks up ONLY the
|
||||
@@ -1062,25 +1109,30 @@ describe("the chunked pipeline (extraction coverage)", () => {
|
||||
auditor: fauxEngine([passThroughAuditStep()]),
|
||||
});
|
||||
await pipeline.runExtraction(chatId);
|
||||
const messages = () => chatStore.getChat(chatId)!.messages as UIMessage[];
|
||||
// Wave B: the record is native — coverage reads the projection.
|
||||
const record = () => chatStore.getChat(chatId)!.messages;
|
||||
const messages = () => projectNative(record());
|
||||
assert.equal(coverageOf(messages(), ledgers.readCoverage(), chatId).uncoveredCount, 0);
|
||||
|
||||
// Data parts are invisible to the extraction view — plan-block
|
||||
// attach does NOT re-open a covered message.
|
||||
const withDataPart = messages().map((m) =>
|
||||
m.id === "u2"
|
||||
? { ...m, parts: [...m.parts, { type: "data-plan-blocks", planBlocks: [] } as unknown as UIMessage["parts"][number]] }
|
||||
: m,
|
||||
);
|
||||
chatStore.saveMessages(chatId, withDataPart);
|
||||
// Data parts are invisible to the extraction view — a plan-block
|
||||
// marker append (whose data part splices into the projected view)
|
||||
// does NOT re-open a covered message.
|
||||
chatStore.saveMessages(chatId, [
|
||||
...record(),
|
||||
newPlanBlockMessage("call_s2", { kind: "full", at: "2026-09-26T00:00:00.000Z", current: null, nodes: [], edges: [] }),
|
||||
]);
|
||||
const afterAttach = coverageOf(messages(), ledgers.readCoverage(), chatId);
|
||||
assert.equal(afterAttach.uncoveredCount, 0, "data parts don't change the fingerprint");
|
||||
|
||||
// A quiz answer merging into an ASKING message changes its view →
|
||||
// exactly that message re-opens, and the next run extracts it alone.
|
||||
const mutated = messages().map((m) =>
|
||||
m.id === "s1" ? { ...m, parts: [...m.parts, { type: "text", text: "the answer merged in late" }] } : m,
|
||||
) as unknown as UIMessage[];
|
||||
// (Native: the answer arrives as the call's tool result — the
|
||||
// projected group gains the answer line.)
|
||||
const mutated = record().map((m) =>
|
||||
(m as { id?: string }).id === "s1"
|
||||
? ({ ...m, content: [...(m as { content: unknown[] }).content, { type: "text", text: "the answer merged in late" }] } as never)
|
||||
: m,
|
||||
);
|
||||
chatStore.saveMessages(chatId, mutated);
|
||||
const afterMerge = coverageOf(messages(), ledgers.readCoverage(), chatId);
|
||||
assert.equal(afterMerge.uncoveredCount, 1, "only the mutated message re-opens");
|
||||
|
||||
+73
-26
@@ -17,9 +17,10 @@
|
||||
import assert from "node:assert/strict";
|
||||
import * as fs from "node:fs";
|
||||
import { after, describe, it } from "node:test";
|
||||
import type { UIMessage } from "ai";
|
||||
import type { AuditResult } from "../src/core/contracts.ts";
|
||||
import type { QuestionAnswerData } from "../src/core/question-core.ts";
|
||||
import type { LhMessage } from "../src/core/native-messages.ts";
|
||||
import { projectNative } from "../src/core/projection.ts";
|
||||
import { getGraph, graphSnapshot, mergeFindings, type GraphDeps } from "../src/core/graph.ts";
|
||||
import { coverageSlots } from "../src/core/coverage.ts";
|
||||
import { graphStore } from "../src/store/graph.ts";
|
||||
@@ -32,9 +33,12 @@ const graphDeps = { chatStore, graphStore } as const satisfies GraphDeps;
|
||||
/** The session merge source for a chat — what the pipeline passes: the live
|
||||
* transcript + its updatedAt checkpoint stamp. (Notes imports pass
|
||||
* messages: null — see the notes-merger test below.) */
|
||||
function sessionSource(chatId: string): { messages: UIMessage[]; foldedAt: string } {
|
||||
function sessionSource(chatId: string): { messages: ReturnType<typeof projectNative>; foldedAt: string } {
|
||||
// Wave B: the record is native history — the merger's transcript view
|
||||
// reads through the one-way projection (same shape the extraction
|
||||
// pipeline passes: its batch slots are projected messages).
|
||||
const record = chatStore.getChat(chatId)!;
|
||||
return { messages: record.messages as UIMessage[], foldedAt: record.updatedAt };
|
||||
return { messages: projectNative(record.messages), foldedAt: record.updatedAt };
|
||||
}
|
||||
|
||||
// ─── ledger isolation (backup-and-clear of data/graph/*.jsonl) ──────────────
|
||||
@@ -72,20 +76,65 @@ after(() => {
|
||||
|
||||
// ─── fixture builders ────────────────────────────────────────────────────────
|
||||
|
||||
function assistant(id: string, parts: unknown[]): UIMessage {
|
||||
return { id, role: "assistant", parts } as unknown as UIMessage;
|
||||
// Native fixture builders (Wave B): the record holds pi-native history —
|
||||
// an assistant message carrying tool calls + one tool result per call. The
|
||||
// projection reproduces the old part shapes (state output-available, input
|
||||
// = arguments, output = result details).
|
||||
interface NativeCall {
|
||||
name: string;
|
||||
toolCallId: string;
|
||||
input: Record<string, unknown>;
|
||||
output: QuestionAnswerData;
|
||||
}
|
||||
|
||||
function user(id: string, text: string): UIMessage {
|
||||
return { id, role: "user", parts: [{ type: "text", text }] } as unknown as UIMessage;
|
||||
function user(id: string, text: string): LhMessage[] {
|
||||
return [{ id, role: "user", content: text, timestamp: Date.now() } as never];
|
||||
}
|
||||
|
||||
function assistant(id: string, calls: NativeCall[]): LhMessage[] {
|
||||
const content = calls.map((c) => ({ type: "toolCall", id: c.toolCallId, name: c.name, arguments: c.input as never }));
|
||||
const out: LhMessage[] = [
|
||||
{
|
||||
id,
|
||||
role: "assistant",
|
||||
content,
|
||||
api: "openai-completions",
|
||||
provider: "openrouter",
|
||||
model: "test",
|
||||
usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
||||
stopReason: "stop",
|
||||
timestamp: Date.now(),
|
||||
} as never,
|
||||
];
|
||||
for (const c of calls) {
|
||||
out.push({
|
||||
id: `r_${c.toolCallId}`,
|
||||
role: "toolResult",
|
||||
toolCallId: c.toolCallId,
|
||||
toolName: c.name,
|
||||
content: [{ type: "text", text: "ok" }],
|
||||
details: c.output,
|
||||
isError: false,
|
||||
timestamp: Date.now(),
|
||||
} as never);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function quizPart(
|
||||
toolCallId: string,
|
||||
input: Record<string, unknown>,
|
||||
output: QuestionAnswerData,
|
||||
): unknown {
|
||||
return { type: "tool-quiz", toolCallId, state: "output-available", input, output };
|
||||
): NativeCall {
|
||||
return { name: "quiz", toolCallId, input, output };
|
||||
}
|
||||
|
||||
function askPart(
|
||||
toolCallId: string,
|
||||
input: Record<string, unknown>,
|
||||
output: QuestionAnswerData,
|
||||
): NativeCall {
|
||||
return { name: "ask_user_question", toolCallId, input, output };
|
||||
}
|
||||
|
||||
function answer(over: Partial<QuestionAnswerData>): QuestionAnswerData {
|
||||
@@ -102,7 +151,7 @@ function answer(over: Partial<QuestionAnswerData>): QuestionAnswerData {
|
||||
|
||||
/** Create a chat in the real store with messages; returns the record.
|
||||
* Tree-blind world: the plan tree is irrelevant to the merger — no tree. */
|
||||
function fixtureChat(title: string, messages: UIMessage[]) {
|
||||
function fixtureChat(title: string, messages: LhMessage[]) {
|
||||
const chat = chatStore.createChat(title);
|
||||
createdChats.push(chat.id);
|
||||
chatStore.saveMessages(chat.id, messages);
|
||||
@@ -110,7 +159,7 @@ function fixtureChat(title: string, messages: UIMessage[]) {
|
||||
}
|
||||
|
||||
/** The full fixture transcript: five graded quizzes + a dismissed one + an ask. */
|
||||
function fixtureMessages(): UIMessage[] {
|
||||
function fixtureMessages(): LhMessage[] {
|
||||
return [
|
||||
user("u1", "teach me the fixture topic"),
|
||||
assistant("a1", [
|
||||
@@ -157,15 +206,13 @@ function fixtureMessages(): UIMessage[] {
|
||||
{ question: "Q6", options: [{ label: "a" }, { label: "b" }], correctAnswer: "a", explanation: "e" },
|
||||
answer({ questionId: "call-6", questionText: "Q6 text", dismissed: true }),
|
||||
),
|
||||
{
|
||||
type: "tool-ask_user_question",
|
||||
toolCallId: "call-7",
|
||||
state: "output-available",
|
||||
input: { question: "free text?" },
|
||||
output: answer({ questionId: "call-7", kind: "ask", questionText: "free text?", text: "because" }),
|
||||
},
|
||||
askPart(
|
||||
"call-7",
|
||||
{ question: "free text?" },
|
||||
answer({ questionId: "call-7", kind: "ask", questionText: "free text?", text: "because" }),
|
||||
),
|
||||
]),
|
||||
];
|
||||
].flat();
|
||||
}
|
||||
|
||||
/** The auditor's verdict for the fixture chat: three verified propositions
|
||||
@@ -332,7 +379,7 @@ describe("the merger (mergeFindings)", () => {
|
||||
answer({ questionId: "call-9", questionText: "Q9 text", correct: true, answeredAt: "2026-08-22T03:00:00.000Z" }),
|
||||
),
|
||||
]),
|
||||
]);
|
||||
].flat());
|
||||
chat2Id = chat2.id;
|
||||
|
||||
// The auditor's identity verdict: this proposition IS the alpha node.
|
||||
@@ -473,7 +520,7 @@ describe("the merger (mergeFindings)", () => {
|
||||
});
|
||||
|
||||
it("never string-matches without a verdict — an identical statement without linkTo is a NEW node", () => {
|
||||
const chat3 = fixtureChat("Graph fixture 3", [user("u1", "third")]);
|
||||
const chat3 = fixtureChat("Graph fixture 3", [user("u1", "third")].flat());
|
||||
chat3Id = chat3.id;
|
||||
const stats = mergeFindings(
|
||||
chat3.id,
|
||||
@@ -623,7 +670,7 @@ describe("session deletion (deleteChatTraces + deleteChatSession, wave 6)", () =
|
||||
answer({ questionId: "d-call-1", questionText: "DQ1 text", correct: true, answeredAt: "2026-09-23T00:00:00.000Z" }),
|
||||
),
|
||||
]),
|
||||
]);
|
||||
].flat());
|
||||
const auditA: AuditResult = {
|
||||
propositions: [
|
||||
{ id: "p1", name: "Shared core", statement: "Shared core statement", aliases: ["shared feel"], messages: ["du1"], evidence: ["e"], understanding: "solid" },
|
||||
@@ -651,7 +698,7 @@ describe("session deletion (deleteChatTraces + deleteChatSession, wave 6)", () =
|
||||
answer({ questionId: "e-call-1", questionText: "EQ1 text", correct: false, answeredAt: "2026-09-23T00:01:00.000Z" }),
|
||||
),
|
||||
]),
|
||||
]);
|
||||
].flat());
|
||||
const auditB: AuditResult = {
|
||||
propositions: [
|
||||
{ id: "q1", name: "Shared again", statement: "Shared core, rephrased", aliases: ["B phrasing"], messages: ["eu1"], evidence: ["e"], linkTo: xId, understanding: "shaky" },
|
||||
@@ -976,7 +1023,7 @@ describe("session coverage read (extraction-UX redesign, 2026-09-24)", () => {
|
||||
user("cov_u1", "tell me about coverage reads"),
|
||||
user("cov_u2", "and about per-session blocks"),
|
||||
user("cov_u3", "a third distinct message"),
|
||||
]);
|
||||
].flat());
|
||||
const empty = sessionCoverage(chat.id)!;
|
||||
assert.ok(empty);
|
||||
assert.equal(empty.totalMessages, 3);
|
||||
@@ -989,7 +1036,7 @@ describe("session coverage read (extraction-UX redesign, 2026-09-24)", () => {
|
||||
|
||||
// Fold the first two messages: the read derives the split + the
|
||||
// newest fold timestamp from the ledger rows.
|
||||
const slots = coverageSlots(chatStore.getChat(chat.id)!.messages as UIMessage[]);
|
||||
const slots = coverageSlots(projectNative(chatStore.getChat(chat.id)!.messages));
|
||||
graphStore.appendCoverage({ chatId: chat.id, fp: slots[0]!.fp, tokens: slots[0]!.tokens, foldedAt: "2026-09-24T10:00:00.000Z" });
|
||||
graphStore.appendCoverage({ chatId: chat.id, fp: slots[1]!.fp, tokens: slots[1]!.tokens, foldedAt: "2026-09-24T11:30:00.000Z" });
|
||||
// Another session's row must not leak into this read.
|
||||
@@ -1005,7 +1052,7 @@ describe("session coverage read (extraction-UX redesign, 2026-09-24)", () => {
|
||||
});
|
||||
|
||||
it("running: true only for THIS session's chat-kind run (the registry pin)", () => {
|
||||
const chat = fixtureChat("coverage running", [user("cov_r1", "running pin")]);
|
||||
const chat = fixtureChat("coverage running", [user("cov_r1", "running pin")].flat());
|
||||
const g = globalThis as { __lhExtractionRuns?: unknown };
|
||||
const realRuns = g.__lhExtractionRuns;
|
||||
try {
|
||||
|
||||
+313
-258
@@ -27,7 +27,6 @@ import assert from "node:assert/strict";
|
||||
import * as fs from "node:fs";
|
||||
import * as path from "node:path";
|
||||
import { after, describe, it } from "node:test";
|
||||
import type { UIMessage } from "ai";
|
||||
import { createFauxCore, fauxAssistantMessage, fauxText, fauxToolCall, type AssistantMessage, type FauxResponseStep } from "@earendil-works/pi-ai";
|
||||
import {
|
||||
accumulateJobUsage,
|
||||
@@ -41,20 +40,17 @@ import {
|
||||
type ResearchResult,
|
||||
type SubagentArtifactData,
|
||||
type SteerMessage,
|
||||
type SubagentSteerMessage,
|
||||
type VisualResult,
|
||||
buildProbeHandoffSteer,
|
||||
type ProbeHandoffSteerMessage,
|
||||
} from "../src/core/contracts.ts";
|
||||
import { composeInstructions, createTeacherAgent, type TeachingAgent } from "../src/agent/teacher-agent.ts";
|
||||
import { composeInstructions, createTeacherAgent } from "../src/agent/teacher-agent.ts";
|
||||
import { createTeacherTools } from "../src/agent/tools.ts";
|
||||
import { webTools } from "../src/agent/tools/web.ts";
|
||||
import { handleChatRequest } from "../src/server/chat-handler.ts";
|
||||
import { recordToPiMessages } from "../src/server/pi/record-adapter.ts";
|
||||
import { abortJob, claimSteers, deliverJobResult, recoverStaleJobs, setJobRespawn, setTurnRunner } from "../src/server/jobs.ts";
|
||||
import { registerJobAbort, unregisterJobAbort } from "../src/agent/subagents/aborts.ts";
|
||||
import { beginTurn, endTurn } from "../src/server/turn-liveness.ts";
|
||||
import { persistTurn, withMessageIds } from "../src/server/turn-service.ts";
|
||||
import { persistTurn } from "../src/server/turn-service.ts";
|
||||
import { chatStore } from "../src/store/chats.ts";
|
||||
import { jobStore } from "../src/store/jobs.ts";
|
||||
import { createJobRunner, pdfModelOverride, researchModelOverride } from "../src/agent/subagents/runner.ts";
|
||||
@@ -62,6 +58,9 @@ import { providerKeyMissing, type ModelSlotName } from "../src/core/settings.ts"
|
||||
import type { PiEngine } from "../src/agent/models.ts";
|
||||
import type { SearchProvider } from "../src/core/search.ts";
|
||||
import { PATHS } from "../config.ts";
|
||||
import { projectNative } from "../src/core/projection.ts";
|
||||
import type { LhMessage } from "../src/core/native-messages.ts";
|
||||
import type { SteerInput, TurnInput } from "../src/core/input-events.ts";
|
||||
|
||||
const JOBS_DIR = path.join(PATHS.data, "jobs");
|
||||
|
||||
@@ -77,6 +76,36 @@ function trackChat(id: string) {
|
||||
createdChats.push(id);
|
||||
}
|
||||
|
||||
/** Wave B native fixture: user text + an assistant tool call + its ack —
|
||||
* the record is agent history; the runner's transcript scan (input
|
||||
* resolution) reads it through the projection. */
|
||||
function nativeToolMessages(text: string, tool: string, toolCallId: string, input: unknown): LhMessage[] {
|
||||
return [
|
||||
{ id: "u1", role: "user", content: text, timestamp: 1 } as never,
|
||||
{
|
||||
id: "a1",
|
||||
role: "assistant",
|
||||
content: [{ type: "toolCall", id: toolCallId, name: tool, arguments: input as never }],
|
||||
api: "openai-completions",
|
||||
provider: "openrouter",
|
||||
model: "test",
|
||||
usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
||||
stopReason: "toolUse",
|
||||
timestamp: 1,
|
||||
} as never,
|
||||
{
|
||||
id: "r1",
|
||||
role: "toolResult",
|
||||
toolCallId,
|
||||
toolName: tool,
|
||||
content: [{ type: "text", text: "Dispatched." }],
|
||||
details: "Dispatched.",
|
||||
isError: false,
|
||||
timestamp: 1,
|
||||
} as never,
|
||||
];
|
||||
}
|
||||
|
||||
function trackJob(id: string) {
|
||||
createdJobs.push(id);
|
||||
}
|
||||
@@ -132,11 +161,13 @@ async function drain(res: Response): Promise<string> {
|
||||
return res.text();
|
||||
}
|
||||
|
||||
function post(chatId: string, messages: unknown[]): Request {
|
||||
function post(chatId: string, input: unknown): Request {
|
||||
return new Request("http://test/api/chat", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ chatId, messages }),
|
||||
// Wave B: the input-events contract — one explicit event, never a
|
||||
// client transcript.
|
||||
body: JSON.stringify({ chatId, input }),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -192,19 +223,21 @@ describe("steer helpers (contracts.ts)", () => {
|
||||
const demo: DemoResult = { title: "T", html: "<html></html>", tested: true, notes: "ok" };
|
||||
const visual: VisualResult = { svg: "<svg/>", notes: "ok" };
|
||||
|
||||
it("buildSubagentSteer: user-role, metadata-marked, artifact on the data part, brief on the text part", () => {
|
||||
it("buildSubagentSteer: a native marker — brief on the model-facing content, artifact payload in details", () => {
|
||||
const job = { id: "job_x", kind: "research" as const, toolCallId: "call-x" };
|
||||
const steer = buildSubagentSteer(job, research);
|
||||
assert.equal(steer.id, "steer_job_x", "deterministic id per job — idempotent delivery");
|
||||
assert.equal(steer.role, "user");
|
||||
assert.deepEqual(steer.metadata, { subagentResult: { jobId: "job_x", kind: "research", toolCallId: "call-x" } });
|
||||
const [text, data] = steer.parts;
|
||||
assert.equal(text.type, "text");
|
||||
assert.ok(text.type === "text" && text.text.includes("BRIEF"), "the model-facing text carries the brief");
|
||||
assert.equal(data.type, "data-subagent-artifact");
|
||||
assert.ok(data.type === "data-subagent-artifact");
|
||||
assert.equal(data.data.kind, "research");
|
||||
assert.deepEqual(data.data.result, research);
|
||||
// Wave B: steers are LhMarkerMessage customs — role "custom", the
|
||||
// model reads `content`, the UI reads `details` (never model-visible).
|
||||
assert.equal(steer.role, "custom");
|
||||
assert.equal(steer.customType, "subagent-steer");
|
||||
assert.equal(steer.display, true);
|
||||
assert.ok(steer.content.includes("BRIEF"), "the model-facing text carries the brief");
|
||||
const data = steer.details as { jobId: string; kind: string; toolCallId: string; result: ResearchResult };
|
||||
assert.equal(data.jobId, "job_x");
|
||||
assert.equal(data.kind, "research");
|
||||
assert.equal(data.toolCallId, "call-x");
|
||||
assert.deepEqual(data.result, research);
|
||||
});
|
||||
|
||||
it("accumulateJobUsage: sums per model call; fields NO call reported stay undefined (absent is not zero)", () => {
|
||||
@@ -248,19 +281,15 @@ describe("steer helpers (contracts.ts)", () => {
|
||||
}, "lastStep always mirrors the LATEST call, never the sums");
|
||||
});
|
||||
|
||||
it("buildAbortSteer: teacher-facing text + the data-subagent-aborted marker part (the message type), same deterministic id space as the result steer", () => {
|
||||
it("buildAbortSteer: a subagent-abort marker — teacher-facing text on content, the abort payload in details", () => {
|
||||
const steer = buildAbortSteer({ id: "job_c", kind: "demo", toolCallId: "call-c" });
|
||||
assert.equal(steer.id, "steer_job_c", "same id space — steerDelivered treats either as delivered");
|
||||
assert.equal(steer.role, "user");
|
||||
assert.equal(steer.metadata.subagentResult.aborted, true);
|
||||
assert.equal(steer.metadata.subagentResult.jobId, "job_c");
|
||||
assert.equal(steer.parts.length, 2);
|
||||
assert.ok(steer.parts[0]!.type === "text" && /aborted/.test(steer.parts[0].text));
|
||||
// The marker PART is the message type (the custom-message scheme): the
|
||||
// UI renders the abort badge off it — metadata stays for pump mechanics.
|
||||
const marker = steer.parts[1]!;
|
||||
assert.equal(marker.type, "data-subagent-aborted");
|
||||
assert.deepEqual(marker.data, { jobId: "job_c", kind: "demo", toolCallId: "call-c", aborted: true });
|
||||
assert.equal(steer.role, "custom");
|
||||
assert.equal(steer.customType, "subagent-abort");
|
||||
assert.ok(/aborted/.test(steer.content), "the teacher-facing text names the abort");
|
||||
// The marker's customType is the message type: the UI renders the
|
||||
// abort badge off the projection (demos.test.tsx pins the card).
|
||||
assert.deepEqual(steer.details, { jobId: "job_c", kind: "demo", toolCallId: "call-c", aborted: true });
|
||||
});
|
||||
|
||||
it("isSubagentFailure: failure shapes only (empty html/svg, failure brief)", () => {
|
||||
@@ -292,28 +321,26 @@ describe("steer helpers (contracts.ts)", () => {
|
||||
const failDemo = buildSubagentSteer({ id: "j2", kind: "demo", toolCallId: "c" }, { title: "T", html: "", tested: true, notes: "FAILED: boom" });
|
||||
const failVisual = buildSubagentSteer({ id: "j3", kind: "visual", toolCallId: "c" }, { svg: "", notes: "FAILED: boom" });
|
||||
for (const steer of [failResearch, failDemo, failVisual]) {
|
||||
const text = steer.parts[0];
|
||||
assert.ok(text.type === "text" && !text.text.includes("rendered in the lesson"), "failure text never claims the artifact landed");
|
||||
assert.ok(!steer.content.includes("rendered in the lesson"), "failure text never claims the artifact landed");
|
||||
}
|
||||
assert.ok(failResearch.parts[0].type === "text" && failResearch.parts[0].text.includes("flag the uncertainty"));
|
||||
assert.ok(failResearch.content.includes("flag the uncertainty"));
|
||||
});
|
||||
|
||||
it("pdf steers (sources v0): brief + page citations on success, PDF failed: shape on failure, abort wording per kind", () => {
|
||||
const pdf: PdfResult = { path: "/tmp/doc.pdf", question: "q?", brief: "The formula is on p. 2.", pagesRead: [2], pagesConsulted: [1, 2] };
|
||||
const ok = buildSubagentSteer({ id: "jp", kind: "pdf", toolCallId: "cp" }, pdf);
|
||||
assert.ok(ok.parts[0].type === "text" && ok.parts[0].text.includes("page citations"), "the teacher is told to cite pages");
|
||||
assert.ok(ok.parts[0].type === "text" && ok.parts[0].text.includes("The formula is on p. 2."));
|
||||
assert.equal(ok.parts[1].type, "data-subagent-artifact");
|
||||
assert.equal((ok.parts[1] as { data: SubagentArtifactData }).data.kind, "pdf");
|
||||
assert.deepEqual((ok.parts[1] as { data: SubagentArtifactData }).data.result, pdf);
|
||||
assert.ok(ok.content.includes("page citations"), "the teacher is told to cite pages");
|
||||
assert.ok(ok.content.includes("The formula is on p. 2."));
|
||||
assert.equal((ok.details as { kind: string }).kind, "pdf");
|
||||
assert.deepEqual((ok.details as { result: unknown }).result, pdf);
|
||||
// failure shape mirrors research
|
||||
assert.equal(isSubagentFailure("pdf", pdf), false);
|
||||
assert.equal(isSubagentFailure("pdf", { ...pdf, brief: "PDF failed: unreadable" }), true);
|
||||
const fail = buildSubagentSteer({ id: "jf", kind: "pdf", toolCallId: "cf" }, { ...pdf, brief: "PDF failed: unreadable" });
|
||||
assert.ok(fail.parts[0].type === "text" && fail.parts[0].text.includes("flag the gap"));
|
||||
assert.ok(fail.content.includes("flag the gap"));
|
||||
// abort wording names the PDF job
|
||||
const aborted = buildAbortSteer({ id: "ja", kind: "pdf", toolCallId: "ca" });
|
||||
assert.ok(aborted.parts[0].type === "text" && aborted.parts[0].text.includes("PDF reading job"));
|
||||
assert.ok(aborted.content.includes("PDF reading job"));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -325,12 +352,12 @@ describe("steer helpers (contracts.ts)", () => {
|
||||
* exactly as it will in production after the wake turn completes.
|
||||
*/
|
||||
function fakeDeliver() {
|
||||
const steers: SubagentSteerMessage[] = [];
|
||||
const steers: SteerMessage[] = [];
|
||||
return {
|
||||
steers,
|
||||
deliver: async (steer: SubagentSteerMessage) => {
|
||||
deliver: async (steer: SteerMessage) => {
|
||||
steers.push(steer);
|
||||
const jobId = steer.metadata.subagentResult.jobId;
|
||||
const jobId = (steer.details as { jobId: string }).jobId;
|
||||
const job = jobStore.getJob(jobId);
|
||||
if (job && job.status === "running") {
|
||||
const failed = job.result ? isSubagentFailure(job.kind, job.result as never) : true;
|
||||
@@ -376,18 +403,21 @@ describe("dispatch (async protocol §2)", () => {
|
||||
jobStore,
|
||||
spawn: (job, input) => void testRunner.runJob(job.id, input),
|
||||
});
|
||||
const agent = (outgoing: UIMessage[]): TeachingAgent =>
|
||||
createTeacherAgent(undefined, { engine, tools, messages: recordToPiMessages(outgoing) });
|
||||
// The handler hands the factory the native build options — the
|
||||
// record IS the agent state (Wave B: no adapter conversion).
|
||||
const agent: Parameters<typeof handleChatRequest>[1] = (opts) =>
|
||||
createTeacherAgent(undefined, { engine, tools, messages: opts.messages });
|
||||
|
||||
researchModelOverride.current = researcherModel();
|
||||
const firstUser = { id: "u1", role: "user", parts: [{ type: "text", text: "teach me graph rendering" }] };
|
||||
const body = await drain(await handleChatRequest(post(chat.id, [firstUser]), agent));
|
||||
const body = await drain(
|
||||
await handleChatRequest(post(chat.id, { type: "text", text: "teach me graph rendering" }), agent),
|
||||
);
|
||||
|
||||
// The teacher's SAME turn carried on past the tool call.
|
||||
assert.ok(body.includes("Back with the brief."), "the turn continued after the dispatch");
|
||||
|
||||
const saved = chatStore.getChat(chat.id)!;
|
||||
const part = saved.messages[1]!.parts.find((p) => p.type === "tool-research");
|
||||
const saved = projectNative(chatStore.getChat(chat.id)!.messages);
|
||||
const part = saved[1]!.parts.find((p) => p.type === "tool-research");
|
||||
assert.ok(part && part.type === "tool-research", "research tool part persisted");
|
||||
assert.equal(part.state, "output-available", "the part resolved at dispatch time (not held pending)");
|
||||
assert.equal(typeof (part as { output?: unknown }).output, "string", "the output is the ack, not the result");
|
||||
@@ -403,12 +433,12 @@ describe("dispatch (async protocol §2)", () => {
|
||||
assert.equal(done.status, "done");
|
||||
assert.equal(done.activity, "delivered");
|
||||
|
||||
// The steer carries the brief + artifact for the (UI) renderer.
|
||||
// The steer carries the brief on its model-facing content; the
|
||||
// artifact payload rides details for the (UI) renderer.
|
||||
assert.equal(delivery.steers.length, 1, "exactly one steer — one job, one result");
|
||||
const steer = delivery.steers[0]!;
|
||||
assert.equal(steer.metadata.subagentResult.jobId, job!.id);
|
||||
const text = steer.parts[0];
|
||||
assert.ok(text.type === "text" && text.text.includes("sigma.js v3 is current"), "steer text carries the brief");
|
||||
assert.equal((steer.details as { jobId: string }).jobId, job!.id);
|
||||
assert.ok(steer.content.includes("sigma.js v3 is current"), "steer text carries the brief");
|
||||
|
||||
// Search threading (2026-09-20): the researcher's web_search ran on
|
||||
// the INJECTED port — the stub saw the scripted query, no network.
|
||||
@@ -460,17 +490,7 @@ describe("research runner (async protocol §3)", () => {
|
||||
|
||||
// A dispatched research part in the stored chat (the input is also
|
||||
// what the runner falls back to on a crash-recovery re-spawn).
|
||||
chatStore.saveMessages(chat.id, [
|
||||
{ id: "u1", role: "user", parts: [{ type: "text", text: "which sigma version?" }] },
|
||||
{
|
||||
id: "a1",
|
||||
role: "assistant",
|
||||
parts: [
|
||||
{ type: "text", text: "Checking." },
|
||||
{ type: "tool-research", toolCallId: "call-run1", state: "output-available", input: RESEARCH_INPUT, output: "Dispatched." },
|
||||
],
|
||||
},
|
||||
] as never);
|
||||
chatStore.saveMessages(chat.id, nativeToolMessages("which sigma version?", "research", "call-run1", RESEARCH_INPUT));
|
||||
|
||||
const job = jobStore.createJob({ kind: "research", chatId: chat.id, toolCallId: "call-run1" });
|
||||
trackJob(job.id);
|
||||
@@ -503,14 +523,7 @@ describe("research runner (async protocol §3)", () => {
|
||||
it("a failing runner still steers (the teacher learns the outcome) and the job lands error", async () => {
|
||||
const chat = chatStore.createChat("Test: runner failure");
|
||||
trackChat(chat.id);
|
||||
chatStore.saveMessages(chat.id, [
|
||||
{ id: "u1", role: "user", parts: [{ type: "text", text: "hi" }] },
|
||||
{
|
||||
id: "a1",
|
||||
role: "assistant",
|
||||
parts: [{ type: "tool-research", toolCallId: "call-err1", state: "output-available", input: RESEARCH_INPUT, output: "Dispatched." }],
|
||||
},
|
||||
] as never);
|
||||
chatStore.saveMessages(chat.id, nativeToolMessages("hi", "research", "call-err1", RESEARCH_INPUT));
|
||||
|
||||
const job = jobStore.createJob({ kind: "research", chatId: chat.id, toolCallId: "call-err1" });
|
||||
trackJob(job.id);
|
||||
@@ -534,24 +547,14 @@ describe("research runner (async protocol §3)", () => {
|
||||
|
||||
const steer = delivery.steers[0]!;
|
||||
assert.ok(steer, "failure still delivers a steer");
|
||||
const text = steer.parts[0];
|
||||
assert.ok(text.type === "text" && text.text.includes("Research failed: model exploded"), "failure brief on the steer text");
|
||||
const data = steer.parts[1];
|
||||
assert.ok(data.type === "data-subagent-artifact");
|
||||
assert.deepEqual((data.data.result as ResearchResult).sources, []);
|
||||
assert.ok(steer.content.includes("Research failed: model exploded"), "failure brief on the steer text");
|
||||
assert.deepEqual(((steer.details as { result: ResearchResult }).result).sources, []);
|
||||
});
|
||||
|
||||
it("runJob resolves the input from the transcript when dispatch-time input is absent (crash recovery)", async () => {
|
||||
const chat = chatStore.createChat("Test: transcript input fallback");
|
||||
trackChat(chat.id);
|
||||
chatStore.saveMessages(chat.id, [
|
||||
{ id: "u1", role: "user", parts: [{ type: "text", text: "go" }] },
|
||||
{
|
||||
id: "a1",
|
||||
role: "assistant",
|
||||
parts: [{ type: "tool-research", toolCallId: "call-fb1", state: "output-available", input: RESEARCH_INPUT, output: "Dispatched." }],
|
||||
},
|
||||
] as never);
|
||||
chatStore.saveMessages(chat.id, nativeToolMessages("go", "research", "call-fb1", RESEARCH_INPUT));
|
||||
const job = jobStore.createJob({ kind: "research", chatId: chat.id, toolCallId: "call-fb1" });
|
||||
trackJob(job.id);
|
||||
|
||||
@@ -595,14 +598,7 @@ describe("pdf runner (sources v0)", () => {
|
||||
it("runJob completes a pdf job: brief + pagesConsulted land on the record, citation steer delivered", async () => {
|
||||
const chat = chatStore.createChat("Test: pdf runner");
|
||||
trackChat(chat.id);
|
||||
chatStore.saveMessages(chat.id, [
|
||||
{ id: "u1", role: "user", parts: [{ type: "text", text: "read this pdf" }] },
|
||||
{
|
||||
id: "a1",
|
||||
role: "assistant",
|
||||
parts: [{ type: "tool-pdf", toolCallId: "call-pdf1", state: "output-available", input: PDF_INPUT, output: "Dispatched." }],
|
||||
},
|
||||
] as never);
|
||||
chatStore.saveMessages(chat.id, nativeToolMessages("read this pdf", "pdf", "call-pdf1", PDF_INPUT));
|
||||
|
||||
const job = jobStore.createJob({ kind: "pdf", chatId: chat.id, toolCallId: "call-pdf1" });
|
||||
trackJob(job.id);
|
||||
@@ -627,9 +623,8 @@ describe("pdf runner (sources v0)", () => {
|
||||
|
||||
assert.equal(delivery.steers.length, 1, "one job, one steer");
|
||||
const steer = delivery.steers[0]!;
|
||||
assert.equal(steer.metadata.subagentResult.kind, "pdf");
|
||||
const text = steer.parts[0];
|
||||
assert.ok(text.type === "text" && text.text.includes("integral rule"), "steer text carries the brief");
|
||||
assert.equal((steer.details as { kind: string }).kind, "pdf");
|
||||
assert.ok(steer.content.includes("integral rule"), "steer text carries the brief");
|
||||
});
|
||||
|
||||
it("an unreadable source: the job lands error with the PDF failed: brief and the steer flags the gap", async () => {
|
||||
@@ -656,8 +651,7 @@ describe("pdf runner (sources v0)", () => {
|
||||
assert.deepEqual(result.pagesConsulted, []);
|
||||
|
||||
const steer = delivery.steers[0]!;
|
||||
const text = steer.parts[0];
|
||||
assert.ok(text.type === "text" && text.text.includes("flag the gap"), "the teacher learns to flag the gap");
|
||||
assert.ok(steer.content.includes("flag the gap"), "the teacher learns to flag the gap");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -693,10 +687,10 @@ describe("researcher agent", () => {
|
||||
*/
|
||||
describe("wake pipeline (src/server/jobs.ts)", () => {
|
||||
/** A chat with a finished job ready for delivery. */
|
||||
function setup(kind: "research" | "demo" = "research"): { chatId: string; jobId: string; steer: SubagentSteerMessage } {
|
||||
function setup(kind: "research" | "demo" = "research"): { chatId: string; jobId: string; steer: SteerMessage } {
|
||||
const chat = chatStore.createChat("Test: wake pipeline");
|
||||
trackChat(chat.id);
|
||||
chatStore.saveMessages(chat.id, [{ id: "u1", role: "user", parts: [{ type: "text", text: "hello" }] }] as never);
|
||||
chatStore.saveMessages(chat.id, [{ id: "u1", role: "user", content: "hello", timestamp: 1 } as never]);
|
||||
|
||||
const job = jobStore.createJob({ kind, chatId: chat.id, toolCallId: `call-${jobStore.listJobs(chat.id).length}` });
|
||||
trackJob(job.id);
|
||||
@@ -709,29 +703,22 @@ describe("wake pipeline (src/server/jobs.ts)", () => {
|
||||
it("delivers a PROBE HANDOFF steer the same way — the teacher's wake turn", async () => {
|
||||
const chat = chatStore.createChat("Test: probe handoff wake");
|
||||
trackChat(chat.id);
|
||||
// The concluded probe: a persisted probe_done part (the seam the
|
||||
// chat-handler's construction branch keys on) and no open question.
|
||||
chatStore.saveMessages(chat.id, [
|
||||
{ id: "u1", role: "user", parts: [{ type: "text", text: "teach me calc" }] },
|
||||
{
|
||||
id: "a1",
|
||||
role: "assistant",
|
||||
parts: [
|
||||
{ type: "tool-probe_done", state: "output-available", output: { ok: true } },
|
||||
],
|
||||
},
|
||||
] as never);
|
||||
// The concluded probe: a persisted probe_done call + result (the
|
||||
// native seam the chat-handler's construction branch keys on) and no
|
||||
// open question.
|
||||
chatStore.saveMessages(chat.id, nativeToolMessages("teach me calc", "probe_done", "call-pd", { ok: true }));
|
||||
const handoff = buildProbeHandoffSteer(chat.id);
|
||||
let seen: { chatId: string; lastMessage: ProbeHandoffSteerMessage } | null = null;
|
||||
setTurnRunner(async (cid, messages) => {
|
||||
seen = { chatId: cid, lastMessage: messages[messages.length - 1] as ProbeHandoffSteerMessage };
|
||||
let seen: { chatId: string; steer: SteerMessage } | undefined;
|
||||
setTurnRunner(async (cid, input) => {
|
||||
seen = { chatId: cid, steer: (input as SteerInput).steer as SteerMessage };
|
||||
return 200;
|
||||
});
|
||||
|
||||
await deliverJobResult(handoff);
|
||||
await waitFor(() => seen !== null, 8000, "handoff wake fired");
|
||||
assert.equal(seen!.chatId, chat.id);
|
||||
assert.equal(seen!.lastMessage.id, `steer_probe_${chat.id}`, "the handoff is the wake turn's final input message");
|
||||
await waitFor(() => seen !== undefined, 8000, "handoff wake fired");
|
||||
const firedHandoff = seen!;
|
||||
assert.equal(firedHandoff.chatId, chat.id);
|
||||
assert.equal(firedHandoff.steer.id, `steer_probe_${chat.id}`, "the handoff IS the wake turn's input");
|
||||
});
|
||||
|
||||
it("a redelivered handoff dedupes via the transcript (idempotent by steer id)", async () => {
|
||||
@@ -752,9 +739,9 @@ describe("wake pipeline (src/server/jobs.ts)", () => {
|
||||
|
||||
it("delivers: fires the turn runner with the record + steer, then flips the job done", async () => {
|
||||
const { chatId, jobId, steer } = setup();
|
||||
let seen: { chatId: string; lastMessage: unknown } | null = null;
|
||||
setTurnRunner(async (cid, messages) => {
|
||||
seen = { chatId: cid, lastMessage: messages[messages.length - 1] };
|
||||
let seen: { chatId: string; steer: SteerMessage } | undefined;
|
||||
setTurnRunner(async (cid, input) => {
|
||||
seen = { chatId: cid, steer: (input as SteerInput).steer as SteerMessage };
|
||||
return 200;
|
||||
});
|
||||
|
||||
@@ -764,9 +751,8 @@ describe("wake pipeline (src/server/jobs.ts)", () => {
|
||||
assert.equal(jobStore.getJob(jobId)!.status, "done");
|
||||
assert.equal(jobStore.getJob(jobId)!.activity, "delivered");
|
||||
assert.ok(seen, "the turn runner fired");
|
||||
assert.equal((seen as { chatId: string }).chatId, chatId);
|
||||
const last = (seen as { lastMessage: SubagentSteerMessage }).lastMessage;
|
||||
assert.equal(last.id, steer.id, "the steer is the wake turn's final input message");
|
||||
assert.equal(seen.chatId, chatId);
|
||||
assert.equal(seen.steer.id, steer.id, "the steer IS the wake turn's input");
|
||||
});
|
||||
|
||||
it("waits while a question part is pending, and fires once it clears", async () => {
|
||||
@@ -777,16 +763,22 @@ describe("wake pipeline (src/server/jobs.ts)", () => {
|
||||
return 200;
|
||||
});
|
||||
|
||||
// An unanswered quiz part blocks the wake (provider conversion would
|
||||
// break on an input-available tool call).
|
||||
// A dangling question call blocks the wake (the model would reject an
|
||||
// unanswered tool call — fork 1: the record ends at the call).
|
||||
chatStore.saveMessages(chatId, [
|
||||
{ id: "u1", role: "user", parts: [{ type: "text", text: "hello" }] },
|
||||
{ id: "u1", role: "user", content: "hello", timestamp: 1 } as never,
|
||||
{
|
||||
id: "a1",
|
||||
role: "assistant",
|
||||
parts: [{ type: "tool-quiz", toolCallId: "quiz-call", state: "input-available", input: { question: "q", choices: ["a", "b"] } }],
|
||||
},
|
||||
] as never);
|
||||
content: [{ type: "toolCall", id: "quiz-call", name: "quiz", arguments: { question: "q", choices: ["a", "b"] } as never }],
|
||||
api: "openai-completions",
|
||||
provider: "openrouter",
|
||||
model: "test",
|
||||
usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
||||
stopReason: "toolUse",
|
||||
timestamp: 1,
|
||||
} as never,
|
||||
]);
|
||||
|
||||
// The delivery promise floats while the quiz blocks — awaiting it now
|
||||
// would deadlock the test (the pump defers until the gate opens).
|
||||
@@ -796,17 +788,21 @@ describe("wake pipeline (src/server/jobs.ts)", () => {
|
||||
assert.equal(fired, 0, "no wake while the quiz is pending");
|
||||
assert.equal(jobStore.getJob(jobId)!.status, "running", "job stays running while blocked");
|
||||
|
||||
// The learner answers: the pending part clears (client adds output).
|
||||
// The learner answers: the real tool result clears the dangling call.
|
||||
const record = chatStore.getChat(chatId)!;
|
||||
const answered = record.messages.map((m) =>
|
||||
m.id === "a1"
|
||||
? {
|
||||
...m,
|
||||
parts: [{ type: "tool-quiz", toolCallId: "quiz-call", state: "output-available", input: { question: "q", choices: ["a", "b"] }, output: { answer: "a", correct: true, explanation: "" } }],
|
||||
}
|
||||
: m,
|
||||
);
|
||||
chatStore.saveMessages(chatId, answered as never);
|
||||
chatStore.saveMessages(chatId, [
|
||||
...record.messages,
|
||||
{
|
||||
id: "r1",
|
||||
role: "toolResult",
|
||||
toolCallId: "quiz-call",
|
||||
toolName: "quiz",
|
||||
content: [{ type: "text", text: "answered" }],
|
||||
details: { answer: "a", correct: true, explanation: "" },
|
||||
isError: false,
|
||||
timestamp: 1,
|
||||
} as never,
|
||||
]);
|
||||
|
||||
await waitFor(() => fired > 0, 8000, "wake after the quiz answer");
|
||||
await delivery; // now it resolves — the steer was delivered
|
||||
@@ -873,15 +869,20 @@ describe("claimSteers + pump duplicate-guard (continuation coalescing)", () => {
|
||||
assert.equal(fired, 0, "the pump never fires a wake for a claimed steer");
|
||||
});
|
||||
|
||||
it("finalizes already-delivered steers without returning them (a wake raced in)", async () => {
|
||||
it("finalizes already-delivered steers; the handler's steerDelivered filter keeps one copy (a wake raced in)", async () => {
|
||||
const { chatId, jobId, steer } = setup();
|
||||
const record = chatStore.getChat(chatId)!;
|
||||
chatStore.saveMessages(chatId, [...record.messages, steer] as never);
|
||||
|
||||
const delivery = deliverJobResult(steer);
|
||||
await delay(100); // let the pump see it first (its top-of-loop idempotent skip)
|
||||
await delay(100); // let the pump's top-of-loop idempotent skip run
|
||||
const claimed = claimSteers(chatId);
|
||||
assert.equal(claimed.length, 0, "delivered steers are not re-claimed");
|
||||
// The pump itself finalizes already-delivered steers at the top of
|
||||
// its loop — the queue is empty by the time a racing turn claims.
|
||||
// (A steer that DID ride the queue gets claimed whole; the HANDLER's
|
||||
// steerDelivered filter keeps exactly one copy at append.)
|
||||
assert.equal(claimed.length, 0, "the pump finalized the delivered steer without firing a wake");
|
||||
assert.ok(steerDelivered(chatStore.getChat(chatId)!.messages, steer.id), "the record already holds it");
|
||||
assert.equal(jobStore.getJob(jobId)!.status, "done");
|
||||
await delivery;
|
||||
});
|
||||
@@ -890,7 +891,7 @@ describe("claimSteers + pump duplicate-guard (continuation coalescing)", () => {
|
||||
assert.deepEqual(claimSteers("chat_never_existed"), []);
|
||||
});
|
||||
|
||||
it("pump path: a steer already carried by the incoming turn body is finalized, never appended again (duplicate-key bug, 2026-09-18)", async () => {
|
||||
it("pump path: a steer coalesced into the record mid-flight is claimed, filtered at append, never double-fired (duplicate-key bug, 2026-09-18)", async () => {
|
||||
const { chatId, jobId, steer } = setup();
|
||||
// Own runner: this chat's gates are OPEN, so the pump would otherwise
|
||||
// fire whatever runner the NEXT test registers (leaked counter).
|
||||
@@ -899,29 +900,29 @@ describe("claimSteers + pump duplicate-guard (continuation coalescing)", () => {
|
||||
fired++;
|
||||
return 200;
|
||||
});
|
||||
// The wake pump's request ALREADY carries the steer (it dequeues only
|
||||
// after the turn returns), and the record does not — the exact window
|
||||
// the chat handler claims in.
|
||||
// The racing turn's write-ahead ALREADY landed the steer in the record
|
||||
// while the entry is still queued — the exact window the handler
|
||||
// claims in (the pump dequeues only after the turn returns 200).
|
||||
const delivery = deliverJobResult(steer);
|
||||
await delay(300); // the pump is inside waitForIdle's grace (750ms)
|
||||
const incoming = [...(chatStore.getChat(chatId)!.messages as never[]), steer];
|
||||
const claimed = claimSteers(chatId, incoming);
|
||||
assert.equal(claimed.length, 0, "the already-carried steer is not re-claimed");
|
||||
assert.equal(jobStore.getJob(jobId)!.status, "done", "job still finalizes — the turn delivers it");
|
||||
const incoming = [...chatStore.getChat(chatId)!.messages, steer];
|
||||
chatStore.saveMessages(chatId, incoming); // the racing turn's write-ahead persist
|
||||
const claimed = claimSteers(chatId);
|
||||
assert.equal(claimed.length, 1, "the queued steer is claimed — its delivery rides the turn");
|
||||
assert.equal(jobStore.getJob(jobId)!.status, "done", "job still finalizes — the claim resolves the delivery");
|
||||
await delivery;
|
||||
assert.deepEqual(claimSteers(chatId), [], "queue is drained");
|
||||
// The handler persists the outgoing write-ahead immediately after the
|
||||
// claim — the pump's post-idle re-check reads the RECORD (not the
|
||||
// queue), so without this the wake would double-fire at grace-end.
|
||||
const record2 = chatStore.getChat(chatId)!;
|
||||
chatStore.saveMessages(chatId, [...record2.messages, ...incoming] as never);
|
||||
await delay(800); // the pump's grace elapses — the delivered steer is skipped
|
||||
assert.equal(fired, 0, "the claimed-and-carried steer never double-fires a wake");
|
||||
// The handler's outgoing = incoming + claimed: with claimed empty the
|
||||
// write-ahead holds exactly ONE copy of the steer id.
|
||||
const outgoing = [...incoming, ...claimed] as never[];
|
||||
const ids = outgoing.map((m: { id: string }) => m.id);
|
||||
// The handler appends only steers NOT already in the record
|
||||
// (steerDelivered) — the write-ahead keeps exactly ONE copy of the
|
||||
// steer id, and the pump's post-idle re-check reads the RECORD and
|
||||
// skips the delivered id.
|
||||
const appends = claimed.filter((c) => !steerDelivered(chatStore.getChat(chatId)!.messages, c.id));
|
||||
assert.equal(appends.length, 0, "the already-persisted steer never appends again");
|
||||
const outgoing = [...chatStore.getChat(chatId)!.messages, ...appends];
|
||||
const ids = outgoing.map((m) => m.id);
|
||||
assert.equal(ids.filter((id) => id === steer.id).length, 1, "exactly one steer copy persists");
|
||||
await delay(800); // the pump's grace elapses — its re-check sees the delivered id
|
||||
assert.equal(fired, 0, "the claimed-and-persisted steer never double-fires a wake");
|
||||
});
|
||||
|
||||
it("pump duplicate-guard: a steer coalesced into the record while the gates were closed is never double-fired", async () => {
|
||||
@@ -932,15 +933,21 @@ describe("claimSteers + pump duplicate-guard (continuation coalescing)", () => {
|
||||
return 200;
|
||||
});
|
||||
|
||||
// Gates closed: a pending quiz parks the pump in waitForIdle.
|
||||
// Gates closed: a dangling question call parks the pump in waitForIdle.
|
||||
chatStore.saveMessages(chatId, [
|
||||
{ id: "u1", role: "user", parts: [{ type: "text", text: "hello" }] },
|
||||
{ id: "u1", role: "user", content: "hello", timestamp: 1 } as never,
|
||||
{
|
||||
id: "a1",
|
||||
role: "assistant",
|
||||
parts: [{ type: "tool-quiz", toolCallId: "quiz-call", state: "input-available", input: { question: "q", choices: ["a", "b"] } }],
|
||||
},
|
||||
] as never);
|
||||
content: [{ type: "toolCall", id: "quiz-call", name: "quiz", arguments: { question: "q", choices: ["a", "b"] } as never }],
|
||||
api: "openai-completions",
|
||||
provider: "openrouter",
|
||||
model: "test",
|
||||
usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
||||
stopReason: "toolUse",
|
||||
timestamp: 1,
|
||||
} as never,
|
||||
]);
|
||||
const delivery = deliverJobResult(steer);
|
||||
await delay(1200);
|
||||
assert.equal(fired, 0, "parked behind the pending quiz");
|
||||
@@ -953,15 +960,20 @@ describe("claimSteers + pump duplicate-guard (continuation coalescing)", () => {
|
||||
|
||||
// The learner answers: the gates open — the parked pump must see the
|
||||
// delivered steer via its post-idle re-check, NOT fire a second turn.
|
||||
const answered = chatStore.getChat(chatId)!.messages.map((m) =>
|
||||
m.id === "a1"
|
||||
? {
|
||||
...m,
|
||||
parts: [{ type: "tool-quiz", toolCallId: "quiz-call", state: "output-available", input: { question: "q", choices: ["a", "b"] }, output: { answer: "a", correct: true, explanation: "" } }],
|
||||
}
|
||||
: m,
|
||||
);
|
||||
chatStore.saveMessages(chatId, answered as never);
|
||||
const answeredRecord = chatStore.getChat(chatId)!;
|
||||
chatStore.saveMessages(chatId, [
|
||||
...answeredRecord.messages,
|
||||
{
|
||||
id: "r1",
|
||||
role: "toolResult",
|
||||
toolCallId: "quiz-call",
|
||||
toolName: "quiz",
|
||||
content: [{ type: "text", text: "answered" }],
|
||||
details: { answer: "a", correct: true, explanation: "" },
|
||||
isError: false,
|
||||
timestamp: 1,
|
||||
} as never,
|
||||
]);
|
||||
|
||||
await delivery;
|
||||
await delay(1500);
|
||||
@@ -990,57 +1002,104 @@ function delay(ms: number): Promise<void> {
|
||||
|
||||
// ─── persist id hygiene (the duplicate-empty-key bug) ────────────────────────
|
||||
|
||||
describe("persistTurn id hygiene", () => {
|
||||
it("withMessageIds fills EMPTY ids (server-only synthesized messages) and preserves existing ones", () => {
|
||||
const filled = withMessageIds([
|
||||
{ id: "", role: "assistant", parts: [{ type: "text", text: "wake turn" }] },
|
||||
{ id: "client-id", role: "user", parts: [{ type: "text", text: "hi" }] },
|
||||
]);
|
||||
assert.notEqual(filled[0]!.id, "", "empty id replaced");
|
||||
assert.match(filled[0]!.id, /^msg_/);
|
||||
assert.equal(filled[1]!.id, "client-id", "client-authored id untouched");
|
||||
assert.notEqual(filled[0]!.id, filled[1]!.id);
|
||||
});
|
||||
|
||||
it("persistTurn dedupes same-id continuation copies, keeping the LAST (the SDK's merged superset)", () => {
|
||||
// The coalescing shape: a steer (user-role) rides a quiz-answer
|
||||
// continuation AFTER the assistant message being continued, so the
|
||||
// SDK's onFinish list carries the input copy AND the extended
|
||||
// response copy under one id — the record must keep only the merge.
|
||||
const chat = chatStore.createChat("Test: dedupe continuation copies");
|
||||
describe("persistTurn (native history, Wave B)", () => {
|
||||
it("stamps EMPTY ids (pi minted no id) at the canonical write and preserves existing ones", () => {
|
||||
const chat = chatStore.createChat("Test: persist stamps ids");
|
||||
trackChat(chat.id);
|
||||
const short: UIMessage = { id: "msg_dup_1", role: "assistant", parts: [{ type: "text", text: "step 1" }] };
|
||||
const long: UIMessage = { id: "msg_dup_1", role: "assistant", parts: [{ type: "text", text: "step 1" }, { type: "text", text: "step 2 (continued)" }] };
|
||||
persistTurn(chatStore, chat.id, [
|
||||
{ id: "u1", role: "user", parts: [{ type: "text", text: "hi" }] },
|
||||
short,
|
||||
{ id: "steer_x", role: "user", parts: [{ type: "text", text: "research complete" }] },
|
||||
long,
|
||||
const stamped = persistTurn(chatStore, chat.id, 0, [
|
||||
{ id: "", role: "user", content: "wake turn", timestamp: 1 } as never,
|
||||
{
|
||||
id: "msg_kept",
|
||||
role: "assistant",
|
||||
content: [{ type: "text", text: "hi" }],
|
||||
api: "openai-completions",
|
||||
provider: "openrouter",
|
||||
model: "test",
|
||||
usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
||||
stopReason: "stop",
|
||||
timestamp: 1,
|
||||
} as never,
|
||||
], false);
|
||||
assert.notEqual(stamped[0]!.id, "", "empty id replaced");
|
||||
assert.match(stamped[0]!.id, /^msg_/);
|
||||
assert.equal(stamped[1]!.id, "msg_kept", "server-authored id untouched");
|
||||
const record = chatStore.getChat(chat.id)!;
|
||||
assert.equal(record.messages.length, 3, "the duplicate entry is dropped");
|
||||
assert.deepEqual(
|
||||
record.messages.map((m) => m.id),
|
||||
["u1", "steer_x", "msg_dup_1"],
|
||||
"order preserved, steer before the merged assistant copy",
|
||||
);
|
||||
const merged = record.messages[2]!;
|
||||
assert.equal(merged.parts.length, 2, "the KEPT copy is the extended one");
|
||||
assert.equal((merged.parts[1] as { text: string }).text, "step 2 (continued)");
|
||||
assert.equal(record.messages.length, 2, "both messages persisted");
|
||||
assert.ok(record.messages.every((m) => m.id && m.id.length > 0), "no empty ids survive persist");
|
||||
});
|
||||
|
||||
it("persistTurn saves every message with a unique non-empty id (wake turns never re-POSTed)", () => {
|
||||
const chat = chatStore.createChat("Test: persist ids");
|
||||
it("baseCount slices the turn's own messages (the agent's state is append-only across turns)", () => {
|
||||
const chat = chatStore.createChat("Test: persist slices the turn tail");
|
||||
trackChat(chat.id);
|
||||
persistTurn(chatStore, chat.id, [
|
||||
{ id: "", role: "user", parts: [{ type: "text", text: "q" }] },
|
||||
{ id: "", role: "assistant", parts: [{ type: "text", text: "a1" }] },
|
||||
{ id: "", role: "assistant", parts: [{ type: "text", text: "a2" }] },
|
||||
chatStore.saveMessages(chat.id, [
|
||||
{ id: "base_1", role: "user", content: "first", timestamp: 1 } as never,
|
||||
]);
|
||||
// The agent's state at onFinish: the base record + the turn's OWN new
|
||||
// messages (Wave B: onFinish receives the full native history — the
|
||||
// slice keeps persist append-only).
|
||||
const state = [
|
||||
{ id: "base_1", role: "user", content: "first", timestamp: 1 } as never,
|
||||
{
|
||||
id: "",
|
||||
role: "assistant",
|
||||
content: [{ type: "text", text: "wake turn" }],
|
||||
api: "openai-completions",
|
||||
provider: "openrouter",
|
||||
model: "test",
|
||||
usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
||||
stopReason: "stop",
|
||||
timestamp: 1,
|
||||
} as never,
|
||||
];
|
||||
const stamped = persistTurn(chatStore, chat.id, 1, state, false);
|
||||
assert.equal(stamped.length, 1, "only the turn's new tail persists");
|
||||
const record = chatStore.getChat(chat.id)!;
|
||||
assert.equal(record.messages.length, 2, "the base record is untouched — append-only");
|
||||
assert.match(record.messages[1]!.id, /^msg_/);
|
||||
});
|
||||
|
||||
it("drops clean question placeholders, keeps errored ones (fork 1: the record ends at the unanswered call)", () => {
|
||||
const chat = chatStore.createChat("Test: persist placeholder filtering");
|
||||
trackChat(chat.id);
|
||||
const stamped = persistTurn(chatStore, chat.id, 0, [
|
||||
{
|
||||
id: "",
|
||||
role: "assistant",
|
||||
content: [{ type: "toolCall", id: "q1", name: "quiz", arguments: { question: "q?" } as never }],
|
||||
api: "openai-completions",
|
||||
provider: "openrouter",
|
||||
model: "test",
|
||||
usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
||||
stopReason: "toolUse",
|
||||
timestamp: 1,
|
||||
} as never,
|
||||
// The loop's synthetic "question posed" result — never a fact.
|
||||
{
|
||||
id: "",
|
||||
role: "toolResult",
|
||||
toolCallId: "q1",
|
||||
toolName: "quiz",
|
||||
content: [{ type: "text", text: "question posed" }],
|
||||
details: "question posed",
|
||||
isError: false,
|
||||
timestamp: 1,
|
||||
} as never,
|
||||
// An ERRORED authoring call DOES persist — the model's retry context.
|
||||
{
|
||||
id: "",
|
||||
role: "toolResult",
|
||||
toolCallId: "q1",
|
||||
toolName: "quiz",
|
||||
content: [{ type: "text", text: "bad options" }],
|
||||
details: "bad options",
|
||||
isError: true,
|
||||
timestamp: 1,
|
||||
} as never,
|
||||
], false);
|
||||
const record = chatStore.getChat(chat.id);
|
||||
const ids = record!.messages.map((m) => m.id);
|
||||
assert.ok(ids.every((id) => id && id.length > 0), "no empty ids survive persist");
|
||||
assert.equal(new Set(ids).size, ids.length, "ids unique — React keys safe");
|
||||
assert.equal(stamped.length, 2, "the clean placeholder is filtered, the errored result stays");
|
||||
const record = chatStore.getChat(chat.id)!;
|
||||
assert.equal(record.messages.length, 2);
|
||||
assert.equal((record.messages[1] as { isError: boolean }).isError, true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1142,17 +1201,34 @@ describe("abort (src/server/jobs.ts abortJob)", () => {
|
||||
function newChatWithResearchJob(label: string, toolCallId: string): { chatId: string; jobId: string } {
|
||||
const chat = chatStore.createChat(`Test: ${label}`);
|
||||
trackChat(chat.id);
|
||||
// Native fixture — the record is agent history (Wave B).
|
||||
chatStore.saveMessages(chat.id, [
|
||||
{ id: "u1", role: "user", parts: [{ type: "text", text: "hi" }] },
|
||||
{ id: "u1", role: "user", content: "hi", timestamp: 1 } as never,
|
||||
{
|
||||
id: "a1",
|
||||
role: "assistant",
|
||||
parts: [
|
||||
content: [
|
||||
{ type: "text", text: "Checking." },
|
||||
{ type: "tool-research", toolCallId, state: "output-available", input: RESEARCH_INPUT, output: "Dispatched." },
|
||||
{ type: "toolCall", id: toolCallId, name: "research", arguments: RESEARCH_INPUT as never },
|
||||
],
|
||||
},
|
||||
] as never);
|
||||
api: "openai-completions",
|
||||
provider: "openrouter",
|
||||
model: "test",
|
||||
usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
||||
stopReason: "toolUse",
|
||||
timestamp: 1,
|
||||
} as never,
|
||||
{
|
||||
id: "r1",
|
||||
role: "toolResult",
|
||||
toolCallId,
|
||||
toolName: "research",
|
||||
content: [{ type: "text", text: "Dispatched." }],
|
||||
details: "Dispatched.",
|
||||
isError: false,
|
||||
timestamp: 1,
|
||||
} as never,
|
||||
]);
|
||||
const job = jobStore.createJob({ kind: "research", chatId: chat.id, toolCallId });
|
||||
trackJob(job.id);
|
||||
return { chatId: chat.id, jobId: job.id };
|
||||
@@ -1161,8 +1237,8 @@ describe("abort (src/server/jobs.ts abortJob)", () => {
|
||||
it("kills the running loop: error \"Aborted by learner\" immediately, loop unwinds silently, teacher gets the abort steer", async () => {
|
||||
const { jobId } = newChatWithResearchJob("abort running job", "call-abort1");
|
||||
const fired: SteerMessage[] = [];
|
||||
setTurnRunner(async (_cid, messages) => {
|
||||
fired.push(messages[messages.length - 1] as SteerMessage);
|
||||
setTurnRunner(async (_cid, input) => {
|
||||
fired.push((input as SteerInput).steer as SteerMessage);
|
||||
return 200;
|
||||
});
|
||||
researchModelOverride.current = hangingModel();
|
||||
@@ -1191,10 +1267,9 @@ describe("abort (src/server/jobs.ts abortJob)", () => {
|
||||
|
||||
await waitFor(() => fired.length > 0, 8000, "abort wake fired");
|
||||
const last = fired[0]!;
|
||||
assert.equal((last.metadata as { subagentResult: { aborted: boolean } }).subagentResult.aborted, true);
|
||||
assert.equal((last.details as { aborted: boolean }).aborted, true);
|
||||
assert.equal(last.id, `steer_${jobId}`);
|
||||
assert.equal(last.parts.length, 2, "teacher-facing text + the data-subagent-aborted marker part — no artifact");
|
||||
assert.equal(last.parts[1]!.type, "data-subagent-aborted");
|
||||
assert.equal(last.customType, "subagent-abort", "the abort marker — no artifact");
|
||||
// aborted is terminal: the wake completing must not flip it
|
||||
assert.equal(jobStore.getJob(jobId)!.status, "error", "terminal after the wake");
|
||||
} finally {
|
||||
@@ -1231,7 +1306,7 @@ describe("abort (src/server/jobs.ts abortJob)", () => {
|
||||
it("aborting a delivering job drops the queued result steer and wakes with the abort note instead", async () => {
|
||||
const chat = chatStore.createChat("Test: abort delivering job");
|
||||
trackChat(chat.id);
|
||||
chatStore.saveMessages(chat.id, [{ id: "u1", role: "user", parts: [{ type: "text", text: "hello" }] }] as never);
|
||||
chatStore.saveMessages(chat.id, [{ id: "u1", role: "user", content: "hello", timestamp: 1 } as never]);
|
||||
const job = jobStore.createJob({ kind: "research", chatId: chat.id, toolCallId: "call-abort3" });
|
||||
trackJob(job.id);
|
||||
const result: ResearchResult = { brief: "Done: it works.", sources: [{ title: "s", url: "https://x.test" }] };
|
||||
@@ -1240,8 +1315,8 @@ describe("abort (src/server/jobs.ts abortJob)", () => {
|
||||
// Block the pump's idle gates so the result steer sits QUEUED, not in flight.
|
||||
assert.ok(beginTurn(chat.id));
|
||||
const fired: SteerMessage[] = [];
|
||||
setTurnRunner(async (_cid, messages) => {
|
||||
fired.push(messages[messages.length - 1] as SteerMessage);
|
||||
setTurnRunner(async (_cid, input) => {
|
||||
fired.push((input as SteerInput).steer as SteerMessage);
|
||||
return 200;
|
||||
});
|
||||
void deliverJobResult(buildSubagentSteer(job, result)).catch(() => {
|
||||
@@ -1257,11 +1332,7 @@ describe("abort (src/server/jobs.ts abortJob)", () => {
|
||||
endTurn(chat.id); // gates open — whatever is left in the queue fires
|
||||
await waitFor(() => fired.length > 0, 8000, "wake fired");
|
||||
assert.equal(fired.length, 1, "exactly one delivery — the result steer was dropped");
|
||||
assert.equal(
|
||||
(fired[0]!.metadata as { subagentResult: { aborted: boolean } }).subagentResult.aborted,
|
||||
true,
|
||||
"the abort steer, not the result",
|
||||
);
|
||||
assert.equal((fired[0]!.details as { aborted: boolean }).aborted, true, "the abort steer, not the result");
|
||||
assert.equal(jobStore.getJob(job.id)!.status, "error", "terminal after the wake");
|
||||
});
|
||||
|
||||
@@ -1297,7 +1368,7 @@ describe("stale-job recovery sweep (liveness-gated)", () => {
|
||||
function orphanJob(withResult?: ResearchResult): { chatId: string; jobId: string } {
|
||||
const chat = chatStore.createChat("Test: stale sweep");
|
||||
trackChat(chat.id);
|
||||
chatStore.saveMessages(chat.id, [{ id: "u1", role: "user", parts: [{ type: "text", text: "hello" }] }] as never);
|
||||
chatStore.saveMessages(chat.id, [{ id: "u1", role: "user", content: "hello", timestamp: 1 } as never]);
|
||||
const job = jobStore.createJob({ kind: "research", chatId: chat.id, toolCallId: `call-sweep-${createdJobs.length}` });
|
||||
trackJob(job.id);
|
||||
if (withResult) jobStore.updateJob(job.id, { result: withResult, activity: "delivering result" });
|
||||
@@ -1360,14 +1431,7 @@ describe("BYOK job refusal (no factory floor)", () => {
|
||||
it("runJob with NO resolvable engine lands the job as error — the envelope wording, no phantom run", async () => {
|
||||
const chat = chatStore.createChat("Test: unconfigured spawn");
|
||||
trackChat(chat.id);
|
||||
chatStore.saveMessages(chat.id, [
|
||||
{ id: "u1", role: "user", parts: [{ type: "text", text: "hi" }] },
|
||||
{
|
||||
id: "a1",
|
||||
role: "assistant",
|
||||
parts: [{ type: "tool-research", toolCallId: "call-nc1", state: "output-available", input: RESEARCH_INPUT, output: "Dispatched." }],
|
||||
},
|
||||
] as never);
|
||||
chatStore.saveMessages(chat.id, nativeToolMessages("hi", "research", "call-nc1", RESEARCH_INPUT));
|
||||
|
||||
const job = jobStore.createJob({ kind: "research", chatId: chat.id, toolCallId: "call-nc1" });
|
||||
trackJob(job.id);
|
||||
@@ -1388,9 +1452,8 @@ describe("BYOK job refusal (no factory floor)", () => {
|
||||
assert.match(done.error ?? "", /No model configured/);
|
||||
assert.match(done.error ?? "", /missing: cheap/);
|
||||
assert.equal(delivery.steers.length, 1, "the refusal STEERED — the teacher is not stranded");
|
||||
const text = delivery.steers[0]!.parts[0];
|
||||
assert.ok(
|
||||
text.type === "text" && text.text.includes("Research failed: No model configured"),
|
||||
delivery.steers[0]!.content.includes("Research failed: No model configured"),
|
||||
"the steer carries the kind's failure shape",
|
||||
);
|
||||
const result = done.result as ResearchResult;
|
||||
@@ -1404,14 +1467,7 @@ describe("provider-key job refusal (key-presence interlude)", () => {
|
||||
it("an engine whose provider key is gone refuses the spawn — envelope wording, no phantom run", async () => {
|
||||
const chat = chatStore.createChat("Test: keyless spawn");
|
||||
trackChat(chat.id);
|
||||
chatStore.saveMessages(chat.id, [
|
||||
{ id: "u1", role: "user", parts: [{ type: "text", text: "hi" }] },
|
||||
{
|
||||
id: "a1",
|
||||
role: "assistant",
|
||||
parts: [{ type: "tool-research", toolCallId: "call-km1", state: "output-available", input: RESEARCH_INPUT, output: "Dispatched." }],
|
||||
},
|
||||
] as never);
|
||||
chatStore.saveMessages(chat.id, nativeToolMessages("hi", "research", "call-km1", RESEARCH_INPUT));
|
||||
|
||||
const job = jobStore.createJob({ kind: "research", chatId: chat.id, toolCallId: "call-km1" });
|
||||
trackJob(job.id);
|
||||
@@ -1441,9 +1497,8 @@ describe("provider-key job refusal (key-presence interlude)", () => {
|
||||
assert.equal(done.error, providerKeyMissing(engine.model.provider, ["cheap"]).error, "the envelope's error line verbatim");
|
||||
assert.deepEqual(seen, [[engine.model.provider, "cheap"]], "the check saw the engine's provider + the researcher's tier");
|
||||
assert.equal(delivery.steers.length, 1, "the refusal STEERED — the teacher is not stranded");
|
||||
const text = delivery.steers[0]!.parts[0];
|
||||
assert.ok(
|
||||
text.type === "text" && text.text.includes(`Research failed: No ${engine.model.provider} key is set`),
|
||||
delivery.steers[0]!.content.includes(`Research failed: No ${engine.model.provider} key is set`),
|
||||
"the steer carries the kind's failure shape",
|
||||
);
|
||||
const result = done.result as ResearchResult;
|
||||
|
||||
@@ -36,7 +36,7 @@ const PLAN_OK = { ok: true, tree: { nodes: [{ id: "n1", title: "N" }], edges: []
|
||||
|
||||
/** The verdict button's user message (the custom-message scheme): its own
|
||||
* message carrying ONLY the data-verdict marker part — no human-authored
|
||||
* text; the model-facing wording is the adapter's (record-adapter.ts). */
|
||||
* text; the model-facing wording is toLlmMessages' (native-messages.ts). */
|
||||
function verdict(accepted: boolean): UIMessage {
|
||||
return {
|
||||
id: `u_verdict_${Math.random().toString(36).slice(2, 6)}`,
|
||||
|
||||
+154
-95
@@ -1,15 +1,20 @@
|
||||
/**
|
||||
* plan-blocks — the INTERIM plan presentation seam (docs/probe-teach-split.md
|
||||
* §6): the full block renders IN FLOW exactly once, attached to the
|
||||
* assistant message carrying the successful plan_init call. The
|
||||
* neighborhood-block machinery is retired (position derives live from
|
||||
* derivePlanStatuses); the persistent DAG window (UI wave) succeeds this.
|
||||
* assistant group carrying the successful plan_init call. Wave B: emission
|
||||
* is a NATIVE plan-block marker (one per TURN, for the last successful
|
||||
* call), and the one-way projection splices its data part right after the
|
||||
* last successful plan_init tool part. The neighborhood-block machinery is
|
||||
* retired (position derives live from derivePlanStatuses); the persistent
|
||||
* DAG window (UI wave) succeeds this.
|
||||
*/
|
||||
import assert from "node:assert";
|
||||
import test from "node:test";
|
||||
import type { UIMessage } from "ai";
|
||||
import { withPlanBlocks, type PlanBlockPart } from "../src/core/plan-blocks.ts";
|
||||
import type { PlanTree } from "../src/core/contracts.ts";
|
||||
import type { LhMessage } from "../src/core/native-messages.ts";
|
||||
import { planBlocksFor } from "../src/core/plan-blocks.ts";
|
||||
import { projectNative } from "../src/core/projection.ts";
|
||||
import type { PlanBlockData } from "../src/core/contracts.ts";
|
||||
|
||||
// 5-node fixture: a → b → c, b → d, d → e.
|
||||
const TREE: PlanTree = {
|
||||
@@ -40,133 +45,187 @@ const TREE2: PlanTree = {
|
||||
version: 2,
|
||||
};
|
||||
|
||||
/** An assistant message carrying a successful plan_init with the fixture tree. */
|
||||
function planInitMessage(id: string, tree: PlanTree = TREE): UIMessage {
|
||||
/** A native assistant message with one tool call (the projection's tool
|
||||
* part source) — minimal fixture shape, same as the record holds. */
|
||||
function assistantWithCall(id: string, name: string, args: unknown, ...text: string[]): LhMessage {
|
||||
const content: unknown[] = [];
|
||||
for (const t of text) content.push({ type: "text", text: t });
|
||||
content.push({ type: "toolCall", id: `call_${id}`, name, arguments: args as never });
|
||||
return {
|
||||
id,
|
||||
role: "assistant",
|
||||
parts: [
|
||||
{ type: "text", text: "Here is the plan." },
|
||||
{ type: "tool-plan_init", state: "output-available", output: { ok: true, tree } },
|
||||
],
|
||||
} as unknown as UIMessage;
|
||||
content,
|
||||
api: "openai-completions",
|
||||
provider: "openrouter",
|
||||
model: "test",
|
||||
usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
||||
stopReason: "stop",
|
||||
timestamp: Date.now(),
|
||||
} as never;
|
||||
}
|
||||
|
||||
/** A message built from raw parts (positional tests need exact part arrays). */
|
||||
function msg(role: string, parts: unknown[]): UIMessage {
|
||||
return { id: `m_${Math.random().toString(36).slice(2, 8)}`, role, parts } as unknown as UIMessage;
|
||||
/** A native tool result for a call (details = what the record + projection
|
||||
* read; the plan tool persists {ok, tree} here). */
|
||||
function resultOf(callId: string, toolName: string, details: unknown, isError = false): LhMessage {
|
||||
return {
|
||||
id: `r_${callId}${isError ? "_e" : ""}`,
|
||||
role: "toolResult",
|
||||
toolCallId: `call_${callId}`,
|
||||
toolName,
|
||||
content: [{ type: "text", text: isError ? "failed" : "ok" }],
|
||||
details,
|
||||
isError,
|
||||
timestamp: Date.now(),
|
||||
} as never;
|
||||
}
|
||||
|
||||
function blocksOf(messages: UIMessage[]): PlanBlockPart[] {
|
||||
return messages.flatMap((m) => m.parts.filter((p) => p.type === "data-plan-block") as PlanBlockPart[]);
|
||||
/** Emit + persist + project: the full persistTurn slice for a turn whose
|
||||
* new messages are `turn`, run over `record`. Returns the projection of
|
||||
* the whole record after the write. */
|
||||
function projectAfter(record: LhMessage[], turn: LhMessage[], tree: PlanTree | null) {
|
||||
const blocks = planBlocksFor(record, turn, tree, projectNative([...record, ...turn]));
|
||||
return projectNative([...record, ...turn, ...blocks]);
|
||||
}
|
||||
|
||||
test("withPlanBlocks: a successful plan_init message gains ONE full block (all nodes, statuses from the transcript)", () => {
|
||||
const out = withPlanBlocks([planInitMessage("m1")], TREE);
|
||||
function blocksOf(messages: ReturnType<typeof projectNative>): PlanBlockData[] {
|
||||
return messages.flatMap((m) =>
|
||||
(m.parts as { type: string; data?: PlanBlockData }[])
|
||||
.filter((p) => p.type === "data-plan-block")
|
||||
.map((p) => p.data!),
|
||||
);
|
||||
}
|
||||
|
||||
test("a successful plan_init turn gains ONE full block (all nodes, statuses from the transcript)", () => {
|
||||
const turn = [
|
||||
assistantWithCall("m1", "plan_init", { tree: TREE }, "Here is the plan."),
|
||||
resultOf("m1", "plan_init", { ok: true, tree: TREE }),
|
||||
];
|
||||
const out = projectAfter([], turn, TREE);
|
||||
const blocks = blocksOf(out);
|
||||
assert.equal(blocks.length, 1);
|
||||
const data = blocks[0]!.data;
|
||||
const data = blocks[0]!;
|
||||
assert.equal(data.kind, "full");
|
||||
assert.equal(data.current, null, "at authoring nothing is current");
|
||||
assert.equal(data.nodes.length, 5);
|
||||
assert.ok(data.nodes.every((n) => n.status === "upcoming"));
|
||||
assert.equal(data.edges.length, 4);
|
||||
// attached to the plan_init message itself
|
||||
assert.equal(blocksOf([out[0]!]).length, 1);
|
||||
});
|
||||
|
||||
test("withPlanBlocks: a FAILED plan_init gains nothing", () => {
|
||||
const failed: UIMessage = {
|
||||
id: "m1",
|
||||
role: "assistant",
|
||||
parts: [
|
||||
{ type: "text", text: "oops" },
|
||||
{ type: "tool-plan_init", state: "output-error", output: { ok: false, error: "bad" } },
|
||||
],
|
||||
} as unknown as UIMessage;
|
||||
assert.equal(blocksOf(withPlanBlocks([failed], TREE)).length, 0);
|
||||
test("a FAILED plan_init gains nothing", () => {
|
||||
const turn = [
|
||||
assistantWithCall("m1", "plan_init", { tree: TREE }, "oops"),
|
||||
resultOf("m1", "plan_init", { ok: false, error: "bad" }, true),
|
||||
];
|
||||
assert.equal(blocksOf(projectAfter([], turn, TREE)).length, 0);
|
||||
});
|
||||
|
||||
test("withPlanBlocks: idempotent — a message that already carries a block is never given another", () => {
|
||||
const once = withPlanBlocks([planInitMessage("m1")], TREE);
|
||||
const twice = withPlanBlocks(once, TREE);
|
||||
assert.equal(blocksOf(twice).length, 1, "no duplicate block on persist replay");
|
||||
test("idempotent — a call whose block the record already carries is never re-emitted", () => {
|
||||
const turn = [
|
||||
assistantWithCall("m1", "plan_init", { tree: TREE }),
|
||||
resultOf("m1", "plan_init", { ok: true, tree: TREE }),
|
||||
];
|
||||
const first = projectAfter([], turn, TREE);
|
||||
assert.equal(blocksOf(first).length, 1);
|
||||
// Persist replay: the same turn's messages land again over a record
|
||||
// that already emitted the marker → no duplicate.
|
||||
const record: LhMessage[] = [...turn, ...planBlocksFor([], turn, TREE, projectNative(turn))];
|
||||
const blocks2 = planBlocksFor(record, turn, TREE, projectNative(record));
|
||||
assert.equal(blocks2.length, 0, "no duplicate block on persist replay");
|
||||
});
|
||||
|
||||
test("withPlanBlocks: a plan REWRITE (second successful plan_init) gets its own full block; the old stays frozen", () => {
|
||||
const rewrittenTree: PlanTree = { nodes: TREE.nodes.slice(0, 3), edges: [{ from: "a", to: "b" }], version: 2 };
|
||||
const out = withPlanBlocks([planInitMessage("m1"), planInitMessage("m2", rewrittenTree)], rewrittenTree);
|
||||
test("a plan REWRITE (a later turn's successful plan_init) gets its own full block; the old stays frozen", () => {
|
||||
const first = [
|
||||
assistantWithCall("m1", "plan_init", { tree: TREE }),
|
||||
resultOf("m1", "plan_init", { ok: true, tree: TREE }),
|
||||
];
|
||||
const record = [...first, ...planBlocksFor([], first, TREE, projectNative(first))];
|
||||
const second = [
|
||||
assistantWithCall("m2", "plan_init", { tree: TREE2 }),
|
||||
resultOf("m2", "plan_init", { ok: true, tree: TREE2 }),
|
||||
];
|
||||
const out = projectAfter(record, second, TREE2);
|
||||
const blocks = blocksOf(out);
|
||||
assert.equal(blocks.length, 2, "one full block per authoring event");
|
||||
assert.equal(blocks[1]!.data.nodes.length, 3);
|
||||
assert.equal(blocks.length, 2, "one full block per authoring turn");
|
||||
assert.equal(blocks[1]!.nodes.length, 3);
|
||||
});
|
||||
|
||||
test("withPlanBlocks: the block renders IN FLOW immediately after the plan_init part — not appended under the turn's later parts", () => {
|
||||
test("the block renders IN FLOW immediately after the plan_init part — not appended under the turn's later parts", () => {
|
||||
// Real-world shape (user session chat_mtwgb9lj): the authoring turn
|
||||
// carries plan_init, then presentation prose, then quiz calls — emission
|
||||
// runs at persistTurn AFTER all of that exists.
|
||||
const turn = msg("assistant", [
|
||||
{ type: "step-start" },
|
||||
{ type: "tool-plan_init", state: "output-available", output: { ok: true, tree: TREE } },
|
||||
{ type: "step-start" },
|
||||
{ type: "text", state: "done", text: "Here's the plan." },
|
||||
{ type: "tool-quiz", state: "input-available", input: { question: "q?" } },
|
||||
]);
|
||||
const out = withPlanBlocks([turn], TREE);
|
||||
const types = out[0]!.parts.map((p) => p.type);
|
||||
assert.equal(types[2], "data-plan-block", "the block directly follows the plan_init part");
|
||||
assert.equal(types.indexOf("data-plan-block"), 2);
|
||||
assert.ok(types.indexOf("data-plan-block") < types.indexOf("text"), "block before the prose");
|
||||
assert.ok(types.indexOf("data-plan-block") < types.indexOf("tool-quiz"), "block before the first check");
|
||||
// carries plan_init + prose + a quiz call — emission runs at persistTurn
|
||||
// AFTER all of that exists.
|
||||
const turn = [
|
||||
assistantWithCall("m1", "plan_init", { tree: TREE }),
|
||||
resultOf("m1", "plan_init", { ok: true, tree: TREE }),
|
||||
assistantWithCall("m1b", "quiz", { question: "q?" }, "Here's the plan."),
|
||||
resultOf("m1", "plan_init", { ok: true, tree: TREE }), // (kept out via emitted set — see next test)
|
||||
];
|
||||
turn.pop(); // drop the stray duplicate — build the real shape below
|
||||
const real = [
|
||||
assistantWithCall("m1", "plan_init", { tree: TREE }),
|
||||
resultOf("m1", "plan_init", { ok: true, tree: TREE }),
|
||||
assistantWithCall("m1b", "quiz", { question: "q?" }, "Here's the plan."),
|
||||
];
|
||||
const out = projectAfter([], real, TREE);
|
||||
const group = out.find((m) => m.role === "assistant")!;
|
||||
const types = (group.parts as unknown[]).map((p) => (p as { type: string }).type);
|
||||
const blockAt = types.indexOf("data-plan-block");
|
||||
assert.ok(blockAt > types.indexOf("tool-plan_init"), "the block directly follows the plan_init part");
|
||||
assert.ok(blockAt < types.indexOf("text"), "block before the prose");
|
||||
assert.ok(blockAt < types.indexOf("tool-quiz"), "block before the first check");
|
||||
assert.equal(types.at(-1), "tool-quiz", "the quiz is still the turn's last part");
|
||||
});
|
||||
|
||||
test("withPlanBlocks: with multiple plan_inits in one turn (a rewrite), the block follows the LAST one", () => {
|
||||
const turn = msg("assistant", [
|
||||
{ type: "tool-plan_init", state: "output-available", output: { ok: true, tree: TREE } },
|
||||
{ type: "text", state: "done", text: "v1" },
|
||||
{ type: "tool-plan_init", state: "output-available", output: { ok: true, tree: TREE2 } },
|
||||
{ type: "text", state: "done", text: "v2" },
|
||||
]);
|
||||
const out = withPlanBlocks([turn], TREE2);
|
||||
const types = out[0]!.parts.map((p) => p.type);
|
||||
assert.equal(types[3], "data-plan-block", "block directly after the last successful init");
|
||||
assert.equal(types[4], "text", "the trailing prose stays after the block");
|
||||
assert.equal(out[0]!.parts.filter((p) => p.type === "data-plan-block").length, 1, "one block per message even on a same-turn rewrite");
|
||||
test("with multiple plan_inits in one turn (a same-turn rewrite), ONE block follows the LAST one", () => {
|
||||
const turn = [
|
||||
assistantWithCall("m1", "plan_init", { tree: TREE }, "v1"),
|
||||
resultOf("m1", "plan_init", { ok: true, tree: TREE }),
|
||||
assistantWithCall("m2", "plan_init", { tree: TREE2 }, "v2"),
|
||||
resultOf("m2", "plan_init", { ok: true, tree: TREE2 }),
|
||||
];
|
||||
const out = projectAfter([], turn, TREE2);
|
||||
const group = out.find((m) => m.role === "assistant")!;
|
||||
const parts = group.parts as unknown[];
|
||||
const blocks = parts.filter((p) => (p as { type: string }).type === "data-plan-block");
|
||||
assert.equal(blocks.length, 1, "one block per turn even on a same-turn rewrite");
|
||||
const types = parts.map((p) => (p as { type: string }).type);
|
||||
assert.ok(
|
||||
types.indexOf("data-plan-block") > types.indexOf("tool-plan_init"),
|
||||
"the block follows the last successful init",
|
||||
);
|
||||
});
|
||||
|
||||
test("withPlanBlocks: quizzes alone emit nothing (no neighborhood blocks anymore)", () => {
|
||||
const quizTurn: UIMessage = {
|
||||
id: "m1",
|
||||
role: "assistant",
|
||||
parts: [
|
||||
{ type: "text", text: "check" },
|
||||
{ type: "tool-quiz", state: "output-available", input: { nodeId: "b" }, output: { correct: true } },
|
||||
],
|
||||
} as unknown as UIMessage;
|
||||
assert.equal(blocksOf(withPlanBlocks([quizTurn], TREE)).length, 0);
|
||||
test("quizzes alone emit nothing (no neighborhood blocks anymore)", () => {
|
||||
const turn = [
|
||||
assistantWithCall("m1", "quiz", { nodeId: "b", question: "q?" }),
|
||||
resultOf("m1", "quiz", { correct: true }),
|
||||
];
|
||||
assert.equal(blocksOf(projectAfter([], turn, TREE)).length, 0);
|
||||
});
|
||||
|
||||
test("withPlanBlocks: no tree / empty tree / no messages are no-ops", () => {
|
||||
assert.equal(withPlanBlocks([planInitMessage("m1")], null).length, 1, "passthrough");
|
||||
test("no tree / empty tree / no messages are no-ops", () => {
|
||||
const turn = [
|
||||
assistantWithCall("m1", "plan_init", { tree: TREE }),
|
||||
resultOf("m1", "plan_init", { ok: true, tree: TREE }),
|
||||
];
|
||||
assert.equal(planBlocksFor([], turn, null, []).length, 0, "no tree → nothing");
|
||||
const emptyTree: PlanTree = { nodes: [], edges: [], version: 0 };
|
||||
assert.equal(blocksOf(withPlanBlocks([planInitMessage("m1")], emptyTree)).length, 0);
|
||||
assert.equal(withPlanBlocks([], TREE).length, 0);
|
||||
assert.equal(planBlocksFor([], turn, emptyTree, []).length, 0, "empty tree → nothing");
|
||||
assert.equal(planBlocksFor([], [], TREE, []).length, 0, "no messages → nothing");
|
||||
});
|
||||
|
||||
test("withPlanBlocks: mid-lesson rewrite snapshot derives statuses from the whole transcript", () => {
|
||||
test("mid-lesson rewrite snapshot derives statuses from the whole transcript", () => {
|
||||
// A tagged quiz call on node b happened before the rewrite: b is current.
|
||||
const quizBefore: UIMessage = {
|
||||
id: "m0",
|
||||
role: "assistant",
|
||||
parts: [
|
||||
{ type: "text", text: "about b" },
|
||||
{ type: "tool-quiz", state: "output-available", input: { nodeId: "b" }, output: { correct: true } },
|
||||
],
|
||||
} as unknown as UIMessage;
|
||||
const out = withPlanBlocks([quizBefore, planInitMessage("m1")], TREE);
|
||||
const data = blocksOf(out)[0]!.data;
|
||||
const before = [
|
||||
assistantWithCall("m0", "quiz", { nodeId: "b", question: "q?" }),
|
||||
resultOf("m0", "quiz", { correct: true }),
|
||||
];
|
||||
const rewrite = [
|
||||
assistantWithCall("m1", "plan_init", { tree: TREE }),
|
||||
resultOf("m1", "plan_init", { ok: true, tree: TREE }),
|
||||
];
|
||||
const out = projectAfter(before, rewrite, TREE);
|
||||
const data = blocksOf(out)[0]!;
|
||||
assert.equal(data.current, "b");
|
||||
const b = data.nodes.find((n) => n.id === "b")!;
|
||||
assert.equal(b.status, "current");
|
||||
});
|
||||
});
|
||||
|
||||
+40
-14
@@ -3,13 +3,18 @@
|
||||
* Server-side; three outcomes: refusal while a question is open, refusal
|
||||
* after the probe is already concluded, ok + handoff queued. The handoff
|
||||
* rides the wake pipeline (deliver injected here — no server seam import).
|
||||
* Wave B: the record is native history — fixtures are pi messages, the
|
||||
* pending-question gate is the dangling-question scan, and the handoff is
|
||||
* a native marker (customType probe-handoff, HANDOFF_TEXT content).
|
||||
*/
|
||||
import assert from "node:assert/strict";
|
||||
import * as fs from "node:fs";
|
||||
import * as path from "node:path";
|
||||
import { after, describe, it } from "node:test";
|
||||
import { probeDoneTool } from "../src/agent/tools/probe.ts";
|
||||
import type { ProbeHandoffSteerMessage } from "../src/core/contracts.ts";
|
||||
import type { SteerMessage } from "../src/core/contracts.ts";
|
||||
import type { LhMessage } from "../src/core/native-messages.ts";
|
||||
import { HANDOFF_TEXT } from "../src/core/contracts.ts";
|
||||
import { chatStore } from "../src/store/chats.ts";
|
||||
import { PATHS } from "../config.ts";
|
||||
|
||||
@@ -40,6 +45,22 @@ const ASSESSMENT = {
|
||||
summary: "Enter at the formal definition.",
|
||||
};
|
||||
|
||||
/** A native assistant message carrying one tool call (the minimal fixture
|
||||
* shape the scans read — role assistant, content blocks). */
|
||||
function assistantWithCall(id: string, toolName: string, args: unknown): LhMessage {
|
||||
return {
|
||||
id,
|
||||
role: "assistant",
|
||||
content: [{ type: "toolCall", id: `call_${id}`, name: toolName, arguments: args as never }],
|
||||
api: "openai-completions",
|
||||
provider: "openrouter",
|
||||
model: "test",
|
||||
usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
||||
stopReason: "stop",
|
||||
timestamp: Date.now(),
|
||||
} as never;
|
||||
}
|
||||
|
||||
async function execute(tools: ReturnType<typeof probeDoneTool>, input: unknown) {
|
||||
const t = tools.probe_done!;
|
||||
assert.ok(typeof t.execute === "function");
|
||||
@@ -49,7 +70,7 @@ async function execute(tools: ReturnType<typeof probeDoneTool>, input: unknown)
|
||||
describe("probe_done: the probe→planning seam", () => {
|
||||
it("ok path: records the assessment and queues the handoff steer (deterministic id)", async () => {
|
||||
const chatId = newChat("Probe: ok");
|
||||
const delivered: ProbeHandoffSteerMessage[] = [];
|
||||
const delivered: SteerMessage[] = [];
|
||||
const tools = probeDoneTool({ chatId, chatStore, deliver: (s) => delivered.push(s) });
|
||||
|
||||
const res = await execute(tools, { assessment: ASSESSMENT });
|
||||
@@ -57,19 +78,19 @@ describe("probe_done: the probe→planning seam", () => {
|
||||
assert.equal(res.error, undefined);
|
||||
assert.equal(delivered.length, 1, "the handoff is queued exactly once");
|
||||
assert.equal(delivered[0]!.id, `steer_probe_${chatId}`);
|
||||
assert.equal((delivered[0]!.metadata as { probeHandoff: { chatId: string } }).probeHandoff.chatId, chatId);
|
||||
// Native marker (Wave B): the model-facing HANDOFF_TEXT rides content;
|
||||
// the UI keys off customType; the chat id rides details.
|
||||
assert.equal(delivered[0]!.customType, "probe-handoff");
|
||||
assert.equal(delivered[0]!.content, HANDOFF_TEXT);
|
||||
assert.equal((delivered[0]!.details as { chatId: string }).chatId, chatId);
|
||||
});
|
||||
|
||||
it("REFUSES while a question is still open — resolve it first", async () => {
|
||||
const chatId = newChat("Probe: open question");
|
||||
chatStore.saveMessages(chatId, [
|
||||
{
|
||||
id: "m1",
|
||||
role: "assistant",
|
||||
parts: [{ type: "tool-quiz", state: "input-available", input: { question: "q?" } }],
|
||||
} as never,
|
||||
assistantWithCall("m1", "quiz", { question: "q?", options: [{ label: "a" }], correctAnswer: "a" }),
|
||||
]);
|
||||
const delivered: ProbeHandoffSteerMessage[] = [];
|
||||
const delivered: SteerMessage[] = [];
|
||||
const tools = probeDoneTool({ chatId, chatStore, deliver: (s) => delivered.push(s) });
|
||||
|
||||
const res = await execute(tools, { assessment: ASSESSMENT });
|
||||
@@ -81,13 +102,18 @@ describe("probe_done: the probe→planning seam", () => {
|
||||
it("REFUSES a second conclusion — probe_done fires exactly once", async () => {
|
||||
const chatId = newChat("Probe: twice");
|
||||
chatStore.saveMessages(chatId, [
|
||||
assistantWithCall("m1", "probe_done", { assessment: ASSESSMENT }),
|
||||
{
|
||||
id: "m1",
|
||||
role: "assistant",
|
||||
parts: [{ type: "tool-probe_done", state: "output-available", input: { assessment: ASSESSMENT }, output: { ok: true } }],
|
||||
id: "m1r",
|
||||
role: "toolResult",
|
||||
toolCallId: "call_m1",
|
||||
toolName: "probe_done",
|
||||
content: [{ type: "text", text: "ok" }],
|
||||
isError: false,
|
||||
timestamp: Date.now(),
|
||||
} as never,
|
||||
]);
|
||||
const delivered: ProbeHandoffSteerMessage[] = [];
|
||||
const delivered: SteerMessage[] = [];
|
||||
const tools = probeDoneTool({ chatId, chatStore, deliver: (s) => delivered.push(s) });
|
||||
|
||||
const res = await execute(tools, { assessment: ASSESSMENT });
|
||||
@@ -115,4 +141,4 @@ describe("probe_done: the probe→planning seam", () => {
|
||||
const res = await execute(tools, { assessment: ASSESSMENT });
|
||||
assert.equal(res.ok, true, "ok:true even when the queue is lost — the next client message constructs the teacher off the transcript seam");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -18,6 +18,10 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { afterEach, describe, it } from "node:test";
|
||||
import { createReattachingTransport } from "../components/chat-transport.ts";
|
||||
import type { ChatInput } from "../src/core/input-events.ts";
|
||||
|
||||
/** A no-op input-events stash (the reattach tests never send). */
|
||||
const NO_INPUT = { takeInput: () => undefined };
|
||||
|
||||
const realFetch = globalThis.fetch;
|
||||
afterEach(() => {
|
||||
@@ -67,7 +71,7 @@ describe("reattach transport (reconnectToStream)", () => {
|
||||
urls.push(String(input));
|
||||
return dripped(sse({ type: "start", messageId: "msg_run_1" }), 8);
|
||||
}) as typeof globalThis.fetch;
|
||||
const transport = createReattachingTransport("chat_x", {});
|
||||
const transport = createReattachingTransport("chat_x", {}, NO_INPUT);
|
||||
await collect((await transport.reconnectToStream(RECONNECT_OPTS))!);
|
||||
assert.deepEqual(urls, ["/api/chats/chat_x/stream"]);
|
||||
});
|
||||
@@ -76,7 +80,7 @@ describe("reattach transport (reconnectToStream)", () => {
|
||||
globalThis.fetch = (async () =>
|
||||
dripped(sse({ type: "turn-settled" }, "[DONE]"), 8)) as typeof globalThis.fetch;
|
||||
let settled = 0;
|
||||
const transport = createReattachingTransport("chat_x", { onSettled: () => settled++ });
|
||||
const transport = createReattachingTransport("chat_x", { onSettled: () => settled++ }, NO_INPUT);
|
||||
const stream = await transport.reconnectToStream(RECONNECT_OPTS);
|
||||
assert.equal(stream, null);
|
||||
assert.equal(settled, 1);
|
||||
@@ -86,7 +90,7 @@ describe("reattach transport (reconnectToStream)", () => {
|
||||
|
||||
it("204 (the SDK's native no-stream signal) resolves null untouched", async () => {
|
||||
globalThis.fetch = (async () => new Response(null, { status: 204 })) as typeof globalThis.fetch;
|
||||
const transport = createReattachingTransport("chat_x", {});
|
||||
const transport = createReattachingTransport("chat_x", {}, NO_INPUT);
|
||||
assert.equal(await transport.reconnectToStream(RECONNECT_OPTS), null);
|
||||
});
|
||||
|
||||
@@ -102,7 +106,7 @@ describe("reattach transport (reconnectToStream)", () => {
|
||||
),
|
||||
13,
|
||||
)) as typeof globalThis.fetch;
|
||||
const transport = createReattachingTransport("chat_x", {});
|
||||
const transport = createReattachingTransport("chat_x", {}, NO_INPUT);
|
||||
const chunks = (await collect((await transport.reconnectToStream(RECONNECT_OPTS))!)) as Array<
|
||||
Record<string, unknown>
|
||||
>;
|
||||
@@ -120,7 +124,7 @@ describe("reattach transport (reconnectToStream)", () => {
|
||||
)) as typeof globalThis.fetch;
|
||||
const transport = createReattachingTransport("chat_x", {
|
||||
lastMessageId: () => "msg_seed",
|
||||
});
|
||||
}, NO_INPUT);
|
||||
const chunks = (await collect((await transport.reconnectToStream(RECONNECT_OPTS))!)) as Array<
|
||||
Record<string, unknown>
|
||||
>;
|
||||
@@ -134,7 +138,7 @@ describe("reattach transport (reconnectToStream)", () => {
|
||||
it("survives a body split byte-by-byte across reads", async () => {
|
||||
globalThis.fetch = (async () =>
|
||||
dripped(sse({ type: "start", messageId: "msg_run_1" }, { type: "finish", finishReason: "stop" }), 1)) as typeof globalThis.fetch;
|
||||
const transport = createReattachingTransport("chat_x", {});
|
||||
const transport = createReattachingTransport("chat_x", {}, NO_INPUT);
|
||||
const chunks = (await collect((await transport.reconnectToStream(RECONNECT_OPTS))!)) as Array<
|
||||
Record<string, unknown>
|
||||
>;
|
||||
@@ -147,7 +151,7 @@ describe("reattach transport (reconnectToStream)", () => {
|
||||
|
||||
it("an unhappy endpoint throws (the SDK surfaces the error banner)", async () => {
|
||||
globalThis.fetch = (async () => new Response("boom", { status: 500 })) as typeof globalThis.fetch;
|
||||
const transport = createReattachingTransport("chat_x", {});
|
||||
const transport = createReattachingTransport("chat_x", {}, NO_INPUT);
|
||||
await assert.rejects(() => transport.reconnectToStream(RECONNECT_OPTS), /boom/);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,89 +0,0 @@
|
||||
/**
|
||||
* Unit tests — the record→pi adapter's SYSTEM MESSAGE flattening (the
|
||||
* custom-message scheme, user-decided 2026-09-12).
|
||||
*
|
||||
* Wake and verdict messages are user-role messages carrying ONLY a marker
|
||||
* data part — no human text exists. The adapter owns their model-facing
|
||||
* wording (flattens the marker to the canonical text from core contracts):
|
||||
* the transcript never persists fake human speech, and the wording can't
|
||||
* drift between the seam, the tests, and reality.
|
||||
*/
|
||||
|
||||
import assert from "node:assert/strict";
|
||||
import { describe, it } from "node:test";
|
||||
import type { UIMessage } from "ai";
|
||||
import { recordToPiMessages } from "../src/server/pi/record-adapter.ts";
|
||||
import { HANDOFF_TEXT, verdictText } from "../src/core/contracts/chat.ts";
|
||||
import { buildAbortSteer, buildProbeHandoffSteer } from "../src/core/contracts/jobs.ts";
|
||||
|
||||
function userText(text: string): UIMessage {
|
||||
return { id: `u_${text.length}`, role: "user", parts: [{ type: "text", text }] };
|
||||
}
|
||||
|
||||
function userParts(parts: unknown[]): UIMessage {
|
||||
return { id: "u_sys", role: "user", parts: parts as UIMessage["parts"] };
|
||||
}
|
||||
|
||||
const texts = (msgs: ReturnType<typeof recordToPiMessages>): string[] => {
|
||||
const out: string[] = [];
|
||||
for (const m of msgs) {
|
||||
if (m.role === "user") {
|
||||
for (const c of m.content) {
|
||||
if ((c as { type?: string }).type === "text") out.push((c as { text: string }).text);
|
||||
}
|
||||
}
|
||||
}
|
||||
return out;
|
||||
};
|
||||
|
||||
describe("record-adapter: system message flattening", () => {
|
||||
it("an ACCEPT verdict flattens to the canonical model-facing text (no human speech persisted)", () => {
|
||||
const out = recordToPiMessages([
|
||||
userText("teach me"),
|
||||
userParts([{ type: "data-verdict", id: "vd_1", data: { accepted: true, at: "…" } }]),
|
||||
]);
|
||||
assert.deepEqual(texts(out), ["teach me", verdictText(true)]);
|
||||
assert.equal(texts(out)[1], "The learner accepted the plan — begin teaching.");
|
||||
});
|
||||
|
||||
it("a DECLINE verdict flattens to the canonical revision text", () => {
|
||||
const out = recordToPiMessages([
|
||||
userParts([{ type: "data-verdict", id: "vd_2", data: { accepted: false, at: "…" } }]),
|
||||
]);
|
||||
assert.deepEqual(texts(out), [verdictText(false)]);
|
||||
assert.match(texts(out)[0]!, /revised/);
|
||||
});
|
||||
|
||||
it("the probe handoff steer flattens to the canonical plan-authoring instruction", () => {
|
||||
const handoff = buildProbeHandoffSteer("chat_x");
|
||||
assert.equal(handoff.parts.length, 1, "the handoff is marker-ONLY — no text part");
|
||||
assert.equal(handoff.parts[0]!.type, "data-probe-handoff");
|
||||
const out = recordToPiMessages([handoff]);
|
||||
assert.deepEqual(texts(out), [HANDOFF_TEXT]);
|
||||
assert.match(texts(out)[0]!, /plan_init/);
|
||||
});
|
||||
|
||||
it("note + verdict (the two-message accept) reach the model as two user texts, in order", () => {
|
||||
const out = recordToPiMessages([
|
||||
userText("Start with the basics, skip the history."),
|
||||
userParts([{ type: "data-verdict", id: "vd_3", data: { accepted: true, at: "…" } }]),
|
||||
]);
|
||||
assert.deepEqual(texts(out), ["Start with the basics, skip the history.", verdictText(true)]);
|
||||
});
|
||||
|
||||
it("sub-agent steers keep their text (payload, not boilerplate): the artifact part adds nothing", () => {
|
||||
const abort = buildAbortSteer({ id: "j1", kind: "research", toolCallId: "c1" });
|
||||
const out = recordToPiMessages([abort]);
|
||||
const got = texts(out);
|
||||
assert.equal(got.length, 1, "one user text — the marker part is UI-only");
|
||||
assert.match(got[0]!, /aborted/i);
|
||||
});
|
||||
|
||||
it("a pure UI data part with no system meaning still never reaches the model", () => {
|
||||
const out = recordToPiMessages([
|
||||
userParts([{ type: "data-plan-block", id: "pb", data: { kind: "full", at: "…", current: null, nodes: [], edges: [] } }]),
|
||||
]);
|
||||
assert.equal(out.length, 0, "no model face at all — skipped");
|
||||
});
|
||||
|
||||
});
|
||||
+5
-2
@@ -221,8 +221,11 @@ test("appendTurnUsage: appends, idempotent per turn id, continuation turns (same
|
||||
let reread = chatStore.getChat(record.id);
|
||||
assert.equal(reread?.turns?.length, 2);
|
||||
|
||||
// saveMessages must not clobber the log (read-mutate-write record)
|
||||
chatStore.saveMessages(record.id, [{ id: "msg_a", role: "user", parts: [] }]);
|
||||
// saveMessages must not clobber the log (read-mutate-write record) —
|
||||
// Wave B: a NATIVE message list (the record holds agent history).
|
||||
chatStore.saveMessages(record.id, [
|
||||
{ id: "msg_a", role: "user", content: "native", timestamp: Date.now() } as never,
|
||||
]);
|
||||
reread = chatStore.getChat(record.id);
|
||||
assert.equal(reread?.turns?.length, 2);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user