# 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. --- # Wave D planning — the native wire + client rewrite (2026-09-26 recon, post-Wave-B) Wave B landed the native record (LhMessage[], input events, projection, record-adapter death). Wave D = seam 6 + the client half — the wire becomes a verbatim passthrough of pi's own AgentEvents, the Vercel AI SDK leaves the codebase entirely, and every projection consumer reads native. Wave C (stateful agents) stays optional and deferred (owner, 2026-09-26). ## What Wave D replaces (the seam, precisely) - `ui-stream.ts`'s translation switch (~250 lines): the AgentEvent → UIMessageChunk remap (text/reasoning part assembly + id minting, toolInputs/newToolParts bookkeeping, input-available validation gating, question-output suppression, finishReason mapping, the start/messageId continuation rule). The log/subscriber/abort/settle machinery STAYS. - The Vercel SDK client: `ai` + `@ai-sdk/react` packages — `useChat` (messages/status/sendMessage/addToolOutput/setMessages/error/stop/ clearError/resumeStream), the `DefaultChatTransport` subclass (chat-transport.ts: send-half prepareSendMessagesRequest, receive-half reconnectToStream adapters — settled terminal, continuation-seam id rewrite, SSE head re-injection), `lastAssistantMessageIsCompleteWithToolCalls`. - `core/projection.ts` + `core/ui-message.ts` (TeacherUIMessage, mergeAdopted): deleted. The grouping canon moves CLIENT-SIDE (fork 1). - The projection's readers go native-read: phases, pending, coverage, graph toolParts, plan-blocks snapshot, extraction view, store summaries, GET /api/chats/[id], app/chat/[id]/page.tsx. ## Forks — ALL RESOLVED (owner rulings, 2026-09-26) 1. **The client's view state — today's exact look is the golden master.** Everything the agent produces renders as ONE continuous document per run: step separators exactly as now, artifact/answered cards inline, the teacher's reaction continuing below an answered quiz. Implemented by a pure client-side grouping function (the projection's canon, app-owned, zero `ai` types) over (record + live turn events). Raw per-native-message block rendering was rejected (visible layout change). 2. **Question tools on the wire — forward everything.** No server-side suppression: the quiz's closing `tool_execution_end` (the in-memory "Question posed" bookkeeping result) flows to the client, which ignores it (question tools are client-owned — the rule lives where the dock lives). The server is a pure passthrough with zero special cases. The placeholder still never persists, never reaches the model, never renders. 3. **Extraction unit — the message, with one inseparability rule.** The coverage/fingerprint unit is the native message; the batcher's one rule: a toolCall message and its toolResult message(s) are ALWAYS grouped into the same chunk (covered together, read together). A fresh answer's result pulls its asking assistant message into the chunk as context. Fingerprints reset to the new unit once (scratch data, accepted). ## The design ### Server: verbatim AgentEvent passthrough (ui-stream.ts rewrite) - The per-turn log stores AgentEvents verbatim; `data: {…}\n\n` lines, one JSON event each, `data: [DONE]\n\n` terminal. Every pi event type forwards as-is: agent_start, message_start/update/end, tool_execution_start/update/ end, turn_start, turn_end, agent_end. `message_update` carries the FULL partial message (`partial: AssistantMessage` on every AssistantMessageEvent) — the client REPLACES the streaming assistant message; no delta assembly. - App-level events kept (our protocol, not pi's): the reattach terminal `{type:"turn-settled"}` and a loop-level error event (agent.continue() throws outside the event stream; mapped through the handler's onError unmasking policy). Question suppression: NONE (fork 2). - The start/messageId continuation seam DIES — the client's grouping canon handles continuation (an answered-quiz continuation turn appends into the same group by construction). - piTurnResponse headers, POST /api/chat response, GET stream route: all unchanged (same subscriber shape, same terminals). ### Client: the session hook + the view model (the SDK dies) - **View model** (new client-safe pure module): the grouping canon verbatim from projection.ts — consecutive assistant+toolResult messages bounded by user/marker messages form one assistant view unit (step separators at turn boundaries); toolResults attach to their call's block (backward toolCallId search — answered cards, error cards, artifact outputs); marker customs (verdict, probe-handoff, steer cards) project to their current render shapes; plan-block markers splice after the last successful plan_init result. Input: (record base + live turn messages). Golden master: today's UI, byte-for-byte. - **Session hook** (new, replaces useChat): owns view messages, status (ready/submitted/streaming vocabulary KEPT — chat.tsx's busy/poll gates transplant mechanically), error, and the channels: - `send(input: ChatInput)` — POST {chatId, input} with the 409 identity retry (retryingFetch survives chat-transport's death); the pendingInput stash + loud-throw contract stays, enforced in the hook. - `answer(toolCallId, answer)` — optimistic local result append + send (the addToolOutput replacement; same UX: instant graded card). - `attach()` — GET /api/chats/[id]/stream → the same SSE event parser; turn-settled → syncFromServer (the settled terminal's contract). - abort channel unchanged (POST /api/activity/abort); cancel detaches the view only (the background-turn ruling is untouched). - **SSE parser**: data lines → view updates (message_update replaces the streaming assistant; message_end finalizes; turn_end appends results; agent_end → settle → syncFromServer; error event → the banner). - **Mid-turn record adoption** (the mergeAdopted lesson, native): steers/ artifacts persist into the record while a turn runs; on the jobs-poll nudge the hook re-reads the record and rebases the view when it is append-only below the live tail (else defers to full sync at idle). - **Renderers**: chat.tsx's switch moves from UIMessage part types to view blocks (text/reasoning/toolCall-with-result) — visually identical. ai-elements' `import type { UIMessage}` swaps to view types; streamdown stays (independent library). ### Scanners go native (projection deleted) - phases: probe→plan on a concluded probe_done (native: probe_done toolCall WITH a clean result); plan→teach on the verdict marker (accepted:true). - pending: `danglingQuestionOf` (+ input) is THE dock seam; streamingToolActivity reads the live streaming message's open toolCalls. - coverage: unit = native message (fork 3) with the call+result chunk rule; messageViewLines goes native (same one-line vocabulary); the ledger resets to the new units once. - graph toolParts: native pairing (assistant toolCall + result lookup). - extraction transcriptView: native lines. - plan-blocks: derivePlanStatuses over the native transcript. - GET /api/chats/[id] serves record.messages verbatim; page.tsx seeds the client view model; store summaries read native scans. ## Files New: client session module (hook + SSE parser + adoption), client view model module (pure). Rewrite: ui-stream.ts (passthrough), coverage.ts, phases.ts, pending.ts, graph.ts toolParts, extraction view, store/chats, chat.tsx (mechanics transplant), ai-elements type swaps. Delete: projection.ts, ui-message.ts, chat-transport.ts (retryingFetch moves into the session module), `ai` + `@ai-sdk/react` from package.json. Routes: unchanged. Agent band (src/agent): untouched. ## Test strategy - Wire pin: scripted turn → SSE data lines are verbatim AgentEvents; subscribe() replay = the same log; [DONE] terminal; turn-settled unchanged; no suppression of question results (fork 2 pinned). - View-model golden tests: the projection suite's expectations (grouping, card attachment, splice, separators) become the view model's tests. - Scanner natives: phases/pending/coverage (incl. the chunk rule)/graph/ plan-blocks. - Hook tests: attach/replay/settle, mid-turn adoption, optimistic answer, error banner, 409 identity retry. - chat-flow rewrite: SSE assertions switch to AgentEvents; the answer path drives the hook (closing the Wave B coverage gap that let the missing stash ship). - Live smoke: real turn + answer + reattach + remote abort on PORT=3004. ## Implementation order 1. Server passthrough (ui-stream + log type) — the wire changes here. 2. View model (pure) + scanner native rewrites + coverage chunk rule. 3. GET/page serve native; store summaries. 4. Session hook + chat.tsx transplant + renderer switch + ai-elements. 5. Deletes (projection, ui-message, chat-transport, deps) + gates. 6. Test migration + live smoke. --- # Wave C planning — pi-native steer/follow-up delivery (2026-09-26 recon) ## Trigger Live crash on first post-Wave-B steer delivery: a pump-fired wake turn double-appended its own steer (the Wave B regression of the 2026-09-18 duplicate-key bug). Hotfixed as `c364013` (the carried filter restored; record `chat_muhlcltz` deduped by hand). The hotfix makes steers WORK again — Wave C is the structural migration onto pi's own delivery seams. ## What pi offers natively (verified in pi-agent-core source) - `getSteeringMessages()` — injected at turn boundaries, mid-run. - `getFollowUpMessages()` — polled when the agent would otherwise STOP; returned messages join the context and the SAME run continues. Emitted as full `message_start`/`message_end` events (our verbatim wire carries them to clients for free) and pushed into the run's `newMessages` (persistTurn persists them). - `shouldStopAfterTurn` runs BEFORE both polls and EXITS the loop — the existing `makeQuestionStopper` guarantees an open quiz is never overridden by a queued steer. No new guard needed. - `CustomAgentMessages` declaration merging — our `LhMarkerMessage` (role "custom") can become a REGISTERED pi custom message type: first-class in agent state, events, and newMessages. `toLlmMessages` already is the sanctioned `convertToLlm` seam. - `runAgentLoopContinue` takes no new messages: the wake's context must already carry them. And pi polls follow-ups only AFTER the first LLM call — an empty-input wake would produce a stray teacher turn before the artifact arrives. ## Owner rulings (2026-09-26) 1. Hotfix first, Wave C after (done: `c364013`). 2. **Follow-ups ONLY** (owner corrected an initial steering choice): turns are typically single-message; follow-up semantics reproduces today's pacing (never interrupt a running turn) with least risk. `getSteeringMessages` is NOT wired. 3. **One queue, no claim machinery** (the unified intent): the per-chat steer queue is the single delivery registry; `claimSteers` and continuation coalescing are DELETED. The idle wake carries its markers as the run's input (write-ahead appends — today's semantics, now the ONLY appender), because follow-up polls don't exist at run start. ## The design ### Delivery paths (exactly one queue, three arrival contexts) 1. **Steer arrives mid-run** (teacher working): the agent's `getFollowUpMessages` returns queued markers at the run's natural stop; the same run continues (no second turn claim, no 409 dance, no client re-attach — the marker streams over the live view). 2. **Steer queued during a quiz chain**: the answer's run stops on the question stopper BEFORE the follow-up poll → the steer waits (exactly today's waitForIdle semantics); it rides the first run that ends without an open question — that run's follow-up poll delivers it. (This replaces continuation coalescing: `claimSteers` dies.) 3. **No run at all** (client closed, idle): the pump keeps `waitForIdle` + fires the wake turn carrying the queued markers as its input appends (write-ahead durable, then the run responds). The pump becomes: gates → fire ONE wake for everything queued → done. The 409 backoff stays (a client turn may race the wake). ### Marker representation - `declare module` augmentation registers `LhMarkerMessage` under `CustomAgentMessages` — type-level only; the runtime shape is unchanged (role "custom", customType, content, details, id, timestamp). Markers stay the record shape (SubagentSteerCard, probe-handoff, verdict rendering all keep working). - `getFollowUpMessages` returns the marker as-is; pi pushes it into context; `toLlmMessages` flattens it for the provider (unchanged). ### Idempotency + crash story - Dequeue-at-poll: a follow-up marker leaves the queue when handed to the loop; it reaches the record at that run's persistTurn. A crash between poll and persist loses it — the SAME exposure today's in-memory queue has (server restart re-derives steers only for running jobs via job respawn). Not worse, not better. - The wake path keeps write-ahead persistence (durable before the run). - `steerDelivered` (id-in-transcript) remains the dedupe oracle at the wake's append and for redelivery races. ### Client - `applyWireEvent` extends to append NON-assistant messages arriving via `message_start`/`message_end` (custom/user roles) — today it drops them, and in-run follow-up markers arrive exactly that way on the live stream. Assistant streaming keeps the slot logic (Wave D fix). - Nothing else: `projectView` already renders steer markers; the wake is still a turn (reattach paths unchanged). ### Scope boundaries - **probe-handoff stays a wake input** (not a follow-up): the prober→teacher switch is a record-level branch (`hasProbeDone` → the next run builds the teacher); `prepareNextTurn` cannot swap a toolset, so the handoff must start a new run regardless. - Abort steers and pdf/research/demo/visual result steers ride the same queue (kind is inert to the delivery mechanism). - Extraction/coverage treatment of markers is unchanged (custom role, view-line-less → invisible to coverage). ## Deletions - `claimSteers` + its handler call site + the carried-filter complexity (nothing left to double-append: the wake is the only appender). - The pump's per-entry retry loop simplifies (one fire for the whole queue snapshot; the 409 backoff survives). - jobs.test coalescing suite → rewritten as follow-up delivery tests. ## Test plan - jobs.test: queue → getFollowUpMessages handoff (dequeue-once); quiz-open deferral (stopper exits before the poll); pump idle wake fires ONE turn for N queued steers; 409 backoff unchanged. - chat-flow: mid-run follow-up e2e — a scripted teacher run whose follow-up poll returns a marker → the SAME stream continues with the marker's message events + the teacher's response; record holds one copy; `steerDelivered` redelivery no-op. - session-core: custom-role message events append to the tail (wire events from in-run injections). - Live smoke on PORT=3004: dispatch a researcher job, deliver mid-turn and idle, reload, abort steer. ## Implementation order 1. Declaration merging (type-level) + agentOpts wiring of `getFollowUpMessages` reading the queue. 2. Client `applyWireEvent` non-assistant appends. 3. Pump simplification + `claimSteers` deletion + handler cleanup. 4. Test migration (jobs, chat-flow, session-core) + live smoke.