From 7ab47d4fcb7cdbb56869bcb37f69d9d9f1e37d0b Mon Sep 17 00:00:00 2001 From: amosblomqvist Date: Sat, 26 Sep 2026 00:37:46 +0300 Subject: [PATCH] =?UTF-8?q?Wave=20B:=20the=20native=20message=20record=20?= =?UTF-8?q?=E2=80=94=20input=20events,=20projection,=20adapter=20death?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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_ 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. --- PI-NATIVE-MIGRATION-PLAN.md | 299 ++++++++++++++++ app/api/chats/[id]/route.ts | 12 +- app/chat/[id]/page.tsx | 6 +- components/chat-transport.ts | 122 +++---- components/chat.tsx | 48 ++- src/agent/subagents/extraction.ts | 16 +- src/agent/subagents/runner.ts | 14 +- src/agent/teacher-agent.ts | 10 +- src/agent/tools/probe.ts | 20 +- src/core/contracts/chat.ts | 19 +- src/core/contracts/jobs.ts | 117 +++--- src/core/index.ts | 6 + src/core/input-events.ts | 121 +++++++ src/core/native-messages.ts | 284 +++++++++++++++ src/core/plan-blocks.ts | 111 +++--- src/core/ports.ts | 7 +- src/core/projection.ts | 199 +++++++++++ src/server/chat-handler.ts | 214 +++++------ src/server/chats.ts | 5 +- src/server/jobs.ts | 77 ++-- src/server/pi/record-adapter.ts | 190 ---------- src/server/pi/ui-stream.ts | 51 ++- src/server/turn-service.ts | 125 ++++--- src/store/chats.ts | 50 ++- tests/chat-flow.test.ts | 316 ++++++++--------- tests/chat-transport.test.ts | 77 ++-- tests/demos.test.tsx | 118 +++--- tests/extraction.test.ts | 134 ++++--- tests/graph.test.ts | 99 ++++-- tests/jobs.test.ts | 571 ++++++++++++++++-------------- tests/phases.test.ts | 2 +- tests/plan-blocks.test.ts | 249 ++++++++----- tests/probe-done.test.ts | 54 ++- tests/reattach-transport.test.ts | 18 +- tests/record-adapter.test.ts | 89 ----- tests/usage.test.ts | 7 +- 36 files changed, 2410 insertions(+), 1447 deletions(-) create mode 100644 PI-NATIVE-MIGRATION-PLAN.md create mode 100644 src/core/input-events.ts create mode 100644 src/core/native-messages.ts create mode 100644 src/core/projection.ts delete mode 100644 src/server/pi/record-adapter.ts delete mode 100644 tests/record-adapter.test.ts diff --git a/PI-NATIVE-MIGRATION-PLAN.md b/PI-NATIVE-MIGRATION-PLAN.md new file mode 100644 index 0000000..57a9bd7 --- /dev/null +++ b/PI-NATIVE-MIGRATION-PLAN.md @@ -0,0 +1,299 @@ +# Pi-native architecture migration plan + +Recon wave 2026-09-26 · READ-ONLY deliverable · `feat/platform` · untracked until owner review +Evidence: wiretap `/tmp/lh-wire/`, 11 controlled OpenRouter replays (`/tmp/rp-*.json/.out`), pi-ai + pi-agent-core dist sources, pi's own session files under `~/.pi/agent/sessions/`. + +--- + +## 0. Executive summary + +- **Two banked facts are overturned by the wiretap evidence** (§1). The tool-stripping mystery was model confabulation — the registry was complete on the wire. The truncation is an upstream (OpenInference) bug triggered by multi-tool-result history volume, not by our serialized shape. +- Five custom seams mapped against pi-native equivalents (§2). Each has a native replacement; the getApiKey fix (76051d0) is the template: delete the custom seam, use the library path. +- What genuinely has no native equivalent is small and enumerable (§3): question-tool UX, tool toggles/phases, jobs, prompt overrides, our reattach/persistence infrastructure. +- Proposed risk order (§4) and test strategy (§5). Seams touching `src/agent` are flagged for Teacher's review per band rules. + +--- + +## 1. Corrections to banked facts (evidence first) + +### 1.1 TOOL-SET STRIPPING — SOLVED: the model confabulated; no seam varies the registry + +- Wiretap captures **7 and 9 are chat_muhb8l00's two exact turns** (timestamps 18:44:47 / 18:48:10 match the record created 18:44:41Z). **Both carried the full 11-tool registry** — `quiz, ask_user_question, read, ls, find, grep, research, pdf, list_sessions, list_propositions, probe_done` — including the graph tools the model claimed not to have. +- The response-side capture shows the same request streaming the "no graph tools in this session… the harness is stripped to the two question tools" answer with `finish_reason: stop`. The claim is false; z-ai/glm-5.3-flash (relayed via OpenRouter upstreams Relace/OpenInference) confabulated a toolset, likely trusting prose descriptions over the actual `tools` array. +- Census of every "missing tool" claim across all master chat records (`data/chats/*.json`): + - `chat_mtxsy1lq` (09-12), `chat_mucnl5j3`/`chat_mucnyjvu` (09-22): honest — probe-phase (no graph tools yet) and/or research correctly absent (no search key → `searchAvailable` false). + - `chat_mu73galx` (09-18): the closed "no sub-agents" scare (probe-phase prober, teacher tools are teacher-only). + - `chat_mufpa030` (09-24): model accurately enumerated its full toolkit; quiz worked. + - **"Another session reportedly lacked quiz entirely": not found in any record** (regex census over all chats). +- Verdict: `tools.ts` gating and `chat-handler`'s ctx passing work as designed. No `src/agent` change required for this mystery (flagged for Teacher anyway since tools.ts is in that band). Mitigation is model-facing (stronger system-prompt tool enumeration, or dropping flash for tool-heavy phases) — an owner decision, not a seam fix. + +### 1.2 HISTORY-SERIALIZATION TRUNCATION — REFINED: upstream bug on multi-tool-result histories; our shape is not the trigger + +11 controlled replays today against `z-ai/glm-5.3-flash` via OpenRouter (all served by the **OpenInference** upstream; the original truncating request `/tmp/replay.json` = wiretap capture 2, the ML session): + +| variant | change vs. truncating request | prompt_tokens | outcome | +|---|---|---|---| +| base | verbatim | 7006 | **TRUNC** (4 chunks, ~22 chars reasoning, billed 258 completion, false `finish: stop`) | +| A | assistant content cleaned (no glued thinking), parallel calls kept | 6803 | **TRUNC** | +| B | parallel calls split into two sequential assistant turns | 7017 | **TRUNC** | +| G | `reasoning` param removed | 7006 | **TRUNC** | +| H | `max_completion_tokens` 131072→8192 | 7006 | **TRUNC** | +| D | both tool results shortened to ~30 chars | 5982 | works (11 tool calls) | +| P | two results at 100+100 chars | 6037 | works | +| L | two results at 300+300 chars | 6201 | **TRUNC** | +| K | two results at 700+700 chars | 6564 | **TRUNC** | +| J | big result (1564) + tiny (30) | 6658 | **TRUNC** | +| E | single result (1564) | 6626 | works | +| F | single result (802) | 6296 | works | +| C9 | capture 9 verbatim (the request that streamed the confabulation live) | 6146 | works (332 chars) | +| I | single result padded to 2764 chars | 7347 | works (4 tool calls) | + +Readings: +- **Not the glued-thinking content** (A truncates clean), **not parallelism** (B truncates sequential), **not prompt size** (I streams 7347 tokens; L truncates at 6201), **not the reasoning param or completion cap** (G, H). +- **Trigger: ≥2 tool-result messages with combined content ≥ ~200–600 chars** (P works at 200, L truncates at 600, J truncates with 1564+30). A single tool result of any tested size (≤2764 chars) streams fine. Direct Anthropic/OpenAI and non-flash GLM tolerate everything. +- The failure mode is consistent: a sliver of reasoning streams, 150–260 completion tokens are billed, `finish_reason: stop` arrives with ~zero content — a silent upstream truncation, stable and deterministic 4+ hours after the live capture. +- **Consequence for the plan:** pi-native serialization would NOT have prevented this — tool results are byte-identical on the wire either way. The live bug needs provider-side action (report to OpenRouter; or pin/avoid the flaky upstream for flash; short-term: route flash traffic elsewhere). The migration below is still correct, for the reasons in §1.3 — but it is not the truncation fix, and saying otherwise would be false comfort. + +### 1.3 What IS wrong with our serialization (the honest indictment of record-adapter) + +- `src/server/pi/record-adapter.ts:161-163` stamps every replayed assistant message `provider: "openrouter", model: "replayed"` → `transformMessages` (`pi-ai/dist/api/transform-messages.js:66`) sees `isSameModel === false` for **every** replayed turn, forever. +- Cross-model path converts thinking blocks to plain text (`transform-messages.js:78-81`), which `openai-completions.js` then glues into the assistant content string (no separator — capture 2's content is literally `"…question.There's already…"`). +- `thinkingSignature` cannot survive the UIMessage round-trip (records persist text only), so replayed turns never restore `reasoning_details`. pi's own sessions do: every assistant line in `~/.pi/agent/sessions/**/*.jsonl` carries full `provider/model/api/usage/stopReason` + `thinkingSignature`, which is why pi's replay stays same-model and signature-faithful. +- Net: every history turn we send diverges from what pi itself would send — discarded reasoning state, cache-hostile, and any provider quirk about reasoning-in-content becomes ours to keep debugging. + +--- + +## 2. Seam-by-seam map: custom remnant → pi-native replacement + +### Seam 1 — Message history persistence & replay +- **Now:** `LHChatRecord` stores Vercel-SDK-shaped UIMessages (part states `input-available`/`output-available`, `data-*` UI parts); `src/server/pi/record-adapter.ts` (`recordToPiMessages`, 189 lines) hand-rebuilds pi-ai `Message[]` per turn; marker-only user messages flatten to canonical text; dangling tool calls get synthesized `INTERRUPTED` results; chat-handler rebuilds history on every POST. +- **Native:** pi persists append-only JSONL records of full pi-ai messages (verified in pi's own session files); `Agent` accepts `initialState.messages` (`pi-agent-core/dist/agent.js:21-45`); custom message types ride `CustomAgentMessages` declaration-merging (`types.d.ts:269-283`) and are filtered from the LLM view by the default `convertToLlm` (`agent.js:8-10`) or mapped in a `transformContext` hook (`agent-loop.js:179-183`). +- **Replace with:** store pi `AgentMessage[]` natively in the chat record (custom messages for plan blocks/artifacts/verdict markers instead of `data-*` parts); replay = `initialState.messages` from the store; dangling-call synthesis and marker flattening move into one `convertToLlm`/`transformContext` hook — one place, tested once. +- **Delete:** `record-adapter.ts` wholesale; the UIMessage part-state model as the history format (UI gets a transcript view model instead). +- **Kills:** the `model: "replayed"` stamp, thinking-glue, lost signatures (§1.3) — replayed turns become same-model, signature-faithful, cache-friendly. + +### Seam 2 — Tool definition wrapper +- **Now:** `src/agent/lh-tool.ts` (244 lines): `LhTool {name, description, io: zod, execute(args, ctx), clientQuestion}`; `toPiTool` bridges via `z.toJSONSchema`, re-runs zod validation inside `execute` so `superRefine` failures throw `ToolInputError`; `TOOL_TIMEOUT_MS` 30s + 250ms abort grace; `clientQuestion` → `executionMode: "sequential"`. +- **Native:** `AgentTool` (`pi-agent-core/dist/types.d.ts:340`): required `label`, TypeBox `parameters`, optional `prepareArguments` shim (raw-arg rescue — replaces our re-validation hack), `execute(toolCallId, params, signal, onUpdate)`, per-tool `executionMode`, `replay` policy. pi's own tools are `ToolDefinition`s (`pi-coding-agent/dist/core/extensions/types.d.ts:344`: `promptSnippet`, `promptGuidelines`, `renderCall`, `renderResult`) wrapped by `wrapToolDefinition` (`core/tools/tool-definition-wrapper.js`; reference impl `core/tools/read.js`: TypeBox `Type.Object` schema + snippet/guidelines). +- **Replace with:** TypeBox-schema ToolDefinitions; `prepareArguments` for arg rescue; timeout stays inside `execute` (it's ours to keep, but as execute internals, not a wrapper layer). +- **Delete:** `LhTool`, `toPiTool`, the zod→JSON bridge, the `clientQuestion` flag (`executionMode: "sequential"` already expresses it). +- **Teacher flag:** `src/agent` band. + +### Seam 3 — Tool registration gates +- **Now:** `src/agent/tools.ts` (244 lines): prober/teacher registries, `filterEnabledTools` (drops `enabled[name] === false`), `probe_done` re-added always-on, `research` gated on `searchAvailable`, graph tools gated on `ctx.graphStore`, quiz schema differs per phase. +- **Native:** registration is plain array assembly (`wrapToolDefinitions`); pi has no gating concept because gating is app policy. +- **Verdict:** the gating logic itself is app policy, works (§1.1 proved no stripping), and stays — but it assembles ToolDefinitions from seam 2 instead of LhTools. `filterEnabledTools` + toggles map unchanged onto the new shape. +- **Teacher flag:** `src/agent` band. + +### Seam 4 — Question tools & turn stopping +- **Now:** `src/agent/teacher-agent.ts` `makeStopper` ends the run on a cleanly-resolved question tool call (maxTurns 20 default); question tools `execute` returns a synthetic `'Question posed'` placeholder never persisted; the client renders the question and the learner's answer arrives as the next user message. +- **Native:** `AgentToolResult.terminate: true` — `agent-loop.js:385` `shouldTerminateToolBatch` stops the loop after a batch whose results all terminate; `types.d.ts:237/330` document the flag; `shouldStopAfterTurn` (`agent-loop.js:154`) covers cross-batch policy. +- **Replace with:** quiz/ask execute → `{ content, terminate: true }`. Delete `makeStopper`. The pending-question UX (client rendering, answer flow) is app-level and stays as-is. +- **This is the cleanest example of the whole wave:** our stopper re-implements a library flag. + +### Seam 5 — Agent construction & steering +- **Now:** stateless rebuild per POST (`teacher-agent.ts` — fresh Agent per turn, `composeInstructions` + `effectivePromptSegments` per turn, maxTurns); steering via `claimSteers` coalescing + the wake pipeline's deferred turn. +- **Native:** stateful `Agent` (`agent.js`): `initialState`, `subscribe` listeners awaited in settlement, `steer()`/`followUp()` queues (`one-at-a-time`/`all` — `steer()` is literally our wake-message semantics), `sessionId` forwarded for cache-aware providers, `thinkingBudgets`, per-agent `toolExecution` default. +- **Replace with (staged):** keep stateless rebuild first, but feed `initialState.messages` from the native store (seam 1) — same wire, smaller step. A stateful per-chat Agent registry (mirroring `turn-runs.ts`) with `steer()` for the wake path is a later, optional consolidation. +- **Keep:** `composeInstructions`/prompt overrides (app feature), write-ahead persistence, turn gates. +- **Teacher flag:** `src/agent` band. + +### Seam 6 — SSE / streaming bridge +- **Now:** `src/server/pi/ui-stream.ts` (423 lines): `runPiTurn` runs the loop, keeps a memory-only chunk log + subscriber set (background-turn wave 00a92e4), and hand-maps pi `AgentEvent`s into the Vercel `UIMessageStream` chunk dialect (text/thinking/toolcall mapping, question-tool output suppression, input gating); `[DONE]` terminal. +- **Native:** pi events are already a typed stream (`AgentEvent`: `agent_start`/`message_update`/`turn_start`/`turn_end`/`agent_end`, carrying partial+final messages; `pi-ai` `AssistantMessageEventStream` underneath); pi's own client binds via transcript snapshots + event subscriptions (`pi-coding-agent/dist/client/remote-session.js`). +- **Replace with:** thin passthrough — serialize pi `AgentEvent`s (or message_update partials) directly instead of remapping into UIMessage chunks; keep our subscriber-log/reattach/abort machinery (that's the 00a92e4 background-turn layer and it's good); the question-tool suppression hacks die naturally because native events carry tool-call state honestly. +- **Cost:** wire dialect changes → **UI stream coordination required** (client half dispatch after merge, same as previous waves). +- **Delete:** the event-mapping switch in `ui-stream.ts` (the log/subscriber/abort halves stay). + +### Already native (the template) +- `getApiKey` (fixed 76051d0): the deleted hook is exactly this wave's pattern — custom seam removed, pi-ai's native key resolution used. Key-presence gating stays app-side at the turn gate. + +--- + +## 3. What genuinely needs custom (no native equivalent — keep, but express natively) + +1. **Question tools awaiting human answers** — closest native is `terminate: true` + app-level answer flow (the "pause the world for a human" semantics is ours). +2. **Tool toggles / phases / graphTools gating** — app policy at registration time (seam 3). +3. **Jobs + extraction pipeline** — outside the agent loop entirely; untouched by this wave. +4. **Prompt override switches** (`composeInstructions`/`effectivePromptSegments`) — app feature; stays. +5. **Write-ahead persistence, background turns, reattach subscribers, remote abort** — our infrastructure from waves 00a92e4/4d694b3; stays. + +--- + +## 4. Risk order + +1. **Wave A — tools + stopping (seams 2+3+4):** smallest blast radius; deletes lh-tool + makeStopper; gates unchanged; UI unaffected (tool names/wire identical). +2. **Wave B — native history store + replay (seam 1):** biggest win (wire fidelity, signatures, cache, kills §1.3), biggest risk (record migration for existing chats). Needs a one-time record migration + golden-master diff (§5) before cutover. +3. **Wave C — stateful agents (seam 5, optional):** only after B settles. +4. **Wave D — SSE dialect (seam 6):** wire contract change; dispatch UI half after merge. +5. **Independent, immediate:** the §1.2 truncation is an upstream bug — report to OpenRouter / route-pin away from the flaky upstream for `glm-5.3-flash`. Not gated on any wave. + +--- + +## 5. Test strategy + +- **Wire-shape pin (kills §1.3 forever):** replay a stored same-model session through `convertMessages` and assert the assistant history carries `reasoning_details`/`thinkingSignature` preserved (fails today on `model: "replayed"`). +- **Terminate pins:** question tool result `terminate: true` ends the loop natively (replaces `makeStopper` pins; mirror the existing chat-flow scripted-teacher tests). +- **Registry census pin:** faux-provider wiretap-style test asserting the full registry (11 tools incl. graph tools) is present on the wire for both phases regardless of toggles-off state — pins §1.1 shut. +- **Golden-master for Wave B:** for every stored chat, diff old adapter output vs native replay output message-by-message before cutover; migrate one throwaway copy of master `data/chats` first (established one-way copy rule). +- **Per-wave gates unchanged:** typecheck / 950+ tests / lint / build; live smoke on PORT=3004 (faux + real provider), reattach and abort paths re-verified after Wave D. + +--- + +## 6. Open owner decisions + +1. Flash-model policy for tool-heavy phases (confabulation §1.1 + truncation §1.2 both bite `z-ai/glm-5.3-flash` via OpenRouter): route-pin, provider-pin, or accept + prompt-mitigate. +2. Whether to report the OpenInference multi-tool-result truncation to OpenRouter (I can package the 15 replay files as a repro bundle). +3. Wave B record migration: in-place upgrade vs new-record-format-version with lazy dual-read (settings-delta-wave precedent). +4. Wave D timing (UI stream coordination). + +--- + +# Wave B planning — native history store + replay (2026-09-26 recon, post-Wave-A) + +Wave A landed (93b59cd): tools/stopping are pi-native. Wave B = seam 1 — the +record becomes pi-native history; `record-adapter.ts` dies. The owner's +standing rulings already settle the plan's §6.3 (record migration): **zero +backwards compat, all session data wiped, no migration** — so the golden-master +cutover is reduced to a fresh-format smoke, not a data project. + +## What Wave B replaces today (the seam, precisely) + +- `src/server/pi/record-adapter.ts` (189 lines): record→pi-ai rebuild per turn; + the `model: "replayed"` stamp (breaks `isSameModel` forever — §1.3), thinking + glued into content, `thinkingSignature` lost (records persist text only), + verdict/handoff marker flattening (`systemTextOf`), dangling-call + INTERRUPTED synthesis. +- `chat-handler`: write-ahead persist of the CLIENT's full UIMessage list (the + client owns the transcript between turns — useChat resend + addToolOutput), + `recordToPiMessages(outgoing)` at agent build, probe_done detection over + UIMessage parts. +- `ui-stream.runPiTurn`: assembles UIMessage parts from pi events; that + assembly is BOTH the wire dialect (Wave D's concern) AND the persist target. +- `turn-service`: `withMessageIds` (SDK client-side-id patch), plan-block + attachment onto UIMessage parts. + +## Consumer census (who reads record.messages as UIMessage part shapes) + +Server/core: phases (`phaseAt3` — tool-probe_done part state), pending +(`findPendingToolCall`), plan-blocks (`withPlanBlocks`), coverage +(`coverageOf`), graph extraction sources (`toolParts`), extraction +(`messageViewLines`), store summaries (phase + pendingQuestion), +`/api/chats/[id]` GET, jobs.ts (steers as data-part messages; `claimSteers` +over the POST body). Client: `useChat` POSTs the full list, renders every +part type, `mergeAdopted`, addToolOutput answers, artifact/plan/abort cards. +**None of these need the STORAGE to be UIMessage — they need a UIMessage-SHAPED +VIEW.** That distinction is Wave B's core move. + +## The design: server-owned native record + one-way projection + +1. **`LHChatRecord.messages: AgentMessage[]`** (pi-ai `UserMessage | + AssistantMessage | ToolResultMessage | CustomMessage`). The agent's own + final messages persist at `message_end` — provider/model/usage/stopReason/ + thinkingSignature stamped by the loop itself (what pi persists in its own + sessions). §1.3 dies by construction: replayed turns are byte-faithful, + same-model, signature-preserving; replay = `state.messages` assignment, + zero conversion. +2. **Custom messages for our non-LLM content** (pi's `CustomMessage`: role + "custom", customType, content, display, details): plan-block, + subagent-artifact, subagent-aborted, question-answered markers. The + verdict/probe-handoff steers persist AS custom messages whose CONTENT is + the canonical model-facing text (verdictText / HANDOFF_TEXT written once + at append) — `systemTextOf` flattening disappears; `convertToLlm` default + passes content through, UI-only types filter via details/customType. +3. **One-way projection `AgentMessage[] → UIMessage[]`** (new + `src/server/pi/project.ts`): serves `/api/chats/[id]` GET, the summary + scans (phase/pending/coverage/plan-blocks/graph/extraction keep their + existing UIMessage code — they read the projection), and the client until + Wave D. The lossy DIRECTION (UIMessage→pi) is deleted; the projection is + Wave D's deletion target. +4. **Question answers are native tool results (owner ruling, 2026-09-26).** + The history stays a NORMAL agent history: quiz toolCall followed by its + toolResult — the process just stops in between. The placeholder result + is an in-memory loop device only (terminate:true ends the turn); it is + never persisted, exactly as today's wire suppression. On answer, the + server appends a native ToolResultMessage whose content is the same + encoding the adapter produces today (the model-facing wire is + byte-shape-identical to current behavior). The record carries the + dangling call only in the pose→answer window, where no turn can run + (the question gate holds steers), and the INTERRUPTED synthesis survives + solely for the abort case — same as today. pi's own resume path is the + mechanism: assign state.messages (record ending at the toolResult), + continue(). +5. **Turn input contract**: the POST body's new-content DELTA (new user text, + answers, markers) is appended natively; the full-list body stays for now + (client unchanged — see fork 3). + +## Forks — ALL RESOLVED (owner rulings, 2026-09-26) + +1. **Answer semantics — normal agent history.** The answer is the tool + call's toolResult, appended natively at answer time; the placeholder is + an in-memory loop device only (terminate), never persisted. No custom + answer markers. Verified against pi's source: the loop always pairs + calls with results in a RUN, but the record may end at the assistant + message (no turn can run in the pose→answer window — the question gate + holds steers); pi's own resume path (assign state.messages, continue() + from the toolResult) is the mechanism. The model-facing wire is + byte-shape-identical to today. +2. **Storage shape — `AgentMessage[]` inside LHChatRecord JSON.** No new + dependencies; metadata stays in the one atomic tmp+rename file. (pi + SessionManager JSONL rejected: pi-coding-agent is a 139MB harness dep + and its tree/branching/compaction machinery is unused here.) +3. **POST contract — input events NOW, in this pass** (owner overrode the + server-side-delta recommendation): POST /api/chat carries + `{chatId, input}` where input is one of: + `{type:"text", text, note?}` (composer send; note rides the pending + verdict interplay), + `{type:"answer", toolCallId, answer:{values,other,text,dontKnow,note}, + dismissed?}` (quiz/ask answer or dismissal — graded client-side as + today, the structured verdict becomes the native toolResult content), + `{type:"verdict", accepted, note?}` (plan verdict). + Consequences: the client's 409 merge-retry DIES (nothing to merge — a + 409 means wait-and-retry the same idempotent event); claimSteers/ + write-ahead of full lists dies; the wake pipeline's internal turns + (setTurnRunner) and jobs steers append natively server-side; useChat + stays as the RENDERER (message list = projected record + stream + assembly — receive side unchanged until Wave D); sends go through a + thin client session-client: POST the event, read the status, cancel + the 200 body (a view may detach freely — the turn runs on), then + attach via the standard resume path. Client touches: chat-transport + send half, chat.tsx call sites (composer, onSubmitAnswer, verdict, + dismiss). Receive half (SSE dialect, part rendering) stays Wave D. +4. **Raw vs validated tool args — resolved by design**: persist pi's own + AssistantMessage verbatim (what message_end carries). That means RAW + streamed args — byte-faithful replay, identical to what pi persists in + its own sessions, cache-parity with the original emission. The dock's + parse seam already tolerates raw (Wave A boundaryParse fix); no other + record consumer reads tool-call args. +5. **Abort semantics — folded into 1**: persist partial (current + behavior); the record ends at the dangling call for the abort case and + the INTERRUPTED synthesis at replay covers it (same as today). + +## Test strategy + +- Wire-shape pin (§5's oldest promise, now trivial): persist a scripted + session's agent messages, rebuild the agent, `convertMessages` → assert + `provider`/`model` real (not "replayed"), `thinkingSignature` present. +- Projection parity: every existing UIMessage consumer test (988 suite) runs + against the projection unchanged — the suite IS the golden master. +- Question flow: pose → answer-as-marker → next turn's model view carries the + verdict; abort mid-question → resume works (kills the synthesis tests). +- Live smoke: real turn + reattach + remote abort on PORT=3004. + +## Blast radius (files) + +Delete: record-adapter.ts; chat-transport's retryingFetch/merge logic. +New: project.ts (AgentMessage[]→UIMessage projection), input-events +contract (core/contracts), client session-client send module. +Rewrite: chat-handler (build = state.messages from record; input-event +append; probe_done detection over native messages), ui-stream (part +assembly becomes WIRE-ONLY; the record write comes from the agent's own +messages at message_end), turn-service (native appends replace +withMessageIds/withPlanBlocks part mutation; plan-blocks/artifact steers +become CustomMessages), store/chats + core scanners (phaseAt3, +findPendingToolCall, coverage, graph sources — read the projection, +mechanical), jobs.ts (steers append native CustomMessages; setTurnRunner +internal path), client send call sites (composer, onSubmitAnswer, verdict, +dismissal — chat-transport send half). useChat's RECEIVE half and all part +renderers untouched (Wave D). Agent band untouched (Wave A's). Wave C +(steer() consolidation) stays deferred. diff --git a/app/api/chats/[id]/route.ts b/app/api/chats/[id]/route.ts index b885575..f215832 100644 --- a/app/api/chats/[id]/route.ts +++ b/app/api/chats/[id]/route.ts @@ -1,4 +1,5 @@ import { deleteChatSession, getChat, isTurnRunning, sessionCoverage, stores, type ChatDeleteMode } from "@/src/server"; +import { projectNative } from "@/src/core"; export async function GET(_req: Request, { params }: { params: Promise<{ id: string }> }) { const { id } = await params; @@ -10,7 +11,16 @@ export async function GET(_req: Request, { params }: { params: Promise<{ id: str // meter + sidebar block — THE coverage surface (the all-sessions GET // route died with the redesign; the Extract POST stays). getChat // already 404'd, so the block exists. - return Response.json({ ...chat, turnInFlight: isTurnRunning(id), coverage: sessionCoverage(id) }); + // Wave B (owner-approved 2026-09-26): the record's messages are native + // agent history — clients receive the one-way projection (the AI-SDK + // UIMessage view) until Wave D. turnInFlight lets a returning client + // re-attach to a running turn; coverage is the chat view's ring meter. + return Response.json({ + ...chat, + messages: projectNative(chat.messages), + turnInFlight: isTurnRunning(id), + coverage: sessionCoverage(id), + }); } /** diff --git a/app/chat/[id]/page.tsx b/app/chat/[id]/page.tsx index 5ee1d70..7a9d13e 100644 --- a/app/chat/[id]/page.tsx +++ b/app/chat/[id]/page.tsx @@ -1,6 +1,6 @@ import { notFound } from "next/navigation"; import { Chat } from "@/components/chat"; -import { slotsConfigured, type TeacherUIMessage } from "@/src/core"; +import { projectNative, slotsConfigured, type TeacherUIMessage } from "@/src/core"; import { getChat, isTurnRunning, readSettingsRecord } from "@/src/server"; export const dynamic = "force-dynamic"; @@ -24,7 +24,9 @@ export default async function ChatPage({ key={chat.id} chatId={chat.id} title={chat.title} - initialMessages={chat.messages as TeacherUIMessage[]} + // Wave B: the record is native history — the client view is the + // one-way projection (GET /api/chats/[id] serves the same shape). + initialMessages={projectNative(chat.messages) as TeacherUIMessage[]} initialConfigured={slotsConfigured(record?.models)} initialPrompt={prompt} // A teacher turn that outlived its client: the remounted view must diff --git a/components/chat-transport.ts b/components/chat-transport.ts index e1e2581..f8e7caf 100644 --- a/components/chat-transport.ts +++ b/components/chat-transport.ts @@ -1,28 +1,31 @@ /** - * Chat transport with 409 merge-retry — the learner's send can race the - * server's wake pipeline (contracts/jobs.ts "THE ASYNC PROTOCOL"): the wake - * turn starts server-side and the client only notices via the /api/jobs poll - * up to a second later. A send in that window gets 409'd ("a turn is already - * running") and, before this file, surfaced as an error banner. + * Chat transport for the INPUT-EVENTS contract (Wave B, owner ruling + * 2026-09-26): the client never re-POSTs its transcript — every send + * carries ONE explicit input event ({text} | {answer} | {verdict} | + * {continue}) stashed by the call site (chat.tsx) before invoking the SDK + * send. The server owns the record; this transport's ONLY send-time job is + * to consume the stash and put it on the wire as {chatId, input}. * - * Why NOT a bare retry: every 409 means the server record is AHEAD of the - * client — the wake turn persisted the steer message (and, once it finishes, - * its response) that the client hasn't seen. The chat handler's write-ahead - * persist makes the request body the canonical transcript, so re-POSTing the - * stale client list would clobber the steer + wake messages out of the - * record (and the job already flipped done — steerDelivered makes - * re-delivery impossible: permanent loss). The retry must therefore MERGE: - * wait for the in-flight turn to finish, refetch the record, append the - * client's unsent messages (by id) to the server's list, and re-POST the - * merged transcript. + * 409 retry (the wake-pipeline race, contracts/jobs.ts "THE ASYNC + * PROTOCOL"): a send can race the server's wake turn (the client only + * notices it via the /api/jobs poll up to a second later) and gets + * 409'd. Under input events the retry is IDENTITY: the event was never + * persisted (the write-ahead only happens once the turn is accepted), so + * waiting for the in-flight turn and re-POSTing the SAME body is safe — + * no merge, no stale-list clobber. The old UIMessage-era merge logic is + * deleted with the UIMessage record (the server now owns the + * record; a lost race means "try again later", not "merge transcripts"). * - * Client-side gap after a merged retry: useChat's local list still lacks the - * steer/wake messages until the next syncFromServer (job settle, remount, or - * a future "server-ahead" heal in the poller). Rare path; self-heals. + * The send path is LOUD about a missing stash: every SDK send must map to + * exactly one event — a send with nothing stashed is a call-site bug + * (it would otherwise silently re-send the previous event), so the + * transport throws instead of guessing. */ + import { DefaultChatTransport } from "ai"; import type { UIMessageChunk } from "ai"; import type { ChatTransport } from "ai"; +import type { ChatInput } from "@/src/core"; import type { TeacherUIMessage } from "@/src/core"; /** How often to poll turnInFlight while the racing turn runs. */ @@ -56,21 +59,10 @@ async function turnInFlight(chatId: string): Promise { } } -async function serverMessages(chatId: string): Promise { - try { - const res = await fetch(`/api/chats/${encodeURIComponent(chatId)}`); - if (!res.ok) return null; - const record = (await res.json()) as { messages?: TeacherUIMessage[] }; - return Array.isArray(record.messages) ? record.messages : null; - } catch { - return null; - } -} - /** - * A fetch that turns a 409 on POST /api/chat into: wait for the running turn, - * merge the outgoing messages onto the fresh server record, re-POST. Non-409 - * responses pass through untouched. + * A fetch that turns a 409 on POST /api/chat into: wait for the running + * turn, re-POST the SAME event body. Non-409 responses pass through + * untouched. */ /** Exported for tests. */ export function retryingFetch(chatId: string, opts: RetryFetchOptions = {}): typeof globalThis.fetch { @@ -90,36 +82,15 @@ export function retryingFetch(chatId: string, opts: RetryFetchOptions = {}): typ if (init?.signal?.aborted) return response; if ((await turnInFlight(chatId)) !== false) continue; - // Turn finished: merge onto the fresh record, then re-POST. - const record = await serverMessages(chatId); - if (!record || typeof init?.body !== "string") return response; - const serverIds = new Set(record.map((m) => m.id)); - const body = JSON.parse(init.body) as { messages?: TeacherUIMessage[] }; - if (!Array.isArray(body.messages)) return response; - const outgoing = body.messages.filter((m) => m.id && !serverIds.has(m.id)); - const merged = JSON.stringify({ ...body, messages: [...record, ...outgoing] }); - - response = await fetch(input, { ...init, body: merged }); // may 409 again → loop + // Turn finished: re-POST the same input event — nothing merged, + // nothing lost (the event was never persisted pre-acceptance). + response = await fetch(input, init); // may 409 again → loop } return response; }; } -/** - * The plain transport (no reattach adapters): sends keep the 409 - * merge-retry. chat.tsx uses createReattachingTransport below — a - * superset of this — but the plain form stays exported for surfaces - * that only ever send (never resume onto a running turn). - */ -export function createChatTransport(chatId: string): DefaultChatTransport { - return new DefaultChatTransport({ - api: "/api/chat", - body: { chatId }, - fetch: retryingFetch(chatId), - }); -} - /* ──────────────────────────────────────────────────────────────────────── * THE REATTACH TRANSPORT (background-turn runner wave, owner ruling * 2026-09-25): a turn runs server-side decoupled from every view, and a @@ -166,6 +137,14 @@ export interface ReattachOptions { lastMessageId?: () => string | undefined; } +/** The input-events half (Wave B): consume the call site's stashed event. + * Exactly one event per SDK send — the ref is cleared on read, so a + * send with nothing stashed fails LOUDLY rather than re-sending the + * previous event. */ +export interface InputEventOptions { + takeInput: () => ChatInput | undefined; +} + /** Concatenate byte payloads (the head stream's event + read-past remainder). */ function concatBytes(a: Uint8Array, b: Uint8Array): Uint8Array { const out = new Uint8Array(a.length + b.length); @@ -179,7 +158,7 @@ function freshMessageId(): string { return `msg_${Date.now().toString(36)}_${crypto.randomUUID().slice(0, 8)}`; } -/** One parsed SSE event off the wire (data: \\n\\n framing). */ +/** One parsed SSE event off the wire (data: \n\n framing). */ interface SseEvent { /** The raw bytes of this event, re-injectable into the head stream. */ raw: Uint8Array; @@ -193,7 +172,7 @@ interface SseEvent { /** * Read the FIRST complete SSE event off a live body (the settled terminal * and the start chunk are both single small events; a long-lived turn's - * log replays after). Resolves as soon as one \\n\\n boundary is seen — the + * log replays after). Resolves as soon as one \n\n boundary is seen — the * caller re-injects the raw bytes (plus any read-past remainder) ahead of * the unconsumed tail, so nothing is lost or reordered. */ @@ -217,16 +196,17 @@ async function readFirstEvent(reader: ReadableStreamDefaultReader): } } -/** The reattach transport: DefaultChatTransport for sends, our seam for resumes. */ +/** The reattach transport: the input-event POST for sends, our seam for resumes. */ class ReattachingChatTransport extends DefaultChatTransport { constructor( private readonly reattach: ReattachOptions, + private readonly inputEvent: InputEventOptions, init: ConstructorParameters>[0], ) { super(init); } - override async reconnectToStream( + override async reconnectToStream( options: Parameters["reconnectToStream"]>[0], ): Promise | null> { const res = await fetch(`/api/chats/${encodeURIComponent(options.chatId)}/stream`, { @@ -300,15 +280,31 @@ class ReattachingChatTransport extends DefaultChatTransport { /** * The chat.tsx wiring: `transport: createReattachingTransport(chatId, refs)`. - * Sends keep the 409 merge-retry fetch; resumes take the adapters above. + * Sends POST the stashed input event with the 409 identity-retry fetch; + * resumes take the adapters above. */ export function createReattachingTransport( chatId: string, reattach: ReattachOptions, + inputEvent: InputEventOptions, ): DefaultChatTransport { - return new ReattachingChatTransport(reattach, { + return new ReattachingChatTransport(reattach, inputEvent, { api: "/api/chat", body: { chatId }, fetch: retryingFetch(chatId), + // THE INPUT-EVENTS CONTRACT (Wave B): replace the SDK's full-transcript + // body with {chatId, input}. The stash is consumed HERE — exactly once + // per SDK send (the 409 retry re-POSTs the built body, not the stash); + // a send with nothing stashed fails LOUDLY (a call-site bug — it must + // never silently re-send the previous event). + prepareSendMessagesRequest: () => { + const input = inputEvent.takeInput(); + if (!input) { + throw new Error( + "Send without a stashed input event — every send call site must set pendingInput before invoking the SDK send (Wave B input-events contract).", + ); + } + return { body: { chatId, input } }; + }, }); -} \ No newline at end of file +} diff --git a/components/chat.tsx b/components/chat.tsx index 7413958..5ea3890 100644 --- a/components/chat.tsx +++ b/components/chat.tsx @@ -26,7 +26,7 @@ import { DictationBar, useDictation } from "@/components/dictation"; import { JobsContext } from "@/components/jobs-context"; import { StatsBar } from "@/components/stats-bar"; import { useActivity } from "@/components/activity-dock"; -import { isProviderKeyRefusal, mergeAdopted, type ChatSettings, type ModelSlotName } from "@/src/core"; +import { isProviderKeyRefusal, mergeAdopted, type ChatInput, type ChatSettings, type ModelSlotName } from "@/src/core"; import { PlanBlock, PlanPopup, latestTree } from "@/components/plan-block"; import { PlanMarginalia } from "@/components/plan-window"; import { MermaidOverlay, SessionMermaidContext } from "@/components/session-overlay"; @@ -181,15 +181,35 @@ export function Chat({ // the per-render transport recreation can never capture a stale one. const reattachSettledRef = useRef<() => void>(() => {}); const reattachLastIdRef = useRef(undefined); + // THE INPUT STASH (Wave B input-events contract, owner ruling + // 2026-09-26): every SDK send maps to EXACTLY ONE explicit input event — + // the call site stashes the event here before invoking the SDK send, and + // the transport consumes it when building the POST body ({chatId, input}; + // the client transcript never rides the wire). Consumed-on-read: a send + // with nothing stashed fails LOUDLY in the transport (a call-site bug — + // it must never silently re-send the previous event). + const pendingInputRef = useRef(null); + const stashInput = (input: ChatInput) => { + pendingInputRef.current = input; + }; + const takeInput = () => { + const input = pendingInputRef.current; + pendingInputRef.current = null; + return input ?? undefined; + }; const { messages, sendMessage, addToolOutput, setMessages, status, error, stop, clearError, resumeStream } = useChat({ id: chatId, messages: initialMessages, - transport: createReattachingTransport(chatId, { - onSettled: () => reattachSettledRef.current(), - lastMessageId: () => reattachLastIdRef.current, - }), + transport: createReattachingTransport( + chatId, + { + onSettled: () => reattachSettledRef.current(), + lastMessageId: () => reattachLastIdRef.current, + }, + { takeInput }, + ), // After the learner answers (addToolOutput), automatically send the // continuation request so the teacher reacts to the answer. sendAutomaticallyWhen: lastAssistantMessageIsCompleteWithToolCalls, @@ -200,6 +220,7 @@ export function Chat({ useEffect(() => { if (!sentInitial.current && initialPrompt && initialMessages.length === 0) { sentInitial.current = true; + stashInput({ type: "text", text: initialPrompt }); void sendMessage({ text: initialPrompt }); } }, [initialPrompt, initialMessages.length, sendMessage]); @@ -343,6 +364,7 @@ export function Chat({ messages.length > 0 && lastAssistantMessageIsCompleteWithToolCalls({ messages }) ) { + stashInput({ type: "continue" }); void sendMessage(); } // mount-only by design (turnInFlight read at initial value) @@ -492,6 +514,7 @@ export function Chat({ status === "ready" && lastAssistantMessageIsCompleteWithToolCalls({ messages: record.messages }) ) { + stashInput({ type: "continue" }); void sendMessage(); } }, [chatId, setMessages, sendMessage, status]); @@ -612,8 +635,8 @@ export function Chat({ // TWO REAL MESSAGES, ONE TURN (the custom-message scheme, user-decided // 2026-09-12): a typed note is the learner's own user message, and the // verdict is its own custom message — marker part ONLY, no human- - // authored text (the model-facing wording lives in the adapter: - // record-adapter flattens it; nothing fake is persisted as speech). + // authored text (the model-facing wording lives in toLlmMessages, + // the native convertToLlm seam; nothing fake is persisted as speech). // setMessages + sendMessage() (no args) fires ONE request carrying // both — the same single-turn mechanism syncFromServer already uses, // so no 409 dance and no double teacher turn. @@ -639,6 +662,10 @@ export function Chat({ ], }); setMessages([...messages, ...appended]); + // The event (Wave B): the server appends the note (its own user + // message) + the verdict marker natively — the optimistic ids above + // are local render only; the settle sync lands the canon. + stashInput({ type: "verdict", accepted, note: note || undefined }); void sendMessage(); }, [text, messages, setMessages, sendMessage], @@ -964,6 +991,7 @@ export function Chat({ if (!t || busy || pending) return; draftRef.current = t; // the refusal flow's preserve-and-replay seam setText(""); + stashInput({ type: "text", text: t }); void sendMessage({ text: t }); }; @@ -972,7 +1000,10 @@ export function Chat({ value={{ chatId, syncFromServer, - sendText: (t: string) => void sendMessage({ text: t }), + sendText: (t: string) => { + stashInput({ type: "text", text: t }); + void sendMessage({ text: t }); + }, openSetup: () => setSetupOpen(true), }} > @@ -1318,6 +1349,7 @@ export function Chat({ if (t) { setText(""); draftRef.current = t; + stashInput({ type: "text", text: t }); void sendMessage({ text: t }); } }} diff --git a/src/agent/subagents/extraction.ts b/src/agent/subagents/extraction.ts index af4c2b3..13a9e0f 100644 --- a/src/agent/subagents/extraction.ts +++ b/src/agent/subagents/extraction.ts @@ -28,6 +28,7 @@ import { getGraph, mergeFindings, type EmissionStats, type GraphDeps } from "../ import type { GraphStore } from "../../core/ports.ts"; import { settleGraphLayout } from "../../core/layout-settle.ts"; import { batchRuns, batchTag, coverageOf, messageViewLines } from "../../core/coverage.ts"; +import { projectNative } from "../../core/projection.ts"; /** * The extraction pipeline (vision §1 construction, 2026-09-02 redesign) — the @@ -92,10 +93,12 @@ import { batchRuns, batchTag, coverageOf, messageViewLines } from "../../core/co export function transcriptView(record: ChatRecord): string { // The shared seam (core/coverage.ts messageViewLines): the LLM's prompt // view and the coverage fingerprint use ONE line builder, so "covered" - // means — exactly — what the extractor saw. + // means — exactly — what the extractor saw. Wave B: the record is native + // history — the extraction view reads through the one-way projection. const lines = [`SESSION: ${record.title}`]; - record.messages.forEach((message, index) => { - lines.push(...messageViewLines(message as UIMessage, (message as UIMessage).id || `m${index}`)); + const projected = projectNative(record.messages); + projected.forEach((message, index) => { + lines.push(...messageViewLines(message, message.id || `m${index}`)); }); return lines.join("\n"); } @@ -868,7 +871,8 @@ export function createExtractionPipeline(deps: ExtractionPipelineDeps): Extracti // engines AND their providers' keys; a refusal lands BEFORE any // stage runs (typed — the trigger skips, routes surface 503). refuseIfStagesUnready(); - const covered = coverageOf(record.messages as UIMessage[], graphStore.readCoverage(), chatId); + const projected = projectNative(record.messages); + const covered = coverageOf(projected, graphStore.readCoverage(), chatId); if (covered.uncovered.length === 0) { console.info(`[extraction ${chatId}] nothing uncovered — no-op`); return { nodes: 0, edges: 0, events: 0 }; @@ -883,11 +887,11 @@ export function createExtractionPipeline(deps: ExtractionPipelineDeps): Extracti // The batch's prompt view: SESSION header + the batch's lines, // same provenance [id] tags the fingerprints used (coverage // and prompt share ONE line builder — core/coverage.ts). - const mids = record.messages.map((m, i) => ((m as UIMessage).id || `m${i}`) as string); + const mids = projected.map((m, i) => (m.id || `m${i}`) as string); const view = [ `SESSION: ${record.title}`, ...batch.flatMap((slot) => { - const idx = record.messages.indexOf(slot.message); + const idx = projected.indexOf(slot.message); return messageViewLines(slot.message, mids[idx] ?? slot.message.id); }), ].join("\n"); diff --git a/src/agent/subagents/runner.ts b/src/agent/subagents/runner.ts index ca636f9..7077952 100644 --- a/src/agent/subagents/runner.ts +++ b/src/agent/subagents/runner.ts @@ -1,4 +1,4 @@ -import { isToolUIPart, type UIMessage } from "ai"; +import { isToolUIPart } from "ai"; import type { PiEngine } from "../models.ts"; import type { SearchProvider } from "../../core/search.ts"; import { @@ -14,7 +14,7 @@ import { type PdfToolInput, type ResearchResult, type ResearchToolInput, - type SubagentSteerMessage, + type SteerMessage, type UsageStepLike, type VisualResult, type VisualToolInput, @@ -23,6 +23,8 @@ import { registerJobAbort, unregisterJobAbort } from "./aborts.ts"; import type { ModelRole } from "../../../config.ts"; import { ROLE_SLOT, modelsNotConfigured, type ModelSlotName, type ProviderKeyMissing } from "../../core/settings.ts"; import type { ChatStore, JobStore } from "../../core/ports.ts"; +import { projectNative } from "../../core/projection.ts"; +import type { UIMessage } from "ai"; import { runDemoBuilder } from "./demo-builder.ts"; import { runPdfReaderAgent } from "./pdf-reader.ts"; import { runResearcherAgent } from "./researcher.ts"; @@ -69,7 +71,7 @@ export interface JobRunnerDeps { * steer is delivered (the wake turn completed and the job status * flipped); rejects when delivery fails unrecoverably. */ - deliver: (steer: SubagentSteerMessage) => Promise; + deliver: (steer: SteerMessage) => Promise; /** Settings-resolved engine per role (model slots wave, 2026-09-20): * the role-override → slot chain, read FRESH per job (the server * wiring passes effectiveSettings-based resolution). Test overrides @@ -274,7 +276,11 @@ export function createJobRunner(deps: JobRunnerDeps): JobRunner { if (input) return input; const chat = chatStore.getChat(job.chatId); if (!chat) return undefined; - return (findToolPart(chat.messages, job.toolCallId)?.input ?? undefined) as JobToolInput | undefined; + // Wave B: the record is native history — the transcript scan reads + // through the projection (same shape the old record held). + return (findToolPart(projectNative(chat.messages), job.toolCallId)?.input ?? undefined) as + | JobToolInput + | undefined; } // ─── shared plumbing ──────────────────────────────────────────────────── diff --git a/src/agent/teacher-agent.ts b/src/agent/teacher-agent.ts index c9be2be..9dc78d3 100644 --- a/src/agent/teacher-agent.ts +++ b/src/agent/teacher-agent.ts @@ -15,6 +15,7 @@ import { } from "./tools.ts"; import { quizInputObject, quizInputProblems } from "../core/question-core.ts"; import { QUESTION_TOOLS, type EnabledTools } from "../core/pending.ts"; +import { toLlmMessages } from "../core/native-messages.ts"; /** * The two teaching-side agents (docs/probe-teach-split.md): the PROBER and @@ -89,7 +90,7 @@ export type StepUsageCollector = (usage: Usage) => void; export interface AgentBuildOptions { /** Model + stream function — tests inject a scripted faux engine. */ engine?: PiEngine; - /** The initial transcript, as pi messages (recordToPiMessages). */ + /** The initial transcript, as pi messages (the record IS agent state). */ messages?: AgentMessage[]; /** Money channel: one call per model request. */ onUsage?: StepUsageCollector; @@ -164,6 +165,13 @@ function baseAgentOptions(kind: AgentKind, opts: AgentBuildOptions, tools: Agent }, streamFn: engine.streamFn, shouldStopAfterTurn: stopper, + // Wave B (owner-approved 2026-09-26): the model-facing conversion is + // THE app seam — markers flatten to their canonical user text + // (verdictText / HANDOFF_TEXT / steer text, all single-sourced), + // UI-only markers (plan-block) drop, the app's id fields strip, and + // an abort's dangling question call gets its INTERRUPTED result. It + // must never throw (pi's convertToLlm contract). + convertToLlm: toLlmMessages, }); if (opts.onUsage) { agent.subscribe((event: AgentEvent) => { diff --git a/src/agent/tools/probe.ts b/src/agent/tools/probe.ts index 6d241c6..9638b1d 100644 --- a/src/agent/tools/probe.ts +++ b/src/agent/tools/probe.ts @@ -1,10 +1,9 @@ import type { AgentTool } from "@earendil-works/pi-agent-core"; import { serverExecute } from "../tool-execute.ts"; import { probeDoneInputSchema, type ProbeDoneResult } from "../../core/contracts.ts"; -import type { UIMessage } from "ai"; -import { findPendingToolCall } from "../../core/pending.ts"; +import { danglingQuestionOf, hasProbeDone, type LhMessage } from "../../core/native-messages.ts"; import type { ChatStore } from "../../core/ports.ts"; -import { buildProbeHandoffSteer, type ProbeHandoffSteerMessage } from "../../core/contracts.ts"; +import { buildProbeHandoffSteer, type SteerMessage } from "../../core/contracts.ts"; /** What the probe_done tool closes over. */ export interface ProbeDoneContext { @@ -13,15 +12,14 @@ export interface ProbeDoneContext { chatStore: ChatStore; /** Queue the handoff steer (the wake pipeline — src/server/jobs.ts). * Injected so this module never imports the server seam. */ - deliver: (steer: ProbeHandoffSteerMessage) => void; + deliver: (steer: SteerMessage) => void; } -/** Does the transcript already carry a concluded probe (any state)? */ -function probeConcluded(messages: UIMessage[]): boolean { - for (const m of messages) { - if (m.parts.some((p) => p.type === "tool-probe_done")) return true; - } - return false; +/** Does the transcript already carry a concluded probe (any state)? + * Wave B: the native scan (any probe_done tool call, any state) — same + * semantics the UIMessage part scan had. */ +function probeConcluded(messages: readonly LhMessage[]): boolean { + return hasProbeDone(messages); } /** @@ -64,7 +62,7 @@ export function probeDoneTool(ctx: ProbeDoneContext): Record.json. The stored messages are full - * AI SDK UIMessages (parts included) — the canonical session record: a - * pending quiz is a tool part in `input-available` state, which is exactly - * what makes resume-mid-question free. */ +/** One persisted session: data/chats/.json. Wave B (owner-approved + * 2026-09-26): the stored messages are pi-NATIVE history (LhMessage — the + * agent loop's own shapes, provider/model/usage/thinkingSignature stamped + * by pi) — the canonical session record IS the agent state; replay is + * `state.messages = record.messages` + continue(). The client-shaped view + * is the one-way projection (core/projection.ts) — a pending quiz is an + * unanswered question tool call, which is exactly what makes + * resume-mid-question free. */ export interface ChatRecord { id: string; title: string; createdAt: string; updatedAt: string; - messages: UIMessage[]; + messages: LhMessage[]; /** The session's plan tree (vision §2) — written by plan tools via saveTree. */ tree?: PlanTree; /** Append-only usage log (observability, 2026-09): one entry per model @@ -152,8 +157,8 @@ export interface TurnUsage { * both verdicts are real transcript events: accept sanctions teaching, * decline asks for revision. The part is the message TYPE (the "custom * message" scheme, 2026-09-12): the message carries no human-authored text, - * the model-facing wording is owned by the adapter (record-adapter.ts - * flattens it to VERDICT_TEXT below), and the UI renders the verdict + * the model-facing wording is owned by toLlmMessages (native-messages.ts + * flattens it to verdictText below), and the UI renders the verdict * marker — never a user bubble. A learner who also typed a note sends the * note as its own real user message first; the verdict message follows. */ export interface PlanVerdictData { diff --git a/src/core/contracts/jobs.ts b/src/core/contracts/jobs.ts index 1d424ae..9158b0d 100644 --- a/src/core/contracts/jobs.ts +++ b/src/core/contracts/jobs.ts @@ -6,8 +6,9 @@ * master. Pure types + protocol docs + tiny pure helpers; type-only imports * of "ai" (safe everywhere: server, client, tests). See docs/vision.md. */ -import type { UIMessage } from "ai"; import type { CallUsage } from "./chat.ts"; +import type { LhMessage, LhMarkerMessage } from "../native-messages.ts"; +import { newHandoffMessage } from "../native-messages.ts"; // ─── Async sub-agent jobs (vision §2.5; plan.md Wave 0 protocol) ──────────── @@ -141,7 +142,7 @@ export function accumulateJobUsage( * * 3. On completion the runner builds a STEER MESSAGE (buildSubagentSteer) * and hands it to the wake pipeline (src/server/jobs.ts): a user-role - * UIMessage carrying the result — a text part with what the MODEL needs + * result-carrying native marker — the content is what the MODEL needs * (the research brief, the builder's notes), plus a data-subagent-artifact * part with what the UI renders (the full result: brief + sources / * SVG / demo HTML). Provider conversion drops data parts, so artifact @@ -245,6 +246,14 @@ export interface PdfResult { } // ─── Sub-agent steer messages (THE ASYNC PROTOCOL §3–4) ───────────────────── +// +// Wave B (owner-approved 2026-09-26): steers are NATIVE record messages — +// LhMarkerMessage customs (role "custom"), the custom-message taxonomy's +// wake members. The model-facing text rides `content` (single-sourced +// below); the UI payload (the artifact, the abort badge) rides `details` +// and never reaches the model (toLlmMessages maps the marker to a user +// message carrying `content` only). The deterministic id (`steer_`) +// IS the idempotency key — steerDelivered matches on it. /** Message metadata marking a sub-agent steer (contracts.ts §3). */ export interface SubagentResultMeta { @@ -264,40 +273,14 @@ export interface SubagentArtifactData { result: ResearchResult | DemoResult | VisualResult | PdfResult; } -/** - * The steer message: user-role on the wire (UIMessage roles are fixed), but - * NEVER a learner interjection — metadata.subagentResult is the marker the - * UI branches on, and the artifact rides the data part (stripped by provider - * conversion, so the model sees only the text part). - */ -export interface SubagentSteerMessage extends UIMessage { - role: "user"; - metadata: { subagentResult: SubagentResultMeta }; - parts: [ - { type: "text"; text: string }, - { type: "data-subagent-artifact"; data: SubagentArtifactData }, - ]; +/** A result steer's marker details: the UI's meta + artifact in one + * payload (the projection splits it into metadata + data part). */ +export interface SteerDetails extends SubagentResultMeta { + result: ResearchResult | DemoResult | VisualResult | PdfResult; } -/** The abort steer: user-role like any steer, but NO artifact part — - * there is no result to render, only the teacher-facing text. Shares the - * result steer's deterministic id space (`steer_${jobId}`): steerDelivered - * matches on metadata jobId, so an abort and a (racing) result steer - * can never BOTH deliver — exactly one wakes the teacher. */ -export interface AbortSteerMessage extends UIMessage { - role: "user"; - metadata: { subagentResult: SubagentResultMeta & { aborted: true } }; - parts: [ - { type: "text"; text: string }, - { type: "data-subagent-aborted"; data: SubagentResultMeta & { aborted: true } }, - ]; -} - -/** Anything the wake pipeline may queue and deliver. */ -export type SteerMessage = - | SubagentSteerMessage - | AbortSteerMessage - | ProbeHandoffSteerMessage; +/** Anything the wake pipeline may queue and deliver — a native marker. */ +export type SteerMessage = LhMarkerMessage; /** Whether a result is the failure shape (html/svg empty, failure brief). */ export function isSubagentFailure( @@ -354,35 +337,38 @@ function steerText(kind: JobKind, result: ResearchResult | DemoResult | VisualRe export function buildSubagentSteer( job: Pick, result: ResearchResult | DemoResult | VisualResult | PdfResult, -): SubagentSteerMessage { +): SteerMessage { return { id: `steer_${job.id}`, - role: "user", - metadata: { subagentResult: { jobId: job.id, kind: job.kind, toolCallId: job.toolCallId } }, - parts: [ - { type: "text", text: steerText(job.kind, result) }, - { type: "data-subagent-artifact", data: { kind: job.kind, result } }, - ], + role: "custom", + customType: "subagent-steer", + content: steerText(job.kind, result), + display: true, + details: { + jobId: job.id, + kind: job.kind, + toolCallId: job.toolCallId, + result, + }, + timestamp: Date.now(), }; } /** Build the abort steer for an aborted job (pure — shared by the * abort path in src/server/jobs.ts and tests). Same id space as the result - * steer; see AbortSteerMessage. */ + * steer (same id space — exactly one delivers). */ export function buildAbortSteer( job: Pick, -): AbortSteerMessage { +): SteerMessage { const meta = { jobId: job.id, kind: job.kind, toolCallId: job.toolCallId, aborted: true } as const; return { id: `steer_${job.id}`, - role: "user", - metadata: { subagentResult: meta }, - parts: [ - { type: "text", text: abortText(job.kind) }, - // The message-type marker (the custom-message scheme): the UI - // renders the abort badge off this part, not off metadata. - { type: "data-subagent-aborted", data: meta }, - ], + role: "custom", + customType: "subagent-abort", + content: abortText(job.kind), + display: true, + details: meta, + timestamp: Date.now(), }; } @@ -403,34 +389,19 @@ function abortText(kind: JobKind): string { // probe_done's execute queues this through the same wake pipeline; the // pump fires it once the prober's turn ends (gates + grace). It carries no // artifact — the assessment it references is durable in the transcript as -// the probe_done tool part. Deterministic id per chat: exactly one -// handoff, idempotent re-delivery. +// the probe_done tool result. Deterministic id per chat: exactly one +// handoff, idempotent re-delivery. The marker's content is HANDOFF_TEXT +// (core/contracts/chat.ts — single source); the UI renders the quiet +// system line off the customType. Nothing human-looking is persisted, ever. -export interface ProbeHandoffSteerMessage extends UIMessage { - id: `steer_probe_${string}`; - role: "user"; - metadata: { probeHandoff: { chatId: string } }; - /** The custom-message scheme (2026-09-12): NO text part — the wake is - * scaffolding, not speech. The model-facing instruction is owned by the - * adapter (record-adapter.ts flattens this part to HANDOFF_TEXT in - * core/contracts/chat.ts); the UI renders the quiet system line off the - * part. Nothing human-looking is persisted, ever. */ - parts: [{ type: "data-probe-handoff"; data: { chatId: string } }]; -} - -export function buildProbeHandoffSteer(chatId: string): ProbeHandoffSteerMessage { - return { - id: `steer_probe_${chatId}`, - role: "user", - metadata: { probeHandoff: { chatId } }, - parts: [{ type: "data-probe-handoff", data: { chatId } }], - }; +export function buildProbeHandoffSteer(chatId: string): SteerMessage { + return newHandoffMessage(chatId); } /** True when the transcript already carries this steer — idempotent * delivery for ANY steer kind: every steer id is deterministic per its * event (steer_ for jobs, steer_probe_ for the handoff). */ -export function steerDelivered(messages: UIMessage[], steerId: string): boolean { +export function steerDelivered(messages: LhMessage[], steerId: string): boolean { return messages.some((m) => m.id === steerId); } diff --git a/src/core/index.ts b/src/core/index.ts index f5c0d34..0f7317b 100644 --- a/src/core/index.ts +++ b/src/core/index.ts @@ -32,6 +32,12 @@ export * from "./pending.ts"; export * from "./prompt-segments.ts"; export * from "./math-delims.ts"; export * from "./ui-message.ts"; +// Wave B (owner-approved 2026-09-26): the native record (LhMessage), the +// one-way client-view projection, and the input-events contract — all +// pure logic, browser-safe. +export * from "./native-messages.ts"; +export * from "./projection.ts"; +export * from "./input-events.ts"; export * from "./tool-parts.ts"; export * from "./prices.ts"; export * from "./cost-summary.ts"; diff --git a/src/core/input-events.ts b/src/core/input-events.ts new file mode 100644 index 0000000..4ff0619 --- /dev/null +++ b/src/core/input-events.ts @@ -0,0 +1,121 @@ +/** + * THE INPUT-EVENTS CONTRACT (Wave B, owner ruling 2026-09-26: "input + * events now, in the same pass"): POST /api/chat carries `{chatId, input}` + * — ONE explicit event, never a client transcript. The server owns the + * record; a send tells it what HAPPENED, not what the client's list looks + * like. The 409 merge-retry died with the full-list body (an input event + * retries idempotently: nothing was appended when a POST is refused). + * + * The four events (the complete client-originated input taxonomy): + * text — a learner-authored user message (composer send, initial + * topic, the demo error-fix loop) + * answer — the pending question's graded result (QuestionAnswerData, + * built client-side exactly as today; a dismissal is an answer + * carrying its flag). Server-side this becomes the call's + * native tool result — fork 1's normal agent history. + * verdict — the plan verdict (accept/decline); an optional note is the + * learner's own real user message, sent first, exactly the + * two-real-messages scheme. + * continue — run the next turn on the current record (mount + * continuations, job-settle re-syncs: the record ends at an + * answered question with no teacher reaction yet). + * + * Server-originated delivery (the wake pipeline's steers) is NOT a client + * input — the handler accepts it on the same path (TurnInput) so every + * turn rides one composition. + */ + +import type { QuestionAnswerData } from "./question-core.ts"; +import type { LhMessage } from "./native-messages.ts"; +import { + danglingQuestionOf, + newAnswerResult, + newUserMessage, + newVerdictMessage, +} from "./native-messages.ts"; +import { QUESTION_TOOLS } from "./pending.ts"; + +/** One client-originated input event (the POST body's `input`). */ +export type ChatInput = + | { type: "text"; text: string } + | { type: "answer"; toolCallId: string; answer: QuestionAnswerData } + | { type: "verdict"; accepted: boolean; note?: string } + | { type: "continue" }; + +/** A wake-pipeline steer delivery — same turn composition, server-origin. */ +export type SteerInput = { type: "steer"; steer: LhMessage }; + +/** Every input the turn composition accepts. */ +export type TurnInput = ChatInput | SteerInput; + +/** A malformed input event (the honest 400 — the orphan-trap discipline). */ +export class ChatInputError extends Error {} + +/** Parse and validate an input event off a request body. */ +export function parseChatInput(input: unknown): ChatInput { + if (typeof input !== "object" || input === null) throw new ChatInputError("input must be an object"); + const i = input as Record; + switch (i.type) { + case "text": + if (typeof i.text !== "string" || (i.text as string).trim().length === 0) { + throw new ChatInputError("text input requires non-empty text"); + } + return { type: "text", text: i.text }; + case "answer": + if (typeof i.toolCallId !== "string" || i.toolCallId.length === 0) { + throw new ChatInputError("answer input requires toolCallId"); + } + if (typeof i.answer !== "object" || i.answer === null) { + throw new ChatInputError("answer input requires the graded answer object"); + } + return { type: "answer", toolCallId: i.toolCallId, answer: i.answer as QuestionAnswerData }; + case "verdict": + if (typeof i.accepted !== "boolean") throw new ChatInputError("verdict input requires accepted"); + if (i.note !== undefined && typeof i.note !== "string") { + throw new ChatInputError("verdict note must be a string"); + } + return { type: "verdict", accepted: i.accepted, note: i.note as string | undefined }; + case "continue": + return { type: "continue" }; + default: + throw new ChatInputError(`unknown input type: ${String(i.type)}`); + } +} + +/** + * The native appends an input event makes to the record (pure). The caller + * persists them write-ahead; the agent's context is the record plus these. + * + * Answer validation is the orphan-400 discipline: the call must be a + * DANGLING QUESTION call in the record (posed, never answered) — anything + * else 400s honestly instead of silently interrupting a question or + * double-answering. + */ +export function inputAppends(record: { messages: LhMessage[] }, input: ChatInput): LhMessage[] { + switch (input.type) { + case "text": + return [newUserMessage(input.text)]; + case "answer": { + const pending = danglingQuestionOf(record.messages); + if (!pending || pending.toolCallId !== input.toolCallId) { + throw new ChatInputError("answer input targets no pending question call"); + } + return [newAnswerResult(input.toolCallId, pending.tool, input.answer)]; + } + case "verdict": { + const appends: LhMessage[] = []; + if (input.note && input.note.trim().length > 0) { + appends.push(newUserMessage(input.note)); + } + appends.push(newVerdictMessage(input.accepted, new Date().toISOString())); + return appends; + } + case "continue": + return []; + } +} + +/** Is this tool name a question tool (dock-owned, answer-gated)? */ +export function isQuestionTool(name: string): boolean { + return (QUESTION_TOOLS as readonly string[]).includes(name); +} diff --git a/src/core/native-messages.ts b/src/core/native-messages.ts new file mode 100644 index 0000000..5e8dc86 --- /dev/null +++ b/src/core/native-messages.ts @@ -0,0 +1,284 @@ +/** + * THE NATIVE MESSAGE RECORD (Wave B, owner-approved 2026-09-26): chat + * records store pi's own message shapes — the exact history the agent + * loop builds — instead of AI-SDK UIMessages. Provider, model, usage, + * stopReason, and thinking signatures are stamped by pi itself; replay is + * `state.messages = record.messages` + `continue()` — the record IS the + * agent state. + * + * WHY pi MESSAGES LACK OUR `id`: pi's session format gives every ENTRY an + * id, never the message. This app needs a per-message id for client + * identity (React keys, append-only adoption, the continuation seam), so + * every record message carries ONE extra field — `id` — alongside the + * native shape. `toLlmMessages` strips it at the wire: the provider never + * sees it (verified single site, below). + * + * UI-ONLY CONTENT (owner ruling, fork 1 + the plan's custom-message + * taxonomy): verdict markers, the probe→teach handoff, sub-agent steers, + * and plan blocks are LhMarkerMessage — this app's declaration-merged + * member of pi-agent-core's CustomAgentMessages extension point, shaped + * exactly like pi's own CustomMessage ({role:"custom", customType, + * content, display, details}). ONE seam — toLlmMessages, wired as the + * agent's convertToLlm — owns every model-facing decision: markers + * flatten to their canonical user text (single-sourced in + * core/contracts), plan blocks are UI-only and filtered, and dangling + * tool calls (an aborted turn) get the interrupted-result synthesis the + * provider protocol demands. + * + * Answers (fork 1, owner 2026-09-26): the learner's answer IS the tool + * call's ToolResultMessage — appended natively at answer time. The + * synthetic "question posed" placeholder is an in-memory loop device + * (Wave A's terminate) and is never persisted: the record ends at the + * unanswered call in the pose→answer window, where no turn can run (the + * question gate holds steers; a POST would 409). + */ + +import type { AgentMessage } from "@earendil-works/pi-agent-core"; +import type { AssistantMessage, Message, ToolResultMessage, UserMessage } from "@earendil-works/pi-ai"; +import { QUESTION_TOOLS } from "./pending.ts"; +import { HANDOFF_TEXT, verdictText } from "./contracts/chat.ts"; + +/** The hard-invariant wording for an aborted turn's unanswered calls — + * carried over verbatim from the pre-native era so the model's view + * never drifted. */ +export const INTERRUPTED_TEXT = + "The question was interrupted — the learner never answered; continue without holding a result for it."; + +// ─── The record's message type ─────────────────────────────────────────────── + +/** + * The app's custom message (declaration-merged into pi-agent-core's + * CustomAgentMessages below). Shaped like pi's own CustomMessage — + * `content` is the MODEL-facing text (single-sourced in core/contracts, + * never fake human speech), `details` is the UI payload (never + * model-visible), `display` marks messages the UI renders (our projection + * keys rendering on customType; the flag documents intent). + */ +export interface LhMarkerMessage { + /** Record identity (pi messages carry no id — see the file header). */ + id: string; + role: "custom"; + customType: "verdict" | "probe-handoff" | "subagent-steer" | "subagent-abort" | "plan-block"; + /** The model-facing text ("" for plan blocks — UI-only). */ + content: string; + display: boolean; + /** The UI payload — per-customType, never model-visible. */ + details: unknown; + timestamp: number; +} + +declare module "@earendil-works/pi-agent-core" { + interface CustomAgentMessages { + lhMarker: LhMarkerMessage; + } +} + +/** A record message: a native pi message (or our marker) plus the record id. */ +export type LhMessage = (UserMessage | AssistantMessage | ToolResultMessage | LhMarkerMessage) & { id: string }; + +/** A record message list is assignable to the agent's context — that IS + * the replay (ui-stream/chat-handler assign it to initialState.messages). */ +export function asAgentMessages(messages: readonly LhMessage[]): AgentMessage[] { + return messages as unknown as AgentMessage[]; +} + +// ─── id minting ────────────────────────────────────────────────────────────── + +/** The app-wide message id shape (same as the UIMessage-era mint). */ +export function newMessageId(): string { + return `msg_${Date.now().toString(36)}_${crypto.randomUUID().slice(0, 8)}`; +} + +// ─── constructors (the input-event → native append mapping lives in +// core/input-events.ts; steers in core/contracts/jobs.ts) ───────────────── + +/** A learner-authored user message (text input, verdict note). */ +export function newUserMessage(text: string): LhMessage & UserMessage { + return { + id: newMessageId(), + role: "user", + content: text, + timestamp: Date.now(), + }; +} + +/** The learner's answer — the question call's tool result (fork 1). + * `answer` is the structured QuestionAnswerData the client graded; the + * MODEL sees its JSON encoding (the pre-native wire shape), + * the UI sees the structured payload via `details`. */ +export function newAnswerResult( + toolCallId: string, + toolName: string, + answer: unknown, +): LhMessage & ToolResultMessage { + return { + id: newMessageId(), + role: "toolResult", + toolCallId, + toolName, + content: [{ type: "text", text: JSON.stringify(answer) }], + details: answer, + isError: false, + timestamp: Date.now(), + }; +} + +/** The plan verdict (the plan→teach seam): marker only — the model-facing + * wording is owned by verdictText (core/contracts/chat.ts), the UI + * renders the quiet accept/decline line off details. */ +export function newVerdictMessage(accepted: boolean, at: string): LhMarkerMessage { + return { + id: newMessageId(), + role: "custom", + customType: "verdict", + content: verdictText(accepted), + display: true, + details: { accepted, at }, + timestamp: Date.now(), + }; +} + +/** The probe→teach handoff wake: scaffolding, not speech — the model reads + * HANDOFF_TEXT, the UI renders the quiet system line. */ +export function newHandoffMessage(chatId: string): LhMarkerMessage { + return { + id: `steer_probe_${chatId}`, + role: "custom", + customType: "probe-handoff", + content: HANDOFF_TEXT, + display: true, + details: { chatId }, + timestamp: Date.now(), + }; +} + +/** The plan presentation block: pure UI history (a frozen snapshot), no + * model face — toLlmMessages filters it. `planCallId` keys idempotent + * emission (one block per successful plan_init call). */ +export function newPlanBlockMessage( + planCallId: string, + block: unknown, +): LhMarkerMessage { + return { + id: `pb_${Date.now().toString(36)}_${crypto.randomUUID().slice(0, 8)}`, + role: "custom", + customType: "plan-block", + content: "", + display: true, + details: { planCallId, block }, + timestamp: Date.now(), + }; +} + +// ─── the one model-facing seam (the agent's convertToLlm) ──────────────────── + +/** The ids of every toolResult in the tail (for the dangling-call scan). */ +function resultIdsFrom(messages: readonly AgentMessage[]): Set { + const ids = new Set(); + for (const m of messages) { + if ((m as { role?: string }).role === "toolResult") { + ids.add((m as ToolResultMessage).toolCallId); + } + } + return ids; +} + +/** + * AgentMessage[] → the provider wire (the agent's convertToLlm — required + * by pi-agent-core's loop config, the ONE place model-facing decisions + * live): + * + * - user / assistant / toolResult pass through VERBATIM — signatures, + * usage, provider stamps all native — minus our record-`id` field. + * - markers flatten to a user message carrying their content (the + * canonical text single-sourced in core/contracts); plan blocks are + * UI-only and filtered. + * - dangling tool calls (an aborted turn's unanswered questions) get the + * synthesized interrupted result — the provider protocol's hard + * invariant: a tool call must be followed by its result. + * + * Contract (pi-agent-core): never throws — an unknown shape passes + * through untouched rather than killing the loop. + */ +export function toLlmMessages(messages: readonly AgentMessage[]): Message[] { + const out: Message[] = []; + for (let i = 0; i < messages.length; i++) { + const m = messages[i]!; + const role = (m as { role?: string }).role; + if (role === "custom") { + const marker = m as LhMarkerMessage; + if (marker.customType === "plan-block") continue; // UI-only + out.push({ + role: "user", + content: [{ type: "text", text: marker.content }], + timestamp: marker.timestamp, + }); + continue; + } + // Strip the record id: the wire carries the native shape only. + const { id: _id, ...native } = m as LhMessage; + out.push(native as Message); + // Dangling-call synthesis: an assistant message whose toolCalls have + // no results in the remaining tail (abort mid-question) — the + // provider rejects unanswered calls outright. + if (role === "assistant") { + const assistant = m as AssistantMessage; + const calls = assistant.content.filter((b) => b.type === "toolCall"); + if (calls.length === 0) continue; + const answered = resultIdsFrom(messages.slice(i + 1)); + for (const call of calls) { + if (answered.has(call.id)) continue; + out.push({ + role: "toolResult", + toolCallId: call.id, + toolName: call.name, + content: [{ type: "text", text: INTERRUPTED_TEXT }], + isError: false, + timestamp: assistant.timestamp, + }); + } + } + } + return out; +} + +// ─── native scans (the UIMessage-era pending/phase seams, natively) ────────── + +/** The pending-question gate (findPendingToolCall's native twin): the + * NEWEST assistant message's FIRST question-tool call with no result in + * the tail — one question at a time, the dock's subject. */ +export function danglingQuestionOf( + messages: readonly LhMessage[], +): { toolCallId: string; tool: string; input: unknown } | null { + for (let mi = messages.length - 1; mi >= 0; mi--) { + const m = messages[mi]!; + if ((m as { role?: string }).role !== "assistant") continue; + const assistant = m as AssistantMessage & { id: string }; + const answered = resultIdsFrom(messages.slice(mi + 1)); + for (const block of assistant.content) { + if (block.type !== "toolCall") continue; + if (!(QUESTION_TOOLS as readonly string[]).includes(block.name)) continue; + if (answered.has(block.id)) continue; + return { toolCallId: block.id, tool: block.name, input: block.arguments }; + } + return null; // the newest assistant message's questions are all answered + } + return null; +} + +/** The probe→teach construction seam (chat-handler): a probe_done call in + * the transcript — any state — means the probe is concluded: teach. */ +export function hasProbeDone(messages: readonly LhMessage[]): boolean { + for (const m of messages) { + if ((m as { role?: string }).role !== "assistant") continue; + const assistant = m as AssistantMessage; + if (assistant.content.some((b) => b.type === "toolCall" && b.name === "probe_done")) return true; + } + return false; +} + +/** Every toolResult id in the record — answer-event validation (the call + * must be a DANGLING question call, not an already-answered one). */ +export function answeredCallIds(messages: readonly LhMessage[]): Set { + return resultIdsFrom(messages); +} diff --git a/src/core/plan-blocks.ts b/src/core/plan-blocks.ts index 32d60a4..788c92f 100644 --- a/src/core/plan-blocks.ts +++ b/src/core/plan-blocks.ts @@ -1,44 +1,38 @@ /** * Plan-block emission (docs/probe-teach-split.md §6): the INTERIM plan - * presentation seam. The plan renders IN FLOW once, at authoring — a full - * `data-plan-block` attached to the assistant message that carries the + * presentation seam. The plan renders IN FLOW once, at authoring — a + * frozen snapshot attached right after the assistant group carrying the * successful plan_init call. It is the presentation moment for the plan * (and the popup's entry point) — statuses on it are accurate at that - * instant (current = null, everything upcoming — unless the authoring is a - * mid-lesson rewrite, in which case the snapshot shows what was true then). + * instant (current = null, everything upcoming — unless the authoring is + * a mid-lesson rewrite, in which case the snapshot shows what was true + * then). * - * The neighborhood-block machinery (frozen fragments on plan movement) is - * RETIRED: position derives live from the transcript (derivePlanStatuses — - * calls, not results), and frozen snapshots can't represent live movement. - * The persistent DAG window (the UI wave) is the successor — always - * present, auto-moving with the lesson. - * - * Idempotent by construction: a message that already carries a plan-block - * part is never given another (persist replays / retry paths are safe). + * Wave B (owner-approved 2026-09-26): the block is a native plan-block + * MARKER appended at the turn's persist (idempotent per plan_init call); + * the projection splices its data part into the assistant group IN FLOW, + * exactly where the UIMessage-era attach put it. The neighborhood-block + * machinery stays retired: position derives live from the transcript. */ import type { PlanBlockData, PlanTree } from "./contracts.ts"; import { derivePlanStatuses } from "./contracts.ts"; import type { UIMessage } from "ai"; +import type { LhMessage } from "./native-messages.ts"; +import { newPlanBlockMessage } from "./native-messages.ts"; +import type { ToolResultMessage } from "@earendil-works/pi-ai"; /** The message part the chat renderer consumes (TeacherUIDataParts key - * "plan-block" — the SDK strips the `data-` prefix from the name). */ + * "plan-block" — the SDK strips the `data-` prefix). Kept for the client + * renderers; the projection emits this shape. */ export interface PlanBlockPart { type: "data-plan-block"; id: string; data: PlanBlockData; } -/** Does this message carry a successful plan_init (the trigger)? */ -function hasSuccessfulPlanInit(message: UIMessage): boolean { - for (const part of message.parts) { - if (part.type !== "tool-plan_init") continue; - const ok = (part as { output?: { ok?: unknown } }).output?.ok; - if (ok === true) return true; - } - return false; -} - +/** The snapshot payload (unchanged since wave 4): statuses derived AT + * EMISSION time from the transcript — the block never re-derives. */ function snapshot(tree: PlanTree, messages: readonly UIMessage[]): PlanBlockData { const statuses = derivePlanStatuses(tree, messages); return { @@ -54,36 +48,47 @@ function snapshot(tree: PlanTree, messages: readonly UIMessage[]): PlanBlockData }; } -/** - * Enrich a message list with plan-presentation blocks — THE emission - * seam's pure half. `messages` is the deduped list persistTurn is about to - * write; `tree` is the record's current tree (the plan tool saved it during - * the turn). Messages already carrying a block are never touched. - */ -export function withPlanBlocks(messages: UIMessage[], tree: PlanTree | null | undefined): UIMessage[] { - if (!tree || tree.nodes.length === 0 || messages.length === 0) return messages; - const blockId = `pb_${Date.now().toString(36)}_${crypto.randomUUID().slice(0, 8)}`; - return messages.map((m) => { - if (m.role !== "assistant" || m.parts.some((p) => p.type === "data-plan-block")) return m; - if (!hasSuccessfulPlanInit(m)) return m; - // POSITION, not append: emission runs at persistTurn — after the whole - // turn is composed — so appending would bury the block under every - // teaching note and quiz of the same turn. The block renders IN FLOW - // immediately after the plan_init call it presents (after the LAST - // one if a turn carried multiple — a rewrite). - const idx = lastIndexOfSuccessfulPlanInit(m.parts); - const parts = m.parts.slice(); - parts.splice(idx + 1, 0, { type: "data-plan-block", id: blockId, data: snapshot(tree, messages) } satisfies PlanBlockPart); - return { ...m, parts }; - }); +/** The record's plan-block markers, keyed by the plan_init call they + * present — the idempotency set for emission. */ +export function emittedPlanBlockCalls(messages: readonly LhMessage[]): Set { + const out = new Set(); + for (const m of messages) { + if ((m as { role?: string }).role !== "custom") continue; + const marker = m as { customType?: string; details?: { planCallId?: unknown } }; + if (marker.customType === "plan-block" && typeof marker.details?.planCallId === "string") { + out.add(marker.details.planCallId); + } + } + return out; } -/** Index of the last tool-plan_init part with an ok output in this message. */ -function lastIndexOfSuccessfulPlanInit(parts: ReadonlyArray<{ type: string }>): number { - let idx = -1; - parts.forEach((p, i) => { - if (p.type !== "tool-plan_init") return; - if ((p as { output?: { ok?: unknown } }).output?.ok === true) idx = i; - }); - return idx; +/** + * The native emission seam (pure): ONE plan-block marker per TURN — for + * the turn's LAST successful plan_init call (a same-turn rewrite presents + * once; a later turn's rewrite gets its own block — the golden-master + * rule the UIMessage-era attach had) that the record hasn't already + * emitted. `tree` is the record's current tree (the plan tool saved it + * during the turn); `transcript` is the full PROJECTED transcript the + * status derivation reads. + */ +export function planBlocksFor( + record: readonly LhMessage[], + newMessages: readonly LhMessage[], + tree: PlanTree | null | undefined, + transcript: readonly UIMessage[], +): LhMessage[] { + if (!tree || tree.nodes.length === 0) return []; + const emitted = emittedPlanBlockCalls(record); + let last: { toolCallId: string } | null = null; + for (const m of newMessages) { + if ((m as { role?: string }).role !== "toolResult") continue; + const result = m as ToolResultMessage & { id: string }; + if (result.toolName !== "plan_init") continue; + const ok = (result.details as { ok?: unknown } | undefined)?.ok; + if (ok !== true) continue; + if (emitted.has(result.toolCallId)) continue; + last = { toolCallId: result.toolCallId }; + } + if (!last) return []; + return [newPlanBlockMessage(last.toolCallId, snapshot(tree, transcript))]; } diff --git a/src/core/ports.ts b/src/core/ports.ts index 722d4ba..a4932ac 100644 --- a/src/core/ports.ts +++ b/src/core/ports.ts @@ -9,7 +9,7 @@ * adapters may import core; core NEVER imports an adapter. */ -import type { UIMessage } from "ai"; +import type { LhMessage } from "./native-messages.ts"; import type { ChatRecord, ChatSettings, @@ -39,8 +39,9 @@ export interface ChatStore { getChat(id: string): ChatRecord | null; /** All sessions, newest activity first (the sidebar's list view). */ listChats(): ChatSummary[]; - /** Persist the full message list (client state + new response) after a turn. */ - saveMessages(id: string, messages: UIMessage[]): void; + /** Persist the full native message list (the record IS the agent + * state — Wave B, owner-approved 2026-09-26). */ + saveMessages(id: string, messages: LhMessage[]): void; /** Persist the session plan tree (plan tools call this in execute). */ saveTree(id: string, tree: PlanTree): void; /** Append one usage entry to the record's turn log (the money channel, diff --git a/src/core/projection.ts b/src/core/projection.ts new file mode 100644 index 0000000..ebbb9cb --- /dev/null +++ b/src/core/projection.ts @@ -0,0 +1,199 @@ +/** + * THE ONE-WAY PROJECTION (Wave B, owner-approved 2026-09-26): native record + * messages → AI-SDK UIMessages. The record is pi-native history; every + * existing consumer — GET /api/chats/[id], the client's renderer, phase/ + * pending/coverage/extraction scanners — reads THIS projection until Wave + * D replaces the client dialect. One direction only: nothing ever converts + * UIMessages back into native messages (the lossy record-adapter direction + * is deleted wholesale). + * + * GROUPING RULE (the golden master): a turn's UIMessage shape is one + * assistant message per GROUP — consecutive assistant + toolResult + * messages, bounded by user/marker messages; toolResults attach to the + * tool part of their call (searching backward — an answered quiz's result + * lands on the part that asked it, even turns later); a group's id is its + * FIRST assistant message's id. This reproduces the client's live view + * exactly: the continuation merge (start-chunk id = the group's id) and + * the settled record converge to the same shape, so the settle sync never + * re-renders what streamed. + * + * Plan blocks (plan-blocks.ts's splice, moved here): a plan-block marker + * has no message of its own — its data part splices into the preceding + * assistant group, IN FLOW right after the last successful plan_init call + * (the presentation moment). + */ + +import type { UIMessage } from "ai"; +import type { AssistantMessage, ToolResultMessage } from "@earendil-works/pi-ai"; +import type { LhMarkerMessage, LhMessage } from "./native-messages.ts"; + +import type { SubagentArtifactData, SubagentResultMeta } from "./contracts/jobs.ts"; +import type { PlanBlockData, PlanVerdictData } from "./contracts/chat.ts"; + +/** Loose part shapes the projection emits (the client's part shapes). */ +type Part = Record & { type: string }; +type LooseMessage = { id: string; role: "user" | "assistant"; parts: Part[]; metadata?: unknown }; + +function textPart(text: string): Part { + return { type: "text", text, state: "done" }; +} + +/** The result a tool part should show, from a native toolResult. */ +function applyResult(part: Part, result: ToolResultMessage): void { + if (result.isError) { + part.state = "output-error"; + part.rawInput = part.input; + const text = result.content.find((c) => c.type === "text"); + part.errorText = text && text.type === "text" ? text.text : "The tool call failed."; + } else { + part.state = "output-available"; + part.output = result.details; + } +} + +/** Does this assistant group carry a successful plan_init (the splice target)? */ +function lastSuccessfulPlanInit(parts: Part[]): number { + let idx = -1; + parts.forEach((p, i) => { + if (p.type !== "tool-plan_init") return; + const ok = (p.output as { ok?: unknown } | undefined)?.ok; + if (ok === true) idx = i; + }); + return idx; +} + +/** Project the native record into client-shaped UIMessages. Pure and + * deterministic: the same record always projects to the same list. */ +export function projectNative(messages: readonly LhMessage[]): UIMessage[] { + const out: LooseMessage[] = []; + + const lastAssistant = (): LooseMessage | undefined => { + const last = out[out.length - 1]; + return last && last.role === "assistant" ? last : undefined; + }; + const findToolPart = (toolCallId: string): Part | undefined => { + for (let i = out.length - 1; i >= 0; i--) { + for (const part of out[i]!.parts) { + if (part.type.startsWith("tool-") && part.toolCallId === toolCallId) return part; + } + } + return undefined; + }; + + for (const m of messages) { + const role = (m as { role?: string }).role; + + if (role === "user") { + const content = m.content as string | Array<{ type: string; text?: string }>; + const texts = typeof content === "string" ? [content] : content.filter((c) => c.type === "text").map((c) => (c as { text: string }).text); + out.push({ id: m.id, role: "user", parts: texts.map(textPart) }); + continue; + } + + if (role === "custom") { + const marker = m as LhMarkerMessage; + projectMarker(marker, out); + continue; + } + + if (role === "toolResult") { + const result = m as ToolResultMessage; + const part = findToolPart(result.toolCallId); + if (part) applyResult(part, result); + continue; + } + + if (role === "assistant") { + const assistant = m as AssistantMessage; + const parts: Part[] = []; + let textN = 0; + let reasoningN = 0; + for (const block of assistant.content) { + if (block.type === "text") { + parts.push({ type: "text", id: `${m.id}-t${textN++}`, text: block.text, state: "done" }); + } else if (block.type === "thinking") { + parts.push({ type: "reasoning", id: `${m.id}-r${reasoningN++}`, text: block.thinking, state: "done" }); + } else if (block.type === "toolCall") { + parts.push({ + type: `tool-${block.name}`, + toolCallId: block.id, + state: "input-available", + input: block.arguments, + }); + } + } + if (parts.length === 0) continue; // presentation-only (nothing renderable) + const group = lastAssistant(); + if (group) { + // Same group: the step separator renders between the steps. + group.parts.push({ type: "step-start" }, ...parts); + } else { + out.push({ id: m.id, role: "assistant", parts: [{ type: "step-start" }, ...parts] }); + } + continue; + } + } + + return out as unknown as UIMessage[]; +} + +/** A marker's projected message (verdict/handoff/steers) or splice (plan block). */ +function projectMarker(marker: LhMarkerMessage, out: LooseMessage[]): void { + switch (marker.customType) { + case "verdict": { + const data = marker.details as PlanVerdictData; + out.push({ + id: marker.id, + role: "user", + parts: [{ type: "data-verdict", id: `vd_${marker.id}`, data }], + }); + return; + } + case "probe-handoff": { + out.push({ + id: marker.id, + role: "user", + parts: [{ type: "data-probe-handoff", id: `ph_${marker.id}`, data: marker.details }], + }); + return; + } + case "subagent-steer": { + const meta = marker.details as SubagentResultMeta; + const artifact = marker.details as { kind: SubagentArtifactData["kind"]; result: SubagentArtifactData["result"] }; + out.push({ + id: marker.id, + role: "user", + metadata: { subagentResult: meta }, + parts: [ + textPart(marker.content), + { type: "data-subagent-artifact", id: `sa_${marker.id}`, data: { kind: artifact.kind, result: artifact.result } }, + ], + }); + return; + } + case "subagent-abort": { + const meta = marker.details as SubagentResultMeta & { aborted: true }; + out.push({ + id: marker.id, + role: "user", + metadata: { subagentResult: meta }, + parts: [ + textPart(marker.content), + { type: "data-subagent-aborted", id: `sa_${marker.id}`, data: meta }, + ], + }); + return; + } + case "plan-block": { + // No message of its own: splice into the preceding assistant + // group, IN FLOW after the last successful plan_init call. + const group = out[out.length - 1]; + if (!group || group.role !== "assistant") return; + const idx = lastSuccessfulPlanInit(group.parts); + if (idx === -1) return; + const { block } = marker.details as { planCallId: string; block: PlanBlockData }; + group.parts.splice(idx + 1, 0, { type: "data-plan-block", id: marker.id, data: block }); + return; + } + } +} diff --git a/src/server/chat-handler.ts b/src/server/chat-handler.ts index f6c6c81..7d6bc35 100644 --- a/src/server/chat-handler.ts +++ b/src/server/chat-handler.ts @@ -1,4 +1,3 @@ -import type { UIMessage } from "ai"; import type { Usage } from "@earendil-works/pi-ai"; import { createProberAgent, createTeacherAgent, type AgentBuildOptions, type TeachingAgent } from "../agent/teacher-agent.ts"; import { piEngineForSpec, type PiEngine } from "../agent/models.ts"; @@ -6,7 +5,6 @@ import { createJobRunner } from "../agent/subagents/runner.ts"; import { createExtractionPipeline } from "../agent/subagents/extraction.ts"; import type { ModelRole } from "../../config.ts"; import { claimSteers, deliverJobResult, setJobRespawn, setTurnRunner } from "./jobs.ts"; -import { recordToPiMessages } from "./pi/record-adapter.ts"; import { piTurnResponse, runPiTurn } from "./pi/ui-stream.ts"; import { effectiveSettings, stores } from "./stores.ts"; import { searchAvailable, searchProvider } from "./search.ts"; @@ -20,26 +18,35 @@ import { import { modelsNotConfigured, providerKeyMissing as providerKeyMissingEnvelope, type ProviderKeyMissing } from "../core/settings.ts"; import { beginTurn, endTurn } from "./turn-liveness.ts"; import { registerTurn, unregisterTurn } from "./turn-runs.ts"; -import { persistTurn, triggerExtraction, withMessageIds } from "./turn-service.ts"; +import { persistTurn, triggerExtraction } from "./turn-service.ts"; +import { hasProbeDone } from "../core/native-messages.ts"; +import { asAgentMessages } from "../core/native-messages.ts"; +import { + ChatInputError, + inputAppends, + parseChatInput, + type TurnInput, +} from "../core/input-events.ts"; +import { steerDelivered } from "../core/contracts.ts"; import type { CallUsage } from "../core/contracts.ts"; /** - * One chat turn: run the teaching agent over the client's message list and - * stream the UI message stream back. Framework-free (plain Request/Response) - * so tests can drive it directly with a scripted faux engine — the Next route - * handler in app/api/chat is a one-line wrapper. + * One chat turn over the server-owned native record. Framework-free (plain + * Request/Response) so tests can drive it directly with a scripted faux + * engine — the Next route handler in app/api/chat is a one-line wrapper. + * + * THE INPUT-EVENTS CONTRACT (Wave B, owner ruling 2026-09-26): POST /api/chat + * carries {chatId, input} — one explicit event (text / answer / verdict / + * continue), never a client transcript. The server owns the record; this + * module is the TURN COMPOSITION ROOT: it turns the event into native + * record appends (core/input-events.ts), builds the agent from the record + * (the record IS the agent state — state.messages = record.messages, + * continue()), and runs the turn as a background object (pi/ui-stream.ts). + * Wake-pipeline steers ride the same composition (TurnInput's steer kind). * - * This module is the TURN COMPOSITION ROOT: it wires the storage ports, the - * job runner + wake pipeline, the teacher tools, and the turn services - * (turn-service.ts) together — all actual work lives behind those seams. * THE SAME COMPOSITION serves both entry points: client requests and the * wake pipeline's server-side steers (src/server/jobs.ts) — one code path * for write-ahead persistence, turn liveness, and the turn services. - * - * Engine note (docs/pi-engine-swap.md): the agent is STATELESS per turn — - * built fresh from the persisted record (recordToPiMessages), run by the - * bridge (pi/ui-stream.ts), discarded at turn end. The record is the only - * source of truth. */ const jobRunner = createJobRunner({ @@ -82,18 +89,12 @@ export const extraction = createExtractionPipeline({ providerKeyPresent(provider) ? null : providerKeyMissingEnvelope(provider, [tier]), }); - // Wake-pipeline wiring (contracts.ts "THE ASYNC PROTOCOL" §4): the pipeline -// fires its server-side teacher turns through THIS composition root, and -// crash recovery re-spawns orphaned sub-agent loops through THIS runner. -setTurnRunner(async (chatId, messages) => { - const res = await handleChatRequest( - new Request("http://localhost/api/chat", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ chatId, messages }), - }), - ); +// delivers its steers through THIS composition root (one event: the steer +// marker, appended natively under the turn claim), and crash recovery +// re-spawns orphaned sub-agent loops through THIS runner. +setTurnRunner(async (chatId, input) => { + const res = await handleChatInput(chatId, input); // Drive the stream to completion server-side — nobody else will read it, // and onFinish (persist, extraction, endTurn) only runs when it drains. await res.arrayBuffer().catch(() => undefined); @@ -110,39 +111,44 @@ function resolveEngine(role: ModelRole): PiEngine | undefined { setJobRespawn((jobId) => void jobRunner.runJob(jobId)); -/** The message-shape gate the SDK's UIMessageValidator owned: a malformed - * body must fail BEFORE the stream exists — the path where no onFinish will - * ever run (the catch clears the turn registry so the chat never locks). */ -function assertValidUiMessages(messages: UIMessage[]): void { - for (const m of messages) { - if (m.role !== "user" && m.role !== "assistant" && m.role !== "system") { - throw new Error(`invalid message role: ${String((m as { role?: unknown }).role)}`); - } - if (!Array.isArray(m.parts)) throw new Error("message has no parts array"); - for (const p of m.parts) { - if (p.type === "text" && typeof (p as { text?: unknown }).text !== "string") { - throw new Error("text part has no text"); - } - } - } -} - +/** The route-facing entry: parse {chatId, input} and run one turn. */ export async function handleChatRequest( req: Request, - buildAgent?: (outgoing: UIMessage[]) => TeachingAgent, + buildAgent?: (opts: AgentBuildOptions & { teaching: boolean }) => TeachingAgent, ): Promise { - let body: { chatId?: unknown; messages?: unknown }; + let body: { chatId?: unknown; input?: unknown }; try { body = await req.json(); } catch { return Response.json({ error: "Invalid JSON body" }, { status: 400 }); } const chatId = typeof body.chatId === "string" ? body.chatId : ""; - if (!chatId || !stores.chatStore.getChat(chatId)) { - return Response.json({ error: "Unknown chat" }, { status: 404 }); + if (!chatId) return Response.json({ error: "Unknown chat" }, { status: 404 }); + let input: TurnInput; + try { + input = parseChatInput(body.input); + } catch (err) { + if (err instanceof ChatInputError) { + return Response.json({ error: err.message }, { status: 400 }); + } + throw err; } - if (!Array.isArray(body.messages)) { - return Response.json({ error: "messages must be an array" }, { status: 400 }); + return handleChatInput(chatId, input, buildAgent); +} + +/** + * The turn composition: one input event → native record appends → one + * agent turn as a background object. Also the wake pipeline's entry + * (TurnInput's steer kind appends the marker under the same claim). + */ +export async function handleChatInput( + chatId: string, + input: TurnInput, + buildAgent?: (opts: AgentBuildOptions & { teaching: boolean }) => TeachingAgent, +): Promise { + const record = stores.chatStore.getChat(chatId); + if (!record) { + return Response.json({ error: "Unknown chat" }, { status: 404 }); } // THE UNIVERSAL TURN GATE (BYOK, owner, 2026-09-22): a turn needs the @@ -154,10 +160,7 @@ export async function handleChatRequest( // write-ahead persist (nothing durable changes), and every downstream // resolution (jobs/extraction gate per-need on their own tiers). const effective = effectiveSettings(); - const conversation = resolveConversationSpec( - stores.chatStore.getChat(chatId)?.settings, - effective.models.strong, - ); + const conversation = resolveConversationSpec(record.settings, effective.models.strong); if (!conversation) { const envelope = modelsNotConfigured(["strong"]); console.info(`[chat ${chatId}] turn refused — ${JSON.stringify(envelope)}`); @@ -179,10 +182,27 @@ export async function handleChatRequest( return Response.json(envelope, { status: 503 }); } + // The input's native appends (pure; the honest 400 for an answer with no + // pending question call). Computed BEFORE beginTurn so a refused input + // leaves no liveness effects; read against the CURRENT record. + let appends; + try { + appends = + input.type === "steer" + ? steerDelivered(record.messages, input.steer.id) + ? [] + : [input.steer] + : inputAppends(record, input); + } catch (err) { + if (err instanceof ChatInputError) { + return Response.json({ error: err.message }, { status: 400 }); + } + throw err; + } + // One turn per chat, ever. A second request while a turn runs means a - // stale/remounted client raced a live one — its message list is outdated, - // and letting it through would double-run the teacher and clobber the - // first turn's persist (saveMessages is last-writer-wins). + // stale/remounted client raced a live one — letting it through would + // double-run the teacher and clobber the first turn's persist. if (!beginTurn(chatId)) { console.info(`[chat ${chatId}] turn already in flight — request rejected`); return Response.json({ error: "A turn is already running for this session" }, { status: 409 }); @@ -190,40 +210,27 @@ export async function handleChatRequest( // CONTINUATION COALESCING (async protocol §4, the delivery-starvation fix): // steers finished while a quiz chain held the wake gates ride THIS turn — - // appended after the learner's answer (conversion-safe: the answer carries - // its output). Claiming happens after beginTurn, so this turn owns the - // registry and the write-ahead persist below makes the claim durable. - // The incoming body is passed too: a pump-fired wake turn ALREADY CARRIES - // its steer, so claimSteers finalizes that entry instead of appending a - // second copy (duplicate-key bug, 2026-09-18). - const steers = claimSteers(chatId, body.messages as UIMessage[]); - const outgoing = ( - steers.length > 0 ? [...(body.messages as UIMessage[]), ...steers] : body.messages - ) as UIMessage[]; + // appended after the learner's answer (conversion-safe: the answer is the + // question call's tool result now). Claiming happens after beginTurn, so + // this turn owns the registry and the write-ahead persist below makes the + // claim durable. Steers already in the transcript (a wake raced in) are + // finalized by the claim itself — the append filter keeps exactly one copy. + const steers = claimSteers(chatId).filter((s) => !steerDelivered(record.messages, s.id)); - // THE SETTINGS VIEW (settings-storage wave, 2026-09-19): resolved fresh - // per turn (the gate above owns the read) — graph gates, tool toggles, - // and the record's chat defaults all read from ONE small file. - - // Write-ahead persistence: the request body IS the client's canonical - // state, so it goes on disk BEFORE the stream starts. The learner's answer - // is durable the instant the request arrives — any remount, second tab, or - // server crash sees truth instead of the pre-answer record. (A client that - // returns mid-turn reads turnInFlight from the registry and re-attaches by - // polling instead of resurrecting the stale transcript.) + // Write-ahead persistence: the input's native appends go on disk BEFORE + // the turn starts — the learner's answer is durable the instant the + // request arrives, and any remount, second tab, or server crash reads + // truth instead of the pre-answer record. + const merged = [...record.messages, ...appends, ...steers]; try { - stores.chatStore.saveMessages(chatId, outgoing); + stores.chatStore.saveMessages(chatId, merged); } catch (err) { console.error(`[chat ${chatId}] write-ahead persist failed:`, err); } - - // MONEY CHANNEL (wave 4): one collector per request, fed by the agent's // message_end events. Every model call's usage lands in the chat record's - // append-only turns log at onFinish — idempotent per turn id, so a - // replayed persist appends once while continuation turns (which share a - // response message id) each keep their usage. + // append-only turns log at onFinish — idempotent per turn id. const turnUsageId = crypto.randomUUID(); const steps: CallUsage[] = []; const collectUsage = (usage: Usage) => { @@ -238,13 +245,12 @@ export async function handleChatRequest( }; // AGENT CONSTRUCTION BRANCH (docs/probe-teach-split.md §2): the transcript - // is the seam — a probe_done tool part (any state) means the probe is + // is the seam — a probe_done call in the record means the probe is // concluded: build the TEACHER (planning + the full toolset). Otherwise // the session is still probing: build the PROBER (quiz/ask + probe_done). // Both share this whole composition (write-ahead persist, liveness, // steers, usage, extraction) — only the agent differs. - const teaching = outgoing.some((m) => m.parts.some((p) => p.type === "tool-probe_done")); - const role: ModelRole = teaching ? "teacher" : "prober"; + const teaching = hasProbeDone(merged); // BYOK (owner, 2026-09-22): the conversation chain was resolved by the // turn gate above (chat.model override → strong slot → refused) — the // engine build is pure from here; thinking levels clamp natively. @@ -252,19 +258,18 @@ export async function handleChatRequest( // Provider-qualified stamp (model slots wave): matches the price table's // provider:id keys. const modelId = `${engine.model.provider}:${engine.model.id}`; - // Tests inject a factory: the agent is stateless per turn (docs/ - // pi-engine-swap.md §2), so each request builds a fresh one off the - // transcript — the factory mirrors that seam exactly. + // Tests inject a factory: the agent is stateless per turn, so each + // request builds a fresh one off the record — the factory mirrors that + // seam exactly. Wave B: the record IS the agent state (native messages; + // the record's id field strips at the wire in toLlmMessages). // CROSS-STREAM SEAM (P2 reconciliation, 2026-09-19): `toolToggles` // rides the build opts as the user-level DELTA map (absent key = // enabled). The definitive toggleable set is TOGGLEABLE_TOOLS — the // sub-agent JOB tools (research/demo/visual/pdf; adjudicated with the - // user: quiz/ask/fs/plan/probe_done are always-on). First-class on - // AgentBuildOptions; the factories drop anything explicitly false and - // the settings write side rejects unknown keys. + // user: quiz/ask/fs/plan/probe_done are always-on). const agentOpts: AgentBuildOptions = { engine, - messages: recordToPiMessages(outgoing), + messages: asAgentMessages(merged), onUsage: collectUsage, toolToggles: effective.agent.tools, // The prompt-override switches (P2, 2026-09-19): same resolution @@ -273,7 +278,7 @@ export async function handleChatRequest( promptSwitches: effective.agent.prompts, }; const constructed: TeachingAgent = buildAgent - ? buildAgent(outgoing) + ? buildAgent({ ...agentOpts, teaching }) : teaching ? createTeacherAgent( { @@ -310,7 +315,6 @@ export async function handleChatRequest( // transcript, not the money channel); when it's ours, usage flows through // the factory's subscription (wired above via onUsage). try { - assertValidUiMessages(outgoing); // THE TURN AS A BACKGROUND OBJECT (owner ruling, 2026-09-25): // runPiTurn executes decoupled from this response — the POST stream // is just the FIRST subscriber (a view). A client disconnect can no @@ -318,7 +322,7 @@ export async function handleChatRequest( // late views reattach via GET /api/chats/[id]/stream. const handle = runPiTurn({ agent: constructed, - uiMessages: outgoing, + base: merged, // Stream-level failures are provider/transport internals — masked, // like the SDK defaults. Tool-input validation messages never // route through here: they ride the error-result path and reach the @@ -327,14 +331,14 @@ export async function handleChatRequest( console.error(`[chat ${chatId}] turn error:`, error); return "An error occurred."; }, - onFinish: (messages, isAborted) => { - persistTurn(stores.chatStore, chatId, messages as UIMessage[], isAborted); + onFinish: (turnMessages, isAborted) => { + // Wave B: the record write is the agent's own final state — + // the new messages are the slice past the merged base. + const stamped = persistTurn(stores.chatStore, chatId, merged.length, turnMessages, isAborted); // usage log append (idempotent per turn id) — the stats bar - // refolds this on the client after the turn settles. messageId - // comes from the id-FILLED list: the bridge's response message - // already carries its id; the re-fill here is idempotent. - const filled = withMessageIds(messages as UIMessage[]); - const response = [...filled].reverse().find((m) => m.role === "assistant"); + // refolds this on the client after the turn settles. The + // provenance message is the turn's FIRST new assistant message. + const response = stamped.find((m) => (m as { role?: string }).role === "assistant"); if (response && steps.length > 0) { stores.chatStore.appendTurnUsage(chatId, { id: turnUsageId, @@ -345,9 +349,7 @@ export async function handleChatRequest( }); } // GRAPH GATE (settings-storage wave, 2026-09-19): the write channel — - // extraction, its auditor — dies with the master switch. (The deeper - // pipeline knobs — embedding/clustering/labeling — gate inside the - // pipeline and its consumers, later waves, via the same resolved view.) + // extraction, its auditor — dies with the master switch. if (extractionEnabled(effective.graph)) { // Fresh per-turn read of the graph section's chunk budget // (extraction-UX redesign, 2026-09-24): the trigger compares @@ -380,4 +382,4 @@ export async function handleChatRequest( console.error(`[chat ${chatId}] turn failed to start:`, err); return Response.json({ error: "Failed to start the turn" }, { status: 500 }); } -} \ No newline at end of file +} diff --git a/src/server/chats.ts b/src/server/chats.ts index c4f2070..ce44dd7 100644 --- a/src/server/chats.ts +++ b/src/server/chats.ts @@ -1,5 +1,6 @@ import { abortExtraction, extractionStatuses } from "../agent/subagents/extraction.ts"; import { coverageOf } from "../core/coverage.ts"; +import { projectNative } from "../core/projection.ts"; import { deleteChatTraces, type ChatDeleteMode, type ChatDeletionStats } from "../core/graph-deletion.ts"; import { titleFromPrompt } from "../core/contracts.ts"; import type { UIMessage } from "ai"; @@ -141,7 +142,9 @@ export function sessionCoverage(chatId: string): SessionCoverage | null { const record = stores.chatStore.getChat(chatId); if (!record) return null; const entries = stores.graphStore.readCoverage(); - const cov = coverageOf(record.messages as UIMessage[], entries, chatId); + // Wave B: the record is native history — coverage reads through the + // projection (same view the extraction pipeline folds). + const cov = coverageOf(projectNative(record.messages), entries, chatId); let lastFoldedAt: string | null = null; for (const entry of entries) { if (entry.chatId !== chatId) continue; diff --git a/src/server/jobs.ts b/src/server/jobs.ts index ab44958..f5fe66e 100644 --- a/src/server/jobs.ts +++ b/src/server/jobs.ts @@ -31,7 +31,6 @@ * be a different object in each). */ -import type { UIMessage } from "ai"; import { buildAbortSteer, buildSubagentSteer, @@ -40,13 +39,16 @@ import { type JobRecord, type SteerMessage, } from "../core/contracts.ts"; -import { findPendingToolCall } from "../core/pending.ts"; +import type { TurnInput } from "../core/input-events.ts"; +import { danglingQuestionOf } from "../core/native-messages.ts"; import { fireJobAbort, isJobLive } from "../agent/subagents/aborts.ts"; import { stores } from "./stores.ts"; import { isTurnRunning } from "./turn-liveness.ts"; -/** Fires one server-side teacher turn: (chatId, messages) → HTTP-ish status. */ -export type TurnRunner = (chatId: string, messages: UIMessage[]) => Promise; +/** Fires one server-side teacher turn: (chatId, input) → HTTP-ish status. + * Wave B: the input is the STEER EVENT — the handler appends the marker + * natively under its own turn claim (one composition for every turn). */ +export type TurnRunner = (chatId: string, input: TurnInput) => Promise; /** Re-runs a job's sub-agent loop (crash recovery for orphaned jobs). */ export type JobRespawn = (jobId: string) => void; @@ -110,10 +112,11 @@ function delay(ms: number): Promise { return new Promise((resolve) => setTimeout(resolve, ms)); } -/** The job a steer belongs to, if it is a job steer (result/abort/context); +/** The job a steer belongs to, if it is a job steer (result/abort); * probe-handoff steers have no job — null. */ function jobIdOf(steer: SteerMessage): string | null { - return (steer.metadata as { subagentResult?: { jobId: string } }).subagentResult?.jobId ?? null; + const details = steer.details as { jobId?: string } | undefined; + return typeof details?.jobId === "string" ? details.jobId : null; } /** Finalize a delivered steer's job bookkeeping — job steers only. */ @@ -167,32 +170,22 @@ export function deliverJobResult(steer: SteerMessage): Promise { * copies of the same steer id (a live React duplicate-key warning the * owner hit 2026-09-18; withMessageIds only heals it at onFinish). */ -export function claimSteers( - chatId: string, - incoming?: ReadonlyArray, -): SteerMessage[] { +export function claimSteers(chatId: string): SteerMessage[] { const s = state(); const queue = s.queues.get(chatId); if (!queue || queue.length === 0) return []; - const record = stores.chatStore.getChat(chatId); - if (!record) return []; // the handler validated the chat; defensive only - const claimed: SteerMessage[] = []; + s.queues.delete(chatId); + // The caller holds the turn registry (chat-handler claims after + // beginTurn), so the write-ahead persist is imminent and durable: + // every queued steer is claimed — its delivery is THIS turn's job. + // Steers already in the transcript (a wake raced in) still finalize + // here; the handler's append filter (steerDelivered) keeps exactly + // one copy in the record. + const claimed = queue.map((entry) => entry.steer); for (const entry of queue) { - if ( - steerDelivered(record.messages, entry.steer.id) || - incoming?.some((m) => m.id === entry.steer.id) - ) { - // A wake already landed it — or this turn's own request already - // carries it (the pump path): the delivery is done either way, - // just finish the bookkeeping. NEVER append twice. - finalizeSteer(entry.steer); - } else { - claimed.push(entry.steer); - finalizeSteer(entry.steer); // delivery is now the client turn's job - } + finalizeSteer(entry.steer); entry.resolve(); } - s.queues.delete(chatId); return claimed; } @@ -256,12 +249,13 @@ export function abortJob(jobId: string): AbortOutcome { } function chatIdOf(steer: SteerMessage): string { - // The probe handoff carries its chat directly (the job record would - // say it too, but the metadata is the cheap path). - const handoff = (steer.metadata as { probeHandoff?: { chatId: string } }).probeHandoff; - if (handoff) return handoff.chatId; - // The chat is resolvable from the job record; the steer's metadata - // carries jobId, which indexes the store. + // The probe handoff carries its chat directly in the marker details (the + // job record would say it too, but the details are the cheap path). + if (steer.customType === "probe-handoff") { + return (steer.details as { chatId: string }).chatId; + } + // The chat is resolvable from the job record; the steer's details + // carry jobId, which indexes the store. const jobId = jobIdOf(steer); const job = jobId ? stores.jobStore.getJob(jobId) : undefined; return job?.chatId ?? ""; @@ -334,9 +328,11 @@ async function pump(chatId: string): Promise { continue; } - // Fire the server-side teacher turn. A 409 = a client turn raced - // in between idle and firing — back off and retry the same steer. - const status = await s.turnRunner(chatId, [...fresh.messages, entry.steer]).catch((err: unknown) => { + // Fire the server-side teacher turn (the steer EVENT: the handler + // appends the marker natively under its own turn claim). A 409 = a + // client turn raced in between idle and firing — back off and + // retry the same steer. + const status = await s.turnRunner(chatId, { type: "steer", steer: entry.steer }).catch((err: unknown) => { console.error(`[steer ${steerId}] wake turn crashed:`, err); return -1; }); @@ -387,22 +383,23 @@ function failJob(jobId: string, err: Error): void { /** * Wait until the chat can take a wake turn: no turn in flight AND no - * unanswered question part (provider conversion would break). Poll; the - * learner may take arbitrarily long to answer, and that is correct — the - * steer fires right after their continuation completes. + * unanswered question call (the record's dangling question — the answer + * arrives as its tool result, and no turn may run in the window). Poll; + * the learner may take arbitrarily long to answer, and that is correct — + * the steer fires right after their answer turn completes. */ async function waitForIdle(chatId: string): Promise { for (;;) { const record = stores.chatStore.getChat(chatId); if (!record) return; - const blocked = isTurnRunning(chatId) || findPendingToolCall(record.messages) !== null; + const blocked = isTurnRunning(chatId) || danglingQuestionOf(record.messages) !== null; if (!blocked) { await delay(GRACE_MS); const recheck = stores.chatStore.getChat(chatId); if ( !isTurnRunning(chatId) && recheck && - findPendingToolCall(recheck.messages) === null + danglingQuestionOf(recheck.messages) === null ) { return; } diff --git a/src/server/pi/record-adapter.ts b/src/server/pi/record-adapter.ts deleted file mode 100644 index 8308012..0000000 --- a/src/server/pi/record-adapter.ts +++ /dev/null @@ -1,190 +0,0 @@ -import type { UIMessage } from "ai"; -import type { AgentMessage } from "@earendil-works/pi-agent-core"; -import type { AssistantMessage, Message, ToolResultMessage } from "@earendil-works/pi-ai"; -import { HANDOFF_TEXT, verdictText } from "../../core/contracts/chat.ts"; - -/** - * The record → pi-ai transcript adapter (docs/pi-engine-swap.md §4). The - * persisted UIMessage record IS the source of truth; every turn builds a - * fresh agent from it (the stateless per-turn decision). This replaces the - * SDK's convertToModelMessages with an owned, explicit mapping: - * - * - user text parts → UserMessage text content. - * - SYSTEM DATA PARTS (the custom-message scheme, 2026-09-12): the wake and - * verdict messages carry ONLY a marker part — no human text exists to - * drop, so the ADAPTER flattens them: data-verdict → verdictText(), - * data-probe-handoff → HANDOFF_TEXT (core/contracts/chat.ts owns both — - * single source, so the model-facing wording can't drift from the seam). - * Other data parts (artifacts, plan blocks) are dropped as before: they - * are UI history; the steer briefs ride their text twin (payload, not - * boilerplate — those texts are content the sub-agent produced). - * - assistant text/reasoning/tool parts → one AssistantMessage whose - * content blocks mirror the part order; tool parts become ToolCall - * blocks (output-error parts keep their rawInput). - * - answered tool parts (output-available) → ToolResultMessage after the - * assistant message, JSON-encoded like the model saw it live. - * - errored tool parts (output-error) → an isError ToolResultMessage - * carrying the validation message (the model's retry context). - * - DANGLING calls (input-available, never answered — an aborted turn) - * → a synthesized interrupted ToolResultMessage. THE HARD INVARIANT: - * an assistant message with tool calls must be followed by tool - * results, or providers reject the request outright. - */ - -const INTERRUPTED = "The question was interrupted — the learner never answered; continue without holding a result for it."; - -function textOf(part: { text?: string }): string { - return typeof part.text === "string" ? part.text : ""; -} - -/** Encode a tool output the way the model saw it live (string verbatim, object JSON). */ -function toolResultText(output: unknown): string { - return typeof output === "string" ? output : JSON.stringify(output); -} - -function zeroUsage() { - return { - input: 0, - output: 0, - cacheRead: 0, - cacheWrite: 0, - totalTokens: 0, - cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, - }; -} - -/** The model-facing text of a marker-only user message, or null when the - * message has no model face at all (UI-only data parts). */ -function systemTextOf(message: { parts: ReadonlyArray<{ type: string; data?: unknown }> }): string | null { - for (const part of message.parts) { - if (part.type === "data-verdict") { - return verdictText((part.data as { accepted?: unknown } | undefined)?.accepted === true); - } - if (part.type === "data-probe-handoff") return HANDOFF_TEXT; - } - return null; -} - -export function recordToPiMessages(messages: UIMessage[]): Message[] { - const out: AgentMessage[] = []; - - for (const message of messages) { - if (message.role === "user") { - const texts = message.parts - .filter((p) => p.type === "text") - .map((p) => textOf(p as { text?: string })) - .filter((t) => t.length > 0); - // The custom-message scheme: a marker-only message (verdict, - // handoff) flattens to its canonical model-facing text — the - // wording lives in the adapter's reach (core contracts), never - // authored as fake human speech in the transcript. - if (texts.length === 0) { - const flat = systemTextOf(message); - if (flat === null) continue; // pure UI data part, no model face - texts.push(flat); - } - out.push({ - role: "user", - content: texts.map((text) => ({ type: "text" as const, text })), - timestamp: Date.now(), - }); - } else if (message.role === "assistant") { - const content: AssistantMessage["content"] = []; - const results: ToolResultMessage[] = []; - let answered = false; - - for (const part of message.parts) { - if (part.type === "text") { - const text = textOf(part); - if (text) content.push({ type: "text", text }); - } else if (part.type === "reasoning") { - const text = textOf(part); - if (text) content.push({ type: "thinking", thinking: text }); - } else if (part.type === "step-start") { - // presentation-only; the pi loop re-derives turn boundaries - } else if (part.type.startsWith("tool-")) { - const tool = part.type.slice("tool-".length); - const p = part as unknown as { - toolCallId: string; - state: string; - input?: unknown; - rawInput?: unknown; - output?: unknown; - errorText?: string; - }; - // The call block (any state — the model authored it). - if (p.state === "input-available" || p.state === "input-streaming" || p.state === "output-available") { - content.push({ - type: "toolCall", - id: p.toolCallId, - name: tool, - arguments: (p.input ?? {}) as Record, - }); - } else if (p.state === "output-error") { - content.push({ - type: "toolCall", - id: p.toolCallId, - name: tool, - arguments: (p.rawInput ?? p.input ?? {}) as Record, - }); - } - // The result, if any. - if (p.state === "output-available") { - results.push({ - role: "toolResult", - toolCallId: p.toolCallId, - toolName: tool, - content: [{ type: "text", text: toolResultText(p.output) }], - isError: false, - timestamp: Date.now(), - }); - answered = true; - } else if (p.state === "output-error") { - results.push({ - role: "toolResult", - toolCallId: p.toolCallId, - toolName: tool, - content: [{ type: "text", text: p.errorText ?? "The tool call failed validation." }], - isError: true, - timestamp: Date.now(), - }); - answered = true; - } - } - // data-* parts (plan blocks, artifacts, markers) never reach the model. - } - - if (content.length === 0) continue; // presentation-only message - out.push({ - role: "assistant", - content, - api: "openai-completions", - provider: "openrouter", - model: "replayed", - usage: zeroUsage(), - stopReason: content[content.length - 1]!.type === "toolCall" ? "toolUse" : "stop", - timestamp: Date.now(), - }); - // Dangling calls: synthesize the interrupted result so providers - // never see an unanswered tool call (the hard invariant). - if (!answered) { - for (const block of content) { - if (block.type === "toolCall" && !results.some((r) => r.toolCallId === block.id)) { - results.push({ - role: "toolResult", - toolCallId: block.id, - toolName: block.name, - content: [{ type: "text", text: INTERRUPTED }], - isError: false, - timestamp: Date.now(), - }); - } - } - } - out.push(...results); - } - // role "system" never appears in records (system prompt is composed separately). - } - - return out as Message[]; -} \ No newline at end of file diff --git a/src/server/pi/ui-stream.ts b/src/server/pi/ui-stream.ts index 3fda1d1..48d8c76 100644 --- a/src/server/pi/ui-stream.ts +++ b/src/server/pi/ui-stream.ts @@ -1,6 +1,8 @@ -import type { Agent, AgentEvent } from "@earendil-works/pi-agent-core"; +import type { Agent, AgentEvent, AgentMessage } from "@earendil-works/pi-agent-core"; import type { UIMessage } from "ai"; import { QUESTION_TOOLS } from "../../core/pending.ts"; +import type { LhMessage } from "../../core/native-messages.ts"; +import { projectNative } from "../../core/projection.ts"; /** * The UI-stream bridge (docs/pi-engine-swap.md §5): runs one agent turn and @@ -23,9 +25,10 @@ import { QUESTION_TOOLS } from "../../core/pending.ts"; * tool-input-error instead and NEVER becomes input-available, so a * malformed question can never mount the dock (the live modal-realism * crash: GLM double-encoded `options` as a JSON string). - * - The persisted message is assembled from the SAME parts the client - * assembles (the processUIMessageStream shapes), so every downstream - * consumer (phases, plan blocks, pending, extraction) is unchanged. + * - The WIRE assembly (below) is the client view only (Wave B): the + * record write is the agent's own native final state — the projection + * (core/projection.ts) reproduces the client's part shapes for every + * consumer (phases, plan blocks, pending, extraction) until Wave D. * * BACKGROUND-TURN RUNNER (owner ruling, 2026-09-25): a turn executes as a * server-side background object decoupled from any single HTTP response — @@ -42,12 +45,17 @@ type Chunk = Record & { type: string }; export interface PiTurnSpec { /** The per-turn agent (stateless engine — built from the record). */ agent: Agent; - /** The outgoing request messages (write-ahead persisted already). */ - uiMessages: UIMessage[]; + /** The turn's base: the native record + this turn's appends + * (write-ahead persisted already). Wave B: the agent's context and + * the record share one shape; onFinish receives the full agent state + * and the caller persists the slice past base.length. */ + base: LhMessage[]; /** The handler's error-unmasking policy (validation messages stay readable). */ onError?: (error: unknown) => string; - /** The canonical end-of-turn seam: persist + usage + extraction + endTurn. */ - onFinish: (messages: UIMessage[], isAborted: boolean) => void; + /** The canonical end-of-turn seam: persist + usage + extraction + endTurn. + * Receives the agent's FULL final state (native messages) — the turn's + * new messages are the slice past base.length. */ + onFinish: (messages: AgentMessage[], isAborted: boolean) => void; } /** One view onto a running turn: push() replays/streams a chunk, done() @@ -117,15 +125,22 @@ interface ToolPart { } export function runPiTurn(spec: PiTurnSpec): PiTurnHandle { - const { agent, uiMessages, onFinish } = spec; + const { agent, base, onFinish } = spec; // The streaming target: continuation (same id, parts appended to a copy - // of the existing message) or a fresh assistant message. - const last = uiMessages[uiMessages.length - 1]; - const continuing = last?.role === "assistant"; + // of the existing message) or a fresh assistant message. The continuation + // rule is the CLIENT's: it keys on the last UIMessage's id — read through + // the projection (the record is native; a turn after an answer continues + // the group that carries the answered quiz, which is the projection's + // last message). + const projected = projectNative(base); + const last = projected[projected.length - 1] as + | { id?: string; role?: string; parts?: UIMessage["parts"] } + | undefined; + const continuing = last?.role === "assistant" && typeof last.id === "string"; const messageId = (continuing && last.id) || `msg_${Date.now().toString(36)}_${crypto.randomUUID().slice(0, 8)}`; - const baseParts: UIMessage["parts"] = continuing ? [...last.parts] : []; + const baseParts: UIMessage["parts"] = continuing && last.parts ? [...last.parts] : []; const parts: UIMessage["parts"] = [...baseParts]; // Stable, collision-free part ids across continuation runs (reasoning @@ -174,11 +189,11 @@ export function runPiTurn(spec: PiTurnSpec): PiTurnHandle { send({ type: "start", messageId }); const settleWith = () => { - const assembled: UIMessage = { id: messageId, role: "assistant", parts }; - // The SDK's onFinish shape: the input copy stays, the extended - // response copy appends (persistTurn's withMessageIds dedupe - // keeps the last occurrence). - onFinish([...uiMessages, assembled], sawAbort); + // Wave B: the record write is the agent's OWN final state (native + // history — signatures, usage, provider stamps all real); the wire + // assembly above served the client view only. Copy the array: the + // agent may outlive this call's read. + onFinish([...agent.state.messages], sawAbort); }; const handle = (event: AgentEvent) => { diff --git a/src/server/turn-service.ts b/src/server/turn-service.ts index 94a9318..a4c845a 100644 --- a/src/server/turn-service.ts +++ b/src/server/turn-service.ts @@ -4,69 +4,85 @@ * implementing. Each takes its dependencies explicitly (storage ports, the * job runner) — no module here imports an adapter. * - * persistTurn — the canonical state write (client + agent messages) + * persistTurn — the canonical state write (Wave B: native history; + * id stamping, placeholder filtering, plan-block + * emission, abort-partial persistence) * triggerExtraction — the graph write channel: fires the extraction * pipeline (the sole graph writer) once a chat - * crosses the v1 quiz-answer threshold. The trigger - * mechanism is a v1 placeholder — this seam is the - * ONLY thing to swap when a better one lands. + * crosses the token-budget threshold. * * Logs are the minimal-observability baseline: one line per failure, tagged * [chat ] — structured logging lands behind these seams later. */ -import type { UIMessage } from "ai"; -import { withPlanBlocks } from "../core/plan-blocks.ts"; +import type { AgentMessage } from "@earendil-works/pi-agent-core"; +import type { ToolResultMessage } from "@earendil-works/pi-ai"; +import type { LhMessage } from "../core/native-messages.ts"; +import { newMessageId } from "../core/native-messages.ts"; +import { QUESTION_TOOLS } from "../core/pending.ts"; +import { planBlocksFor } from "../core/plan-blocks.ts"; +import { projectNative } from "../core/projection.ts"; import { coverageOf } from "../core/coverage.ts"; import type { ChatStore, GraphStore } from "../core/ports.ts"; import type { ExtractionPipeline } from "../agent/subagents/extraction.ts"; import { ModelsNotConfiguredError, ProviderKeyMissingError } from "../core/settings.ts"; /** - * The canonical state write: the client owns the message list between turns - * (useChat); this persists client messages + the agent response. Persists - * even when aborted — the partial transcript (including any pending - * question part) is what makes resume work. + * The canonical state write (Wave B, owner-approved 2026-09-26): append the + * turn's own messages — the agent loop's native history, provider/model/ + * usage/thinkingSignature stamped by pi itself. Persists even when aborted + * — the partial transcript (including a pending question's unanswered + * call) is what makes resume work. + * + * - Id stamping: pi messages carry no id; the record's identity needs one + * per message (client keys, append-only adoption, the continuation + * seam). Minted here, at the single canonical write. + * - Placeholder filtering: a cleanly-posed question's synthetic + * "question posed" result is an in-memory loop device (Wave A's + * terminate) — NEVER persisted (fork 1: the record ends at the + * unanswered call; the learner's answer arrives as the real tool + * result). Errored question calls DO persist — the model's retry + * context. + * - Plan blocks: one frozen snapshot per successful plan_init call, + * idempotent (persist replays are safe). + * + * Returns the stamped new messages (usage provenance reads them). */ -/** - * The SDK assigns assistant-message ids CLIENT-side (useChat); the server's - * onFinish synthesis leaves them EMPTY. A message persisted only server-side - * (the last turn's, or a wake turn's — nobody re-POSTs it with a client id) - * would otherwise land in the record with id "" — and two of those in one - * transcript break React keys (the chat map) and steer-dedup lookups. Fill any - * missing id here, at the single canonical write: client-authored ids are - * NEVER touched (the client's next POST still carries them verbatim). - */ -export function withMessageIds(messages: UIMessage[]): UIMessage[] { - const filled = messages.map((m) => - m.id ? m : { ...m, id: `msg_${Date.now().toString(36)}_${crypto.randomUUID().slice(0, 8)}` }, - ); - // Same id twice = the SDK's continuation merge couldn't happen in place - // (continuation coalescing appends a user-role steer AFTER the assistant - // message being continued, so onFinish carries the input copy AND the - // extended response copy under one id). Keep the LAST occurrence — it is - // the SDK's final merge state (a superset of the earlier copy). - const seen = new Set(); - const deduped: UIMessage[] = []; - for (let i = filled.length - 1; i >= 0; i--) { - const m = filled[i]!; - if (seen.has(m.id)) continue; - seen.add(m.id); - deduped.unshift(m); - } - return deduped; -} - -export function persistTurn(chatStore: ChatStore, chatId: string, messages: UIMessage[], isAborted: boolean): void { +export function persistTurn( + chatStore: ChatStore, + chatId: string, + baseCount: number, + turnMessages: readonly AgentMessage[], + isAborted: boolean, +): LhMessage[] { try { - const deduped = withMessageIds(messages); - // Plan-block emission (wave 4): blocks are frozen transcript content — - // attached here, once, at the canonical state write. The tree was - // saved by the plan tool during the turn; snapshots derive now. - const tree = chatStore.getChat(chatId)?.tree; - chatStore.saveMessages(chatId, withPlanBlocks(deduped, tree)); + const record = chatStore.getChat(chatId); + if (!record) return []; + const stamped: LhMessage[] = []; + for (const m of turnMessages.slice(baseCount)) { + const message = m as LhMessage; + // The placeholder question result: loop device, never a record fact. + if ( + (message as { role?: string }).role === "toolResult" && + (QUESTION_TOOLS as readonly string[]).includes((message as ToolResultMessage).toolName) && + !(message as ToolResultMessage).isError + ) { + continue; + } + stamped.push(message.id ? message : ({ ...message, id: newMessageId() } as LhMessage)); + } + const tree = record.tree; + const blocks = planBlocksFor( + record.messages, + stamped, + tree, + projectNative([...record.messages, ...stamped]), + ); + chatStore.saveMessages(chatId, [...record.messages, ...stamped, ...blocks]); + return stamped; } catch (err) { console.error(`[chat ${chatId}] persist failed:`, err, { isAborted }); + return []; } } @@ -74,15 +90,11 @@ export function persistTurn(chatStore: ChatStore, chatId: string, messages: UIMe * The graph write channel (vision §1 construction): the extraction pipeline * (extractor → auditor → merger — the sole graph writer) runs ASYNC once a * chat's UNCOVERED transcript tokens reach the chunk budget - * (vision-extraction-coverage.md, 2026-09-15 — replacing the old - * every-3-quiz-answers count: content size is the honest signal, and - * quiz-less sessions extract too). The budget is the graph section's - * extractionChunkTokens setting (extraction-UX redesign, 2026-09-24) — - * the composition root passes the per-turn effective read (chat-handler - * resolves fresh per turn, same as every other gate). The pipeline - * itself extracts only the uncovered batches. Fire-and-forget — never blocks the turn; failures are - * logged by the pipeline and leave coverage untouched, so the next trigger - * (or the manual extract button) re-runs what's left. + * (vision-extraction-coverage.md, 2026-09-15). Coverage reads through the + * projection (the record is native history; the extraction view stays + * client-shaped until Wave D). Fire-and-forget — never blocks the turn; + * failures are logged by the pipeline and leave coverage untouched, so the + * next trigger (or the manual extract button) re-runs what's left. * * Deliberately isolated in this one seam — the pipeline is * trigger-agnostic. Skipped under tests: scripted chats are fixtures, not @@ -98,7 +110,7 @@ export function triggerExtraction( try { const record = deps.chatStore.getChat(chatId); if (!record) return; - const covered = coverageOf(record.messages as UIMessage[], deps.graphStore.readCoverage(), chatId); + const covered = coverageOf(projectNative(record.messages), deps.graphStore.readCoverage(), chatId); if (covered.uncoveredTokens < chunkTokens) return; void extraction.runExtraction(chatId).catch((err) => { // BYOK (owner, 2026-09-22) + key presence (provider-key interlude, @@ -119,4 +131,3 @@ export function triggerExtraction( console.error(`[chat ${chatId}] extraction trigger failed:`, err); } } - diff --git a/src/store/chats.ts b/src/store/chats.ts index 0326188..5f2259e 100644 --- a/src/store/chats.ts +++ b/src/store/chats.ts @@ -12,9 +12,10 @@ import * as path from "node:path"; import { PATHS } from "../../config.ts"; import type { ChatStore } from "../core/ports.ts"; import type { ChatRecord, ChatSettings, ChatSummary, ExtractionRunUsage, PlanTree, TurnUsage } from "../core/contracts.ts"; -import type { UIMessage } from "ai"; +import type { LhMessage } from "../core/native-messages.ts"; import { findPendingToolCall } from "../core/pending.ts"; import { phaseAt3 } from "../core/phases.ts"; +import { projectNative } from "../core/projection.ts"; function chatFile(id: string): string { if (!/^[A-Za-z0-9_-]+$/.test(id)) throw new Error(`Invalid chat id: ${id}`); @@ -51,28 +52,43 @@ function listChats(): ChatSummary[] { .filter((f) => f.endsWith(".json")) .map((f) => getChat(f.slice(0, -5))) .filter((r): r is ChatRecord => r !== null) - .map(({ messages, tree, turns, extractions, ...summary }) => ({ - // turns and extractions stay out of the list payload (list = - // summaries; usage aggregates ride the full record, the picker - // never needs them). - ...summary, - messageCount: messages.length, - // Record-derived status for the session picker: phase from the - // transcript's explicit markers (probe_done / plan_init — the - // event-driven phases, docs/probe-teach-split.md §4), pending - // question from the last unanswered quiz/ask part. Liveness (busy) - // is NOT record-derived — the API route merges it (ChatListItem). - phase: phaseAt3(messages), - pendingQuestion: findPendingToolCall(messages) !== null, - })) + .map(({ messages, tree, turns, extractions, ...summary }) => { + // Per-record isolation (Wave B): a record whose messages fail the + // projection (e.g. pre-Wave-B UIMessage-format scratch — the + // zero-back-compat wipe makes these "corrupt") must not blank the + // whole sidebar — same isolation getChat's per-file catch gives. + try { + const projected = projectNative(messages); + return { + // turns and extractions stay out of the list payload (list = + // summaries; usage aggregates ride the full record, the picker + // never needs them). + ...summary, + messageCount: messages.length, + // Record-derived status for the session picker: phase from the + // transcript's explicit markers (probe_done / plan_init — the + // event-driven phases, docs/probe-teach-split.md §4), pending + // question from the last unanswered quiz/ask part — both read + // through the one-way projection. Liveness (busy) is NOT + // record-derived — the API route merges it (ChatListItem). + phase: phaseAt3(projected), + pendingQuestion: findPendingToolCall(projected) !== null, + }; + } catch (err) { + console.error(`[chat ${summary.id}] summary projection failed:`, err); + return null; + } + }) + .filter((s): s is ChatSummary => s !== null) .sort((a, b) => b.updatedAt.localeCompare(a.updatedAt)); } catch { return []; } } -/** Persist the full message list (client state + new response) after a turn. */ -function saveMessages(id: string, messages: UIMessage[]): void { +/** Persist the full native message list (the record IS the agent state — + * Wave B: replay is state.messages = record.messages). */ +function saveMessages(id: string, messages: LhMessage[]): void { const record = getChat(id); if (!record) throw new Error(`Chat not found: ${id}`); record.messages = messages; diff --git a/tests/chat-flow.test.ts b/tests/chat-flow.test.ts index 6c78551..2640cd3 100644 --- a/tests/chat-flow.test.ts +++ b/tests/chat-flow.test.ts @@ -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[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 { @@ -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>(); 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`); diff --git a/tests/chat-transport.test.ts b/tests/chat-transport.test.ts index b7b18be..de4a497 100644 --- a/tests/chat-transport.test.ts +++ b/tests/chat-transport.test.ts @@ -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/ → 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 { + 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}`); diff --git a/tests/demos.test.tsx b/tests/demos.test.tsx index e5ed24b..ee58f55 100644 --- a/tests/demos.test.tsx +++ b/tests/demos.test.tsx @@ -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(); + const html = renderToStaticMarkup(); 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(); + const html = renderToStaticMarkup(); assert.match(html, /FAILED: never passed verification/); assert.ok(!html.includes(" { { id: "jv", kind: "visual", toolCallId: "cv" }, { svg, notes: "Looking at the render, the diagram is clean and accurate." }, ); - const html = renderToStaticMarkup(); + const html = renderToStaticMarkup(); 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(); + // 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(); 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(); 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(); + const html = renderToStaticMarkup(); 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(); + const html = renderToStaticMarkup(); 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(); + const html = renderToStaticMarkup(); 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(); + const html = renderToStaticMarkup(); assert.match(html, /FAILED: iteration limit/); assert.ok(!html.includes("circle") && !html.includes(" { 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"); diff --git a/tests/extraction.test.ts b/tests/extraction.test.ts index 5817162..426ab7f 100644 --- a/tests/extraction.test.ts +++ b/tests/extraction.test.ts @@ -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; + 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, output: Record): 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, tree: unknown): unknown { - return { - type: `tool-${name}`, - toolCallId, - state: "output-available", - input, - output: { ok: true, tree }, - }; +function quizPart(toolCallId: string, input: Record, output: Record): NativeCallDesc { + return { name: "quiz", toolCallId, input, output }; +} + +function planPart(toolCallId: string, name: string, input: Record, 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"); diff --git a/tests/graph.test.ts b/tests/graph.test.ts index 3ceec3e..440944d 100644 --- a/tests/graph.test.ts +++ b/tests/graph.test.ts @@ -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; 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; + 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, 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, + output: QuestionAnswerData, +): NativeCall { + return { name: "ask_user_question", toolCallId, input, output }; } function answer(over: Partial): QuestionAnswerData { @@ -102,7 +151,7 @@ function answer(over: Partial): 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 { diff --git a/tests/jobs.test.ts b/tests/jobs.test.ts index 1aa9361..53a58d0 100644 --- a/tests/jobs.test.ts +++ b/tests/jobs.test.ts @@ -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 { 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: "", tested: true, notes: "ok" }; const visual: VisualResult = { 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[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 { // ─── 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; diff --git a/tests/phases.test.ts b/tests/phases.test.ts index 6a93431..74e0155 100644 --- a/tests/phases.test.ts +++ b/tests/phases.test.ts @@ -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)}`, diff --git a/tests/plan-blocks.test.ts b/tests/plan-blocks.test.ts index a7267fa..ae9cdbc 100644 --- a/tests/plan-blocks.test.ts +++ b/tests/plan-blocks.test.ts @@ -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): 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"); -}); \ No newline at end of file +}); diff --git a/tests/probe-done.test.ts b/tests/probe-done.test.ts index 1a55447..c880e18 100644 --- a/tests/probe-done.test.ts +++ b/tests/probe-done.test.ts @@ -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, input: unknown) { const t = tools.probe_done!; assert.ok(typeof t.execute === "function"); @@ -49,7 +70,7 @@ async function execute(tools: ReturnType, 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"); }); -}); \ No newline at end of file +}); diff --git a/tests/reattach-transport.test.ts b/tests/reattach-transport.test.ts index 26cbeba..f010f6c 100644 --- a/tests/reattach-transport.test.ts +++ b/tests/reattach-transport.test.ts @@ -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 >; @@ -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 >; @@ -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 >; @@ -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/); }); }); diff --git a/tests/record-adapter.test.ts b/tests/record-adapter.test.ts deleted file mode 100644 index dd94522..0000000 --- a/tests/record-adapter.test.ts +++ /dev/null @@ -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): 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"); - }); - -}); \ No newline at end of file diff --git a/tests/usage.test.ts b/tests/usage.test.ts index 4207838..f520548 100644 --- a/tests/usage.test.ts +++ b/tests/usage.test.ts @@ -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);