Wave D: native AgentEvent wire + client session rewrite (SDK purged)
Server: ui-stream.ts is verbatim pi AgentEvent passthrough (SSE data lines + [DONE]); only app-level events are the loop-level error and turn-settled terminal; onError carries raw provider text (owner ruling). Client: useChat + the AI-SDK transport replaced by components/session-core.ts (SessionModel: 409 identity-retry, optimistic c_ appends, absorbed() adoption, needsContinuation, shared consume for POST + reattach) and the session.ts hook; the golden-master grouping canon moved client-side into core/view.ts (projectView); scanners read the native record (phaseAt3Lh, danglingQuestionOf, graph toolParts, plan-blocks, runner, extraction). Coverage: the native message is the unit (fork 3) with the call+result inseparability bond rule; view-line-less messages (markers, error results) are invisible to coverage and never checkpointed. Deletes: chat-transport, projection, ui-message, their tests (ported to tests/session-core.test.ts), and the ai + @ai-sdk/react dependencies — the Vercel SDK is fully out of the tree. Wire pins in chat-flow rewritten onto the AgentEvent dialect (eventsOf parser). Gates: tsc clean, 994/994 tests, eslint clean, build green; live smoke on PORT=3004 (verbatim wire → native record).
This commit is contained in:
@@ -1,299 +0,0 @@
|
|||||||
# 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 `"<thinking>…question.<text>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.
|
|
||||||
@@ -1,5 +1,4 @@
|
|||||||
import { deleteChatSession, getChat, isTurnRunning, sessionCoverage, stores, type ChatDeleteMode } from "@/src/server";
|
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 }> }) {
|
export async function GET(_req: Request, { params }: { params: Promise<{ id: string }> }) {
|
||||||
const { id } = await params;
|
const { id } = await params;
|
||||||
@@ -11,13 +10,13 @@ export async function GET(_req: Request, { params }: { params: Promise<{ id: str
|
|||||||
// meter + sidebar block — THE coverage surface (the all-sessions GET
|
// meter + sidebar block — THE coverage surface (the all-sessions GET
|
||||||
// route died with the redesign; the Extract POST stays). getChat
|
// route died with the redesign; the Extract POST stays). getChat
|
||||||
// already 404'd, so the block exists.
|
// already 404'd, so the block exists.
|
||||||
// Wave B (owner-approved 2026-09-26): the record's messages are native
|
// Wave D (owner-approved 2026-09-26): the record's messages are native
|
||||||
// agent history — clients receive the one-way projection (the AI-SDK
|
// agent history, served VERBATIM — the client derives its view
|
||||||
// UIMessage view) until Wave D. turnInFlight lets a returning client
|
// (core/view.ts projectView). turnInFlight lets a returning client
|
||||||
// re-attach to a running turn; coverage is the chat view's ring meter.
|
// re-attach to a running turn; coverage is the chat view's ring meter.
|
||||||
return Response.json({
|
return Response.json({
|
||||||
...chat,
|
...chat,
|
||||||
messages: projectNative(chat.messages),
|
messages: chat.messages,
|
||||||
turnInFlight: isTurnRunning(id),
|
turnInFlight: isTurnRunning(id),
|
||||||
coverage: sessionCoverage(id),
|
coverage: sessionCoverage(id),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { notFound } from "next/navigation";
|
import { notFound } from "next/navigation";
|
||||||
import { Chat } from "@/components/chat";
|
import { Chat } from "@/components/chat";
|
||||||
import { projectNative, slotsConfigured, type TeacherUIMessage } from "@/src/core";
|
import { slotsConfigured } from "@/src/core";
|
||||||
import { getChat, isTurnRunning, readSettingsRecord } from "@/src/server";
|
import { getChat, isTurnRunning, readSettingsRecord } from "@/src/server";
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
@@ -24,9 +24,10 @@ export default async function ChatPage({
|
|||||||
key={chat.id}
|
key={chat.id}
|
||||||
chatId={chat.id}
|
chatId={chat.id}
|
||||||
title={chat.title}
|
title={chat.title}
|
||||||
// Wave B: the record is native history — the client view is the
|
// Wave D: the record IS native agent history — the client view is
|
||||||
// one-way projection (GET /api/chats/[id] serves the same shape).
|
// derived app-side (core/view.ts projectView; GET serves the same
|
||||||
initialMessages={projectNative(chat.messages) as TeacherUIMessage[]}
|
// native messages).
|
||||||
|
initialMessages={chat.messages}
|
||||||
initialConfigured={slotsConfigured(record?.models)}
|
initialConfigured={slotsConfigured(record?.models)}
|
||||||
initialPrompt={prompt}
|
initialPrompt={prompt}
|
||||||
// A teacher turn that outlived its client: the remounted view must
|
// A teacher turn that outlived its client: the remounted view must
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import type { UIMessage } from "ai";
|
import type { LhViewMessage, LhViewPart } from "@/src/core";
|
||||||
import { ArrowDownIcon, DownloadIcon } from "lucide-react";
|
import { ArrowDownIcon, DownloadIcon } from "lucide-react";
|
||||||
import type { ComponentProps } from "react";
|
import type { ComponentProps } from "react";
|
||||||
import { useCallback } from "react";
|
import { useCallback } from "react";
|
||||||
@@ -100,7 +100,7 @@ export const ConversationScrollButton = ({
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const getMessageText = (message: UIMessage): string =>
|
const getMessageText = (message: LhViewMessage): string =>
|
||||||
message.parts
|
message.parts
|
||||||
.filter((part) => part.type === "text")
|
.filter((part) => part.type === "text")
|
||||||
.map((part) => part.text)
|
.map((part) => part.text)
|
||||||
@@ -110,21 +110,21 @@ export type ConversationDownloadProps = Omit<
|
|||||||
ComponentProps<typeof Button>,
|
ComponentProps<typeof Button>,
|
||||||
"onClick"
|
"onClick"
|
||||||
> & {
|
> & {
|
||||||
messages: UIMessage[];
|
messages: LhViewMessage[];
|
||||||
filename?: string;
|
filename?: string;
|
||||||
formatMessage?: (message: UIMessage, index: number) => string;
|
formatMessage?: (message: LhViewMessage, index: number) => string;
|
||||||
};
|
};
|
||||||
|
|
||||||
const defaultFormatMessage = (message: UIMessage): string => {
|
const defaultFormatMessage = (message: LhViewMessage): string => {
|
||||||
const roleLabel =
|
const roleLabel =
|
||||||
message.role.charAt(0).toUpperCase() + message.role.slice(1);
|
message.role.charAt(0).toUpperCase() + message.role.slice(1);
|
||||||
return `**${roleLabel}:** ${getMessageText(message)}`;
|
return `**${roleLabel}:** ${getMessageText(message)}`;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const messagesToMarkdown = (
|
export const messagesToMarkdown = (
|
||||||
messages: UIMessage[],
|
messages: LhViewMessage[],
|
||||||
formatMessage: (
|
formatMessage: (
|
||||||
message: UIMessage,
|
message: LhViewMessage,
|
||||||
index: number
|
index: number
|
||||||
) => string = defaultFormatMessage
|
) => string = defaultFormatMessage
|
||||||
): string => messages.map((msg, i) => formatMessage(msg, i)).join("\n\n");
|
): string => messages.map((msg, i) => formatMessage(msg, i)).join("\n\n");
|
||||||
|
|||||||
@@ -15,9 +15,10 @@ import { cn } from "@/lib/utils";
|
|||||||
import { cjk } from "@streamdown/cjk";
|
import { cjk } from "@streamdown/cjk";
|
||||||
import { code } from "@streamdown/code";
|
import { code } from "@streamdown/code";
|
||||||
import { createMathPlugin } from "@streamdown/math";
|
import { createMathPlugin } from "@streamdown/math";
|
||||||
import type { UIMessage } from "ai";
|
|
||||||
import { ChevronLeftIcon, ChevronRightIcon } from "lucide-react";
|
import { ChevronLeftIcon, ChevronRightIcon } from "lucide-react";
|
||||||
import type { ComponentProps, HTMLAttributes, ReactElement } from "react";
|
import type { ComponentProps, HTMLAttributes, ReactElement } from "react";
|
||||||
|
import type { LhViewMessage } from "@/src/core";
|
||||||
import {
|
import {
|
||||||
createContext,
|
createContext,
|
||||||
memo,
|
memo,
|
||||||
@@ -36,7 +37,7 @@ import { mdComponents } from "@/components/md-blocks";
|
|||||||
import { transformMathDelimiters } from "@/src/core";
|
import { transformMathDelimiters } from "@/src/core";
|
||||||
|
|
||||||
export type MessageProps = HTMLAttributes<HTMLDivElement> & {
|
export type MessageProps = HTMLAttributes<HTMLDivElement> & {
|
||||||
from: UIMessage["role"];
|
from: LhViewMessage["role"];
|
||||||
};
|
};
|
||||||
|
|
||||||
export const Message = ({ className, from, ...props }: MessageProps) => (
|
export const Message = ({ className, from, ...props }: MessageProps) => (
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { CheckIcon, CircleX } from "lucide-react";
|
import { CheckIcon, CircleX } from "lucide-react";
|
||||||
import type { PdfResult, ResearchResult, SubagentArtifactData, TeacherUIMessage } from "@/src/core";
|
import type { PdfResult, ResearchResult, SubagentArtifactData } from "@/src/core";
|
||||||
|
import type { LhViewMessage } from "@/src/core";
|
||||||
import { isSubagentFailure } from "@/src/core";
|
import { isSubagentFailure } from "@/src/core";
|
||||||
import { DemoOutputCard } from "@/components/artifacts/demo-card";
|
import { DemoOutputCard } from "@/components/artifacts/demo-card";
|
||||||
import { PdfBriefCard, ResearchBriefCard } from "@/components/artifacts/job-card";
|
import { PdfBriefCard, ResearchBriefCard } from "@/components/artifacts/job-card";
|
||||||
@@ -24,7 +25,7 @@ import { VisualOutputCard } from "@/components/artifacts/visual-card";
|
|||||||
* The sub-agent's verification notes are model-facing glue and do NOT
|
* The sub-agent's verification notes are model-facing glue and do NOT
|
||||||
* render; only a failure note does.
|
* render; only a failure note does.
|
||||||
*/
|
*/
|
||||||
export function SubagentSteerCard({ message }: { message: TeacherUIMessage }) {
|
export function SubagentSteerCard({ message }: { message: LhViewMessage }) {
|
||||||
const artifactPart = message.parts.find((p) => p.type === "data-subagent-artifact");
|
const artifactPart = message.parts.find((p) => p.type === "data-subagent-artifact");
|
||||||
const data = artifactPart?.data as SubagentArtifactData | undefined;
|
const data = artifactPart?.data as SubagentArtifactData | undefined;
|
||||||
// The abort steer (2026-09 dock semantics): its message-type part is
|
// The abort steer (2026-09 dock semantics): its message-type part is
|
||||||
|
|||||||
@@ -1,310 +0,0 @@
|
|||||||
/**
|
|
||||||
* 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}.
|
|
||||||
*
|
|
||||||
* 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").
|
|
||||||
*
|
|
||||||
* 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. */
|
|
||||||
const TURN_POLL_MS = 500;
|
|
||||||
/** Give up waiting (a wake turn can run long) and surface the 409 as before. */
|
|
||||||
const TURN_WAIT_MAX_MS = 30_000;
|
|
||||||
|
|
||||||
/** Injectable timing (tests). */
|
|
||||||
export interface RetryFetchOptions {
|
|
||||||
pollMs?: number;
|
|
||||||
waitMaxMs?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
function delay(ms: number, signal?: AbortSignal): Promise<void> {
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
const t = setTimeout(resolve, ms);
|
|
||||||
signal?.addEventListener("abort", () => {
|
|
||||||
clearTimeout(t);
|
|
||||||
reject(new DOMException("Aborted", "AbortError"));
|
|
||||||
}, { once: true });
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async function turnInFlight(chatId: string): Promise<boolean | null> {
|
|
||||||
try {
|
|
||||||
const res = await fetch(`/api/jobs?chatId=${encodeURIComponent(chatId)}`);
|
|
||||||
if (!res.ok) return null;
|
|
||||||
return ((await res.json()) as { turnInFlight?: boolean }).turnInFlight === true;
|
|
||||||
} catch {
|
|
||||||
return null; // network hiccup: keep waiting, the deadline bounds us
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 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 {
|
|
||||||
const pollMs = opts.pollMs ?? TURN_POLL_MS;
|
|
||||||
const waitMaxMs = opts.waitMaxMs ?? TURN_WAIT_MAX_MS;
|
|
||||||
return async (input, init) => {
|
|
||||||
let response = await fetch(input, init);
|
|
||||||
let waited = 0;
|
|
||||||
|
|
||||||
while (response.status === 409) {
|
|
||||||
// Free the 409 body's socket before waiting on the racing turn.
|
|
||||||
await response.body?.cancel().catch(() => {});
|
|
||||||
if (init?.signal?.aborted || waited >= waitMaxMs) return response;
|
|
||||||
|
|
||||||
await delay(pollMs, init?.signal ?? undefined);
|
|
||||||
waited += pollMs;
|
|
||||||
if (init?.signal?.aborted) return response;
|
|
||||||
if ((await turnInFlight(chatId)) !== false) continue;
|
|
||||||
|
|
||||||
// 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 REATTACH TRANSPORT (background-turn runner wave, owner ruling
|
|
||||||
* 2026-09-25): a turn runs server-side decoupled from every view, and a
|
|
||||||
* view that arrives late (navigate away mid-turn, wake-turn discovery)
|
|
||||||
* re-enters it through the SDK's OWN resume path — useChat's
|
|
||||||
* resumeStream() → reconnectToStream() — so the replay burst and the
|
|
||||||
* live deltas flow through the same processUIMessageStream assembly as
|
|
||||||
* first-subscriber streaming. No second rendering dialect exists.
|
|
||||||
*
|
|
||||||
* Three adapters over the platform contract, all in this file:
|
|
||||||
*
|
|
||||||
* 1. ENDPOINT: the SDK's default reconnect GETs /api/chat/{id}/stream;
|
|
||||||
* ours is GET /api/chats/[id]/stream (same chunk dialect).
|
|
||||||
* 2. THE SETTLED TERMINAL: "no turn running" is a terminal EVENT
|
|
||||||
* (turn-settled + [DONE]), not a 204 — one wire protocol end to end.
|
|
||||||
* The first SSE event is peeked; turn-settled cancels the body, fires
|
|
||||||
* onSettled (the Chat clears its in-flight flag and refetches the
|
|
||||||
* record), and resolves null — the SDK's "no active stream" signal
|
|
||||||
* (it sets status back to ready, no stream consumed).
|
|
||||||
* 3. THE CONTINUATION SEAM: the SDK seeds its resume streaming message
|
|
||||||
* EMPTY (the resume trigger passes lastMessage: undefined), so a
|
|
||||||
* reattached turn that CONTINUES the seeded last assistant message
|
|
||||||
* (answer-quiz reaction, wake continuation) would hit the SDK's
|
|
||||||
* replace-last-message path (start id === last id) and drop that
|
|
||||||
* message's parts for the whole stream — the answered-quiz card
|
|
||||||
* would flicker out until the settle sync restores it. When the
|
|
||||||
* peeked start chunk's messageId equals the seeded last message's id,
|
|
||||||
* rewrite it to a fresh id: this run's parts mount as their own
|
|
||||||
* message while the seeded one keeps rendering — pixel-identical to
|
|
||||||
* the live continuation (card above, reaction below) — and the settle
|
|
||||||
* syncFromServer lands the server's single assembled message (the
|
|
||||||
* server is the transcript's canon; the split never persists).
|
|
||||||
* Part ids stay server-prefixed; nothing keys parts by message id.
|
|
||||||
* ──────────────────────────────────────────────────────────────────────── */
|
|
||||||
|
|
||||||
/** The reattach half of the transport contract (chat.tsx wires the refs). */
|
|
||||||
export interface ReattachOptions {
|
|
||||||
/** turn-settled — the stream endpoint found no running turn (raced
|
|
||||||
* settle, or none ever started): the record is current; the caller
|
|
||||||
* refetches it and clears its in-flight flag. */
|
|
||||||
onSettled?: () => void;
|
|
||||||
/** The LIVE last message id of the seeded transcript (a Chat ref) —
|
|
||||||
* the continuation detection above. */
|
|
||||||
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);
|
|
||||||
out.set(a);
|
|
||||||
out.set(b, a.length);
|
|
||||||
return out;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Same id shape the server's runPiTurn mints — collision-free by design. */
|
|
||||||
function freshMessageId(): string {
|
|
||||||
return `msg_${Date.now().toString(36)}_${crypto.randomUUID().slice(0, 8)}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** One parsed SSE event off the wire (data: <json>\n\n framing). */
|
|
||||||
interface SseEvent {
|
|
||||||
/** The raw bytes of this event, re-injectable into the head stream. */
|
|
||||||
raw: Uint8Array;
|
|
||||||
/** Bytes read PAST the event boundary (the next event can arrive in
|
|
||||||
* the same read) — they must ride the head of the tail verbatim. */
|
|
||||||
rest: Uint8Array;
|
|
||||||
/** The parsed JSON payload (events here are always single data lines). */
|
|
||||||
data: Record<string, unknown>;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 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
|
|
||||||
* caller re-injects the raw bytes (plus any read-past remainder) ahead of
|
|
||||||
* the unconsumed tail, so nothing is lost or reordered.
|
|
||||||
*/
|
|
||||||
async function readFirstEvent(reader: ReadableStreamDefaultReader<Uint8Array>): Promise<SseEvent> {
|
|
||||||
const decoder = new TextDecoder();
|
|
||||||
let buf = "";
|
|
||||||
for (;;) {
|
|
||||||
const { done, value } = await reader.read();
|
|
||||||
if (done) throw new Error("The reattach stream ended before its first event.");
|
|
||||||
buf += decoder.decode(value, { stream: true });
|
|
||||||
const end = buf.indexOf("\n\n");
|
|
||||||
if (end === -1) continue;
|
|
||||||
const event = buf.slice(0, end);
|
|
||||||
const dataLine = event.split("\n").find((l) => l.startsWith("data: "));
|
|
||||||
if (!dataLine) throw new Error("The reattach stream's first event is malformed.");
|
|
||||||
return {
|
|
||||||
raw: new TextEncoder().encode(`${event}\n\n`),
|
|
||||||
rest: new TextEncoder().encode(buf.slice(end + 2)),
|
|
||||||
data: JSON.parse(dataLine.slice("data: ".length)) as Record<string, unknown>,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** The reattach transport: the input-event POST for sends, our seam for resumes. */
|
|
||||||
class ReattachingChatTransport extends DefaultChatTransport<TeacherUIMessage> {
|
|
||||||
constructor(
|
|
||||||
private readonly reattach: ReattachOptions,
|
|
||||||
private readonly inputEvent: InputEventOptions,
|
|
||||||
init: ConstructorParameters<typeof DefaultChatTransport<TeacherUIMessage>>[0],
|
|
||||||
) {
|
|
||||||
super(init);
|
|
||||||
}
|
|
||||||
|
|
||||||
override async reconnectToStream(
|
|
||||||
options: Parameters<ChatTransport<TeacherUIMessage>["reconnectToStream"]>[0],
|
|
||||||
): Promise<ReadableStream<UIMessageChunk> | null> {
|
|
||||||
const res = await fetch(`/api/chats/${encodeURIComponent(options.chatId)}/stream`, {
|
|
||||||
signal: options.abortSignal,
|
|
||||||
});
|
|
||||||
if (res.status === 204) return null; // the SDK's native no-stream signal
|
|
||||||
if (!res.ok) throw new Error((await res.text()) || "Failed to attach to the running turn.");
|
|
||||||
if (!res.body) throw new Error("The reattach response body is empty.");
|
|
||||||
|
|
||||||
const reader = res.body.getReader();
|
|
||||||
let first: SseEvent;
|
|
||||||
try {
|
|
||||||
first = await readFirstEvent(reader);
|
|
||||||
} catch (err) {
|
|
||||||
void reader.cancel().catch(() => {});
|
|
||||||
throw err;
|
|
||||||
}
|
|
||||||
|
|
||||||
// The settled terminal: nothing to attach to — the record is current.
|
|
||||||
if (first.data.type === "turn-settled") {
|
|
||||||
await reader.cancel().catch(() => {});
|
|
||||||
this.reattach.onSettled?.();
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// The continuation seam (see the block comment): a start chunk whose
|
|
||||||
// id matches the seeded last message would REPLACE that message with
|
|
||||||
// an empty streaming state — rewrite to a fresh id so this run mounts
|
|
||||||
// beside it and the settle sync merges the canon back.
|
|
||||||
let firstEvent = first.raw;
|
|
||||||
if (
|
|
||||||
first.data.type === "start" &&
|
|
||||||
typeof first.data.messageId === "string" &&
|
|
||||||
first.data.messageId === this.reattach.lastMessageId?.()
|
|
||||||
) {
|
|
||||||
firstEvent = new TextEncoder().encode(
|
|
||||||
`data: ${JSON.stringify({ ...first.data, messageId: freshMessageId() })}\n\n`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
// The head bytes: the (possibly rewritten) first event plus anything
|
|
||||||
// the peek read past the boundary — then the live tail, untouched.
|
|
||||||
const head = first.rest.length > 0 ? concatBytes(firstEvent, first.rest) : firstEvent;
|
|
||||||
|
|
||||||
// Re-inject the (possibly rewritten) head ahead of the live tail and
|
|
||||||
// hand the SDK the SAME SSE parse the POST response goes through.
|
|
||||||
const tail = new ReadableStream<Uint8Array>({
|
|
||||||
start(controller) {
|
|
||||||
controller.enqueue(head);
|
|
||||||
void (async () => {
|
|
||||||
try {
|
|
||||||
for (;;) {
|
|
||||||
const { done, value } = await reader.read();
|
|
||||||
if (done) {
|
|
||||||
controller.close();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
controller.enqueue(value);
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
controller.error(err);
|
|
||||||
}
|
|
||||||
})();
|
|
||||||
},
|
|
||||||
cancel() {
|
|
||||||
return reader.cancel();
|
|
||||||
},
|
|
||||||
});
|
|
||||||
return this.processResponseStream(tail);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The chat.tsx wiring: `transport: createReattachingTransport(chatId, refs)`.
|
|
||||||
* 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<TeacherUIMessage> {
|
|
||||||
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 } };
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
+58
-135
@@ -1,9 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { createContext, Fragment, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
|
import { createContext, Fragment, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
|
||||||
import { useChat } from "@ai-sdk/react";
|
import { useChatSession } from "@/components/session";
|
||||||
import { lastAssistantMessageIsCompleteWithToolCalls } from "ai";
|
|
||||||
import { createReattachingTransport } from "@/components/chat-transport";
|
|
||||||
import { SetupDialog } from "@/components/setup-dialog";
|
import { SetupDialog } from "@/components/setup-dialog";
|
||||||
import { Hint } from "@/components/ui/hint";
|
import { Hint } from "@/components/ui/hint";
|
||||||
import { useZeroKeys, ZeroKeysPill } from "@/components/setup-cta";
|
import { useZeroKeys, ZeroKeysPill } from "@/components/setup-cta";
|
||||||
@@ -26,7 +24,7 @@ import { DictationBar, useDictation } from "@/components/dictation";
|
|||||||
import { JobsContext } from "@/components/jobs-context";
|
import { JobsContext } from "@/components/jobs-context";
|
||||||
import { StatsBar } from "@/components/stats-bar";
|
import { StatsBar } from "@/components/stats-bar";
|
||||||
import { useActivity } from "@/components/activity-dock";
|
import { useActivity } from "@/components/activity-dock";
|
||||||
import { isProviderKeyRefusal, mergeAdopted, type ChatInput, type ChatSettings, type ModelSlotName } from "@/src/core";
|
import { isProviderKeyRefusal, needsContinuation, type ChatInput, type ChatSettings, type LhMessage, type ModelSlotName } from "@/src/core";
|
||||||
import { PlanBlock, PlanPopup, latestTree } from "@/components/plan-block";
|
import { PlanBlock, PlanPopup, latestTree } from "@/components/plan-block";
|
||||||
import { PlanMarginalia } from "@/components/plan-window";
|
import { PlanMarginalia } from "@/components/plan-window";
|
||||||
import { MermaidOverlay, SessionMermaidContext } from "@/components/session-overlay";
|
import { MermaidOverlay, SessionMermaidContext } from "@/components/session-overlay";
|
||||||
@@ -34,7 +32,8 @@ import { WaypointsIcon } from "lucide-react";
|
|||||||
import type { PlanBlockData } from "@/src/core";
|
import type { PlanBlockData } from "@/src/core";
|
||||||
import { QuestionDock } from "@/components/question-dock";
|
import { QuestionDock } from "@/components/question-dock";
|
||||||
import { SidebarToggle } from "@/components/sidebar";
|
import { SidebarToggle } from "@/components/sidebar";
|
||||||
import type { ArtifactPartProps, JobRecord, Phase3, QuestionAnswerData, TeacherUIMessage, TurnUsage } from "@/src/core";
|
import type { ArtifactPartProps, JobRecord, Phase3, QuestionAnswerData, TurnUsage } from "@/src/core";
|
||||||
|
import type { LhViewMessage } from "@/src/core";
|
||||||
import { PhaseChip, PhaseDivider, planNodeCount } from "@/components/phase-divider";
|
import { PhaseChip, PhaseDivider, planNodeCount } from "@/components/phase-divider";
|
||||||
import { CoverageRing } from "@/components/extraction-coverage";
|
import { CoverageRing } from "@/components/extraction-coverage";
|
||||||
import { findPendingToolCall, phaseAt3, phaseTimeline, streamingToolActivity } from "@/src/core";
|
import { findPendingToolCall, phaseAt3, phaseTimeline, streamingToolActivity } from "@/src/core";
|
||||||
@@ -141,7 +140,7 @@ function SystemLine({ children, testId }: { children: React.ReactNode; testId?:
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function isSubagentSteer(m: TeacherUIMessage): boolean {
|
function isSubagentSteer(m: LhViewMessage): boolean {
|
||||||
return (m.metadata as { subagentResult?: unknown } | undefined)?.subagentResult != null;
|
return (m.metadata as { subagentResult?: unknown } | undefined)?.subagentResult != null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -164,7 +163,7 @@ export function Chat({
|
|||||||
}: {
|
}: {
|
||||||
chatId: string;
|
chatId: string;
|
||||||
title: string;
|
title: string;
|
||||||
initialMessages: TeacherUIMessage[];
|
initialMessages: LhMessage[];
|
||||||
initialPrompt?: string;
|
initialPrompt?: string;
|
||||||
/** A teacher turn is running server-side but attached to no client
|
/** A teacher turn is running server-side but attached to no client
|
||||||
* (navigated away mid-turn, returned while in flight). */
|
* (navigated away mid-turn, returned while in flight). */
|
||||||
@@ -174,61 +173,31 @@ export function Chat({
|
|||||||
* only decides whether the pill rides the composer. */
|
* only decides whether the pill rides the composer. */
|
||||||
initialConfigured?: boolean;
|
initialConfigured?: boolean;
|
||||||
}) {
|
}) {
|
||||||
// Reattach plumbing (background-turn runner wave): the transport holds
|
// Reattach plumbing (background-turn runner wave): the hook holds the
|
||||||
// two stable refs — the settled terminal's callback (assigned below,
|
// settled terminal's callback — a stable ref assigned below, once
|
||||||
// once syncFromServer exists) and the LIVE last-message id (the
|
// syncFromServer exists (refs, not closures: no stale capture).
|
||||||
// continuation seam — see chat-transport.ts). Refs, not closures, so
|
|
||||||
// the per-render transport recreation can never capture a stale one.
|
|
||||||
const reattachSettledRef = useRef<() => void>(() => {});
|
const reattachSettledRef = useRef<() => void>(() => {});
|
||||||
const reattachLastIdRef = useRef<string | undefined>(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<ChatInput | null>(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 } =
|
// THE SESSION HOOK (Wave D, owner-approved 2026-09-26): the app-owned
|
||||||
useChat<TeacherUIMessage>({
|
// replacement for useChat + the AI-SDK transport. The record is native
|
||||||
id: chatId,
|
// history; the VIEW (grouped, golden-master) is derived client-side
|
||||||
messages: initialMessages,
|
// (core/view.ts projectView); sends are ONE explicit input event each
|
||||||
transport: createReattachingTransport(
|
// ({chatId, input} — the stash indirection died with the SDK), and the
|
||||||
chatId,
|
// turn stream is the VERBATIM AgentEvent passthrough the server emits.
|
||||||
{
|
const { messages, nativeMessages, send, submitAnswer, setMessages, adoptRecord, status, error, stop, clearError, resumeStream, setReattachSettled } =
|
||||||
onSettled: () => reattachSettledRef.current(),
|
useChatSession({ chatId, initialMessages, initiallyInFlight });
|
||||||
lastMessageId: () => reattachLastIdRef.current,
|
|
||||||
},
|
// The reattach settled terminal → the sync-and-continue seam (below).
|
||||||
{ takeInput },
|
setReattachSettled(() => reattachSettledRef.current());
|
||||||
),
|
|
||||||
// After the learner answers (addToolOutput), automatically send the
|
|
||||||
// continuation request so the teacher reacts to the answer.
|
|
||||||
sendAutomaticallyWhen: lastAssistantMessageIsCompleteWithToolCalls,
|
|
||||||
});
|
|
||||||
|
|
||||||
// New chat with a topic: send the first message exactly once.
|
// New chat with a topic: send the first message exactly once.
|
||||||
const sentInitial = useRef(false);
|
const sentInitial = useRef(false);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!sentInitial.current && initialPrompt && initialMessages.length === 0) {
|
if (!sentInitial.current && initialPrompt && initialMessages.length === 0) {
|
||||||
sentInitial.current = true;
|
sentInitial.current = true;
|
||||||
stashInput({ type: "text", text: initialPrompt });
|
void send({ type: "text", text: initialPrompt });
|
||||||
void sendMessage({ text: initialPrompt });
|
|
||||||
}
|
}
|
||||||
}, [initialPrompt, initialMessages.length, sendMessage]);
|
}, [initialPrompt, initialMessages.length, send]);
|
||||||
|
|
||||||
// The reattach seam's live half: the transport compares the stream's
|
|
||||||
// start id against the transcript's CURRENT last message — the
|
|
||||||
// continuation rewrite keys on it (chat-transport.ts, seam 3).
|
|
||||||
reattachLastIdRef.current = messages.at(-1)?.id;
|
|
||||||
|
|
||||||
// Reattach on mount (stream-reattach wave): a turn that outlived its
|
// Reattach on mount (stream-reattach wave): a turn that outlived its
|
||||||
// client (navigate away mid-turn, back while in flight) is re-entered
|
// client (navigate away mid-turn, back while in flight) is re-entered
|
||||||
@@ -358,14 +327,8 @@ export function Chat({
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (mountContinuation.current) return;
|
if (mountContinuation.current) return;
|
||||||
mountContinuation.current = true;
|
mountContinuation.current = true;
|
||||||
if (
|
if (!turnInFlight && status === "ready" && needsContinuation(initialMessages)) {
|
||||||
!turnInFlight &&
|
void send({ type: "continue" });
|
||||||
status === "ready" &&
|
|
||||||
messages.length > 0 &&
|
|
||||||
lastAssistantMessageIsCompleteWithToolCalls({ messages })
|
|
||||||
) {
|
|
||||||
stashInput({ type: "continue" });
|
|
||||||
void sendMessage();
|
|
||||||
}
|
}
|
||||||
// mount-only by design (turnInFlight read at initial value)
|
// mount-only by design (turnInFlight read at initial value)
|
||||||
}, []);
|
}, []);
|
||||||
@@ -469,7 +432,7 @@ export function Chat({
|
|||||||
void (async () => {
|
void (async () => {
|
||||||
const res = await fetch(`/api/chats/${encodeURIComponent(chatId)}`);
|
const res = await fetch(`/api/chats/${encodeURIComponent(chatId)}`);
|
||||||
if (!cancelled && res.ok) {
|
if (!cancelled && res.ok) {
|
||||||
const record = (await res.json().catch(() => null)) as { messages?: TeacherUIMessage[] } | null;
|
const record = (await res.json().catch(() => null)) as { messages?: LhMessage[] } | null;
|
||||||
if (Array.isArray(record?.messages)) setMessages(record.messages);
|
if (Array.isArray(record?.messages)) setMessages(record.messages);
|
||||||
}
|
}
|
||||||
if (cancelled) return;
|
if (cancelled) return;
|
||||||
@@ -502,22 +465,19 @@ export function Chat({
|
|||||||
const syncFromServer = useCallback(async () => {
|
const syncFromServer = useCallback(async () => {
|
||||||
const res = await fetch(`/api/chats/${chatId}`);
|
const res = await fetch(`/api/chats/${chatId}`);
|
||||||
if (!res.ok) return;
|
if (!res.ok) return;
|
||||||
const record = (await res.json()) as { messages?: TeacherUIMessage[]; turns?: TurnUsage[] };
|
const record = (await res.json()) as { messages?: LhMessage[]; turns?: TurnUsage[] };
|
||||||
if (Array.isArray(record.turns)) setTurns(record.turns);
|
if (Array.isArray(record.turns)) setTurns(record.turns);
|
||||||
if (!Array.isArray(record.messages)) return;
|
if (!Array.isArray(record.messages)) return;
|
||||||
setMessages(record.messages);
|
setMessages(record.messages);
|
||||||
// setMessages does NOT fire sendAutomaticallyWhen (the SDK only checks
|
// The continuation seam (contracts.ts protocol §4): a job result
|
||||||
// it inside addToolOutput) — without this the teacher would stay silent
|
// landing in the record leaves the agent owing a continuation — the
|
||||||
// after a job result lands. sendMessage() with no args re-requests with
|
// record ENDS on un-consumed results, the dock owns no question, and
|
||||||
// the current list: the continuation. (contracts.ts protocol §4.)
|
// nothing runs. The native fold (needsContinuation) is the old SDK
|
||||||
if (
|
// lastAssistantMessageIsCompleteWithToolCalls check.
|
||||||
status === "ready" &&
|
if (status === "ready" && needsContinuation(record.messages)) {
|
||||||
lastAssistantMessageIsCompleteWithToolCalls({ messages: record.messages })
|
void send({ type: "continue" });
|
||||||
) {
|
|
||||||
stashInput({ type: "continue" });
|
|
||||||
void sendMessage();
|
|
||||||
}
|
}
|
||||||
}, [chatId, setMessages, sendMessage, status]);
|
}, [chatId, setMessages, send, status]);
|
||||||
|
|
||||||
// The settled terminal's other half (stream-reattach wave): the
|
// The settled terminal's other half (stream-reattach wave): the
|
||||||
// transport resolved turn-settled — no turn running, the record is
|
// transport resolved turn-settled — no turn running, the record is
|
||||||
@@ -536,23 +496,20 @@ export function Chat({
|
|||||||
// nothing about a running turn should hide it. The full snapshot sync
|
// nothing about a running turn should hide it. The full snapshot sync
|
||||||
// mid-stream is forbidden (it breaks the SDK's in-place continuation
|
// mid-stream is forbidden (it breaks the SDK's in-place continuation
|
||||||
// merge — the stale-snapshot lesson), so adoption here is APPEND-ONLY
|
// merge — the stale-snapshot lesson), so adoption here is APPEND-ONLY
|
||||||
// (core mergeAdopted): insert the record's new ids in their canonical
|
// (the session hook's adoptRecord): insert the record's new ids in their canonical
|
||||||
// order, never rewrite a local one, keep the in-flight tail last. The
|
// order, never rewrite a local one, keep the in-flight tail last. The
|
||||||
// full syncFromServer stays the net at idle (reorder, plan blocks, turns).
|
// full syncFromServer stays the net at idle (reorder, plan blocks, turns).
|
||||||
const messagesRef = useRef(messages);
|
|
||||||
messagesRef.current = messages;
|
|
||||||
const adoptAppendOnly = useCallback(async () => {
|
const adoptAppendOnly = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`/api/chats/${chatId}`);
|
const res = await fetch(`/api/chats/${chatId}`);
|
||||||
if (!res.ok) return;
|
if (!res.ok) return;
|
||||||
const record = (await res.json()) as { messages?: TeacherUIMessage[] };
|
const record = (await res.json()) as { messages?: LhMessage[] };
|
||||||
if (!Array.isArray(record.messages)) return;
|
if (!Array.isArray(record.messages)) return;
|
||||||
const merged = mergeAdopted(messagesRef.current, record.messages);
|
adoptRecord(record.messages);
|
||||||
if (merged) setMessages(merged);
|
|
||||||
} catch {
|
} catch {
|
||||||
/* transient — the idle full sync is the net */
|
/* transient — the idle full sync is the net */
|
||||||
}
|
}
|
||||||
}, [chatId, setMessages]);
|
}, [chatId, adoptRecord]);
|
||||||
|
|
||||||
// Turn-start window: the server inserts claimed steers right after our
|
// Turn-start window: the server inserts claimed steers right after our
|
||||||
// outgoing message, BEFORE the stream produces anything — so a bounded,
|
// outgoing message, BEFORE the stream produces anything — so a bounded,
|
||||||
@@ -637,38 +594,14 @@ export function Chat({
|
|||||||
// verdict is its own custom message — marker part ONLY, no human-
|
// verdict is its own custom message — marker part ONLY, no human-
|
||||||
// authored text (the model-facing wording lives in toLlmMessages,
|
// authored text (the model-facing wording lives in toLlmMessages,
|
||||||
// the native convertToLlm seam; nothing fake is persisted as speech).
|
// the native convertToLlm seam; nothing fake is persisted as speech).
|
||||||
// setMessages + sendMessage() (no args) fires ONE request carrying
|
// ONE input event, ONE turn: the session hook appends the optimistic
|
||||||
// both — the same single-turn mechanism syncFromServer already uses,
|
// note + verdict marker (local ids — the settle sync lands the
|
||||||
// so no 409 dance and no double teacher turn.
|
// server canon) and POSTs {type:"verdict"}; the server persists the
|
||||||
const stamp = Date.now().toString(36);
|
// pair as its own write-ahead — the same single-turn mechanism
|
||||||
const rnd = crypto.randomUUID().slice(0, 8);
|
// syncFromServer uses, no 409 dance, no double teacher turn.
|
||||||
const appended: TeacherUIMessage[] = [];
|
void send({ type: "verdict", accepted, note: note || undefined });
|
||||||
if (note) {
|
|
||||||
appended.push({
|
|
||||||
id: `msg_${stamp}_${rnd}`,
|
|
||||||
role: "user",
|
|
||||||
parts: [{ type: "text", text: note }],
|
|
||||||
});
|
|
||||||
}
|
|
||||||
appended.push({
|
|
||||||
id: `verdict_${stamp}_${rnd}`,
|
|
||||||
role: "user",
|
|
||||||
parts: [
|
|
||||||
{
|
|
||||||
type: "data-verdict",
|
|
||||||
id: `vd_${stamp}_${rnd}`,
|
|
||||||
data: { accepted, at: new Date().toISOString() },
|
|
||||||
},
|
},
|
||||||
],
|
[text, send],
|
||||||
});
|
|
||||||
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],
|
|
||||||
);
|
);
|
||||||
|
|
||||||
// ── Session usage (wave-4 stats bar) ──
|
// ── Session usage (wave-4 stats bar) ──
|
||||||
@@ -955,16 +888,13 @@ export function Chat({
|
|||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
const record = (await res.json()) as {
|
const record = (await res.json()) as {
|
||||||
turnInFlight?: boolean;
|
turnInFlight?: boolean;
|
||||||
messages?: TeacherUIMessage[];
|
messages?: LhMessage[];
|
||||||
};
|
};
|
||||||
// Ground rule (2026-09-15): steer content persisted by the
|
// Ground rule (2026-09-15): steer content persisted by the
|
||||||
// wake shows NOW, at the wake turn's start — not at its
|
// wake shows NOW, at the wake turn's start — not at its
|
||||||
// finish. Append-only is safe here: no local stream runs
|
// finish. Append-only is safe here: no local stream runs
|
||||||
// under a server-side wake turn.
|
// under a server-side wake turn.
|
||||||
if (Array.isArray(record.messages)) {
|
if (Array.isArray(record.messages)) adoptRecord(record.messages);
|
||||||
const merged = mergeAdopted(messagesRef.current, record.messages);
|
|
||||||
if (merged) setMessages(merged);
|
|
||||||
}
|
|
||||||
if (!record.turnInFlight) {
|
if (!record.turnInFlight) {
|
||||||
setTurnInFlight(false);
|
setTurnInFlight(false);
|
||||||
// Heal a stream-death banner (the failed-attach fallback): the
|
// Heal a stream-death banner (the failed-attach fallback): the
|
||||||
@@ -991,8 +921,7 @@ export function Chat({
|
|||||||
if (!t || busy || pending) return;
|
if (!t || busy || pending) return;
|
||||||
draftRef.current = t; // the refusal flow's preserve-and-replay seam
|
draftRef.current = t; // the refusal flow's preserve-and-replay seam
|
||||||
setText("");
|
setText("");
|
||||||
stashInput({ type: "text", text: t });
|
void send({ type: "text", text: t });
|
||||||
void sendMessage({ text: t });
|
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -1001,8 +930,7 @@ export function Chat({
|
|||||||
chatId,
|
chatId,
|
||||||
syncFromServer,
|
syncFromServer,
|
||||||
sendText: (t: string) => {
|
sendText: (t: string) => {
|
||||||
stashInput({ type: "text", text: t });
|
void send({ type: "text", text: t });
|
||||||
void sendMessage({ text: t });
|
|
||||||
},
|
},
|
||||||
openSetup: () => setSetupOpen(true),
|
openSetup: () => setSetupOpen(true),
|
||||||
}}
|
}}
|
||||||
@@ -1192,7 +1120,7 @@ export function Chat({
|
|||||||
return (
|
return (
|
||||||
<Reasoning key={pi} isStreaming={status === "streaming" && isLastPart} className="mb-2">
|
<Reasoning key={pi} isStreaming={status === "streaming" && isLastPart} className="mb-2">
|
||||||
<ReasoningTrigger />
|
<ReasoningTrigger />
|
||||||
<ReasoningContent>{part.text}</ReasoningContent>
|
<ReasoningContent>{part.text ?? ""}</ReasoningContent>
|
||||||
</Reasoning>
|
</Reasoning>
|
||||||
);
|
);
|
||||||
case "step-start":
|
case "step-start":
|
||||||
@@ -1310,17 +1238,13 @@ export function Chat({
|
|||||||
}
|
}
|
||||||
onSubmitAnswer={(output) => {
|
onSubmitAnswer={(output) => {
|
||||||
if (!pending) return;
|
if (!pending) return;
|
||||||
// THE INPUT-EVENTS CONTRACT (Wave B): the answer ride order is
|
// THE ANSWER IS THE QUESTION CALL'S TOOL RESULT (fork 1, owner
|
||||||
// stash-then-send — addToolOutput triggers the SDK's automatic
|
// 2026-09-26): one input event, one turn — the hook appends
|
||||||
// continuation send (sendAutomaticallyWhen), and the transport
|
// the optimistic graded result (the AnsweredCard shows at
|
||||||
// consumes THIS stash to build {chatId, input}. Dismissals ride
|
// once) and the server's write-ahead persists it before the
|
||||||
// the same callback (QuestionAnswerData.dismissed).
|
// turn; the teacher's reaction streams below it. Dismissals
|
||||||
stashInput({ type: "answer", toolCallId: pending.toolCallId, answer: output });
|
// ride the same callback (QuestionAnswerData.dismissed).
|
||||||
void addToolOutput({
|
submitAnswer(pending.tool, pending.toolCallId, output);
|
||||||
tool: pending.tool as "quiz" | "ask_user_question",
|
|
||||||
toolCallId: pending.toolCallId,
|
|
||||||
output,
|
|
||||||
});
|
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</Conversation>
|
</Conversation>
|
||||||
@@ -1355,8 +1279,7 @@ export function Chat({
|
|||||||
if (t) {
|
if (t) {
|
||||||
setText("");
|
setText("");
|
||||||
draftRef.current = t;
|
draftRef.current = t;
|
||||||
stashInput({ type: "text", text: t });
|
void send({ type: "text", text: t });
|
||||||
void sendMessage({ text: t });
|
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -0,0 +1,513 @@
|
|||||||
|
/**
|
||||||
|
* THE SESSION MODEL (Wave D): the chat session's state machine, extracted
|
||||||
|
* PURE — no React, one injectable fetch — so the attach/replay/settle/
|
||||||
|
* error/abort paths are testable in the repo's node:test world. The React
|
||||||
|
* hook (components/session.ts) is a thin adapter: it re-renders on change
|
||||||
|
* and projects the view (core/view.ts).
|
||||||
|
*
|
||||||
|
* ONE VIEW PROTOCOL: every turn is a server-side background process (owner
|
||||||
|
* ruling 2026-09-25); this model is a view. Sends POST one explicit input
|
||||||
|
* event ({chatId, input}); the response and the reattach stream
|
||||||
|
* (GET /api/chats/[id]/stream) are the SAME verbatim AgentEvent SSE,
|
||||||
|
* applied by the same function. Status vocabulary (pre-Wave-D parity):
|
||||||
|
* ready | submitted | streaming | error.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { AgentEvent } from "@earendil-works/pi-agent-core";
|
||||||
|
import type { ToolResultMessage } from "@earendil-works/pi-ai";
|
||||||
|
import {
|
||||||
|
needsContinuation,
|
||||||
|
newAnswerResult,
|
||||||
|
newUserMessage,
|
||||||
|
newVerdictMessage,
|
||||||
|
projectView,
|
||||||
|
type ChatInput,
|
||||||
|
type LhMessage,
|
||||||
|
type LhViewMessage,
|
||||||
|
type LiveExecution,
|
||||||
|
type QuestionAnswerData,
|
||||||
|
} from "../src/core";
|
||||||
|
|
||||||
|
export type SessionStatus = "ready" | "submitted" | "streaming" | "error";
|
||||||
|
|
||||||
|
/** How often to poll turnInFlight while a racing turn runs (409 retry). */
|
||||||
|
export const TURN_POLL_MS = 500;
|
||||||
|
/** Give up waiting (a wake turn can run long) and surface the 409. */
|
||||||
|
export const TURN_WAIT_MAX_MS = 30_000;
|
||||||
|
|
||||||
|
export interface SessionModelDeps {
|
||||||
|
/** Injectable fetch (tests); defaults to the global. */
|
||||||
|
fetch?: typeof globalThis.fetch;
|
||||||
|
/** Injectable retry timing (tests). */
|
||||||
|
pollMs?: number;
|
||||||
|
waitMaxMs?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A local optimistic id (never a server stamp — absorption and the
|
||||||
|
* settle swap both key on this prefix). */
|
||||||
|
let localSeq = 0;
|
||||||
|
function localId(): string {
|
||||||
|
return `c_${Date.now().toString(36)}_${(localSeq++).toString(36)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function delay(ms: number, signal?: AbortSignal): Promise<void> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const t = setTimeout(resolve, ms);
|
||||||
|
signal?.addEventListener(
|
||||||
|
"abort",
|
||||||
|
() => {
|
||||||
|
clearTimeout(t);
|
||||||
|
reject(new DOMException("Aborted", "AbortError"));
|
||||||
|
},
|
||||||
|
{ once: true },
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function turnInFlight(model: SessionModel): Promise<boolean | null> {
|
||||||
|
try {
|
||||||
|
const res = await model.fetch(`/api/jobs?chatId=${encodeURIComponent(model.chatId)}`);
|
||||||
|
if (!res.ok) return null;
|
||||||
|
return ((await res.json()) as { turnInFlight?: boolean }).turnInFlight === true;
|
||||||
|
} catch {
|
||||||
|
return null; // network hiccup: keep waiting, the deadline bounds us
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One wire event off the SSE body: parsed JSON, or the [DONE] terminal. */
|
||||||
|
async function* sseEvents(reader: ReadableStreamDefaultReader<Uint8Array>): AsyncGenerator<Record<string, unknown>> {
|
||||||
|
const decoder = new TextDecoder();
|
||||||
|
let buf = "";
|
||||||
|
for (;;) {
|
||||||
|
const { done, value } = await reader.read();
|
||||||
|
if (done) return;
|
||||||
|
buf += decoder.decode(value, { stream: true });
|
||||||
|
for (;;) {
|
||||||
|
const end = buf.indexOf("\n\n");
|
||||||
|
if (end === -1) break;
|
||||||
|
const event = buf.slice(0, end);
|
||||||
|
buf = buf.slice(end + 2);
|
||||||
|
const dataLine = event.split("\n").find((l) => l.startsWith("data: "));
|
||||||
|
if (!dataLine) continue;
|
||||||
|
const data = dataLine.slice("data: ".length);
|
||||||
|
if (data === "[DONE]") return;
|
||||||
|
yield JSON.parse(data) as Record<string, unknown>;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The dangling question call's tool name (the optimistic answer result's
|
||||||
|
* toolName — the server stamps the same from its own record scan). */
|
||||||
|
export function pendingCallTool(messages: readonly LhMessage[], toolCallId: string): string | null {
|
||||||
|
for (const m of messages) {
|
||||||
|
if ((m as { role?: string }).role !== "assistant") continue;
|
||||||
|
const content = (m as { content?: unknown }).content;
|
||||||
|
if (!Array.isArray(content)) continue;
|
||||||
|
for (const block of content as Array<{ type?: string; id?: string; name?: string }>) {
|
||||||
|
if (block.type === "toolCall" && block.id === toolCallId) return block.name ?? null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Has the record ABSORBED this optimistic tail entry? The write-ahead
|
||||||
|
* persists content-identical copies with server-stamped ids — match on the
|
||||||
|
* content, not the (local) id; only `c_`-prefixed local entries are ever
|
||||||
|
* absorbed (live turn content is never dropped).
|
||||||
|
*/
|
||||||
|
export function absorbed(m: LhMessage, record: readonly LhMessage[]): boolean {
|
||||||
|
if (typeof m.id !== "string" || !m.id.startsWith("c_")) return false;
|
||||||
|
const role = (m as { role?: string }).role;
|
||||||
|
if (role === "user") {
|
||||||
|
const content = (m as { content?: unknown }).content;
|
||||||
|
return record.some(
|
||||||
|
(r) => (r as { role?: string }).role === "user" && (r as { content?: unknown }).content === content,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (role === "toolResult") {
|
||||||
|
const toolCallId = (m as ToolResultMessage).toolCallId;
|
||||||
|
return record.some(
|
||||||
|
(r) =>
|
||||||
|
(r as { role?: string }).role === "toolResult" &&
|
||||||
|
(r as ToolResultMessage).toolCallId === toolCallId,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (role === "custom") {
|
||||||
|
const marker = m as { customType?: string; details?: { accepted?: unknown } };
|
||||||
|
if (marker.customType !== "verdict") return false;
|
||||||
|
return record.some(
|
||||||
|
(r) =>
|
||||||
|
(r as { role?: string }).role === "custom" &&
|
||||||
|
(r as { customType?: string }).customType === "verdict" &&
|
||||||
|
(r as { details?: { accepted?: unknown } }).details?.accepted === marker.details?.accepted,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SessionState {
|
||||||
|
messages: LhViewMessage[];
|
||||||
|
nativeMessages: LhMessage[];
|
||||||
|
status: SessionStatus;
|
||||||
|
error: { message: string } | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SessionModelOptions extends SessionModelDeps {
|
||||||
|
chatId: string;
|
||||||
|
initialMessages: LhMessage[];
|
||||||
|
/** Fires on every state change (the hook re-renders). */
|
||||||
|
onChange?: () => void;
|
||||||
|
/** The reattach settled terminal (turn-settled): no turn running, the
|
||||||
|
* record is current — the caller refetches it. */
|
||||||
|
onReattachSettled?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class SessionModel {
|
||||||
|
readonly chatId: string;
|
||||||
|
/** Injectable fetch (tests); defaults to the global. */
|
||||||
|
fetch: typeof globalThis.fetch;
|
||||||
|
private readonly pollMs: number;
|
||||||
|
private readonly waitMaxMs: number;
|
||||||
|
private onReattachSettled?: () => void;
|
||||||
|
/** The hook's re-render trigger (set by the adapter). */
|
||||||
|
onChange?: () => void;
|
||||||
|
|
||||||
|
// THE STATE: the record (server canon) + the live tail (this client's
|
||||||
|
// optimistic appends and the streaming turn's messages). The tail dies
|
||||||
|
// at every full sync; the write-ahead absorbs the optimistic entries.
|
||||||
|
private base: LhMessage[];
|
||||||
|
private tail: LhMessage[] = [];
|
||||||
|
// The live turn's execution layer (view model mid-turn states):
|
||||||
|
private executions = new Map<string, LiveExecution>();
|
||||||
|
private streamingIds = new Set<string>();
|
||||||
|
private statusValue: SessionStatus = "ready";
|
||||||
|
private errorValue: { message: string } | null = null;
|
||||||
|
// The single live consume (POST response or reattach stream): a newer
|
||||||
|
// consume cancels the older — one reader at a time.
|
||||||
|
private consumeAbort: AbortController | null = null;
|
||||||
|
/** Synchronous ready check for the gate (send/read mount logic). */
|
||||||
|
readyRef = true;
|
||||||
|
|
||||||
|
constructor(opts: SessionModelOptions) {
|
||||||
|
this.chatId = opts.chatId;
|
||||||
|
this.base = opts.initialMessages;
|
||||||
|
this.fetch = opts.fetch ?? globalThis.fetch;
|
||||||
|
this.pollMs = opts.pollMs ?? TURN_POLL_MS;
|
||||||
|
this.waitMaxMs = opts.waitMaxMs ?? TURN_WAIT_MAX_MS;
|
||||||
|
this.onChange = opts.onChange;
|
||||||
|
this.onReattachSettled = opts.onReattachSettled;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Injectable settle hook (the adapter wires its ref). */
|
||||||
|
setReattachSettled(fn: () => void): void {
|
||||||
|
this.onReattachSettled = fn;
|
||||||
|
}
|
||||||
|
|
||||||
|
getState(): SessionState {
|
||||||
|
const live =
|
||||||
|
this.statusValue === "submitted" || this.statusValue === "streaming"
|
||||||
|
? { executions: this.executions, inFlight: true, streamingIds: this.streamingIds }
|
||||||
|
: undefined;
|
||||||
|
return {
|
||||||
|
messages: projectView([...this.base, ...this.tail], live),
|
||||||
|
nativeMessages: [...this.base, ...this.tail],
|
||||||
|
status: this.statusValue,
|
||||||
|
error: this.errorValue,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
get status(): SessionStatus {
|
||||||
|
return this.statusValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Mutate model state + notify (the hook re-renders). */
|
||||||
|
private mutate(fn: () => void): void {
|
||||||
|
fn();
|
||||||
|
this.onChange?.();
|
||||||
|
}
|
||||||
|
|
||||||
|
private setStatus(status: SessionStatus): void {
|
||||||
|
this.statusValue = status;
|
||||||
|
this.readyRef = status === "ready";
|
||||||
|
this.onChange?.();
|
||||||
|
}
|
||||||
|
|
||||||
|
private setError(message: string | null): void {
|
||||||
|
this.errorValue = message === null ? null : { message };
|
||||||
|
this.onChange?.();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The 409 identity-retry fetch (the Wave B contract): the event was
|
||||||
|
* never persisted (write-ahead happens only on acceptance), so the
|
||||||
|
* retry waits out the running turn and re-POSTs the SAME body. */
|
||||||
|
async postInputEvent(input: ChatInput, signal?: AbortSignal): Promise<Response> {
|
||||||
|
const body = JSON.stringify({ chatId: this.chatId, input });
|
||||||
|
const init: RequestInit = {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body,
|
||||||
|
signal,
|
||||||
|
};
|
||||||
|
let response = await this.fetch("/api/chat", init);
|
||||||
|
let waited = 0;
|
||||||
|
while (response.status === 409) {
|
||||||
|
await response.body?.cancel().catch(() => {});
|
||||||
|
if (signal?.aborted || waited >= this.waitMaxMs) return response;
|
||||||
|
await delay(this.pollMs, signal);
|
||||||
|
waited += this.pollMs;
|
||||||
|
if (signal?.aborted) return response;
|
||||||
|
if ((await turnInFlight(this)) !== false) continue;
|
||||||
|
response = await this.fetch("/api/chat", init); // may 409 again → loop
|
||||||
|
}
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ONE event application for both the POST stream and the reattach
|
||||||
|
* replay (pi's events carry the FULL partial message — a view REPLACES,
|
||||||
|
* never assembles deltas).
|
||||||
|
*/
|
||||||
|
applyWireEvent(event: Record<string, unknown>): void {
|
||||||
|
if (event.type === "error") {
|
||||||
|
// Loop-level failure (agent.continue threw outside the stream) —
|
||||||
|
// raw text (owner ruling, Wave D): the banner shows exactly this.
|
||||||
|
this.setError(String(event.errorText ?? "An error occurred."));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const agent = event as unknown as AgentEvent;
|
||||||
|
switch (agent.type) {
|
||||||
|
case "message_start":
|
||||||
|
case "message_update":
|
||||||
|
case "message_end": {
|
||||||
|
const message = agent.message as LhMessage & {
|
||||||
|
role?: string;
|
||||||
|
id?: string;
|
||||||
|
};
|
||||||
|
if ((message as { role?: string }).role !== "assistant") return;
|
||||||
|
const id = message.id ?? localId();
|
||||||
|
const withId = { ...message, id } as LhMessage;
|
||||||
|
if (agent.type === "message_start") {
|
||||||
|
this.streamingIds.add(id);
|
||||||
|
this.mutate(() => {
|
||||||
|
this.tail = [...this.tail, withId];
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
this.mutate(() => {
|
||||||
|
const idx = this.tail.findIndex((m) => m.id === id);
|
||||||
|
this.tail = idx === -1 ? [...this.tail, withId] : this.tail.with(idx, withId);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (agent.type === "message_end") {
|
||||||
|
const stop = (message as { stopReason?: string }).stopReason;
|
||||||
|
if (stop === "error") {
|
||||||
|
// Provider error, verbatim (owner ruling) — the record
|
||||||
|
// persists the same message; no masking anywhere.
|
||||||
|
this.setError(
|
||||||
|
String((message as { errorMessage?: string }).errorMessage ?? "An error occurred."),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
case "tool_execution_end": {
|
||||||
|
// The execution layer: clean question placeholders are IGNORED
|
||||||
|
// downstream (view.ts fork 2); errors keep their validation text.
|
||||||
|
const result = agent.result as
|
||||||
|
| { content?: Array<{ type?: string; text?: string }>; details?: unknown }
|
||||||
|
| null;
|
||||||
|
const text = result?.content?.find((c) => c.type === "text")?.text ?? "The tool call failed.";
|
||||||
|
this.mutate(() => {
|
||||||
|
this.executions = new Map(this.executions).set(agent.toolCallId, {
|
||||||
|
isError: agent.isError,
|
||||||
|
output: result?.details,
|
||||||
|
errorText: text,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
case "turn_end": {
|
||||||
|
// The results batch: append as native messages (client-stamped
|
||||||
|
// ids — the settle sync lands the server canon).
|
||||||
|
this.mutate(() => {
|
||||||
|
this.tail = [
|
||||||
|
...this.tail,
|
||||||
|
...(agent.toolResults as ToolResultMessage[]).map(
|
||||||
|
(r) => ({ ...r, id: localId() }) as unknown as LhMessage,
|
||||||
|
),
|
||||||
|
];
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return; // agent_start/turn_start/tool_execution_start|update: no view work
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Consume one SSE body to its [DONE] terminal. The turn-settled
|
||||||
|
* terminal (stream-route only) fires the settle hook and ends. */
|
||||||
|
async consumeStream(res: Response): Promise<void> {
|
||||||
|
if (!res.body) throw new Error("The turn stream body is empty.");
|
||||||
|
const reader = res.body.getReader();
|
||||||
|
let sawEvent = false;
|
||||||
|
try {
|
||||||
|
for await (const event of sseEvents(reader)) {
|
||||||
|
if (event.type === "turn-settled") {
|
||||||
|
// The raced-settle terminal: no turn to attach to — the
|
||||||
|
// record is current; the settle hook refetches.
|
||||||
|
await reader.cancel().catch(() => {});
|
||||||
|
this.onReattachSettled?.();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!sawEvent) {
|
||||||
|
sawEvent = true;
|
||||||
|
if (this.statusValue !== "error") this.setStatus("streaming");
|
||||||
|
}
|
||||||
|
this.applyWireEvent(event);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
this.streamingIds = new Set();
|
||||||
|
this.executions = new Map();
|
||||||
|
if (this.statusValue !== "error") this.setStatus("ready");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reattach (the wake/handoff/mount path): a server-side turn with no
|
||||||
|
* live view of ours. The record is refetched FIRST (write-ahead
|
||||||
|
* adoption — the tail's optimistic entries are absorbed, its partial
|
||||||
|
* stream content is about to be fully replayed), then the stream
|
||||||
|
* replays the turn from its first event through the SAME consume path.
|
||||||
|
*/
|
||||||
|
async resumeStream(): Promise<void> {
|
||||||
|
this.consumeAbort?.abort();
|
||||||
|
const controller = new AbortController();
|
||||||
|
this.consumeAbort = controller;
|
||||||
|
this.setStatus("submitted");
|
||||||
|
try {
|
||||||
|
const recordRes = await this.fetch(`/api/chats/${encodeURIComponent(this.chatId)}`, {
|
||||||
|
signal: controller.signal,
|
||||||
|
});
|
||||||
|
if (recordRes.ok) {
|
||||||
|
const record = (await recordRes.json()) as { messages?: LhMessage[] };
|
||||||
|
if (Array.isArray(record.messages)) {
|
||||||
|
this.mutate(() => {
|
||||||
|
this.base = record.messages!;
|
||||||
|
this.tail = [];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const res = await this.fetch(`/api/chats/${encodeURIComponent(this.chatId)}/stream`, {
|
||||||
|
signal: controller.signal,
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error((await res.text()) || "Failed to attach to the running turn.");
|
||||||
|
await this.consumeStream(res);
|
||||||
|
} catch (err) {
|
||||||
|
if ((err as Error).name === "AbortError") return; // a newer consume won
|
||||||
|
this.setError((err as Error).message);
|
||||||
|
this.setStatus("error");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* SEND: one explicit input event. Optimistic appends first (the instant
|
||||||
|
* answer/note UX — the server's write-ahead persists the canon), then
|
||||||
|
* POST + consume. Ready-gated: one turn per client at a time (the
|
||||||
|
* server's 409 + identity retry covers the wake-race window).
|
||||||
|
*/
|
||||||
|
async send(input: ChatInput): Promise<void> {
|
||||||
|
if (!this.readyRef) return;
|
||||||
|
this.consumeAbort?.abort();
|
||||||
|
const controller = new AbortController();
|
||||||
|
this.consumeAbort = controller;
|
||||||
|
this.setError(null);
|
||||||
|
this.setStatus("submitted");
|
||||||
|
// The optimistic appends — exactly the messages the server's
|
||||||
|
// write-ahead will persist (inputAppends), with LOCAL ids so
|
||||||
|
// adoption can tell them from the record's absorbed copies.
|
||||||
|
const optimistic: LhMessage[] = [];
|
||||||
|
if (input.type === "text") optimistic.push({ ...newUserMessage(input.text), id: localId() });
|
||||||
|
else if (input.type === "answer") {
|
||||||
|
const tool = pendingCallTool([...this.base, ...this.tail], input.toolCallId) ?? "quiz";
|
||||||
|
optimistic.push({ ...newAnswerResult(input.toolCallId, tool, input.answer), id: localId() });
|
||||||
|
} else if (input.type === "verdict") {
|
||||||
|
if (input.note) optimistic.push({ ...newUserMessage(input.note), id: localId() });
|
||||||
|
optimistic.push({
|
||||||
|
...newVerdictMessage(input.accepted, new Date().toISOString()),
|
||||||
|
id: localId(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (optimistic.length > 0) this.mutate(() => {
|
||||||
|
this.tail = [...this.tail, ...optimistic];
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
const res = await this.postInputEvent(input, controller.signal);
|
||||||
|
if (controller.signal.aborted) return;
|
||||||
|
if (res.status === 409) {
|
||||||
|
this.setError("A turn is already running — try again in a moment.");
|
||||||
|
this.setStatus("error");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!res.ok) {
|
||||||
|
// Refusal envelopes (503 BYOK/key gates) and 400s: the RAW body
|
||||||
|
// text rides the error message — the banner shows it, and the
|
||||||
|
// page's refusal flow JSON-parses the envelope out of it.
|
||||||
|
throw new Error((await res.text()) || `The send failed (${res.status}).`);
|
||||||
|
}
|
||||||
|
await this.consumeStream(res);
|
||||||
|
} catch (err) {
|
||||||
|
if ((err as Error).name === "AbortError") return; // stop() or a newer consume won
|
||||||
|
this.setError((err as Error).message);
|
||||||
|
this.setStatus("error");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The answer path (the dock): the answer IS the question call's tool
|
||||||
|
* result (fork 1), riding as its input event. */
|
||||||
|
submitAnswer(tool: string, toolCallId: string, output: QuestionAnswerData): Promise<void> {
|
||||||
|
void tool;
|
||||||
|
return this.send({ type: "answer", toolCallId, answer: output });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Local detach: cancel the consume, keep the partial tail. The page's
|
||||||
|
* settle watch adopts the record when the server turn finishes. */
|
||||||
|
stop(): void {
|
||||||
|
this.consumeAbort?.abort();
|
||||||
|
this.consumeAbort = null;
|
||||||
|
this.streamingIds = new Set();
|
||||||
|
this.executions = new Map();
|
||||||
|
this.setStatus("ready");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Full snapshot swap (the idle settle sync — server canon). */
|
||||||
|
setMessages(record: LhMessage[]): void {
|
||||||
|
this.mutate(() => {
|
||||||
|
this.base = record;
|
||||||
|
this.tail = [];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Append-only adoption (the mid-turn steer/artifact ground rule): swap
|
||||||
|
* in the record — every current base message must still be present
|
||||||
|
* (the server appends only) — keeping the live tail, minus the
|
||||||
|
* optimistic entries the record has absorbed (their server-stamped
|
||||||
|
* copies just landed; without this the input bubble would double).
|
||||||
|
*/
|
||||||
|
adoptRecord(record: LhMessage[]): void {
|
||||||
|
const recordIds = new Set(record.map((m) => (m as { id?: string }).id));
|
||||||
|
const baseAbsorbed = this.base.some((m) => !recordIds.has((m as { id?: string }).id));
|
||||||
|
if (baseAbsorbed) return; // not a pure append — the idle full sync is the net
|
||||||
|
this.mutate(() => {
|
||||||
|
this.base = record;
|
||||||
|
this.tail = this.tail.filter((m) => !absorbed(m, record));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The mount/sync continuation fold (native-messages.ts). */
|
||||||
|
static needsContinuation = needsContinuation;
|
||||||
|
|
||||||
|
clearError(): void {
|
||||||
|
this.setError(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* THE SESSION HOOK (Wave D, owner-approved 2026-09-26): the React adapter
|
||||||
|
* over the pure SessionModel (components/session-core.ts — the state
|
||||||
|
* machine, testable without a renderer). All mechanics live in the model;
|
||||||
|
* this hook re-renders on change and derives the view (core/view.ts
|
||||||
|
* projectView — the golden-master grouping, client-side).
|
||||||
|
*
|
||||||
|
* Every turn is a server-side background process (owner ruling
|
||||||
|
* 2026-09-25); the hook is a view. Sends are ONE explicit input event
|
||||||
|
* each ({chatId, input}); the turn stream is the VERBATIM AgentEvent
|
||||||
|
* passthrough. Status vocabulary (pre-Wave-D parity): ready | submitted
|
||||||
|
* | streaming | error.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useCallback, useEffect, useMemo, useReducer, useRef } from "react";
|
||||||
|
import type { ChatInput, LhMessage, QuestionAnswerData } from "@/src/core";
|
||||||
|
import { SessionModel, type SessionState } from "@/components/session-core";
|
||||||
|
|
||||||
|
export interface UseChatSessionOptions {
|
||||||
|
chatId: string;
|
||||||
|
/** The native record seed (server-rendered page or a full sync). */
|
||||||
|
initialMessages: LhMessage[];
|
||||||
|
/** Reattach on mount: a turn is running server-side with no live view. */
|
||||||
|
initiallyInFlight?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useChatSession({ chatId, initialMessages, initiallyInFlight }: UseChatSessionOptions) {
|
||||||
|
// The model is per-mount (chatId-keyed component); onChange forces the
|
||||||
|
// re-render. The ref pattern keeps it stable across renders.
|
||||||
|
const modelRef = useRef<SessionModel | null>(null);
|
||||||
|
if (modelRef.current === null) {
|
||||||
|
modelRef.current = new SessionModel({ chatId, initialMessages });
|
||||||
|
}
|
||||||
|
const model = modelRef.current;
|
||||||
|
const [, forceRender] = useReducer((n: number) => n + 1, 0);
|
||||||
|
useEffect(() => {
|
||||||
|
model.onChange = forceRender;
|
||||||
|
return () => {
|
||||||
|
model.onChange = undefined;
|
||||||
|
};
|
||||||
|
}, [model, forceRender]);
|
||||||
|
|
||||||
|
// Reattach on mount (StrictMode-safe: a newer resume cancels the older).
|
||||||
|
useEffect(() => {
|
||||||
|
if (initiallyInFlight) void model.resumeStream();
|
||||||
|
}, [initiallyInFlight, model]);
|
||||||
|
|
||||||
|
// The view + state, derived per render from the model.
|
||||||
|
const state = useMemo<SessionState>(() => model.getState(), [model, forceRender] as const);
|
||||||
|
|
||||||
|
// The reattach settled terminal → the sync-and-continue seam (chat.tsx).
|
||||||
|
const setReattachSettled = useCallback(
|
||||||
|
(fn: () => void) => {
|
||||||
|
model.setReattachSettled(fn);
|
||||||
|
},
|
||||||
|
[model],
|
||||||
|
);
|
||||||
|
|
||||||
|
const send = useCallback((input: ChatInput) => void model.send(input), [model]);
|
||||||
|
const submitAnswer = useCallback(
|
||||||
|
(tool: string, toolCallId: string, output: QuestionAnswerData) =>
|
||||||
|
void model.submitAnswer(tool, toolCallId, output),
|
||||||
|
[model],
|
||||||
|
);
|
||||||
|
const setMessages = useCallback((record: LhMessage[]) => model.setMessages(record), [model]);
|
||||||
|
const adoptRecord = useCallback((record: LhMessage[]) => model.adoptRecord(record), [model]);
|
||||||
|
const stop = useCallback(() => model.stop(), [model]);
|
||||||
|
const clearError = useCallback(() => model.clearError(), [model]);
|
||||||
|
const resumeStream = useCallback(() => void model.resumeStream(), [model]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
messages: state.messages,
|
||||||
|
nativeMessages: state.nativeMessages,
|
||||||
|
status: state.status,
|
||||||
|
error: state.error,
|
||||||
|
send,
|
||||||
|
submitAnswer,
|
||||||
|
setMessages,
|
||||||
|
adoptRecord,
|
||||||
|
stop,
|
||||||
|
clearError,
|
||||||
|
resumeStream,
|
||||||
|
setReattachSettled,
|
||||||
|
};
|
||||||
|
}
|
||||||
Generated
+2
-174
@@ -8,7 +8,6 @@
|
|||||||
"name": "learning-harness",
|
"name": "learning-harness",
|
||||||
"version": "0.2.0",
|
"version": "0.2.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@ai-sdk/react": "^4.0.79",
|
|
||||||
"@earendil-works/pi-agent-core": "^0.85.1",
|
"@earendil-works/pi-agent-core": "^0.85.1",
|
||||||
"@earendil-works/pi-ai": "^0.85.1",
|
"@earendil-works/pi-ai": "^0.85.1",
|
||||||
"@mozilla/readability": "^0.6.0",
|
"@mozilla/readability": "^0.6.0",
|
||||||
@@ -19,7 +18,6 @@
|
|||||||
"@streamdown/math": "^1.0.2",
|
"@streamdown/math": "^1.0.2",
|
||||||
"@streamdown/mermaid": "^1.0.2",
|
"@streamdown/mermaid": "^1.0.2",
|
||||||
"@xyflow/react": "^12.11.3",
|
"@xyflow/react": "^12.11.3",
|
||||||
"ai": "^7.0.76",
|
|
||||||
"ansi-to-react": "^6.2.6",
|
"ansi-to-react": "^6.2.6",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
@@ -73,91 +71,6 @@
|
|||||||
"typescript": "^5.6.0"
|
"typescript": "^5.6.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@ai-sdk/gateway": {
|
|
||||||
"version": "4.0.61",
|
|
||||||
"resolved": "https://registry.npmjs.org/@ai-sdk/gateway/-/gateway-4.0.61.tgz",
|
|
||||||
"integrity": "sha512-b+PRdwM9/x0427CKqfd4zpCRgSzQeZsCZ9PtBOaLU2S1l+eRj8++Bwt766VnLerd/F5snKWAq13miV+OPcvclA==",
|
|
||||||
"license": "Apache-2.0",
|
|
||||||
"dependencies": {
|
|
||||||
"@ai-sdk/provider": "4.0.7",
|
|
||||||
"@ai-sdk/provider-utils": "5.0.28",
|
|
||||||
"@vercel/oidc": "3.2.0"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=22"
|
|
||||||
},
|
|
||||||
"peerDependencies": {
|
|
||||||
"zod": "^3.25.76 || ^4.1.8"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@ai-sdk/mcp": {
|
|
||||||
"version": "2.0.35",
|
|
||||||
"resolved": "https://registry.npmjs.org/@ai-sdk/mcp/-/mcp-2.0.35.tgz",
|
|
||||||
"integrity": "sha512-hyaryid0qH/d8VtNZoJKeqcLy6b3v9xlkBL3JklbFXMAZJ8nmkxngrxQtjbNRTKjSFoyQ95/7IuRIijrSljMww==",
|
|
||||||
"license": "Apache-2.0",
|
|
||||||
"dependencies": {
|
|
||||||
"@ai-sdk/provider": "4.0.7",
|
|
||||||
"@ai-sdk/provider-utils": "5.0.28",
|
|
||||||
"pkce-challenge": "^5.0.1"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=22"
|
|
||||||
},
|
|
||||||
"peerDependencies": {
|
|
||||||
"zod": "^3.25.76 || ^4.1.8"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@ai-sdk/provider": {
|
|
||||||
"version": "4.0.7",
|
|
||||||
"resolved": "https://registry.npmjs.org/@ai-sdk/provider/-/provider-4.0.7.tgz",
|
|
||||||
"integrity": "sha512-6or44XprPzKbr8zkmzosowSE0pxkvJcoojBL+mCZvPUt3kvXp3XSNqeVun9golb1acEfSo6yaEBRT18h2VU+1Q==",
|
|
||||||
"license": "Apache-2.0",
|
|
||||||
"dependencies": {
|
|
||||||
"json-schema": "^0.4.0"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=22"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@ai-sdk/provider-utils": {
|
|
||||||
"version": "5.0.28",
|
|
||||||
"resolved": "https://registry.npmjs.org/@ai-sdk/provider-utils/-/provider-utils-5.0.28.tgz",
|
|
||||||
"integrity": "sha512-TnHUyd/rCYQqHg5RuiOaz/hUql6U+kbUaBW0Rp+0N5UhnAInA9CzzV0HXvuAAPwppDsK6fAx9Rd+tRawpJ/3pg==",
|
|
||||||
"license": "Apache-2.0",
|
|
||||||
"dependencies": {
|
|
||||||
"@ai-sdk/provider": "4.0.7",
|
|
||||||
"@standard-schema/spec": "^1.1.0",
|
|
||||||
"@workflow/serde": "4.1.0",
|
|
||||||
"eventsource-parser": "^3.0.8",
|
|
||||||
"undici": "^7.28.0"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=22"
|
|
||||||
},
|
|
||||||
"peerDependencies": {
|
|
||||||
"zod": "^3.25.76 || ^4.1.8"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@ai-sdk/react": {
|
|
||||||
"version": "4.0.79",
|
|
||||||
"resolved": "https://registry.npmjs.org/@ai-sdk/react/-/react-4.0.79.tgz",
|
|
||||||
"integrity": "sha512-x7dZ/teRKXgJfukoOSiKA4pMv1AbMLjTGA8/d9Dofl8Mgf4DfRNuBxs6xlcedPypWTJXkK8whac2THkOpoFzug==",
|
|
||||||
"license": "Apache-2.0",
|
|
||||||
"dependencies": {
|
|
||||||
"@ai-sdk/mcp": "2.0.35",
|
|
||||||
"@ai-sdk/provider": "4.0.7",
|
|
||||||
"@ai-sdk/provider-utils": "5.0.28",
|
|
||||||
"ai": "7.0.76",
|
|
||||||
"swr": "^2.4.1",
|
|
||||||
"throttleit": "2.1.0"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=22"
|
|
||||||
},
|
|
||||||
"peerDependencies": {
|
|
||||||
"react": "^18 || ~19.0.1 || ~19.1.2 || ^19.2.1"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@alloc/quick-lru": {
|
"node_modules/@alloc/quick-lru": {
|
||||||
"version": "5.2.0",
|
"version": "5.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz",
|
||||||
@@ -5245,12 +5158,6 @@
|
|||||||
"integrity": "sha512-1bnPQqSxSuc3Ii6MhBysoWCg58j97aUjuCSZrGSmDxNqtytIi0k8utUenAwTZN4V5mXXYGsVUI9zeBqy+jBOSQ==",
|
"integrity": "sha512-1bnPQqSxSuc3Ii6MhBysoWCg58j97aUjuCSZrGSmDxNqtytIi0k8utUenAwTZN4V5mXXYGsVUI9zeBqy+jBOSQ==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/@standard-schema/spec": {
|
|
||||||
"version": "1.1.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz",
|
|
||||||
"integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==",
|
|
||||||
"license": "MIT"
|
|
||||||
},
|
|
||||||
"node_modules/@streamdown/cjk": {
|
"node_modules/@streamdown/cjk": {
|
||||||
"version": "1.0.3",
|
"version": "1.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/@streamdown/cjk/-/cjk-1.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/@streamdown/cjk/-/cjk-1.0.3.tgz",
|
||||||
@@ -6682,21 +6589,6 @@
|
|||||||
"d3-transition": "^3.0.1"
|
"d3-transition": "^3.0.1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@vercel/oidc": {
|
|
||||||
"version": "3.2.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/@vercel/oidc/-/oidc-3.2.0.tgz",
|
|
||||||
"integrity": "sha512-UycprH3T6n3jH0k44NHMa7pnFHGu/N05MjojYr+Mc6I7obkoLIJujSWwin1pCvdy/eOxrI/l3uDLQsmcrOb4ug==",
|
|
||||||
"license": "Apache-2.0",
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 20"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@workflow/serde": {
|
|
||||||
"version": "4.1.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/@workflow/serde/-/serde-4.1.0.tgz",
|
|
||||||
"integrity": "sha512-pav4F2BoirECWR7Nf1TKt+2eETcBj7jj4cBefQ8VXQCA6NPkaKeLfj/zMgi+3zYV5ZIBT4GuUiphsj0/b9hPQQ==",
|
|
||||||
"license": "Apache-2.0"
|
|
||||||
},
|
|
||||||
"node_modules/@xmldom/xmldom": {
|
"node_modules/@xmldom/xmldom": {
|
||||||
"version": "0.8.15",
|
"version": "0.8.15",
|
||||||
"resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.8.15.tgz",
|
"resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.8.15.tgz",
|
||||||
@@ -6789,23 +6681,6 @@
|
|||||||
"node": ">= 14"
|
"node": ">= 14"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/ai": {
|
|
||||||
"version": "7.0.76",
|
|
||||||
"resolved": "https://registry.npmjs.org/ai/-/ai-7.0.76.tgz",
|
|
||||||
"integrity": "sha512-Mytm/RFJzwJ0nzXiHYAE3dgNpFG/2K2Tks5alwm+PzAQeB6RJAJ97eGuZ8wssy66PcvtGWGIscS4tRi6dg0KLQ==",
|
|
||||||
"license": "Apache-2.0",
|
|
||||||
"dependencies": {
|
|
||||||
"@ai-sdk/gateway": "4.0.61",
|
|
||||||
"@ai-sdk/provider": "4.0.7",
|
|
||||||
"@ai-sdk/provider-utils": "5.0.28"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=22"
|
|
||||||
},
|
|
||||||
"peerDependencies": {
|
|
||||||
"zod": "^3.25.76 || ^4.1.8"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/ajv": {
|
"node_modules/ajv": {
|
||||||
"version": "6.15.0",
|
"version": "6.15.0",
|
||||||
"resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz",
|
"resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz",
|
||||||
@@ -10139,15 +10014,6 @@
|
|||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/eventsource-parser": {
|
|
||||||
"version": "3.1.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.1.1.tgz",
|
|
||||||
"integrity": "sha512-EKN1vKAMcZ8MlYMpaNuxN6R9yakzH6uajHcHVTqWJzvu5pWw9DyhbP35HH8MVBQ+dZjAfDxk+A8NiR9KWaXiyQ==",
|
|
||||||
"license": "MIT",
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18.0.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/exponential-backoff": {
|
"node_modules/exponential-backoff": {
|
||||||
"version": "3.1.3",
|
"version": "3.1.3",
|
||||||
"resolved": "https://registry.npmjs.org/exponential-backoff/-/exponential-backoff-3.1.3.tgz",
|
"resolved": "https://registry.npmjs.org/exponential-backoff/-/exponential-backoff-3.1.3.tgz",
|
||||||
@@ -12091,12 +11957,6 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/json-schema": {
|
|
||||||
"version": "0.4.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/json-schema/-/json-schema-0.4.0.tgz",
|
|
||||||
"integrity": "sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA==",
|
|
||||||
"license": "(AFL-2.1 OR BSD-3-Clause)"
|
|
||||||
},
|
|
||||||
"node_modules/json-schema-to-ts": {
|
"node_modules/json-schema-to-ts": {
|
||||||
"version": "3.1.1",
|
"version": "3.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/json-schema-to-ts/-/json-schema-to-ts-3.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/json-schema-to-ts/-/json-schema-to-ts-3.1.1.tgz",
|
||||||
@@ -14680,15 +14540,6 @@
|
|||||||
"url": "https://github.com/sponsors/jonschlinkert"
|
"url": "https://github.com/sponsors/jonschlinkert"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/pkce-challenge": {
|
|
||||||
"version": "5.0.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/pkce-challenge/-/pkce-challenge-5.0.1.tgz",
|
|
||||||
"integrity": "sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ==",
|
|
||||||
"license": "MIT",
|
|
||||||
"engines": {
|
|
||||||
"node": ">=16.20.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/pkijs": {
|
"node_modules/pkijs": {
|
||||||
"version": "3.4.0",
|
"version": "3.4.0",
|
||||||
"resolved": "https://registry.npmjs.org/pkijs/-/pkijs-3.4.0.tgz",
|
"resolved": "https://registry.npmjs.org/pkijs/-/pkijs-3.4.0.tgz",
|
||||||
@@ -16530,19 +16381,6 @@
|
|||||||
"url": "https://github.com/sponsors/ljharb"
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/swr": {
|
|
||||||
"version": "2.5.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/swr/-/swr-2.5.1.tgz",
|
|
||||||
"integrity": "sha512-BRw55e8r0B7SpDN20CAzoQAHl7y1yP7/Zt7oqUjMv0vSt2u2Xnkm88Ws+VypbV9BXHQVuSuyVq7zMjO16wSExw==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"dequal": "^2.0.3",
|
|
||||||
"use-sync-external-store": "^1.6.0"
|
|
||||||
},
|
|
||||||
"peerDependencies": {
|
|
||||||
"react": "^16.11.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/tailwind-merge": {
|
"node_modules/tailwind-merge": {
|
||||||
"version": "3.6.0",
|
"version": "3.6.0",
|
||||||
"resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-3.6.0.tgz",
|
"resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-3.6.0.tgz",
|
||||||
@@ -16627,18 +16465,6 @@
|
|||||||
"fs-extra": "^10.0.0"
|
"fs-extra": "^10.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/throttleit": {
|
|
||||||
"version": "2.1.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/throttleit/-/throttleit-2.1.0.tgz",
|
|
||||||
"integrity": "sha512-nt6AMGKW1p/70DF/hGBdJB57B8Tspmbp5gfJ8ilhLnt7kkr2ye7hzD6NVG8GGErk2HWF34igrL2CXmNIkzKqKw==",
|
|
||||||
"license": "MIT",
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
},
|
|
||||||
"funding": {
|
|
||||||
"url": "https://github.com/sponsors/sindresorhus"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/tiny-async-pool": {
|
"node_modules/tiny-async-pool": {
|
||||||
"version": "1.3.0",
|
"version": "1.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/tiny-async-pool/-/tiny-async-pool-1.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/tiny-async-pool/-/tiny-async-pool-1.3.0.tgz",
|
||||||
@@ -17046,7 +16872,9 @@
|
|||||||
"version": "7.29.0",
|
"version": "7.29.0",
|
||||||
"resolved": "https://registry.npmjs.org/undici/-/undici-7.29.0.tgz",
|
"resolved": "https://registry.npmjs.org/undici/-/undici-7.29.0.tgz",
|
||||||
"integrity": "sha512-IDxfleLmmbSskfWSUATiN1nfn2rDuvnMOqb5CWR92iIfojA0Ud+ulOAAEQ57LPr9rWmsreUyf5lwyao+7GNNVw==",
|
"integrity": "sha512-IDxfleLmmbSskfWSUATiN1nfn2rDuvnMOqb5CWR92iIfojA0Ud+ulOAAEQ57LPr9rWmsreUyf5lwyao+7GNNVw==",
|
||||||
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=20.18.1"
|
"node": ">=20.18.1"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,7 +17,6 @@
|
|||||||
"desktop:dist": "next build && node scripts/build-server.mjs && electron-builder --mac --arm64"
|
"desktop:dist": "next build && node scripts/build-server.mjs && electron-builder --mac --arm64"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@ai-sdk/react": "^4.0.79",
|
|
||||||
"@earendil-works/pi-agent-core": "^0.85.1",
|
"@earendil-works/pi-agent-core": "^0.85.1",
|
||||||
"@earendil-works/pi-ai": "^0.85.1",
|
"@earendil-works/pi-ai": "^0.85.1",
|
||||||
"typebox": "1.3.7",
|
"typebox": "1.3.7",
|
||||||
@@ -29,7 +28,6 @@
|
|||||||
"@streamdown/math": "^1.0.2",
|
"@streamdown/math": "^1.0.2",
|
||||||
"@streamdown/mermaid": "^1.0.2",
|
"@streamdown/mermaid": "^1.0.2",
|
||||||
"@xyflow/react": "^12.11.3",
|
"@xyflow/react": "^12.11.3",
|
||||||
"ai": "^7.0.76",
|
|
||||||
"ansi-to-react": "^6.2.6",
|
"ansi-to-react": "^6.2.6",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import * as crypto from "node:crypto";
|
import * as crypto from "node:crypto";
|
||||||
import type { UIMessage } from "ai";
|
|
||||||
import type { Usage } from "@earendil-works/pi-ai";
|
import type { Usage } from "@earendil-works/pi-ai";
|
||||||
import type { AgentTool } from "@earendil-works/pi-agent-core";
|
import type { AgentTool } from "@earendil-works/pi-agent-core";
|
||||||
import { Type, type Static, type TSchema } from "typebox";
|
import { Type, type Static, type TSchema } from "typebox";
|
||||||
@@ -28,7 +27,6 @@ import { getGraph, mergeFindings, type EmissionStats, type GraphDeps } from "../
|
|||||||
import type { GraphStore } from "../../core/ports.ts";
|
import type { GraphStore } from "../../core/ports.ts";
|
||||||
import { settleGraphLayout } from "../../core/layout-settle.ts";
|
import { settleGraphLayout } from "../../core/layout-settle.ts";
|
||||||
import { batchRuns, batchTag, coverageOf, messageViewLines } from "../../core/coverage.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
|
* The extraction pipeline (vision §1 construction, 2026-09-02 redesign) — the
|
||||||
@@ -93,12 +91,11 @@ import { projectNative } from "../../core/projection.ts";
|
|||||||
export function transcriptView(record: ChatRecord): string {
|
export function transcriptView(record: ChatRecord): string {
|
||||||
// The shared seam (core/coverage.ts messageViewLines): the LLM's prompt
|
// The shared seam (core/coverage.ts messageViewLines): the LLM's prompt
|
||||||
// view and the coverage fingerprint use ONE line builder, so "covered"
|
// view and the coverage fingerprint use ONE line builder, so "covered"
|
||||||
// means — exactly — what the extractor saw. Wave B: the record is native
|
// means — exactly — what the extractor saw. Wave D: the record is native
|
||||||
// history — the extraction view reads through the one-way projection.
|
// history — the view reads the native messages directly.
|
||||||
const lines = [`SESSION: ${record.title}`];
|
const lines = [`SESSION: ${record.title}`];
|
||||||
const projected = projectNative(record.messages);
|
record.messages.forEach((message, index) => {
|
||||||
projected.forEach((message, index) => {
|
lines.push(...messageViewLines(message, (message as { id?: string }).id || `m${index}`));
|
||||||
lines.push(...messageViewLines(message, message.id || `m${index}`));
|
|
||||||
});
|
});
|
||||||
return lines.join("\n");
|
return lines.join("\n");
|
||||||
}
|
}
|
||||||
@@ -871,14 +868,16 @@ export function createExtractionPipeline(deps: ExtractionPipelineDeps): Extracti
|
|||||||
// engines AND their providers' keys; a refusal lands BEFORE any
|
// engines AND their providers' keys; a refusal lands BEFORE any
|
||||||
// stage runs (typed — the trigger skips, routes surface 503).
|
// stage runs (typed — the trigger skips, routes surface 503).
|
||||||
refuseIfStagesUnready();
|
refuseIfStagesUnready();
|
||||||
const projected = projectNative(record.messages);
|
const covered = coverageOf(record.messages, graphStore.readCoverage(), chatId);
|
||||||
const covered = coverageOf(projected, graphStore.readCoverage(), chatId);
|
|
||||||
if (covered.uncovered.length === 0) {
|
if (covered.uncovered.length === 0) {
|
||||||
console.info(`[extraction ${chatId}] nothing uncovered — no-op`);
|
console.info(`[extraction ${chatId}] nothing uncovered — no-op`);
|
||||||
return { nodes: 0, edges: 0, events: 0 };
|
return { nodes: 0, edges: 0, events: 0 };
|
||||||
}
|
}
|
||||||
const chunkTokens = deps.getChunkTokens?.() ?? FACTORY_SETTINGS.graph.extractionChunkTokens;
|
const chunkTokens = deps.getChunkTokens?.() ?? FACTORY_SETTINGS.graph.extractionChunkTokens;
|
||||||
const batches = batchRuns(covered.uncovered, chunkTokens);
|
// The batcher's one rule (owner, Wave D): a tool-call message and
|
||||||
|
// its result message(s) are INSEPARABLE — one chunk, covered
|
||||||
|
// together, read together (core/coverage.ts batchRuns).
|
||||||
|
const batches = batchRuns(covered.slots, covered.uncovered, chunkTokens);
|
||||||
console.info(
|
console.info(
|
||||||
`[extraction ${chatId}] pipeline start — ${covered.uncoveredTokens} uncovered tok, ` +
|
`[extraction ${chatId}] pipeline start — ${covered.uncoveredTokens} uncovered tok, ` +
|
||||||
`${covered.uncoveredCount} messages, ${batches.length} batch(es) of ≤${chunkTokens} tok`,
|
`${covered.uncoveredCount} messages, ${batches.length} batch(es) of ≤${chunkTokens} tok`,
|
||||||
@@ -887,13 +886,9 @@ export function createExtractionPipeline(deps: ExtractionPipelineDeps): Extracti
|
|||||||
// The batch's prompt view: SESSION header + the batch's lines,
|
// The batch's prompt view: SESSION header + the batch's lines,
|
||||||
// same provenance [id] tags the fingerprints used (coverage
|
// same provenance [id] tags the fingerprints used (coverage
|
||||||
// and prompt share ONE line builder — core/coverage.ts).
|
// and prompt share ONE line builder — core/coverage.ts).
|
||||||
const mids = projected.map((m, i) => (m.id || `m${i}`) as string);
|
|
||||||
const view = [
|
const view = [
|
||||||
`SESSION: ${record.title}`,
|
`SESSION: ${record.title}`,
|
||||||
...batch.flatMap((slot) => {
|
...batch.flatMap((slot) => messageViewLines(slot.message, slot.message.id)),
|
||||||
const idx = projected.indexOf(slot.message);
|
|
||||||
return messageViewLines(slot.message, mids[idx] ?? slot.message.id);
|
|
||||||
}),
|
|
||||||
].join("\n");
|
].join("\n");
|
||||||
setPhase(chatId, "extracting", "chat");
|
setPhase(chatId, "extracting", "chat");
|
||||||
const findings = await extract(chatId, view);
|
const findings = await extract(chatId, view);
|
||||||
@@ -918,7 +913,9 @@ export function createExtractionPipeline(deps: ExtractionPipelineDeps): Extracti
|
|||||||
const stats = mergeFindings(chatId, auditResult, { chatStore, graphStore }, {
|
const stats = mergeFindings(chatId, auditResult, { chatStore, graphStore }, {
|
||||||
messages: batch.map((slot) => slot.message),
|
messages: batch.map((slot) => slot.message),
|
||||||
foldedAt,
|
foldedAt,
|
||||||
coverage: batch.map((slot) => ({ chatId, fp: slot.fp, tokens: slot.tokens, foldedAt })),
|
coverage: batch
|
||||||
|
.filter((slot) => messageViewLines(slot.message, slot.message.id).length > 0)
|
||||||
|
.map((slot) => ({ chatId, fp: slot.fp, tokens: slot.tokens, foldedAt })),
|
||||||
batchTag: batchTag(batch),
|
batchTag: batchTag(batch),
|
||||||
});
|
});
|
||||||
total.nodes += stats.nodes;
|
total.nodes += stats.nodes;
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import { isToolUIPart } from "ai";
|
|
||||||
import type { PiEngine } from "../models.ts";
|
import type { PiEngine } from "../models.ts";
|
||||||
import type { SearchProvider } from "../../core/search.ts";
|
import type { SearchProvider } from "../../core/search.ts";
|
||||||
import {
|
import {
|
||||||
@@ -23,8 +22,6 @@ import { registerJobAbort, unregisterJobAbort } from "./aborts.ts";
|
|||||||
import type { ModelRole } from "../../../config.ts";
|
import type { ModelRole } from "../../../config.ts";
|
||||||
import { ROLE_SLOT, modelsNotConfigured, type ModelSlotName, type ProviderKeyMissing } from "../../core/settings.ts";
|
import { ROLE_SLOT, modelsNotConfigured, type ModelSlotName, type ProviderKeyMissing } from "../../core/settings.ts";
|
||||||
import type { ChatStore, JobStore } from "../../core/ports.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 { runDemoBuilder } from "./demo-builder.ts";
|
||||||
import { runPdfReaderAgent } from "./pdf-reader.ts";
|
import { runPdfReaderAgent } from "./pdf-reader.ts";
|
||||||
import { runResearcherAgent } from "./researcher.ts";
|
import { runResearcherAgent } from "./researcher.ts";
|
||||||
@@ -276,22 +273,18 @@ export function createJobRunner(deps: JobRunnerDeps): JobRunner {
|
|||||||
if (input) return input;
|
if (input) return input;
|
||||||
const chat = chatStore.getChat(job.chatId);
|
const chat = chatStore.getChat(job.chatId);
|
||||||
if (!chat) return undefined;
|
if (!chat) return undefined;
|
||||||
// Wave B: the record is native history — the transcript scan reads
|
// Wave D: the record is native history — the scan reads the native
|
||||||
// through the projection (same shape the old record held).
|
// assistant blocks directly (a toolCall's arguments are the input).
|
||||||
return (findToolPart(projectNative(chat.messages), job.toolCallId)?.input ?? undefined) as
|
for (const m of chat.messages) {
|
||||||
| JobToolInput
|
if ((m as { role?: string }).role !== "assistant") continue;
|
||||||
| undefined;
|
const content = (m as { content?: unknown }).content;
|
||||||
}
|
if (!Array.isArray(content)) continue;
|
||||||
|
for (const block of content as Array<{ type?: string; id?: string; arguments?: unknown }>) {
|
||||||
// ─── shared plumbing ────────────────────────────────────────────────────
|
if (block.type !== "toolCall" || block.id !== job.toolCallId) continue;
|
||||||
|
return block.arguments as JobToolInput | undefined;
|
||||||
function findToolPart(messages: UIMessage[], toolCallId: string) {
|
|
||||||
for (const m of messages) {
|
|
||||||
for (const part of m.parts) {
|
|
||||||
if (isToolUIPart(part) && part.toolCallId === toolCallId) return part;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return null;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -3,10 +3,8 @@
|
|||||||
*
|
*
|
||||||
* Part of the domain-split contracts (src/core/contracts/): each domain file
|
* Part of the domain-split contracts (src/core/contracts/): each domain file
|
||||||
* is owned outright by its stream — cross-domain schema changes go through
|
* is owned outright by its stream — cross-domain schema changes go through
|
||||||
* master. Pure types + protocol docs + tiny pure helpers; type-only imports
|
* master. Pure types + protocol docs + tiny pure helpers. See docs/vision.md.
|
||||||
* of "ai" (safe everywhere: server, client, tests). See docs/vision.md.
|
|
||||||
*/
|
*/
|
||||||
import type { UIMessage } from "ai";
|
|
||||||
import type { ThinkingLevel } from "@earendil-works/pi-agent-core";
|
import type { ThinkingLevel } from "@earendil-works/pi-agent-core";
|
||||||
import type { LhMessage } from "../native-messages.ts";
|
import type { LhMessage } from "../native-messages.ts";
|
||||||
import type { Phase3 } from "../phases.ts";
|
import type { Phase3 } from "../phases.ts";
|
||||||
@@ -20,8 +18,8 @@ import type { EmissionStats, UnderstandingLevel } from "./graph.ts";
|
|||||||
* agent loop's own shapes, provider/model/usage/thinkingSignature stamped
|
* agent loop's own shapes, provider/model/usage/thinkingSignature stamped
|
||||||
* by pi) — the canonical session record IS the agent state; replay is
|
* by pi) — the canonical session record IS the agent state; replay is
|
||||||
* `state.messages = record.messages` + continue(). The client-shaped view
|
* `state.messages = record.messages` + continue(). The client-shaped view
|
||||||
* is the one-way projection (core/projection.ts) — a pending quiz is an
|
* is the client's own grouping fold (core/view.ts, Wave D) — a pending quiz
|
||||||
* unanswered question tool call, which is exactly what makes
|
* is an unanswered question tool call, which is exactly what makes
|
||||||
* resume-mid-question free. */
|
* resume-mid-question free. */
|
||||||
export interface ChatRecord {
|
export interface ChatRecord {
|
||||||
id: string;
|
id: string;
|
||||||
|
|||||||
@@ -115,3 +115,28 @@ export interface PlanToolResult {
|
|||||||
tree: PlanTree;
|
tree: PlanTree;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// ─── the native fold (Wave D: derivePlanStatuses over LhMessage[]) ───────────
|
||||||
|
|
||||||
|
import type { LhMessage } from "../native-messages.ts";
|
||||||
|
|
||||||
|
/** The same derivation over the NATIVE record: quiz CALLS only (any state —
|
||||||
|
* a dangling call still moves position; results feed nothing). */
|
||||||
|
export function derivePlanStatusesLh(tree: PlanTree, messages: readonly LhMessage[]): DerivedPlanStatuses {
|
||||||
|
const ids = new Set(tree.nodes.map((n) => n.id));
|
||||||
|
const runs: string[] = [];
|
||||||
|
for (const m of messages) {
|
||||||
|
if ((m as { role?: string }).role !== "assistant") continue;
|
||||||
|
const content = (m as { content?: unknown }).content;
|
||||||
|
if (!Array.isArray(content)) continue;
|
||||||
|
for (const block of content as Array<{ type?: string; name?: string; arguments?: unknown }>) {
|
||||||
|
if (block.type !== "toolCall" || block.name !== "quiz") continue;
|
||||||
|
const nodeId = (block.arguments as { nodeId?: unknown } | undefined)?.nodeId;
|
||||||
|
if (typeof nodeId !== "string" || !ids.has(nodeId)) continue;
|
||||||
|
if (runs.at(-1) !== nodeId) runs.push(nodeId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const current = runs.at(-1) ?? null;
|
||||||
|
const visited = new Set(runs.slice(0, -1));
|
||||||
|
return { current, visited };
|
||||||
|
}
|
||||||
|
|||||||
+181
-69
@@ -1,63 +1,83 @@
|
|||||||
/**
|
/**
|
||||||
* Extraction coverage (vision-extraction-coverage.md, settled 2026-09-15) —
|
* Extraction coverage (vision-extraction-coverage.md, settled 2026-09-15) —
|
||||||
* the pure core. THE MESSAGE IS THE UNIT OF COVERAGE: a per-chat append-only
|
* the pure core. THE MESSAGE IS THE UNIT OF COVERAGE (Wave D, owner ruling
|
||||||
* ledger records which messages have been extracted, by CONTENT FINGERPRINT
|
* 2026-09-26: the native message, not the old projected group): a per-chat
|
||||||
* over exactly what the extraction view sees; "uncovered" is DERIVED at
|
* append-only ledger records which messages have been extracted, by CONTENT
|
||||||
* read time (fingerprint the current transcript, diff against the ledger) —
|
* FINGERPRINT over exactly what the extraction view sees; "uncovered" is
|
||||||
* never dual-tracked. Runs are token-budgeted batches of the oldest
|
* DERIVED at read time (fingerprint the current transcript, diff against the
|
||||||
* uncovered messages, so a tool call and its result (which share a message)
|
* ledger) — never dual-tracked. Runs are token-budgeted batches of the oldest
|
||||||
* can never be separated.
|
* uncovered messages, and THE ONE BATCH RULE (owner): a tool-call message and
|
||||||
|
* its tool-result message(s) are INSEPARABLE — they always appear in the same
|
||||||
|
* chunk (a covered partner rides along as context; checkpoints are
|
||||||
|
* idempotent, so re-marking it is a no-op).
|
||||||
*
|
*
|
||||||
* The fingerprint and the LLM prompt share ONE line builder —
|
* The fingerprint and the LLM prompt share ONE line builder —
|
||||||
* messageViewLines — so "covered" and "what the model saw" cannot drift
|
* messageViewLines — so "covered" and "what the model saw" cannot drift
|
||||||
* apart. The line logic is tree-blind, identical to the extractor's view:
|
* apart. The line logic is tree-blind, identical to the extractor's view:
|
||||||
* user/teacher text verbatim, quiz outcomes as one-line summaries, other
|
* user/teacher text verbatim, quiz outcomes as one-line summaries, other
|
||||||
* tool results as one generic line each, data parts invisible (plan-block
|
* clean tool results as one generic line each, markers invisible (a
|
||||||
* attach does not re-open a covered message).
|
* plan-block marker does not re-open a covered message).
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { createHash } from "node:crypto";
|
import { createHash } from "node:crypto";
|
||||||
import type { UIMessage } from "ai";
|
|
||||||
import { isToolUIPart } from "ai";
|
|
||||||
|
|
||||||
import type { CoverageEntry } from "./contracts/graph.ts";
|
import type { CoverageEntry } from "./contracts/graph.ts";
|
||||||
|
import type { LhMessage } from "./native-messages.ts";
|
||||||
|
import type { ToolResultMessage } from "@earendil-works/pi-ai";
|
||||||
|
|
||||||
export type { CoverageEntry };
|
export type { CoverageEntry };
|
||||||
|
|
||||||
/** The quiz outcome line — both the fingerprint and the LLM view see the
|
/** The quiz outcome line — both the fingerprint and the LLM view see the
|
||||||
* quiz answer, so an answered quiz re-opens the message that asked it. */
|
* quiz answer, so an answered quiz re-opens (the answer is its own native
|
||||||
function quizLine(part: { toolCallId: string; input: unknown; output: unknown }): string {
|
* message now; its fingerprint is new the moment it lands). */
|
||||||
const input = part.input as { question?: unknown } | undefined;
|
function quizLine(result: ToolResultMessage): string {
|
||||||
const out = part.output as Partial<{ questionText: string; correct: boolean; dontKnow: boolean; note: string }> | undefined;
|
const out = result.details as
|
||||||
const question = typeof input?.question === "string" ? input.question : out?.questionText ?? "(no question)";
|
| Partial<{ questionText: string; correct: boolean; dontKnow: boolean; note: string }>
|
||||||
|
| undefined;
|
||||||
|
const question = typeof out?.questionText === "string" ? out.questionText : "(no question)";
|
||||||
const outcome = out?.dontKnow === true ? "did not know" : out?.correct === true ? "answered correctly" : "answered incorrectly";
|
const outcome = out?.dontKnow === true ? "did not know" : out?.correct === true ? "answered correctly" : "answered incorrectly";
|
||||||
const note = typeof out?.note === "string" && out.note ? ` (note: ${out.note})` : "";
|
const note = typeof out?.note === "string" && out.note ? ` (note: ${out.note})` : "";
|
||||||
return `QUIZ [${part.toolCallId}] ${question} — the learner ${outcome}${note}`;
|
return `QUIZ [${result.toolCallId}] ${question} — the learner ${outcome}${note}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The user-facing text of a native message (string or text blocks). */
|
||||||
|
function textOf(content: unknown): string[] {
|
||||||
|
if (typeof content === "string") return content.trim() ? [content] : [];
|
||||||
|
if (!Array.isArray(content)) return [];
|
||||||
|
return content
|
||||||
|
.filter((c): c is { type: "text"; text: string } => (c as { type?: string }).type === "text")
|
||||||
|
.map((c) => c.text)
|
||||||
|
.filter((t) => t.trim().length > 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The extraction-relevant lines of ONE message — the shared seam between
|
* The extraction-relevant lines of ONE native message — the shared seam
|
||||||
* transcriptView (the LLM's prompt) and messageFingerprint (coverage). Same
|
* between transcriptView (the LLM's prompt) and messageFingerprint
|
||||||
* logic, same order, so a covered message is by construction exactly what
|
* (coverage). Same logic, same order, so a covered message is by
|
||||||
* the extractor saw. `mid` is the provenance tag (the message id; the
|
* construction exactly what the extractor saw. `mid` is the provenance tag
|
||||||
* transcript view passes an index fallback for legacy id-less messages,
|
* (the message id; the transcript view passes an index fallback for legacy
|
||||||
* coverage passes the id — persisted messages always have ids).
|
* id-less messages, coverage passes the id — persisted messages always have
|
||||||
|
* ids).
|
||||||
*/
|
*/
|
||||||
export function messageViewLines(message: UIMessage, mid: string): string[] {
|
export function messageViewLines(message: LhMessage, mid: string): string[] {
|
||||||
const lines: string[] = [];
|
const lines: string[] = [];
|
||||||
const who = message.role === "user" ? "LEARNER" : "TEACHER";
|
const role = (message as { role?: string }).role;
|
||||||
for (const part of message.parts) {
|
if (role === "user") {
|
||||||
if (part.type === "text" && part.text.trim()) {
|
for (const text of textOf((message as { content: unknown }).content)) {
|
||||||
lines.push(`${who} [${mid}] ${part.text.trim()}`);
|
lines.push(`LEARNER [${mid}] ${text.trim()}`);
|
||||||
} else if (isToolUIPart(part)) {
|
|
||||||
const name = part.type.slice("tool-".length);
|
|
||||||
if (name === "quiz" && part.state === "output-available") {
|
|
||||||
lines.push(quizLine(part as unknown as { toolCallId: string; input: unknown; output: unknown }));
|
|
||||||
} else if (part.state === "output-available") {
|
|
||||||
lines.push(`(${name} tool result delivered)`);
|
|
||||||
}
|
}
|
||||||
|
} else if (role === "assistant") {
|
||||||
|
for (const text of textOf((message as { content: unknown }).content)) {
|
||||||
|
lines.push(`TEACHER [${mid}] ${text.trim()}`);
|
||||||
}
|
}
|
||||||
|
} else if (role === "toolResult") {
|
||||||
|
const result = message as ToolResultMessage;
|
||||||
|
if (result.isError) return lines; // a failed call is retry context, not learning evidence
|
||||||
|
lines.push(
|
||||||
|
result.toolName === "quiz" ? quizLine(result) : `(${result.toolName} tool result delivered)`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
// custom markers are invisible to the extraction view.
|
||||||
return lines;
|
return lines;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -69,33 +89,28 @@ export function viewTokens(lines: string[]): number {
|
|||||||
/**
|
/**
|
||||||
* A message's coverage fingerprint: sha256 of role + its view lines (with
|
* A message's coverage fingerprint: sha256 of role + its view lines (with
|
||||||
* the message's real id as the provenance tag). Changes exactly when the
|
* the message's real id as the provenance tag). Changes exactly when the
|
||||||
* extraction view of the message changes: an answered quiz merges into the
|
* extraction view of the message changes. (Wave D: the unit is the native
|
||||||
* asking message → new line → the message re-opens itself, and only itself.
|
* message — an answered quiz is the answer's OWN toolResult message, so
|
||||||
* Plan-block part attach changes data parts (invisible to the view) → the
|
* answering opens exactly that new message; the asking message is pulled
|
||||||
* fingerprint is stable → still covered.
|
* into the batch by the inseparability rule, not re-opened.)
|
||||||
*/
|
*/
|
||||||
export function messageFingerprint(message: UIMessage): string {
|
export function messageFingerprint(message: LhMessage): string {
|
||||||
const h = createHash("sha256");
|
const h = createHash("sha256");
|
||||||
h.update(JSON.stringify([message.role, messageViewLines(message, message.id)]));
|
h.update(JSON.stringify([(message as { role?: string }).role, messageViewLines(message, message.id)]));
|
||||||
return h.digest("hex");
|
return h.digest("hex");
|
||||||
}
|
}
|
||||||
|
|
||||||
/** One message's coverage-relevant projection. */
|
/** One message's coverage-relevant projection. */
|
||||||
export interface CoverageSlot {
|
export interface CoverageSlot {
|
||||||
message: UIMessage;
|
message: LhMessage;
|
||||||
fp: string;
|
fp: string;
|
||||||
tokens: number;
|
tokens: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Fingerprint a whole transcript, in order (used by the trigger, the run,
|
/** Fingerprint a whole transcript, in order (used by the trigger, the run,
|
||||||
* and the coverage view — one derivation everywhere). */
|
* and the coverage view — one derivation everywhere). */
|
||||||
export function coverageSlots(messages: ReadonlyArray<UIMessage>): CoverageSlot[] {
|
export function coverageSlots(messages: ReadonlyArray<LhMessage>): CoverageSlot[] {
|
||||||
return messages.map((message) => {
|
return messages.map((message) => ({ message, fp: messageFingerprint(message), tokens: viewTokens(messageViewLines(message, message.id)) }));
|
||||||
const lines = messageViewLines(message, message.id);
|
|
||||||
const h = createHash("sha256");
|
|
||||||
h.update(JSON.stringify([message.role, lines]));
|
|
||||||
return { message, fp: h.digest("hex"), tokens: viewTokens(lines) };
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The covered-fingerprint set for one chat, from the ledger. */
|
/** The covered-fingerprint set for one chat, from the ledger. */
|
||||||
@@ -106,14 +121,19 @@ export function coveredSet(entries: ReadonlyArray<CoverageEntry>, chatId: string
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** The derived state: which slots are uncovered, plus the token totals.
|
/** The derived state: which slots are uncovered, plus the token totals.
|
||||||
* "Uncovered" needs no ledger of its own — this diff IS the coverage view. */
|
* "Uncovered" needs no ledger of its own — this diff IS the coverage view.
|
||||||
|
* `uncovered` is INDEXES into `slots` (the batcher needs positions for the
|
||||||
|
* inseparability rule). Messages with NO view lines (custom markers, an
|
||||||
|
* error result) are invisible to coverage entirely: the extractor can
|
||||||
|
* never see them, so they are neither covered nor uncovered — a marker
|
||||||
|
* attach never opens (or joins) an extraction run. */
|
||||||
export function coverageOf(
|
export function coverageOf(
|
||||||
messages: ReadonlyArray<UIMessage>,
|
messages: ReadonlyArray<LhMessage>,
|
||||||
entries: ReadonlyArray<CoverageEntry>,
|
entries: ReadonlyArray<CoverageEntry>,
|
||||||
chatId: string,
|
chatId: string,
|
||||||
): {
|
): {
|
||||||
slots: CoverageSlot[];
|
slots: CoverageSlot[];
|
||||||
uncovered: CoverageSlot[];
|
uncovered: number[];
|
||||||
coveredTokens: number;
|
coveredTokens: number;
|
||||||
uncoveredTokens: number;
|
uncoveredTokens: number;
|
||||||
coveredCount: number;
|
coveredCount: number;
|
||||||
@@ -121,40 +141,132 @@ export function coverageOf(
|
|||||||
} {
|
} {
|
||||||
const covered = coveredSet(entries, chatId);
|
const covered = coveredSet(entries, chatId);
|
||||||
const slots = coverageSlots(messages);
|
const slots = coverageSlots(messages);
|
||||||
const uncovered = slots.filter((s) => !covered.has(s.fp));
|
const uncovered: number[] = [];
|
||||||
const coveredCount = slots.length - uncovered.length;
|
|
||||||
let coveredTokens = 0;
|
let coveredTokens = 0;
|
||||||
let uncoveredTokens = 0;
|
let uncoveredTokens = 0;
|
||||||
for (const s of slots) {
|
for (let i = 0; i < slots.length; i++) {
|
||||||
if (covered.has(s.fp)) coveredTokens += s.tokens;
|
const slot = slots[i]!;
|
||||||
else uncoveredTokens += s.tokens;
|
if (messageViewLines(slot.message, slot.message.id).length === 0) continue; // invisible
|
||||||
|
if (covered.has(slot.fp)) coveredTokens += slot.tokens;
|
||||||
|
else {
|
||||||
|
uncovered.push(i);
|
||||||
|
uncoveredTokens += slot.tokens;
|
||||||
}
|
}
|
||||||
return { slots, uncovered, coveredTokens, uncoveredTokens, coveredCount, uncoveredCount: uncovered.length };
|
}
|
||||||
|
const visibleCount = slots.length - slots.filter(
|
||||||
|
(s) => messageViewLines(s.message, s.message.id).length === 0,
|
||||||
|
).length;
|
||||||
|
return {
|
||||||
|
slots,
|
||||||
|
uncovered,
|
||||||
|
coveredTokens,
|
||||||
|
uncoveredTokens,
|
||||||
|
coveredCount: visibleCount - uncovered.length,
|
||||||
|
uncoveredCount: uncovered.length,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── the inseparability rule (owner, Wave D fork 3) ─────────────────────────
|
||||||
|
//
|
||||||
|
// A tool-call message and its tool-result message(s) always appear in the
|
||||||
|
// same chunk. Bonding is adjacency-honest: results follow their call's
|
||||||
|
// assistant message contiguously in the native record (the agent loop
|
||||||
|
// appends the results batch; the question gate guarantees nothing can land
|
||||||
|
// between a quiz call and its learner answer). A result whose call's
|
||||||
|
// assistant message has NO other bond partners still pulls that message in
|
||||||
|
// (the extractor needs the question with its answer).
|
||||||
|
|
||||||
|
/** The call ids an assistant message carries. */
|
||||||
|
function callIdsOf(message: LhMessage): Set<string> {
|
||||||
|
const out = new Set<string>();
|
||||||
|
const content = (message as { content?: unknown }).content;
|
||||||
|
if (!Array.isArray(content)) return out;
|
||||||
|
for (const block of content as Array<{ type?: string; id?: string }>) {
|
||||||
|
if (block.type === "toolCall" && typeof block.id === "string") out.add(block.id);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The contiguous bond-range containing slot i: an assistant-with-calls
|
||||||
|
* message plus its immediately-following results; a toolResult message
|
||||||
|
* plus the assistant message that made its call (and that call's sibling
|
||||||
|
* results). Anything else bonds to itself alone. */
|
||||||
|
function bondRange(messages: ReadonlyArray<LhMessage>, i: number): [number, number] {
|
||||||
|
const roleOf = (m: LhMessage | undefined) => (m ? (m as { role?: string }).role : undefined);
|
||||||
|
const m = messages[i];
|
||||||
|
if (!m) return [i, i];
|
||||||
|
if (roleOf(m) === "assistant") {
|
||||||
|
const calls = callIdsOf(m);
|
||||||
|
if (calls.size === 0) return [i, i];
|
||||||
|
let end = i;
|
||||||
|
while (
|
||||||
|
roleOf(messages[end + 1]) === "toolResult" &&
|
||||||
|
calls.has((messages[end + 1] as ToolResultMessage).toolCallId)
|
||||||
|
) {
|
||||||
|
end++;
|
||||||
|
}
|
||||||
|
return [i, end];
|
||||||
|
}
|
||||||
|
if (roleOf(m) === "toolResult") {
|
||||||
|
let start = i;
|
||||||
|
while (roleOf(messages[start - 1]) === "toolResult") start--;
|
||||||
|
const owner = messages[start - 1];
|
||||||
|
if (
|
||||||
|
owner &&
|
||||||
|
roleOf(owner) === "assistant" &&
|
||||||
|
callIdsOf(owner).has((m as ToolResultMessage).toolCallId)
|
||||||
|
) {
|
||||||
|
const [s, e] = bondRange(messages, start - 1);
|
||||||
|
return [s, Math.max(e, i)];
|
||||||
|
}
|
||||||
|
return [start, i]; // orphaned results (no asking message): keep the contiguous result run together
|
||||||
|
}
|
||||||
|
return [i, i];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Batch the uncovered messages into token-budgeted RUNS: consecutive slots,
|
* Batch the uncovered messages into token-budgeted RUNS: consecutive slots,
|
||||||
* oldest first, greedy fill — a message is never split (it is the unit), so
|
* oldest first, greedy fill — a message is never split, and a bond-range
|
||||||
* a tool call and its result always travel together. A single message larger
|
* (call + results) never splits either (owner fork 3): a selected uncovered
|
||||||
* than the budget becomes its own (over-budget) run rather than being
|
* slot EXPANDS to its whole bond-range, covered partners riding as context
|
||||||
* dropped. Runs are ephemeral — the durable record is per-message coverage,
|
* (checkpoint idempotency makes re-marking them a no-op). A single
|
||||||
* checkpointed after each run merges.
|
* bond-range larger than the budget becomes its own (over-budget) run
|
||||||
|
* rather than being dropped. Runs are ephemeral — the durable record is
|
||||||
|
* per-message coverage, checkpointed after each run merges.
|
||||||
*/
|
*/
|
||||||
export function batchRuns(uncovered: ReadonlyArray<CoverageSlot>, budget: number): CoverageSlot[][] {
|
export function batchRuns(slots: ReadonlyArray<CoverageSlot>, uncovered: ReadonlyArray<number>, budget: number): CoverageSlot[][] {
|
||||||
if (budget <= 0 || uncovered.length === 0) return [];
|
if (budget <= 0 || uncovered.length === 0) return [];
|
||||||
|
const messages = slots.map((s) => s.message);
|
||||||
|
// Expand: every uncovered index marks its whole bond-range as needed.
|
||||||
|
const needed = new Set<number>();
|
||||||
|
for (const i of uncovered) {
|
||||||
|
const [start, end] = bondRange(messages, i);
|
||||||
|
for (let j = start; j <= end; j++) needed.add(j);
|
||||||
|
}
|
||||||
|
// Group the needed slots in order, greedy by budget, never splitting a
|
||||||
|
// bond-range: a range that would overflow starts a new run (and an
|
||||||
|
// over-budget range rides alone).
|
||||||
const runs: CoverageSlot[][] = [];
|
const runs: CoverageSlot[][] = [];
|
||||||
let current: CoverageSlot[] = [];
|
let current: CoverageSlot[] = [];
|
||||||
let currentTokens = 0;
|
let currentTokens = 0;
|
||||||
for (const slot of uncovered) {
|
const flush = () => {
|
||||||
if (current.length > 0 && currentTokens + slot.tokens > budget) {
|
if (current.length > 0) runs.push(current);
|
||||||
runs.push(current);
|
|
||||||
current = [];
|
current = [];
|
||||||
currentTokens = 0;
|
currentTokens = 0;
|
||||||
|
};
|
||||||
|
for (let i = 0; i < slots.length; i++) {
|
||||||
|
if (!needed.has(i)) continue;
|
||||||
|
const [start, end] = bondRange(messages, i);
|
||||||
|
const rangeTokens = slots.slice(start, end + 1).reduce((t, s) => t + s.tokens, 0);
|
||||||
|
if (current.length > 0 && currentTokens + rangeTokens > budget) flush();
|
||||||
|
// The whole range enters together — skip past its end in the outer loop.
|
||||||
|
for (let j = start; j <= end; j++) {
|
||||||
|
current.push(slots[j]!);
|
||||||
|
currentTokens += slots[j]!.tokens;
|
||||||
}
|
}
|
||||||
current.push(slot);
|
i = end;
|
||||||
currentTokens += slot.tokens;
|
|
||||||
}
|
}
|
||||||
if (current.length > 0) runs.push(current);
|
flush();
|
||||||
return runs;
|
return runs;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+27
-12
@@ -39,7 +39,8 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { randomBytes } from "node:crypto";
|
import { randomBytes } from "node:crypto";
|
||||||
import { isToolUIPart, type UIMessage } from "ai";
|
import type { LhMessage } from "./native-messages.ts";
|
||||||
|
import type { ToolResultMessage } from "@earendil-works/pi-ai";
|
||||||
import type { CoverageEntry } from "./contracts/graph.ts";
|
import type { CoverageEntry } from "./contracts/graph.ts";
|
||||||
import type {
|
import type {
|
||||||
AuditResult,
|
AuditResult,
|
||||||
@@ -85,7 +86,7 @@ function newGraphNodeId(): string {
|
|||||||
* fresh understanding observations land as new events; the notes path
|
* fresh understanding observations land as new events; the notes path
|
||||||
* passes neither and keeps the whole-file checkpoint. */
|
* passes neither and keeps the whole-file checkpoint. */
|
||||||
export interface MergeSource {
|
export interface MergeSource {
|
||||||
messages: UIMessage[] | null;
|
messages: LhMessage[] | null;
|
||||||
foldedAt: string;
|
foldedAt: string;
|
||||||
/** The batch being merged (chat path): one entry per message in
|
/** The batch being merged (chat path): one entry per message in
|
||||||
* `messages`, same order — undefined (notes path / coverage-less
|
* `messages`, same order — undefined (notes path / coverage-less
|
||||||
@@ -97,7 +98,11 @@ export interface MergeSource {
|
|||||||
|
|
||||||
// ─── transcript scanning (shared by the merger + trigger) ─────────────────────
|
// ─── transcript scanning (shared by the merger + trigger) ─────────────────────
|
||||||
|
|
||||||
/** A flattened, order-preserving view of every tool part in a transcript. */
|
/** A flattened, order-preserving view of every tool call (+ its result
|
||||||
|
* state) in a native transcript. Wave D: the pairing is native — calls
|
||||||
|
* come from assistant content blocks; a result map built from the
|
||||||
|
* toolResult messages carries the outcome (a dangling call is
|
||||||
|
* input-available, an error result is output-error). */
|
||||||
interface ToolPartView {
|
interface ToolPartView {
|
||||||
name: string;
|
name: string;
|
||||||
toolCallId: string;
|
toolCallId: string;
|
||||||
@@ -106,17 +111,27 @@ interface ToolPartView {
|
|||||||
output: unknown;
|
output: unknown;
|
||||||
}
|
}
|
||||||
|
|
||||||
function toolParts(messages: UIMessage[]): ToolPartView[] {
|
function toolParts(messages: LhMessage[]): ToolPartView[] {
|
||||||
|
const results = new Map<string, ToolResultMessage>();
|
||||||
|
for (const m of messages) {
|
||||||
|
if ((m as { role?: string }).role === "toolResult") {
|
||||||
|
results.set((m as ToolResultMessage).toolCallId, m as ToolResultMessage);
|
||||||
|
}
|
||||||
|
}
|
||||||
const out: ToolPartView[] = [];
|
const out: ToolPartView[] = [];
|
||||||
for (const message of messages) {
|
for (const m of messages) {
|
||||||
for (const part of message.parts) {
|
if ((m as { role?: string }).role !== "assistant") continue;
|
||||||
if (!isToolUIPart(part)) continue;
|
const content = (m as { content?: unknown }).content;
|
||||||
|
if (!Array.isArray(content)) continue;
|
||||||
|
for (const block of content as Array<{ type?: string; id?: string; name?: string; arguments?: unknown }>) {
|
||||||
|
if (block.type !== "toolCall" || typeof block.id !== "string") continue;
|
||||||
|
const result = results.get(block.id);
|
||||||
out.push({
|
out.push({
|
||||||
name: part.type.slice("tool-".length),
|
name: block.name ?? "",
|
||||||
toolCallId: part.toolCallId,
|
toolCallId: block.id,
|
||||||
state: part.state,
|
state: result ? (result.isError ? "output-error" : "output-available") : "input-available",
|
||||||
input: part.input,
|
input: block.arguments,
|
||||||
output: part.output,
|
output: result && !result.isError ? result.details : undefined,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+5
-3
@@ -31,13 +31,15 @@ export * from "./phases.ts";
|
|||||||
export * from "./pending.ts";
|
export * from "./pending.ts";
|
||||||
export * from "./prompt-segments.ts";
|
export * from "./prompt-segments.ts";
|
||||||
export * from "./math-delims.ts";
|
export * from "./math-delims.ts";
|
||||||
export * from "./ui-message.ts";
|
|
||||||
// Wave B (owner-approved 2026-09-26): the native record (LhMessage), the
|
// Wave B (owner-approved 2026-09-26): the native record (LhMessage), the
|
||||||
// one-way client-view projection, and the input-events contract — all
|
// and the input-events contract — all
|
||||||
// pure logic, browser-safe.
|
// pure logic, browser-safe.
|
||||||
export * from "./native-messages.ts";
|
export * from "./native-messages.ts";
|
||||||
export * from "./projection.ts";
|
|
||||||
export * from "./input-events.ts";
|
export * from "./input-events.ts";
|
||||||
|
// Wave D (owner-approved 2026-09-26): the client view model — the grouping
|
||||||
|
// canon (one continuous assistant document, step separators) ported from
|
||||||
|
// the deleted server projection to the client side. Pure, browser-safe.
|
||||||
|
export * from "./view.ts";
|
||||||
export * from "./tool-parts.ts";
|
export * from "./tool-parts.ts";
|
||||||
export * from "./prices.ts";
|
export * from "./prices.ts";
|
||||||
export * from "./cost-summary.ts";
|
export * from "./cost-summary.ts";
|
||||||
|
|||||||
@@ -282,3 +282,48 @@ export function hasProbeDone(messages: readonly LhMessage[]): boolean {
|
|||||||
export function answeredCallIds(messages: readonly LhMessage[]): Set<string> {
|
export function answeredCallIds(messages: readonly LhMessage[]): Set<string> {
|
||||||
return resultIdsFrom(messages);
|
return resultIdsFrom(messages);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── the continuation fold (Wave D: the SDK's
|
||||||
|
// lastAssistantMessageIsCompleteWithToolCalls, natively) ──────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Does the record END on un-consumed tool results — a turn that stopped
|
||||||
|
* after its tools ran (a job dispatch's ack, an aborted answer turn),
|
||||||
|
* leaving the agent owing a continuation? This is the mount/sync
|
||||||
|
* continuation seam's gate (chat.tsx): when true and nothing runs, the
|
||||||
|
* client sends {type:"continue"} and the teacher picks up from the record.
|
||||||
|
*
|
||||||
|
* The pre-Wave-D client asked the SDK's
|
||||||
|
* lastAssistantMessageIsCompleteWithToolCalls (last view message is
|
||||||
|
* assistant; its last step's tool calls all output-available or
|
||||||
|
* output-error; at least one). The native fold over the record: the last
|
||||||
|
* message is a toolResult, and the results' OWNING assistant message has
|
||||||
|
* at least one call, ALL of them answered (a dangling question — the
|
||||||
|
* dock's domain — fails the all-answered rule and never fires a turn,
|
||||||
|
* exactly as the SDK's every(output-available) did).
|
||||||
|
*/
|
||||||
|
export function needsContinuation(messages: readonly LhMessage[]): boolean {
|
||||||
|
const last = messages[messages.length - 1];
|
||||||
|
if (!last || (last as { role?: string }).role !== "toolResult") return false;
|
||||||
|
// The trailing results batch (contiguous by construction — the agent
|
||||||
|
// appends its batch; the question gate keeps a quiz call and its
|
||||||
|
// learner answer adjacent).
|
||||||
|
let start = messages.length - 1;
|
||||||
|
while (
|
||||||
|
start > 0 &&
|
||||||
|
(messages[start - 1] as { role?: string } | undefined)?.role === "toolResult"
|
||||||
|
) {
|
||||||
|
start--;
|
||||||
|
}
|
||||||
|
const owner = messages[start - 1];
|
||||||
|
if (!owner || (owner as { role?: string }).role !== "assistant") return false;
|
||||||
|
const content = (owner as { content?: unknown }).content;
|
||||||
|
if (!Array.isArray(content)) return false;
|
||||||
|
const answered = new Set(
|
||||||
|
messages.slice(start).map((m) => (m as ToolResultMessage).toolCallId),
|
||||||
|
);
|
||||||
|
const calls = (content as Array<{ type?: string; id?: string }>).filter(
|
||||||
|
(b) => b.type === "toolCall" && typeof b.id === "string",
|
||||||
|
);
|
||||||
|
return calls.length > 0 && calls.every((b) => answered.has(b.id!));
|
||||||
|
}
|
||||||
|
|||||||
+16
-2
@@ -1,4 +1,18 @@
|
|||||||
import { isToolUIPart, type UIMessage } from "ai";
|
/**
|
||||||
|
* Wave D: these scans read VIEW messages (core/view.ts LhViewMessage — the
|
||||||
|
* client's grouped transcript). The part shapes are the same vocabulary the
|
||||||
|
* pre-Wave-D client dialect used; a tool part is structural: `tool-` prefix +
|
||||||
|
* toolCallId. The server-side record scans (danglingQuestionOf over native
|
||||||
|
* LhMessage[]) live in native-messages.ts.
|
||||||
|
*/
|
||||||
|
interface UIMessage {
|
||||||
|
role?: string;
|
||||||
|
parts: Array<Record<string, unknown> & { type: string }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isToolUIPart(part: UIMessage["parts"][number]): boolean {
|
||||||
|
return part.type.startsWith("tool-") && typeof part.toolCallId === "string";
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The pending-question gate: an unanswered QUESTION tool part (quiz/ask)
|
* The pending-question gate: an unanswered QUESTION tool part (quiz/ask)
|
||||||
@@ -76,7 +90,7 @@ function findPending(messages: UIMessage[], names: readonly string[]): PendingTo
|
|||||||
if (isToolUIPart(part) && part.state === "input-available") {
|
if (isToolUIPart(part) && part.state === "input-available") {
|
||||||
const tool = part.type.slice("tool-".length);
|
const tool = part.type.slice("tool-".length);
|
||||||
if (names.includes(tool)) {
|
if (names.includes(tool)) {
|
||||||
return { toolCallId: part.toolCallId, tool, input: part.input };
|
return { toolCallId: part.toolCallId as string, tool, input: part.input };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+51
-7
@@ -1,15 +1,14 @@
|
|||||||
/**
|
/**
|
||||||
* Phase derivation (docs/probe-teach-split.md §4) — EXPLICIT EVENTS, never
|
* Phase derivation (docs/probe-teach-split.md §4) — EXPLICIT EVENTS, never
|
||||||
* occupancy. The probe→teach agent split (2026-09-11) gave every phase
|
* occupancy. The probe→teach agent split (2026-09-11) gave every phase
|
||||||
* boundary a transcript marker:
|
* boundary a transcript event:
|
||||||
*
|
*
|
||||||
* probe — until the first message containing a `tool-probe_done` part
|
* probe — until the prober's `probe_done` call CONCLUDES (a clean
|
||||||
* (the prober concludes; the assessment rides the tool part)
|
* toolResult for it; the assessment rides the call's arguments)
|
||||||
* plan — probe concluded, until the learner ACCEPTS the presented
|
* plan — probe concluded, until the learner ACCEPTS the presented
|
||||||
* plan (a `data-verdict` part with accepted:true on the verdict
|
* plan (the verdict marker with accepted:true — the explicit
|
||||||
* button's own user message — the explicit verdict gate;
|
* verdict gate; plan_init itself is only the PRESENTATION and
|
||||||
* plan_init itself is only the PRESENTATION and moves no
|
* moves no phase; a declined verdict stays in plan)
|
||||||
* phase; a declined verdict stays in plan)
|
|
||||||
* teach — teaching is sanctioned
|
* teach — teaching is sanctioned
|
||||||
*
|
*
|
||||||
* The former derived-occupancy seam (phase-of-tree via `currentNodeId`)
|
* The former derived-occupancy seam (phase-of-tree via `currentNodeId`)
|
||||||
@@ -18,6 +17,14 @@
|
|||||||
* progress fields at all. No backwards compatibility: legacy sessions
|
* progress fields at all. No backwards compatibility: legacy sessions
|
||||||
* without probe_done simply read as probe (they are wiped data).
|
* without probe_done simply read as probe (they are wiped data).
|
||||||
*
|
*
|
||||||
|
* TWO FOLDS over the same events (Wave D): `phaseAt3`/`phaseTimeline` fold
|
||||||
|
* VIEW messages (the client's dividers and dock — part shapes, pure and
|
||||||
|
* structural); `phaseAt3Lh` folds the NATIVE record (server summaries,
|
||||||
|
* tests) — probe→plan advances at the probe_done RESULT message, plan→teach
|
||||||
|
* at the verdict marker. Both are strictly sequential; the two derivations
|
||||||
|
* agree on every settled record (a result/marker message projects into the
|
||||||
|
* same view boundary).
|
||||||
|
*
|
||||||
* Pure: no imports, no runtime deps — safe on server, client, and tests.
|
* Pure: no imports, no runtime deps — safe on server, client, and tests.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
@@ -83,3 +90,40 @@ export function phaseTimeline(messages: readonly PhaseMessage[]): Phase3[] {
|
|||||||
}
|
}
|
||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── the native fold (Wave D: record-side phase over LhMessage[]) ───────────
|
||||||
|
|
||||||
|
import type { LhMessage } from "./native-messages.ts";
|
||||||
|
import type { ToolResultMessage } from "@earendil-works/pi-ai";
|
||||||
|
|
||||||
|
/** The phase at `upToIndex` (inclusive; default: the last message) of a
|
||||||
|
* NATIVE record. probe→plan advances at the probe_done call's clean
|
||||||
|
* toolResult; plan→teach at the verdict marker (details.accepted true). */
|
||||||
|
export function phaseAt3Lh(messages: readonly LhMessage[], upToIndex?: number): Phase3 {
|
||||||
|
let phase: Phase3 = "probe";
|
||||||
|
const last = Math.min(upToIndex ?? messages.length - 1, messages.length - 1);
|
||||||
|
for (let i = 0; i <= last; i++) {
|
||||||
|
const m = messages[i]!;
|
||||||
|
const role = (m as { role?: string }).role;
|
||||||
|
if (role === "toolResult") {
|
||||||
|
const result = m as ToolResultMessage;
|
||||||
|
if (
|
||||||
|
phase === "probe" &&
|
||||||
|
result.toolName === "probe_done" &&
|
||||||
|
!result.isError
|
||||||
|
) {
|
||||||
|
phase = "plan";
|
||||||
|
}
|
||||||
|
} else if (role === "custom") {
|
||||||
|
const marker = m as { customType?: string; details?: { accepted?: unknown } };
|
||||||
|
if (
|
||||||
|
phase === "plan" &&
|
||||||
|
marker.customType === "verdict" &&
|
||||||
|
marker.details?.accepted === true
|
||||||
|
) {
|
||||||
|
phase = "teach";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return phase;
|
||||||
|
}
|
||||||
|
|||||||
@@ -16,8 +16,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import type { PlanBlockData, PlanTree } from "./contracts.ts";
|
import type { PlanBlockData, PlanTree } from "./contracts.ts";
|
||||||
import { derivePlanStatuses } from "./contracts.ts";
|
import { derivePlanStatusesLh } from "./contracts.ts";
|
||||||
import type { UIMessage } from "ai";
|
|
||||||
import type { LhMessage } from "./native-messages.ts";
|
import type { LhMessage } from "./native-messages.ts";
|
||||||
import { newPlanBlockMessage } from "./native-messages.ts";
|
import { newPlanBlockMessage } from "./native-messages.ts";
|
||||||
import type { ToolResultMessage } from "@earendil-works/pi-ai";
|
import type { ToolResultMessage } from "@earendil-works/pi-ai";
|
||||||
@@ -32,9 +31,11 @@ export interface PlanBlockPart {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** The snapshot payload (unchanged since wave 4): statuses derived AT
|
/** The snapshot payload (unchanged since wave 4): statuses derived AT
|
||||||
* EMISSION time from the transcript — the block never re-derives. */
|
* EMISSION time from the transcript — the block never re-derives. Wave D:
|
||||||
function snapshot(tree: PlanTree, messages: readonly UIMessage[]): PlanBlockData {
|
* the derivation reads the NATIVE transcript (quiz CALLS only — results
|
||||||
const statuses = derivePlanStatuses(tree, messages);
|
* feed nothing, contracts/plan.ts derivePlanStatusesLh). */
|
||||||
|
function snapshot(tree: PlanTree, messages: readonly LhMessage[]): PlanBlockData {
|
||||||
|
const statuses = derivePlanStatusesLh(tree, messages);
|
||||||
return {
|
return {
|
||||||
kind: "full",
|
kind: "full",
|
||||||
at: new Date().toISOString(),
|
at: new Date().toISOString(),
|
||||||
@@ -68,14 +69,14 @@ export function emittedPlanBlockCalls(messages: readonly LhMessage[]): Set<strin
|
|||||||
* once; a later turn's rewrite gets its own block — the golden-master
|
* 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
|
* 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
|
* emitted. `tree` is the record's current tree (the plan tool saved it
|
||||||
* during the turn); `transcript` is the full PROJECTED transcript the
|
* during the turn); `transcript` is the full NATIVE transcript the status
|
||||||
* status derivation reads.
|
* derivation reads.
|
||||||
*/
|
*/
|
||||||
export function planBlocksFor(
|
export function planBlocksFor(
|
||||||
record: readonly LhMessage[],
|
record: readonly LhMessage[],
|
||||||
newMessages: readonly LhMessage[],
|
newMessages: readonly LhMessage[],
|
||||||
tree: PlanTree | null | undefined,
|
tree: PlanTree | null | undefined,
|
||||||
transcript: readonly UIMessage[],
|
transcript: readonly LhMessage[],
|
||||||
): LhMessage[] {
|
): LhMessage[] {
|
||||||
if (!tree || tree.nodes.length === 0) return [];
|
if (!tree || tree.nodes.length === 0) return [];
|
||||||
const emitted = emittedPlanBlockCalls(record);
|
const emitted = emittedPlanBlockCalls(record);
|
||||||
|
|||||||
@@ -1,199 +0,0 @@
|
|||||||
/**
|
|
||||||
* 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<string, unknown> & { 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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,120 +0,0 @@
|
|||||||
import type { UIMessage } from "ai";
|
|
||||||
import type {
|
|
||||||
DemoToolInput,
|
|
||||||
PdfToolInput,
|
|
||||||
PlanVerdictData,
|
|
||||||
PlanBlockData,
|
|
||||||
ProbeDoneInput,
|
|
||||||
ProbeDoneResult,
|
|
||||||
ResearchToolInput,
|
|
||||||
SubagentArtifactData,
|
|
||||||
SubagentResultMeta,
|
|
||||||
VisualToolInput,
|
|
||||||
} from "./contracts.ts";
|
|
||||||
import type { AskToolInput, QuestionAnswerData, QuizToolInput } from "./question-core.ts";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The typed UI message for the teacher chat — gives the frontend typed tool
|
|
||||||
* parts ("tool-quiz" / "tool-ask_user_question" / "tool-research" / …) without
|
|
||||||
* importing the server-side agent into the client bundle.
|
|
||||||
*
|
|
||||||
* Question tools are CLIENT-SIDE (no execute): their OUTPUT type is the
|
|
||||||
* structured answer record the client builds and sends via addToolOutput.
|
|
||||||
* Job tools are SERVER-EXECUTED: their output is the plain-string dispatch
|
|
||||||
* acknowledgement (the result later arrives as a steer message — see
|
|
||||||
* SubagentSteerMessage in contracts.ts, whose artifact rides the
|
|
||||||
* data-subagent-artifact part typed here as DATA_PARTS).
|
|
||||||
*/
|
|
||||||
export type TeacherUITools = {
|
|
||||||
quiz: { input: QuizToolInput; output: QuestionAnswerData };
|
|
||||||
ask_user_question: { input: AskToolInput; output: QuestionAnswerData };
|
|
||||||
/** The prober's conclusion tool (docs/probe-teach-split.md §2): the
|
|
||||||
* input IS the learner assessment; the output confirms the handoff. */
|
|
||||||
probe_done: { input: ProbeDoneInput; output: ProbeDoneResult };
|
|
||||||
research: { input: ResearchToolInput; output: string };
|
|
||||||
demo: { input: DemoToolInput; output: string };
|
|
||||||
visual: { input: VisualToolInput; output: string };
|
|
||||||
/** The pdf dispatch tool (sources v0): fire-and-forget ack output like
|
|
||||||
* research — the brief arrives later as a steer message. */
|
|
||||||
pdf: { input: PdfToolInput; output: string };
|
|
||||||
/** The learner-filesystem tools (sources v1, both agents, always-on):
|
|
||||||
* read may return an image-bearing result; the rest are plain text. */
|
|
||||||
read: { input: { path: string; offset?: number; limit?: number }; output: unknown };
|
|
||||||
ls: { input: { path?: string; limit?: number }; output: unknown };
|
|
||||||
find: { input: { pattern: string; path?: string; limit?: number }; output: unknown };
|
|
||||||
grep: {
|
|
||||||
input: {
|
|
||||||
pattern: string;
|
|
||||||
path?: string;
|
|
||||||
glob?: string;
|
|
||||||
ignoreCase?: boolean;
|
|
||||||
literal?: boolean;
|
|
||||||
context?: number;
|
|
||||||
limit?: number;
|
|
||||||
};
|
|
||||||
output: unknown;
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
export type TeacherUIDataParts = {
|
|
||||||
"subagent-artifact": SubagentArtifactData;
|
|
||||||
/** Plan blocks (wave 4): frozen plan snapshots rendered in the chat flow. */
|
|
||||||
"plan-block": PlanBlockData;
|
|
||||||
/** The learner's plan verdict (accept/decline): its own user message,
|
|
||||||
* carrying ONLY this part — the plan→teach phase seam (docs/probe-
|
|
||||||
* teach-split.md §4, the custom-message scheme 2026-09-12). */
|
|
||||||
verdict: PlanVerdictData;
|
|
||||||
/** The probe→teach handoff wake: the wake pipeline's steer message,
|
|
||||||
* carrying ONLY this part (the model-facing text lives in the adapter). */
|
|
||||||
"probe-handoff": { chatId: string };
|
|
||||||
/** The abort steer's message-type marker (the abort badge renders off it). */
|
|
||||||
"subagent-aborted": SubagentResultMeta & { aborted: true };
|
|
||||||
};
|
|
||||||
|
|
||||||
export type TeacherUIMessage = UIMessage<unknown, TeacherUIDataParts, TeacherUITools>;
|
|
||||||
|
|
||||||
// ─── Append-only record adoption (background-process ground rules) ──────────
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Merge server-added messages into the client's live list WITHOUT the full
|
|
||||||
* snapshot swap (owner ground rules, 2026-09-15: content delivered into the
|
|
||||||
* record — context blocks, sub-agent artifacts, abort notes — must become
|
|
||||||
* visible the moment it persists, mid-turn included; it sits between messages
|
|
||||||
* and nothing about a running turn should hide it).
|
|
||||||
*
|
|
||||||
* Safe against the stale-snapshot lesson (mid-turn full syncs break the SDK's
|
|
||||||
* in-place continuation merge): this NEVER rewrites or removes an existing
|
|
||||||
* message — it returns the RECORD's message order when that order is exactly
|
|
||||||
* the client's persisted core plus new server-added ids (the server appends
|
|
||||||
* only: steers are inserted right after the last persisted user message),
|
|
||||||
* with the client's in-flight streaming assistant message — its last entry,
|
|
||||||
* not yet persisted — kept at the tail. Any other shape (a missing local id
|
|
||||||
* = the record was rewritten/reordered) returns null: the caller defers to
|
|
||||||
* the full sync at idle, the pre-existing safety net.
|
|
||||||
*/
|
|
||||||
export function mergeAdopted(
|
|
||||||
local: ReadonlyArray<TeacherUIMessage>,
|
|
||||||
record: ReadonlyArray<TeacherUIMessage>,
|
|
||||||
): TeacherUIMessage[] | null {
|
|
||||||
const recordIds = new Set(record.map((m) => m.id));
|
|
||||||
// The in-flight tail: the client's last message may legitimately be absent
|
|
||||||
// from the record (the SDK's streaming assistant message persists only at
|
|
||||||
// onFinish). Exactly one such tail is allowed.
|
|
||||||
let core = local;
|
|
||||||
let tail: TeacherUIMessage | undefined;
|
|
||||||
const last = local.at(-1);
|
|
||||||
if (last && !recordIds.has(last.id)) {
|
|
||||||
tail = last;
|
|
||||||
core = local.slice(0, -1);
|
|
||||||
}
|
|
||||||
// No rewrite happened if every core message is still in the record —
|
|
||||||
// otherwise this is not a pure append; bail to the full-sync-at-idle.
|
|
||||||
if (core.some((m) => !recordIds.has(m.id))) return null;
|
|
||||||
// Nothing new → nothing to do.
|
|
||||||
const localIds = new Set(local.map((m) => m.id));
|
|
||||||
if (!record.some((m) => !localIds.has(m.id))) return null;
|
|
||||||
// The record's order is canonical for everything persisted (the server
|
|
||||||
// appends only; plan-block part attachment changes parts, not order) —
|
|
||||||
// merged = record + the in-flight tail.
|
|
||||||
return tail ? [...record, tail] : [...record];
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,289 @@
|
|||||||
|
/**
|
||||||
|
* THE VIEW MODEL (Wave D, owner-approved 2026-09-26): the client's transcript
|
||||||
|
* view — native record messages (+ the live turn's working messages) grouped
|
||||||
|
* into today's exact render shape. The golden master is the pre-Wave-D UI
|
||||||
|
* (owner fork 1): everything the agent produces is ONE continuous document
|
||||||
|
* per run — step separators between the loop's steps, artifact and answered
|
||||||
|
* cards inline, the teacher's reaction continuing below an answered quiz.
|
||||||
|
*
|
||||||
|
* This is the grouping canon of the deleted server-side projection
|
||||||
|
* (core/projection.ts, Wave B), moved client-side, app-owned, with zero AI-SDK
|
||||||
|
* types — plus the LIVE-TURN layer the projection never had: mid-turn tool
|
||||||
|
* execution states (input-streaming → input-available/output-error/output),
|
||||||
|
* so the dock mounts only after a call clears the validation boundary
|
||||||
|
* (tool_execution_end) and the shimmer names the running activity.
|
||||||
|
*
|
||||||
|
* GROUPING RULE: one assistant view 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.
|
||||||
|
*
|
||||||
|
* Plan blocks: 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).
|
||||||
|
*
|
||||||
|
* Question tools on the wire (owner fork 2): the agent's closing
|
||||||
|
* tool_execution_end carries the in-memory "Question posed" placeholder —
|
||||||
|
* projectView IGNORES clean question executions (the part stays
|
||||||
|
* input-available; the learner's answer arrives as a real toolResult). The
|
||||||
|
* placeholder never persists, so settled records never see it.
|
||||||
|
*
|
||||||
|
* Pure and deterministic: client-safe (no node deps), same input → same view.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { AssistantMessage, ToolResultMessage } from "@earendil-works/pi-ai";
|
||||||
|
import type { LhMarkerMessage, LhMessage } from "./native-messages.ts";
|
||||||
|
import { QUESTION_TOOLS } from "./pending.ts";
|
||||||
|
import type { SubagentArtifactData, SubagentResultMeta } from "./contracts/jobs.ts";
|
||||||
|
import type { PlanBlockData, PlanVerdictData } from "./contracts/chat.ts";
|
||||||
|
|
||||||
|
/** A view part — the loose part vocabulary the renderers consume (the same
|
||||||
|
* shape names the pre-Wave-D UIMessage dialect used, so the renderer
|
||||||
|
* switch and the phase/pending scanners transplant unchanged). The common
|
||||||
|
* fields are optional-typed so the pre-Wave-D renderer code compiles
|
||||||
|
* against it with minimal casts; anything else stays `unknown` through
|
||||||
|
* the index signature. */
|
||||||
|
export interface LhViewPart extends Record<string, unknown> {
|
||||||
|
type: string;
|
||||||
|
id?: string;
|
||||||
|
text?: string;
|
||||||
|
state?: string;
|
||||||
|
toolCallId?: string;
|
||||||
|
input?: unknown;
|
||||||
|
output?: unknown;
|
||||||
|
rawInput?: unknown;
|
||||||
|
errorText?: string;
|
||||||
|
data?: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A view message: user (bubble) or assistant (one continuous run). */
|
||||||
|
export interface LhViewMessage {
|
||||||
|
id: string;
|
||||||
|
role: "user" | "assistant";
|
||||||
|
parts: LhViewPart[];
|
||||||
|
/** The subagentResult meta the steer cards read (marker details). */
|
||||||
|
metadata?: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A tool call's live execution outcome (from tool_execution_end). */
|
||||||
|
export interface LiveExecution {
|
||||||
|
isError: boolean;
|
||||||
|
/** The clean result's details payload (the card's output). */
|
||||||
|
output?: unknown;
|
||||||
|
/** The failure text (output-error parts). */
|
||||||
|
errorText?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The live turn's execution layer, maintained by the session hook. */
|
||||||
|
export interface LiveTurnState {
|
||||||
|
/** toolCallId → its execution outcome seen so far. */
|
||||||
|
executions: ReadonlyMap<string, LiveExecution>;
|
||||||
|
/** True while the turn runs — calls with no execution yet render
|
||||||
|
* input-streaming (the shimmer); settled dangling calls render
|
||||||
|
* input-available (the dock mounts from the record). */
|
||||||
|
inFlight: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
function textPart(text: string): LhViewPart {
|
||||||
|
return { type: "text", text, state: "done" };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The result a tool part should show, from a native toolResult. */
|
||||||
|
function applyResult(part: LhViewPart, 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: LhViewPart[]): 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 (+ live turn working list) into the client's
|
||||||
|
* view messages. Pure: the same input always projects to the same list. */
|
||||||
|
export function projectView(messages: readonly LhMessage[], live?: LiveTurnState): LhViewMessage[] {
|
||||||
|
const out: LhViewMessage[] = [];
|
||||||
|
|
||||||
|
const lastAssistant = (): LhViewMessage | undefined => {
|
||||||
|
const last = out[out.length - 1];
|
||||||
|
return last && last.role === "assistant" ? last : undefined;
|
||||||
|
};
|
||||||
|
const findToolPart = (toolCallId: string): LhViewPart | 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") {
|
||||||
|
projectMarker(m as LhMarkerMessage, out);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (role === "toolResult") {
|
||||||
|
applyResultTo(findToolPart((m as ToolResultMessage).toolCallId), m as ToolResultMessage);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (role === "assistant") {
|
||||||
|
const assistant = m as AssistantMessage;
|
||||||
|
const parts: LhViewPart[] = [];
|
||||||
|
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(toolPartOf(block, live));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A tool call block → its view part, with the live execution layer. */
|
||||||
|
function toolPartOf(
|
||||||
|
block: { id: string; name: string; arguments?: unknown },
|
||||||
|
live: LiveTurnState | undefined,
|
||||||
|
): LhViewPart {
|
||||||
|
const part: LhViewPart = {
|
||||||
|
type: `tool-${block.name}`,
|
||||||
|
toolCallId: block.id,
|
||||||
|
input: block.arguments,
|
||||||
|
};
|
||||||
|
const exec = live?.executions.get(block.id);
|
||||||
|
const isQuestion = (QUESTION_TOOLS as readonly string[]).includes(block.name);
|
||||||
|
if (exec) {
|
||||||
|
if (exec.isError) {
|
||||||
|
// An invalid call NEVER becomes input-available — the dock's mount
|
||||||
|
// gate keys on the clean execution end (the live modal-realism
|
||||||
|
// crash: a malformed quiz must never mount).
|
||||||
|
part.state = "output-error";
|
||||||
|
part.rawInput = block.arguments;
|
||||||
|
part.errorText = exec.errorText ?? "The tool call failed.";
|
||||||
|
} else if (isQuestion) {
|
||||||
|
// Fork 2: the clean "Question posed" placeholder is IGNORED —
|
||||||
|
// question tools are client-owned; the dock mounts on the clean
|
||||||
|
// end, the answer arrives as a real toolResult.
|
||||||
|
part.state = "input-available";
|
||||||
|
} else {
|
||||||
|
part.state = "output-available";
|
||||||
|
part.output = exec.output;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// No execution yet: mid-turn it is input-streaming (the shimmer
|
||||||
|
// activity); in a settled record a dangling call is the dock's
|
||||||
|
// pending question (input-available).
|
||||||
|
part.state = live?.inFlight ? "input-streaming" : "input-available";
|
||||||
|
}
|
||||||
|
return part;
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyResultTo(part: LhViewPart | undefined, result: ToolResultMessage): void {
|
||||||
|
if (part) applyResult(part, result);
|
||||||
|
// A result with no visible part (e.g. a call whose assistant message
|
||||||
|
// rendered nothing) is dropped from the view — the record keeps it.
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A marker's projected message (verdict/handoff/steers) or splice (plan block). */
|
||||||
|
function projectMarker(marker: LhMarkerMessage, out: LhViewMessage[]): 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -322,7 +322,6 @@ export async function handleChatInput(
|
|||||||
// late views reattach via GET /api/chats/[id]/stream.
|
// late views reattach via GET /api/chats/[id]/stream.
|
||||||
const handle = runPiTurn({
|
const handle = runPiTurn({
|
||||||
agent: constructed,
|
agent: constructed,
|
||||||
base: merged,
|
|
||||||
// Stream-level failures are provider/transport internals — masked,
|
// Stream-level failures are provider/transport internals — masked,
|
||||||
// like the SDK defaults. Tool-input validation messages never
|
// like the SDK defaults. Tool-input validation messages never
|
||||||
// route through here: they ride the error-result path and reach the
|
// route through here: they ride the error-result path and reach the
|
||||||
|
|||||||
+1
-3
@@ -1,9 +1,7 @@
|
|||||||
import { abortExtraction, extractionStatuses } from "../agent/subagents/extraction.ts";
|
import { abortExtraction, extractionStatuses } from "../agent/subagents/extraction.ts";
|
||||||
import { coverageOf } from "../core/coverage.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 { deleteChatTraces, type ChatDeleteMode, type ChatDeletionStats } from "../core/graph-deletion.ts";
|
||||||
import { titleFromPrompt } from "../core/contracts.ts";
|
import { titleFromPrompt } from "../core/contracts.ts";
|
||||||
import type { UIMessage } from "ai";
|
|
||||||
import { graphRevision } from "./graph.ts";
|
import { graphRevision } from "./graph.ts";
|
||||||
import { stores } from "./stores.ts";
|
import { stores } from "./stores.ts";
|
||||||
import { isTurnRunning } from "./turn-liveness.ts";
|
import { isTurnRunning } from "./turn-liveness.ts";
|
||||||
@@ -144,7 +142,7 @@ export function sessionCoverage(chatId: string): SessionCoverage | null {
|
|||||||
const entries = stores.graphStore.readCoverage();
|
const entries = stores.graphStore.readCoverage();
|
||||||
// Wave B: the record is native history — coverage reads through the
|
// Wave B: the record is native history — coverage reads through the
|
||||||
// projection (same view the extraction pipeline folds).
|
// projection (same view the extraction pipeline folds).
|
||||||
const cov = coverageOf(projectNative(record.messages), entries, chatId);
|
const cov = coverageOf(record.messages, entries, chatId);
|
||||||
let lastFoldedAt: string | null = null;
|
let lastFoldedAt: string | null = null;
|
||||||
for (const entry of entries) {
|
for (const entry of entries) {
|
||||||
if (entry.chatId !== chatId) continue;
|
if (entry.chatId !== chatId) continue;
|
||||||
|
|||||||
+72
-296
@@ -1,67 +1,66 @@
|
|||||||
import type { Agent, AgentEvent, AgentMessage } 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
|
* THE NATIVE WIRE (Wave D, owner-approved 2026-09-26): one agent turn, streamed
|
||||||
* re-emits it as an AI-SDK-shaped UIMessageStream SSE response — the wire
|
* to every view as VERBATIM pi AgentEvents — no translation, no remapping, no
|
||||||
* protocol `useChat` (Band D, unchanged) parses:
|
* part assembly. The SSE body is the agent's own event stream:
|
||||||
*
|
*
|
||||||
* data: {"type":"text-delta", …}\n\n … data: [DONE]
|
* data: {"type":"agent_start"}\n\n
|
||||||
|
* data: {"type":"message_update","message":{…},"assistantMessageEvent":{…}}\n\n
|
||||||
|
* …
|
||||||
|
* data: {"type":"agent_end","messages":[…]}\n\n
|
||||||
|
* data: [DONE]\n\n
|
||||||
*
|
*
|
||||||
* Load-bearing protocol rules (verified against ai@7's client):
|
* Load-bearing protocol facts (verified against pi-agent-core):
|
||||||
* - The `start` chunk's messageId MUST be the transcript's last message id
|
* - Every `message_update` carries the FULL partial assistant message (each
|
||||||
* when it is an assistant message (the continuation id rule) — a fresh id
|
* AssistantMessageEvent ships `partial`) — a view REPLACES its streaming
|
||||||
* there duplicates every prior part client-side.
|
* assistant with `event.message`; no delta assembly exists anywhere.
|
||||||
* - Question tools (quiz / ask_user_question): emit tool-input-available and
|
* - Question tools (quiz / ask_user_question): NO suppression (owner ruling,
|
||||||
* NEVER tool-output-available (the client throws on an output for a call it
|
* Wave D fork 2) — the closing `tool_execution_end` with the in-memory
|
||||||
* owns). The synthetic "question posed" result lives in memory only; the
|
* "Question posed" placeholder flows to the client, which ignores it
|
||||||
* record's part stays output-less until the learner answers.
|
* (question tools are client-owned; the answer arrives as the input event).
|
||||||
* - tool-input-available is emitted ONLY after the call cleared validation
|
* The placeholder still never persists and never reaches the model.
|
||||||
* at the boundary (clean tool_execution_end) — the SDK's
|
* - Tool-call validation gating survives by construction: `tool_execution_end`
|
||||||
* InvalidToolInputError gating. An invalid call surfaces as
|
* only fires after the call cleared the boundary (an invalid call arrives
|
||||||
* tool-input-error instead and NEVER becomes input-available, so a
|
* as isError — never a clean end), so the dock's mount rule keys on it.
|
||||||
* malformed question can never mount the dock (the live modal-realism
|
* - App-level events beyond pi's taxonomy: the reattach terminal
|
||||||
* crash: GLM double-encoded `options` as a JSON string).
|
* {"type":"turn-settled"} (served by turn-runs when no turn is running) and
|
||||||
* - The WIRE assembly (below) is the client view only (Wave B): the
|
* the loop-level {"type":"error","errorText"} (agent.continue() threw
|
||||||
* record write is the agent's own native final state — the projection
|
* outside the event stream — mapped through the handler's masking policy).
|
||||||
* (core/projection.ts) reproduces the client's part shapes for every
|
* - Stream-level provider errors ride their message verbatim
|
||||||
* consumer (phases, plan blocks, pending, extraction) until Wave D.
|
* (message_end with stopReason "error" + errorMessage).
|
||||||
*
|
*
|
||||||
* BACKGROUND-TURN RUNNER (owner ruling, 2026-09-25): a turn executes as a
|
* BACKGROUND-TURN RUNNER (owner ruling, 2026-09-25): a turn executes as a
|
||||||
* server-side background object decoupled from any single HTTP response —
|
* server-side background object decoupled from any single HTTP response —
|
||||||
* clients are VIEWS. runPiTurn returns the handle (event log + subscriber
|
* clients are VIEWS. runPiTurn returns the handle (event log + subscriber
|
||||||
* set); piTurnResponse wraps a subscriber stream as the SSE response, and
|
* set); piTurnResponse wraps a subscriber stream as the SSE response, and
|
||||||
* the POST /api/chat response and GET /api/chats/[id]/stream are BOTH
|
* the POST /api/chat response and GET /api/chats/[id]/stream are BOTH
|
||||||
* exactly that. Cancelling a view detaches it ONLY — never aborts the
|
* exactly that. Cancelling a view detaches it ONLY — never aborts the turn;
|
||||||
* turn; the handle's abort() is the one remote kill.
|
* the handle's abort() is the one remote kill.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
/** SSE chunk — the flat JSON line shape the client's zod schema accepts. */
|
/** The wire event: a verbatim pi AgentEvent, or one of the two app events. */
|
||||||
type Chunk = Record<string, unknown> & { type: string };
|
export type TurnWireEvent =
|
||||||
|
| AgentEvent
|
||||||
|
| { type: "error"; errorText: string };
|
||||||
|
|
||||||
export interface PiTurnSpec {
|
export interface PiTurnSpec {
|
||||||
/** The per-turn agent (stateless engine — built from the record). */
|
/** The per-turn agent (stateless engine — built from the record). */
|
||||||
agent: Agent;
|
agent: Agent;
|
||||||
/** The turn's base: the native record + this turn's appends
|
/** The handler's error-masking policy for LOOP-level failures (the
|
||||||
* (write-ahead persisted already). Wave B: the agent's context and
|
* event stream itself is verbatim — provider errors ride their
|
||||||
* the record share one shape; onFinish receives the full agent state
|
* message's stopReason/errorMessage through untouched). */
|
||||||
* 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;
|
onError?: (error: unknown) => string;
|
||||||
/** The canonical end-of-turn seam: persist + usage + extraction + endTurn.
|
/** The canonical end-of-turn seam: persist + usage + extraction + endTurn.
|
||||||
* Receives the agent's FULL final state (native messages) — the turn's
|
* Receives the agent's FULL final state (native messages) — the caller
|
||||||
* new messages are the slice past base.length. */
|
* persists the slice past its own base count. */
|
||||||
onFinish: (messages: AgentMessage[], isAborted: boolean) => void;
|
onFinish: (messages: AgentMessage[], isAborted: boolean) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** One view onto a running turn: push() replays/streams a chunk, done()
|
/** One view onto a running turn: push() replays/streams an event, done()
|
||||||
* terminates the view ([DONE] + close). Cheap to be a Set member. */
|
* terminates the view ([DONE] + close). Cheap to be a Set member. */
|
||||||
export interface PiTurnSubscriber {
|
export interface PiTurnSubscriber {
|
||||||
push(chunk: Chunk): void;
|
push(event: TurnWireEvent): void;
|
||||||
done(): void;
|
done(): void;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -75,109 +74,35 @@ export interface PiTurnHandle {
|
|||||||
* view only — the turn runs on. */
|
* view only — the turn runs on. */
|
||||||
subscribe(): ReadableStream<Uint8Array>;
|
subscribe(): ReadableStream<Uint8Array>;
|
||||||
/** The remote kill — the ONLY way a running turn dies early: sawAbort +
|
/** The remote kill — the ONLY way a running turn dies early: sawAbort +
|
||||||
* agent.abort(); the loop's agent_end does the rest (finish chunk to
|
* agent.abort(); the loop's agent_end does the rest (the settle seam
|
||||||
* every subscriber, then onFinish persists the partial transcript). */
|
* persists the partial transcript to every listener). */
|
||||||
abort(): void;
|
abort(): void;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Map a pi stop reason to the finish chunk's enum. */
|
|
||||||
function finishReasonOf(stopReason: string | undefined): string {
|
|
||||||
switch (stopReason) {
|
|
||||||
case "stop":
|
|
||||||
return "stop";
|
|
||||||
case "length":
|
|
||||||
return "length";
|
|
||||||
case "toolUse":
|
|
||||||
return "tool-calls";
|
|
||||||
case "error":
|
|
||||||
return "error";
|
|
||||||
default:
|
|
||||||
return "other";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function resultErrorText(result: unknown): string {
|
|
||||||
const content = (result as { content?: Array<{ type: string; text?: string }> } | undefined)?.content;
|
|
||||||
const text = content?.find((c) => c.type === "text")?.text;
|
|
||||||
return text ?? "The tool call failed.";
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Loose part shapes the bridge assembles (the client's part shapes). */
|
|
||||||
interface TextPart {
|
|
||||||
type: "text";
|
|
||||||
text: string;
|
|
||||||
state: string;
|
|
||||||
}
|
|
||||||
interface ReasoningPart {
|
|
||||||
type: "reasoning";
|
|
||||||
id: string;
|
|
||||||
text: string;
|
|
||||||
state: string;
|
|
||||||
}
|
|
||||||
interface ToolPart {
|
|
||||||
type: string;
|
|
||||||
toolCallId: string;
|
|
||||||
state: string;
|
|
||||||
input?: unknown;
|
|
||||||
rawInput?: unknown;
|
|
||||||
output?: unknown;
|
|
||||||
errorText?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function runPiTurn(spec: PiTurnSpec): PiTurnHandle {
|
export function runPiTurn(spec: PiTurnSpec): PiTurnHandle {
|
||||||
const { agent, base, onFinish } = spec;
|
const { agent, onError, onFinish } = spec;
|
||||||
|
|
||||||
// The streaming target: continuation (same id, parts appended to a copy
|
|
||||||
// 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 ? [...last.parts] : [];
|
|
||||||
const parts: UIMessage["parts"] = [...baseParts];
|
|
||||||
|
|
||||||
// Stable, collision-free part ids across continuation runs (reasoning
|
|
||||||
// parts persist their id; text ids only need per-run uniqueness).
|
|
||||||
let textN = baseParts.filter((p) => p.type === "text").length;
|
|
||||||
let reasoningN = baseParts.filter((p) => p.type === "reasoning").length;
|
|
||||||
const textIds = new Map<number, string>(); // contentIndex → chunk id
|
|
||||||
const reasoningIds = new Map<number, string>();
|
|
||||||
const toolInputs = new Map<string, { toolName: string; input: unknown }>();
|
|
||||||
/** The streaming text/reasoning parts of THIS run, keyed by chunk id. */
|
|
||||||
const activeParts = new Map<string, TextPart | ReasoningPart>();
|
|
||||||
/** Tool parts created this run, keyed by toolCallId. */
|
|
||||||
const newToolParts = new Map<string, ToolPart>();
|
|
||||||
|
|
||||||
let settled = false;
|
let settled = false;
|
||||||
let sawAbort = false;
|
let sawAbort = false;
|
||||||
let finishReason: string | undefined;
|
|
||||||
let errorMessage: string | undefined;
|
|
||||||
|
|
||||||
// THE PER-TURN EVENT LOG (memory-only — the settle-only disk writes
|
// THE PER-TURN EVENT LOG (memory-only — the settle-only disk writes
|
||||||
// are unchanged): every emitted chunk, in order, so any late subscriber
|
// are unchanged): every forwarded event, in order, so any late
|
||||||
// gets a FULL replay. Unbounded BY HONEST DESIGN (owner ruling
|
// subscriber gets a FULL replay. Unbounded BY HONEST DESIGN (owner
|
||||||
// 2026-09-25): a turn's chunk log is finite by construction — the agent
|
// ruling 2026-09-25): a turn's event log is finite by construction —
|
||||||
// loop ends (or the 30-minute liveness TTL bounds the pathological
|
// the agent loop ends (or the 30-minute liveness TTL bounds the
|
||||||
// never-settling case), and concurrency is human-scale (one turn per
|
// pathological never-settling case), and concurrency is human-scale
|
||||||
// chat, a few chats). No cap, no truncation: replay must never
|
// (one turn per chat, a few chats). No cap, no truncation: replay must
|
||||||
// silently drop history. The log dies with the turn at settle.
|
// never silently drop history. The log dies with the turn at settle.
|
||||||
const log: Chunk[] = [];
|
const log: TurnWireEvent[] = [];
|
||||||
const subscribers = new Set<PiTurnSubscriber>();
|
const subscribers = new Set<PiTurnSubscriber>();
|
||||||
|
|
||||||
// Send = append to the log + fan out to every live view. A view's own
|
// Send = append to the log + fan out to every live view. A view's own
|
||||||
// enqueue failure (a cancelled reader) is swallowed by the view — the
|
// enqueue failure (a cancelled reader) is swallowed by the view — the
|
||||||
// turn must still settle; onFinish persists regardless of who watches.
|
// turn must still settle; onFinish persists regardless of who watches.
|
||||||
const send = (chunk: Chunk) => {
|
const send = (event: TurnWireEvent) => {
|
||||||
if (settled) return;
|
if (settled) return;
|
||||||
log.push(chunk);
|
log.push(event);
|
||||||
for (const sub of subscribers) sub.push(chunk);
|
for (const sub of subscribers) sub.push(event);
|
||||||
};
|
};
|
||||||
const closeAll = () => {
|
const closeAll = () => {
|
||||||
if (settled) return;
|
if (settled) return;
|
||||||
@@ -185,175 +110,23 @@ export function runPiTurn(spec: PiTurnSpec): PiTurnHandle {
|
|||||||
for (const sub of subscribers) sub.done();
|
for (const sub of subscribers) sub.done();
|
||||||
subscribers.clear();
|
subscribers.clear();
|
||||||
};
|
};
|
||||||
|
// The settle seam: the record write is the agent's OWN final state
|
||||||
send({ type: "start", messageId });
|
// (native history — signatures, usage, provider stamps all real).
|
||||||
|
// Copy the array: the agent may outlive this call's read.
|
||||||
const settleWith = () => {
|
const settleWith = () => {
|
||||||
// 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);
|
onFinish([...agent.state.messages], sawAbort);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handle = (event: AgentEvent) => {
|
const handle = (event: AgentEvent) => {
|
||||||
switch (event.type) {
|
// VERBATIM: every pi AgentEvent forwards as-is — no remapping.
|
||||||
case "turn_start":
|
send(event);
|
||||||
send({ type: "start-step" });
|
if (event.type === "message_end") {
|
||||||
parts.push({ type: "step-start" });
|
const m = event.message as { role?: string; stopReason?: string } | undefined;
|
||||||
break;
|
if (m?.role === "assistant" && m.stopReason === "aborted") sawAbort = true;
|
||||||
case "message_update": {
|
|
||||||
const streamEvent = event.assistantMessageEvent;
|
|
||||||
switch (streamEvent.type) {
|
|
||||||
case "text_start": {
|
|
||||||
const id = `${messageId}-t${textN++}`;
|
|
||||||
textIds.set(streamEvent.contentIndex, id);
|
|
||||||
const part: TextPart = { type: "text", text: "", state: "streaming" };
|
|
||||||
activeParts.set(id, part);
|
|
||||||
parts.push(part as UIMessage["parts"][number]);
|
|
||||||
send({ type: "text-start", id });
|
|
||||||
break;
|
|
||||||
}
|
}
|
||||||
case "text_delta": {
|
if (event.type === "agent_end") {
|
||||||
const id = textIds.get(streamEvent.contentIndex);
|
|
||||||
const part = id !== undefined ? (activeParts.get(id) as TextPart | undefined) : undefined;
|
|
||||||
if (part) part.text += streamEvent.delta;
|
|
||||||
if (id !== undefined) send({ type: "text-delta", id, delta: streamEvent.delta });
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
case "text_end": {
|
|
||||||
const id = textIds.get(streamEvent.contentIndex);
|
|
||||||
const part = id !== undefined ? (activeParts.get(id) as TextPart | undefined) : undefined;
|
|
||||||
if (part) part.state = "done";
|
|
||||||
activeParts.delete(id ?? "");
|
|
||||||
if (id !== undefined) send({ type: "text-end", id });
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
case "thinking_start": {
|
|
||||||
const id = `${messageId}-r${reasoningN++}`;
|
|
||||||
reasoningIds.set(streamEvent.contentIndex, id);
|
|
||||||
const part: ReasoningPart = { type: "reasoning", id, text: "", state: "streaming" };
|
|
||||||
activeParts.set(id, part);
|
|
||||||
parts.push(part as UIMessage["parts"][number]);
|
|
||||||
send({ type: "reasoning-start", id });
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
case "thinking_delta": {
|
|
||||||
const id = reasoningIds.get(streamEvent.contentIndex);
|
|
||||||
const part = id !== undefined ? (activeParts.get(id) as ReasoningPart | undefined) : undefined;
|
|
||||||
if (part) part.text += streamEvent.delta;
|
|
||||||
if (id !== undefined) send({ type: "reasoning-delta", id, delta: streamEvent.delta });
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
case "thinking_end": {
|
|
||||||
const id = reasoningIds.get(streamEvent.contentIndex);
|
|
||||||
const part = id !== undefined ? (activeParts.get(id) as ReasoningPart | undefined) : undefined;
|
|
||||||
if (part) part.state = "done";
|
|
||||||
activeParts.delete(id ?? "");
|
|
||||||
if (id !== undefined) send({ type: "reasoning-end", id });
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
case "toolcall_start": {
|
|
||||||
const block = (event.message as { content: any[] }).content[streamEvent.contentIndex];
|
|
||||||
if (block?.type !== "toolCall") break;
|
|
||||||
toolInputs.set(block.id, { toolName: block.name, input: undefined });
|
|
||||||
const part: ToolPart = {
|
|
||||||
type: `tool-${block.name}`,
|
|
||||||
toolCallId: block.id,
|
|
||||||
state: "input-streaming",
|
|
||||||
input: undefined,
|
|
||||||
};
|
|
||||||
newToolParts.set(block.id, part);
|
|
||||||
parts.push(part as unknown as UIMessage["parts"][number]);
|
|
||||||
send({ type: "tool-input-start", toolCallId: block.id, toolName: block.name });
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
case "toolcall_delta": {
|
|
||||||
const block = (event.message as { content: any[] }).content[streamEvent.contentIndex];
|
|
||||||
if (block?.type !== "toolCall") break;
|
|
||||||
send({
|
|
||||||
type: "tool-input-delta",
|
|
||||||
toolCallId: block.id,
|
|
||||||
inputTextDelta: streamEvent.delta,
|
|
||||||
});
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
case "toolcall_end": {
|
|
||||||
const toolCall = streamEvent.toolCall;
|
|
||||||
toolInputs.set(toolCall.id, { toolName: toolCall.name, input: toolCall.arguments });
|
|
||||||
// Input stays input-streaming here: it only becomes available
|
|
||||||
// once the call clears validation at the execution boundary
|
|
||||||
// (tool_execution_end) — an invalid input must never reach
|
|
||||||
// input-available, where the dock would mount on it.
|
|
||||||
const part = newToolParts.get(toolCall.id);
|
|
||||||
if (part) part.input = toolCall.arguments;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
case "tool_execution_end": {
|
|
||||||
const known = toolInputs.get(event.toolCallId);
|
|
||||||
const toolName = event.toolName || known?.toolName || "";
|
|
||||||
const input = known?.input;
|
|
||||||
const part = newToolParts.get(event.toolCallId);
|
|
||||||
if (event.isError) {
|
|
||||||
const errorText = resultErrorText(event.result);
|
|
||||||
if (part) {
|
|
||||||
part.state = "output-error";
|
|
||||||
part.rawInput = input;
|
|
||||||
part.errorText = errorText;
|
|
||||||
}
|
|
||||||
send({
|
|
||||||
type: "tool-input-error",
|
|
||||||
toolCallId: event.toolCallId,
|
|
||||||
toolName,
|
|
||||||
input,
|
|
||||||
errorText,
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
// Validation cleared: only NOW does the input become available
|
|
||||||
// to the client (the InvalidToolInputError gating the SDK owned).
|
|
||||||
if (part) {
|
|
||||||
part.state = "input-available";
|
|
||||||
part.input = input;
|
|
||||||
}
|
|
||||||
send({ type: "tool-input-available", toolCallId: event.toolCallId, toolName, input });
|
|
||||||
if ((QUESTION_TOOLS as readonly string[]).includes(toolName)) {
|
|
||||||
// Client-owned: the answer arrives via addToolOutput;
|
|
||||||
// the synthetic placeholder result never reaches wire
|
|
||||||
// or record.
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
const output = (event.result as { details?: unknown } | undefined)?.details;
|
|
||||||
if (part) {
|
|
||||||
part.state = "output-available";
|
|
||||||
part.output = output;
|
|
||||||
}
|
|
||||||
send({ type: "tool-output-available", toolCallId: event.toolCallId, output });
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
case "turn_end":
|
|
||||||
send({ type: "finish-step" });
|
|
||||||
break;
|
|
||||||
case "message_end":
|
|
||||||
if (event.message && (event.message as { role?: string }).role === "assistant") {
|
|
||||||
const m = event.message as { stopReason?: string; errorMessage?: string };
|
|
||||||
finishReason = m.stopReason;
|
|
||||||
if (m.stopReason === "aborted") sawAbort = true;
|
|
||||||
if (m.stopReason === "error") errorMessage = m.errorMessage;
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
case "agent_end": {
|
|
||||||
if (errorMessage !== undefined) {
|
|
||||||
send({ type: "error", errorText: spec.onError?.(errorMessage) ?? errorMessage });
|
|
||||||
}
|
|
||||||
send({ type: "finish", finishReason: finishReasonOf(finishReason) });
|
|
||||||
closeAll();
|
closeAll();
|
||||||
settleWith();
|
settleWith();
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
agent.subscribe(handle);
|
agent.subscribe(handle);
|
||||||
@@ -363,9 +136,12 @@ export function runPiTurn(spec: PiTurnSpec): PiTurnHandle {
|
|||||||
await agent.continue();
|
await agent.continue();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
// A loop-level failure (not a provider error — those ride the
|
// A loop-level failure (not a provider error — those ride the
|
||||||
// stream as stopReason "error"): surface, persist, close.
|
// stream as stopReason "error"): surface, close, persist. If the
|
||||||
send({ type: "error", errorText: spec.onError?.(err) ?? "An error occurred." });
|
// agent already settled (agent_end reached before the throw),
|
||||||
send({ type: "finish", finishReason: "error" });
|
// the seam has run — never settle twice.
|
||||||
|
if (settled) return;
|
||||||
|
console.error("[turn] agent loop failed:", err);
|
||||||
|
send({ type: "error", errorText: onError?.(err) ?? "An error occurred." });
|
||||||
closeAll();
|
closeAll();
|
||||||
settleWith();
|
settleWith();
|
||||||
}
|
}
|
||||||
@@ -379,10 +155,10 @@ export function runPiTurn(spec: PiTurnSpec): PiTurnHandle {
|
|||||||
const encoder = new TextEncoder();
|
const encoder = new TextEncoder();
|
||||||
let closed = false;
|
let closed = false;
|
||||||
sub = {
|
sub = {
|
||||||
push(chunk) {
|
push(event) {
|
||||||
if (closed) return;
|
if (closed) return;
|
||||||
try {
|
try {
|
||||||
controller.enqueue(encoder.encode(`data: ${JSON.stringify(chunk)}\n\n`));
|
controller.enqueue(encoder.encode(`data: ${JSON.stringify(event)}\n\n`));
|
||||||
} catch {
|
} catch {
|
||||||
/* reader cancelled — stop emitting, keep running */
|
/* reader cancelled — stop emitting, keep running */
|
||||||
closed = true;
|
closed = true;
|
||||||
@@ -399,11 +175,11 @@ export function runPiTurn(spec: PiTurnSpec): PiTurnHandle {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
// Attach THEN replay — synchronous, so no chunk can fall
|
// Attach THEN replay — synchronous, so no event can fall
|
||||||
// between replay and live (single-threaded: the agent's
|
// between replay and live (single-threaded: the agent's
|
||||||
// handle() only runs after this stack returns).
|
// handle() only runs after this stack returns).
|
||||||
subscribers.add(sub);
|
subscribers.add(sub);
|
||||||
for (const chunk of log) sub.push(chunk);
|
for (const event of log) sub.push(event);
|
||||||
// A subscriber that attached after settle (the unregister
|
// A subscriber that attached after settle (the unregister
|
||||||
// race) still gets the full history + [DONE].
|
// race) still gets the full history + [DONE].
|
||||||
if (settled) sub.done();
|
if (settled) sub.done();
|
||||||
|
|||||||
@@ -21,7 +21,6 @@ import type { LhMessage } from "../core/native-messages.ts";
|
|||||||
import { newMessageId } from "../core/native-messages.ts";
|
import { newMessageId } from "../core/native-messages.ts";
|
||||||
import { QUESTION_TOOLS } from "../core/pending.ts";
|
import { QUESTION_TOOLS } from "../core/pending.ts";
|
||||||
import { planBlocksFor } from "../core/plan-blocks.ts";
|
import { planBlocksFor } from "../core/plan-blocks.ts";
|
||||||
import { projectNative } from "../core/projection.ts";
|
|
||||||
import { coverageOf } from "../core/coverage.ts";
|
import { coverageOf } from "../core/coverage.ts";
|
||||||
import type { ChatStore, GraphStore } from "../core/ports.ts";
|
import type { ChatStore, GraphStore } from "../core/ports.ts";
|
||||||
import type { ExtractionPipeline } from "../agent/subagents/extraction.ts";
|
import type { ExtractionPipeline } from "../agent/subagents/extraction.ts";
|
||||||
@@ -72,12 +71,10 @@ export function persistTurn(
|
|||||||
stamped.push(message.id ? message : ({ ...message, id: newMessageId() } as LhMessage));
|
stamped.push(message.id ? message : ({ ...message, id: newMessageId() } as LhMessage));
|
||||||
}
|
}
|
||||||
const tree = record.tree;
|
const tree = record.tree;
|
||||||
const blocks = planBlocksFor(
|
const blocks = planBlocksFor(record.messages, stamped, tree, [
|
||||||
record.messages,
|
...record.messages,
|
||||||
stamped,
|
...stamped,
|
||||||
tree,
|
]);
|
||||||
projectNative([...record.messages, ...stamped]),
|
|
||||||
);
|
|
||||||
chatStore.saveMessages(chatId, [...record.messages, ...stamped, ...blocks]);
|
chatStore.saveMessages(chatId, [...record.messages, ...stamped, ...blocks]);
|
||||||
return stamped;
|
return stamped;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -110,7 +107,7 @@ export function triggerExtraction(
|
|||||||
try {
|
try {
|
||||||
const record = deps.chatStore.getChat(chatId);
|
const record = deps.chatStore.getChat(chatId);
|
||||||
if (!record) return;
|
if (!record) return;
|
||||||
const covered = coverageOf(projectNative(record.messages), deps.graphStore.readCoverage(), chatId);
|
const covered = coverageOf(record.messages, deps.graphStore.readCoverage(), chatId);
|
||||||
if (covered.uncoveredTokens < chunkTokens) return;
|
if (covered.uncoveredTokens < chunkTokens) return;
|
||||||
void extraction.runExtraction(chatId).catch((err) => {
|
void extraction.runExtraction(chatId).catch((err) => {
|
||||||
// BYOK (owner, 2026-09-22) + key presence (provider-key interlude,
|
// BYOK (owner, 2026-09-22) + key presence (provider-key interlude,
|
||||||
|
|||||||
+10
-11
@@ -13,9 +13,8 @@ import { PATHS } from "../../config.ts";
|
|||||||
import type { ChatStore } from "../core/ports.ts";
|
import type { ChatStore } from "../core/ports.ts";
|
||||||
import type { ChatRecord, ChatSettings, ChatSummary, ExtractionRunUsage, PlanTree, TurnUsage } from "../core/contracts.ts";
|
import type { ChatRecord, ChatSettings, ChatSummary, ExtractionRunUsage, PlanTree, TurnUsage } from "../core/contracts.ts";
|
||||||
import type { LhMessage } from "../core/native-messages.ts";
|
import type { LhMessage } from "../core/native-messages.ts";
|
||||||
import { findPendingToolCall } from "../core/pending.ts";
|
import { danglingQuestionOf } from "../core/native-messages.ts";
|
||||||
import { phaseAt3 } from "../core/phases.ts";
|
import { phaseAt3Lh } from "../core/phases.ts";
|
||||||
import { projectNative } from "../core/projection.ts";
|
|
||||||
|
|
||||||
function chatFile(id: string): string {
|
function chatFile(id: string): string {
|
||||||
if (!/^[A-Za-z0-9_-]+$/.test(id)) throw new Error(`Invalid chat id: ${id}`);
|
if (!/^[A-Za-z0-9_-]+$/.test(id)) throw new Error(`Invalid chat id: ${id}`);
|
||||||
@@ -58,7 +57,6 @@ function listChats(): ChatSummary[] {
|
|||||||
// zero-back-compat wipe makes these "corrupt") must not blank the
|
// zero-back-compat wipe makes these "corrupt") must not blank the
|
||||||
// whole sidebar — same isolation getChat's per-file catch gives.
|
// whole sidebar — same isolation getChat's per-file catch gives.
|
||||||
try {
|
try {
|
||||||
const projected = projectNative(messages);
|
|
||||||
return {
|
return {
|
||||||
// turns and extractions stay out of the list payload (list =
|
// turns and extractions stay out of the list payload (list =
|
||||||
// summaries; usage aggregates ride the full record, the picker
|
// summaries; usage aggregates ride the full record, the picker
|
||||||
@@ -66,13 +64,14 @@ function listChats(): ChatSummary[] {
|
|||||||
...summary,
|
...summary,
|
||||||
messageCount: messages.length,
|
messageCount: messages.length,
|
||||||
// Record-derived status for the session picker: phase from the
|
// Record-derived status for the session picker: phase from the
|
||||||
// transcript's explicit markers (probe_done / plan_init — the
|
// transcript's explicit events (probe_done concluded / verdict
|
||||||
// event-driven phases, docs/probe-teach-split.md §4), pending
|
// accepted — docs/probe-teach-split.md §4), pending question
|
||||||
// question from the last unanswered quiz/ask part — both read
|
// from the native dangling-call scan. Wave D: both folds read
|
||||||
// through the one-way projection. Liveness (busy) is NOT
|
// the NATIVE record (core/phases.ts phaseAt3Lh +
|
||||||
// record-derived — the API route merges it (ChatListItem).
|
// native-messages.ts danglingQuestionOf). Liveness (busy) is
|
||||||
phase: phaseAt3(projected),
|
// NOT record-derived — the API route merges it (ChatListItem).
|
||||||
pendingQuestion: findPendingToolCall(projected) !== null,
|
phase: phaseAt3Lh(messages),
|
||||||
|
pendingQuestion: danglingQuestionOf(messages) !== null,
|
||||||
};
|
};
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(`[chat ${summary.id}] summary projection failed:`, err);
|
console.error(`[chat ${summary.id}] summary projection failed:`, err);
|
||||||
|
|||||||
+69
-53
@@ -33,7 +33,7 @@ import { abortTurn, runningTurn, settledTurnResponse } from "../src/server/turn-
|
|||||||
import { chatStore } from "../src/store/chats.ts";
|
import { chatStore } from "../src/store/chats.ts";
|
||||||
import { buildAnswer, coerceCorrectAnswer, normalizeOptions, resolveCorrect, type QuestionData } from "../src/core/question-core.ts";
|
import { buildAnswer, coerceCorrectAnswer, normalizeOptions, resolveCorrect, type QuestionData } from "../src/core/question-core.ts";
|
||||||
import { danglingQuestionOf, newAnswerResult } from "../src/core/native-messages.ts";
|
import { danglingQuestionOf, newAnswerResult } from "../src/core/native-messages.ts";
|
||||||
import { projectNative } from "../src/core/projection.ts";
|
import { projectView } from "../src/core/view.ts";
|
||||||
import type { PiEngine } from "../src/agent/models.ts";
|
import type { PiEngine } from "../src/agent/models.ts";
|
||||||
import type { LhMessage } from "../src/core/native-messages.ts";
|
import type { LhMessage } from "../src/core/native-messages.ts";
|
||||||
import { PATHS } from "../config.ts";
|
import { PATHS } from "../config.ts";
|
||||||
@@ -108,6 +108,18 @@ function gradedAnswer(input: typeof QUIZ_INPUT, values: string[]) {
|
|||||||
return buildAnswer(qd, { values });
|
return buildAnswer(qd, { values });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Parse an SSE body into its wire events (verbatim pi AgentEvents +
|
||||||
|
* the app-level error/turn-settled terminals). Structural beats substring:
|
||||||
|
* key order never matters. */
|
||||||
|
function eventsOf(body: string): Array<Record<string, any>> {
|
||||||
|
const out: Array<Record<string, any>> = [];
|
||||||
|
for (const line of body.split("\n")) {
|
||||||
|
if (!line.startsWith("data: ") || line === "data: [DONE]") continue;
|
||||||
|
out.push(JSON.parse(line.slice(6)));
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
async function drain(res: Response): Promise<string> {
|
async function drain(res: Response): Promise<string> {
|
||||||
if (res.status !== 200) {
|
if (res.status !== 200) {
|
||||||
assert.fail(`expected 200, got ${res.status}: ${await res.text()}`);
|
assert.fail(`expected 200, got ${res.status}: ${await res.text()}`);
|
||||||
@@ -213,7 +225,7 @@ describe("teacher chat flow (scripted model)", () => {
|
|||||||
|
|
||||||
// The projection pins the client view: reasoning part + a pending
|
// The projection pins the client view: reasoning part + a pending
|
||||||
// (input-available) quiz part — the dock's mount state.
|
// (input-available) quiz part — the dock's mount state.
|
||||||
const view1 = projectNative(saved1.messages);
|
const view1 = projectView(saved1.messages);
|
||||||
const reasoningPart = view1[1]!.parts.find((p) => p.type === "reasoning");
|
const reasoningPart = view1[1]!.parts.find((p) => p.type === "reasoning");
|
||||||
assert.ok(reasoningPart && reasoningPart.type === "reasoning", "reasoning part projected");
|
assert.ok(reasoningPart && reasoningPart.type === "reasoning", "reasoning part projected");
|
||||||
const quizPart = view1[1]!.parts.find((p) => p.type === "tool-quiz");
|
const quizPart = view1[1]!.parts.find((p) => p.type === "tool-quiz");
|
||||||
@@ -241,7 +253,7 @@ describe("teacher chat flow (scripted model)", () => {
|
|||||||
// Native: user, assistant, answer toolResult, reaction assistant.
|
// Native: user, assistant, answer toolResult, reaction assistant.
|
||||||
assert.equal(saved2.messages.length, 4, "the answer + reaction append natively");
|
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");
|
assert.equal((saved2.messages[2] as { role?: string }).role, "toolResult", "the graded answer is the call's tool result");
|
||||||
const view2 = projectNative(saved2.messages);
|
const view2 = projectView(saved2.messages);
|
||||||
assert.equal(view2.length, 2, "the reaction continues the same assistant message (one group)");
|
assert.equal(view2.length, 2, "the reaction continues the same assistant message (one group)");
|
||||||
const finalParts = view2[1]!.parts;
|
const finalParts = view2[1]!.parts;
|
||||||
const textPart = finalParts.find((p) => p.type === "text");
|
const textPart = finalParts.find((p) => p.type === "text");
|
||||||
@@ -298,20 +310,28 @@ describe("teacher chat flow (scripted model)", () => {
|
|||||||
await drain(ok);
|
await drain(ok);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("streams tool-input-start before tool-input-available (protocol pin for the 'Writing a question…' indicator)", async () => {
|
it("streams the toolCall partial before tool_execution_start/end (protocol pin for the 'Writing a question…' shimmer)", async () => {
|
||||||
// The client's writing indicator keys off tool parts in state
|
// Wave D: the wire is VERBATIM pi AgentEvents. The client's writing
|
||||||
// 'input-streaming', which the stream processor sets from the
|
// indicator derives 'input-streaming' from a toolCall block seen in a
|
||||||
// tool-input-start chunk. If a transport change ever stopped emitting
|
// message_update with no execution yet; the dock mounts on a CLEAN
|
||||||
// it, the indicator would silently die — pin the protocol.
|
// tool_execution_end. Pin the ordering that drives both — if the
|
||||||
|
// passthrough ever stopped emitting one of these, the indicator or
|
||||||
|
// the dock would silently die.
|
||||||
const { engine } = scriptedTeacher(defaultLesson);
|
const { engine } = scriptedTeacher(defaultLesson);
|
||||||
const chat = createConfiguredChat("Test: protocol pin");
|
const chat = createConfiguredChat("Test: protocol pin");
|
||||||
createdChats.push(chat.id);
|
createdChats.push(chat.id);
|
||||||
const body = await drain(await handleChatRequest(post(chat.id, { type: "text", text: "quiz me" }), 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 events = eventsOf(body);
|
||||||
const availIdx = body.indexOf('"type":"tool-input-available"');
|
const callSeen = events.findIndex(
|
||||||
assert.ok(startIdx !== -1, "tool-input-start chunk present in the stream");
|
(e) => e.type === "message_update" && JSON.stringify(e.message?.content ?? []).includes('"type":"toolCall"'),
|
||||||
assert.ok(availIdx !== -1, "tool-input-available chunk present in the stream");
|
);
|
||||||
assert.ok(startIdx < availIdx, "start precedes available");
|
const execStart = events.findIndex((e) => e.type === "tool_execution_start" && e.toolCallId === "call-1");
|
||||||
|
const execEnd = events.findIndex((e) => e.type === "tool_execution_end" && e.toolCallId === "call-1");
|
||||||
|
assert.ok(callSeen !== -1, "a message_update carries the toolCall block (the shimmer source)");
|
||||||
|
assert.ok(execStart !== -1, "tool_execution_start present in the stream");
|
||||||
|
assert.ok(execEnd !== -1, "tool_execution_end present in the stream");
|
||||||
|
assert.ok(callSeen < execStart && execStart < execEnd, "partial → execution start → execution end");
|
||||||
|
assert.equal(events[execEnd]!.isError, false, "the clean end is what mounts the dock");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("a cancelled view NEVER aborts the turn — it runs to completion (owner ruling 2026-09-25)", async () => {
|
it("a cancelled view NEVER aborts the turn — it runs to completion (owner ruling 2026-09-25)", async () => {
|
||||||
@@ -375,22 +395,17 @@ describe("teacher chat flow (scripted model)", () => {
|
|||||||
createdChats.push(chat.id);
|
createdChats.push(chat.id);
|
||||||
const body = await drain(await handleChatRequest(post(chat.id, { type: "text", text: "quiz me" }), 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
|
// WIRE pin (the live modal-realism crash): the invalid call's execution
|
||||||
// become input-available — the dock mounts on input-available parts and
|
// end is an ERROR — the dock only mounts on a CLEAN tool_execution_end,
|
||||||
// crashed on a malformed quiz before tool-input-error could replace it.
|
// so a malformed quiz can never reach it. The retry's end is clean.
|
||||||
// The boundary gates this on clean tool_execution_end.
|
const events = eventsOf(body);
|
||||||
assert.ok(
|
const badEnd = events.find((e) => e.type === "tool_execution_end" && e.toolCallId === "call-bad");
|
||||||
!body.includes('"type":"tool-input-available","toolCallId":"call-bad"'),
|
assert.ok(badEnd, "the invalid call's execution end is on the wire");
|
||||||
"the invalid call never reached input-available on the wire",
|
assert.equal(badEnd.isError, true, "the invalid call surfaced as an error execution");
|
||||||
);
|
assert.match(String(badEnd.result?.content?.[0]?.text ?? ""), /does not match any option value/);
|
||||||
assert.ok(
|
const goodEnd = events.find((e) => e.type === "tool_execution_end" && e.toolCallId === "call-good");
|
||||||
body.includes('"type":"tool-input-error","toolCallId":"call-bad"'),
|
assert.ok(goodEnd, "the valid retry's execution end is on the wire");
|
||||||
"the invalid call surfaced as tool-input-error",
|
assert.equal(goodEnd.isError, false, "the valid retry's end is CLEAN — the dock mounts on it");
|
||||||
);
|
|
||||||
assert.ok(
|
|
||||||
body.includes('"type":"tool-input-available","toolCallId":"call-good"'),
|
|
||||||
"the valid retry DID become input-available",
|
|
||||||
);
|
|
||||||
|
|
||||||
// The model was re-prompted (the error did not end the turn)…
|
// The model was re-prompted (the error did not end the turn)…
|
||||||
assert.equal(calls.length, 2, "the invalid call triggered a second model prompt");
|
assert.equal(calls.length, 2, "the invalid call triggered a second model prompt");
|
||||||
@@ -405,7 +420,7 @@ describe("teacher chat flow (scripted model)", () => {
|
|||||||
// model's retry context — errored question calls DO persist), the
|
// model's retry context — errored question calls DO persist), the
|
||||||
// retry as the dangling quiz. The projection renders the error part.
|
// retry as the dangling quiz. The projection renders the error part.
|
||||||
const saved = chatStore.getChat(chat.id)!;
|
const saved = chatStore.getChat(chat.id)!;
|
||||||
const view = projectNative(saved.messages);
|
const view = projectView(saved.messages);
|
||||||
const quizParts = view[1]!.parts.filter((p) => p.type === "tool-quiz");
|
const quizParts = view[1]!.parts.filter((p) => p.type === "tool-quiz");
|
||||||
const failed = quizParts.find(
|
const failed = quizParts.find(
|
||||||
(p) => p.type === "tool-quiz" && (p as { state?: string }).state === "output-error",
|
(p) => p.type === "tool-quiz" && (p as { state?: string }).state === "output-error",
|
||||||
@@ -446,17 +461,14 @@ describe("teacher chat flow (scripted model)", () => {
|
|||||||
createdChats.push(chat.id);
|
createdChats.push(chat.id);
|
||||||
const body = await drain(await handleChatRequest(post(chat.id, { type: "text", text: "quiz me" }), agentFactory(engine)));
|
const body = await drain(await handleChatRequest(post(chat.id, { type: "text", text: "quiz me" }), agentFactory(engine)));
|
||||||
|
|
||||||
assert.ok(
|
const events = eventsOf(body);
|
||||||
!body.includes('"type":"tool-input-available","toolCallId":"call-stringy"'),
|
const stringyEnd = events.find((e) => e.type === "tool_execution_end" && e.toolCallId === "call-stringy");
|
||||||
"the structurally invalid quiz never became input-available on the wire",
|
assert.ok(stringyEnd, "the invalid quiz's execution end is on the wire");
|
||||||
);
|
assert.equal(stringyEnd.isError, true, "the structural reject surfaced as an error execution");
|
||||||
assert.ok(
|
assert.match(String(stringyEnd.result?.content?.[0]?.text ?? ""), /options/);
|
||||||
body.includes('"type":"tool-input-error","toolCallId":"call-stringy"'),
|
|
||||||
"the invalid quiz surfaced as tool-input-error",
|
|
||||||
);
|
|
||||||
|
|
||||||
const saved = chatStore.getChat(chat.id)!;
|
const saved = chatStore.getChat(chat.id)!;
|
||||||
const view = projectNative(saved.messages);
|
const view = projectView(saved.messages);
|
||||||
const failed = view[1]!.parts.find(
|
const failed = view[1]!.parts.find(
|
||||||
(p) => (p as { toolCallId?: string }).toolCallId === "call-stringy",
|
(p) => (p as { toolCallId?: string }).toolCallId === "call-stringy",
|
||||||
);
|
);
|
||||||
@@ -488,15 +500,18 @@ describe("teacher chat flow (scripted model)", () => {
|
|||||||
createdChats.push(chat.id);
|
createdChats.push(chat.id);
|
||||||
const body = await drain(await handleChatRequest(post(chat.id, { type: "text", text: "quiz me twice" }), 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.
|
// (B) wire determinism: question tools execute sequentially, so the
|
||||||
const availA = body.indexOf('"type":"tool-input-available","toolCallId":"call-a"');
|
// quizzes' CLEAN execution ends arrive in call order (the dock mounts
|
||||||
const availB = body.indexOf('"type":"tool-input-available","toolCallId":"call-b"');
|
// on clean ends — the mount order is the wire order).
|
||||||
assert.ok(availA !== -1 && availB !== -1, "both quizzes became available");
|
const events = eventsOf(body);
|
||||||
assert.ok(availA < availB, "call A's input became available before call B's (sequential execution)");
|
const endA = events.findIndex((e) => e.type === "tool_execution_end" && e.toolCallId === "call-a" && e.isError === false);
|
||||||
|
const endB = events.findIndex((e) => e.type === "tool_execution_end" && e.toolCallId === "call-b" && e.isError === false);
|
||||||
|
assert.ok(endA !== -1 && endB !== -1, "both quizzes' clean ends are on the wire");
|
||||||
|
assert.ok(endA < endB, "call A's end precedes call B's (sequential execution)");
|
||||||
|
|
||||||
// Saved in call order (the projection preserves the content order).
|
// Saved in call order (the projection preserves the content order).
|
||||||
const saved = chatStore.getChat(chat.id)!;
|
const saved = chatStore.getChat(chat.id)!;
|
||||||
const quizIds = projectNative(saved.messages)[1]!.parts
|
const quizIds = projectView(saved.messages)[1]!.parts
|
||||||
.filter((p) => p.type === "tool-quiz")
|
.filter((p) => p.type === "tool-quiz")
|
||||||
.map((p) => (p as { toolCallId: string }).toolCallId);
|
.map((p) => (p as { toolCallId: string }).toolCallId);
|
||||||
assert.deepEqual(quizIds, ["call-a", "call-b"], "parts persisted in call order");
|
assert.deepEqual(quizIds, ["call-a", "call-b"], "parts persisted in call order");
|
||||||
@@ -576,7 +591,7 @@ describe("mid-turn navigation (turn liveness + write-ahead)", () => {
|
|||||||
const midTurn = chatStore.getChat(chat.id)!;
|
const midTurn = chatStore.getChat(chat.id)!;
|
||||||
assert.equal(midTurn.messages.length, 3, "write-ahead appended the answer tool result before finish");
|
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");
|
assert.equal((midTurn.messages[2] as { role?: string }).role, "toolResult");
|
||||||
const midQuiz = projectNative(midTurn.messages)[1]!.parts.find((p) => p.type === "tool-quiz");
|
const midQuiz = projectView(midTurn.messages)[1]!.parts.find((p) => p.type === "tool-quiz");
|
||||||
assert.ok(midQuiz && midQuiz.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");
|
assert.equal((midQuiz as { state?: string }).state, "output-available", "write-ahead persisted the answer before finish");
|
||||||
|
|
||||||
@@ -585,7 +600,7 @@ describe("mid-turn navigation (turn liveness + write-ahead)", () => {
|
|||||||
// Native: user, assistant, answer result, reaction — one append-only
|
// Native: user, assistant, answer result, reaction — one append-only
|
||||||
// history; the projection still renders ONE assistant group.
|
// history; the projection still renders ONE assistant group.
|
||||||
assert.equal(saved2.messages.length, 4, "full turn persisted as usual at finish");
|
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(projectView(saved2.messages).length, 2, "the projection groups the answered quiz + reaction");
|
||||||
assert.equal(isTurnRunning(chat.id), false, "registry cleared at finish");
|
assert.equal(isTurnRunning(chat.id), false, "registry cleared at finish");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -732,14 +747,15 @@ describe("background-turn runner (owner ruling 2026-09-25: every turn is a serve
|
|||||||
// Let the turn finish; both views close at settle.
|
// Let the turn finish; both views close at settle.
|
||||||
release();
|
release();
|
||||||
const text = await secondText;
|
const text = await secondText;
|
||||||
assert.ok(text.startsWith('data: {"type":"start"'), "replay starts from the very first chunk (the start event)");
|
const replay = eventsOf(text);
|
||||||
|
assert.equal(replay[0]!.type, "agent_start", "replay starts from the very first event");
|
||||||
assert.ok(text.includes("Gated reply."), "replay carries the full streamed content");
|
assert.ok(text.includes("Gated reply."), "replay carries the full streamed content");
|
||||||
assert.ok(text.endsWith("data: [DONE]\n\n"), "live tail ends with the SSE terminal, same dialect as POST");
|
assert.ok(text.endsWith("data: [DONE]\n\n"), "live tail ends with the SSE terminal, same dialect as POST");
|
||||||
// Wire honesty: a normal turn settles via agent_end with NO error chunk
|
// Wire honesty: a normal turn settles with NO error event (the live
|
||||||
// (the live smoke caught a close()-throw masked into the error path
|
// smoke caught a close()-throw masked into the error path — unit
|
||||||
// — unit asserts never looked; these pins would have).
|
// asserts never looked; these pins would have).
|
||||||
assert.ok(!text.includes('"type":"error"'), "a clean turn never emits an error chunk");
|
assert.ok(!text.includes('"type":"error"'), "a clean turn never emits an error event");
|
||||||
assert.ok(text.includes('"finishReason":"stop"'), "the finish chunk carries the stop reason");
|
assert.ok(replay.some((e) => e.type === "message_end" && e.message?.stopReason === "stop"), "message_end carries the stop reason");
|
||||||
// And the first view also closes cleanly (no hang, no drop): read its
|
// And the first view also closes cleanly (no hang, no drop): read its
|
||||||
// remaining chunks through the reader we already hold.
|
// remaining chunks through the reader we already hold.
|
||||||
const decoder = new TextDecoder();
|
const decoder = new TextDecoder();
|
||||||
|
|||||||
@@ -1,118 +0,0 @@
|
|||||||
/**
|
|
||||||
* 409 identity-retry transport tests (components/chat-transport.ts).
|
|
||||||
*
|
|
||||||
* 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";
|
|
||||||
|
|
||||||
const realFetch = globalThis.fetch;
|
|
||||||
afterEach(() => {
|
|
||||||
globalThis.fetch = realFetch;
|
|
||||||
});
|
|
||||||
|
|
||||||
interface Call {
|
|
||||||
url: string;
|
|
||||||
body: string | undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
function installFetch(routes: {
|
|
||||||
/** POST /api/chat → Response; receives every attempt. */
|
|
||||||
chatPOST: (call: Call, attempt: number) => Response;
|
|
||||||
/** GET /api/jobs?chatId=… → turnInFlight value per poll (poll index). */
|
|
||||||
jobsGET?: (poll: number) => boolean;
|
|
||||||
}): Call[] {
|
|
||||||
const calls: Call[] = [];
|
|
||||||
let poll = 0;
|
|
||||||
globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => {
|
|
||||||
const url = String(input);
|
|
||||||
calls.push({ url, body: init?.body as string | undefined });
|
|
||||||
if (url === "/api/chat" && init?.method === "POST") {
|
|
||||||
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 });
|
|
||||||
throw new Error(`unexpected fetch: ${url}`);
|
|
||||||
}) as typeof globalThis.fetch;
|
|
||||||
return calls;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** One input event — the POST body's whole unit. */
|
|
||||||
function outgoing(input: Record<string, unknown>): string {
|
|
||||||
return JSON.stringify({ chatId: "chat_x", input });
|
|
||||||
}
|
|
||||||
|
|
||||||
describe("retryingFetch (409 merge-retry)", { skip: process.env.NO_SLOW ? true : undefined }, () => {
|
|
||||||
it("passes non-409 responses through untouched", async () => {
|
|
||||||
const calls = installFetch({
|
|
||||||
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({ 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, 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
|
|
||||||
});
|
|
||||||
const fetch = retryingFetch("chat_x", { pollMs: 10, waitMaxMs: 100 });
|
|
||||||
const res = await fetch("/api/chat", { method: "POST", body: outgoing(event) });
|
|
||||||
assert.equal(res.status, 200);
|
|
||||||
|
|
||||||
// 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("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 })),
|
|
||||||
});
|
|
||||||
const fetch = retryingFetch("chat_x", { pollMs: 10, waitMaxMs: 100 });
|
|
||||||
const res = await fetch("/api/chat", { method: "POST", body: outgoing({ type: "text", text: "q" }) });
|
|
||||||
assert.equal(res.status, 200);
|
|
||||||
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-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 })),
|
|
||||||
});
|
|
||||||
const fetch = retryingFetch("chat_x", { pollMs: 10, waitMaxMs: 100 });
|
|
||||||
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);
|
|
||||||
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 () => {
|
|
||||||
// turnInFlight stuck true; two polls (~1s) then force the deadline path
|
|
||||||
// by asserting we eventually get the 409 back — bounded by poll count.
|
|
||||||
let polls = 0;
|
|
||||||
const calls = installFetch({
|
|
||||||
chatPOST: () => new Response("busy", { status: 409 }),
|
|
||||||
jobsGET: () => true,
|
|
||||||
});
|
|
||||||
const fetch = retryingFetch("chat_x", { pollMs: 10, waitMaxMs: 100 });
|
|
||||||
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}`);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
+188
-47
@@ -1,7 +1,9 @@
|
|||||||
import assert from "node:assert/strict";
|
import assert from "node:assert/strict";
|
||||||
import { describe, it } from "node:test";
|
import { describe, it } from "node:test";
|
||||||
import * as fs from "node:fs";
|
import * as fs from "node:fs";
|
||||||
import type { UIMessage } from "ai";
|
import type { LhMessage } from "../src/core/native-messages.ts";
|
||||||
|
import { newAnswerResult, newPlanBlockMessage, newUserMessage } from "../src/core/native-messages.ts";
|
||||||
|
import type { ToolResultMessage } from "@earendil-works/pi-ai";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
batchRuns,
|
batchRuns,
|
||||||
@@ -11,20 +13,47 @@ import {
|
|||||||
messageFingerprint,
|
messageFingerprint,
|
||||||
messageViewLines,
|
messageViewLines,
|
||||||
} from "../src/core/coverage.ts";
|
} from "../src/core/coverage.ts";
|
||||||
import type { CoverageEntry } from "../src/core/contracts.ts";
|
import type { CoverageEntry } from "../src/core/contracts/graph.ts";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Extraction coverage — the pure core (vision-extraction-coverage.md,
|
* Extraction coverage — the pure core (vision-extraction-coverage.md,
|
||||||
* settled 2026-09-15). THE MESSAGE IS THE UNIT OF COVERAGE; the fingerprint
|
* settled 2026-09-15; Wave D native rebase 2026-09-26). THE NATIVE MESSAGE
|
||||||
* and the LLM prompt share ONE line builder (messageViewLines), so "covered"
|
* IS THE UNIT OF COVERAGE (owner ruling); the fingerprint and the LLM prompt
|
||||||
* is by construction exactly "what the extractor saw".
|
* share ONE line builder (messageViewLines), so "covered" is by construction
|
||||||
|
* exactly "what the extractor saw". Batches obey the ONE INSEPARABILITY RULE
|
||||||
|
* (owner fork 3): a tool-call message and its tool-result messages always
|
||||||
|
* appear in the same chunk.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
function user(id: string, text: string): UIMessage {
|
function user(id: string, text: string): LhMessage {
|
||||||
return { id, role: "user", parts: [{ type: "text", text }] } as unknown as UIMessage;
|
return { ...newUserMessage(text), id };
|
||||||
}
|
}
|
||||||
|
|
||||||
function slot(messages: UIMessage[], id: string) {
|
function teacher(id: string, text: string): LhMessage {
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
role: "assistant",
|
||||||
|
content: [{ type: "text", text }],
|
||||||
|
} as unknown as LhMessage;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** An assistant message calling `calls` (each {id,name}) — the bond anchor. */
|
||||||
|
function assistantWithCalls(
|
||||||
|
id: string,
|
||||||
|
calls: Array<{ id: string; name: string; args?: unknown }>,
|
||||||
|
): LhMessage {
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
role: "assistant",
|
||||||
|
content: calls.map((c) => ({ type: "toolCall", id: c.id, name: c.name, arguments: c.args ?? {} })),
|
||||||
|
} as unknown as LhMessage;
|
||||||
|
}
|
||||||
|
|
||||||
|
function result(id: string, callId: string, name: string, details: unknown): LhMessage {
|
||||||
|
return { ...newAnswerResult(callId, name, details), id } as LhMessage;
|
||||||
|
}
|
||||||
|
|
||||||
|
function slot(messages: ReadonlyArray<LhMessage>, id: string) {
|
||||||
const found = coverageSlots(messages).find((s) => s.message.id === id);
|
const found = coverageSlots(messages).find((s) => s.message.id === id);
|
||||||
assert.ok(found, `slot ${id}`);
|
assert.ok(found, `slot ${id}`);
|
||||||
return found;
|
return found;
|
||||||
@@ -37,9 +66,8 @@ describe("messageFingerprint — what changes it, what never does", () => {
|
|||||||
const same = messageFingerprint(user("m1", "hello world"));
|
const same = messageFingerprint(user("m1", "hello world"));
|
||||||
assert.notEqual(a, b);
|
assert.notEqual(a, b);
|
||||||
assert.equal(a, same);
|
assert.equal(a, same);
|
||||||
// Role is hashed too — a user and an assistant line never collide.
|
// Role is hashed too — a learner and a teacher line never collide.
|
||||||
const teacher = { id: "m1", role: "assistant", parts: [{ type: "text", text: "hello world" }] } as unknown as UIMessage;
|
assert.notEqual(a, messageFingerprint(teacher("m1", "hello world")));
|
||||||
assert.notEqual(a, messageFingerprint(teacher));
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("the message id is the provenance tag — part of exactly what the extractor saw", () => {
|
it("the message id is the provenance tag — part of exactly what the extractor saw", () => {
|
||||||
@@ -48,39 +76,61 @@ describe("messageFingerprint — what changes it, what never does", () => {
|
|||||||
// extraction view byte-for-byte. (Ids are stable in practice — the
|
// extraction view byte-for-byte. (Ids are stable in practice — the
|
||||||
// write-ahead persist owns them.)
|
// write-ahead persist owns them.)
|
||||||
assert.notEqual(messageFingerprint(user("m1", "same")), messageFingerprint(user("m2", "same")));
|
assert.notEqual(messageFingerprint(user("m1", "same")), messageFingerprint(user("m2", "same")));
|
||||||
// The id is the ONLY tag: the same id + content re-fingerprints equal.
|
// The same id + content re-fingerprints equal.
|
||||||
assert.equal(messageFingerprint(user("m1", "same")), messageFingerprint(user("m1", "same")));
|
assert.equal(messageFingerprint(user("m1", "same")), messageFingerprint(user("m1", "same")));
|
||||||
});
|
});
|
||||||
|
|
||||||
it("data parts are invisible — plan-block attach never re-opens a covered message", () => {
|
it("marker messages are invisible — a plan-block attach never re-opens anything", () => {
|
||||||
const plain = user("m1", "teach me the chain rule");
|
// Wave D: the marker is its OWN message (not a part on another one);
|
||||||
const withData = {
|
// its view is empty, so it neither re-opens others nor itself
|
||||||
...plain,
|
// carries any learning evidence.
|
||||||
parts: [...plain.parts, { type: "data-plan-blocks", planBlocks: [] }],
|
const marker = newPlanBlockMessage("call_p1", {
|
||||||
} as unknown as UIMessage;
|
kind: "full",
|
||||||
assert.equal(messageFingerprint(plain), messageFingerprint(withData));
|
at: "2026-09-26T00:00:00.000Z",
|
||||||
|
current: null,
|
||||||
|
nodes: [],
|
||||||
|
edges: [],
|
||||||
|
}) as LhMessage;
|
||||||
|
assert.deepEqual(messageViewLines(marker, marker.id), []);
|
||||||
|
// And the message it rides beside keeps its fingerprint untouched:
|
||||||
|
const before = messageFingerprint(teacher("t1", "the chain rule"));
|
||||||
|
const withMarker = [teacher("t1", "the chain rule"), marker];
|
||||||
|
assert.equal(
|
||||||
|
messageFingerprint(withMarker[0]!),
|
||||||
|
before,
|
||||||
|
"the neighbour's fingerprint is unchanged by the attach",
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("a quiz answer merging in changes the fingerprint — and only that message's", () => {
|
it("an answered quiz is its OWN message — the asking message never re-opens", () => {
|
||||||
const asking = {
|
// Wave D: the answer arrives as the call's toolResult message; the
|
||||||
id: "a1",
|
// asking assistant message's fingerprint is STABLE (the old wire
|
||||||
role: "assistant",
|
// merged the answer into the asking group and re-opened it).
|
||||||
parts: [{ type: "tool-quiz", toolCallId: "q1", state: "input-available", input: { question: "Q?" } }],
|
const asking = assistantWithCalls("a1", [{ id: "q1", name: "quiz", args: { question: "Q?" } }]);
|
||||||
} as unknown as UIMessage;
|
const answer = result("r1", "q1", "quiz", { questionText: "Q?", correct: true });
|
||||||
const answered = {
|
const fpAsking = messageFingerprint(asking);
|
||||||
id: "a1",
|
assert.equal(fpAsking, messageFingerprint(assistantWithCalls("a1", [{ id: "q1", name: "quiz", args: { question: "Q?" } }])));
|
||||||
role: "assistant",
|
// The answer's own line carries the graded outcome:
|
||||||
parts: [
|
const lines = messageViewLines(answer, "r1");
|
||||||
{ type: "tool-quiz", toolCallId: "q1", state: "output-available", input: { question: "Q?" }, output: { questionText: "Q?", correct: true } },
|
assert.deepEqual(lines, ["QUIZ [q1] Q? — the learner answered correctly"]);
|
||||||
],
|
assert.ok(slot([asking, answer], "r1").tokens > 0, "the quiz outcome counts toward coverage tokens");
|
||||||
} as unknown as UIMessage;
|
});
|
||||||
const fpBefore = messageFingerprint(asking);
|
|
||||||
assert.notEqual(fpBefore, messageFingerprint(answered));
|
it("quiz outcome wording — dontKnow and incorrect each get their line", () => {
|
||||||
// An output-bearing line's mass counts toward coverage tokens.
|
const dk = result("r1", "q1", "quiz", { questionText: "Q?", dontKnow: true });
|
||||||
assert.ok(
|
assert.deepEqual(messageViewLines(dk, "r1"), ["QUIZ [q1] Q? — the learner did not know"]);
|
||||||
messageViewLines(answered, answered.id).join("").length >
|
const wrong = result("r2", "q2", "quiz", { questionText: "W?", correct: false, note: "mixed up signs" });
|
||||||
messageViewLines(asking, asking.id).join("").length,
|
assert.deepEqual(messageViewLines(wrong, "r2"), ["QUIZ [q2] W? — the learner answered incorrectly (note: mixed up signs)"]);
|
||||||
);
|
});
|
||||||
|
|
||||||
|
it("other clean tool results get one generic line; errors are invisible", () => {
|
||||||
|
const demo = result("r1", "j1", "demo", { title: "T" });
|
||||||
|
assert.deepEqual(messageViewLines(demo, "r1"), ["(demo tool result delivered)"]);
|
||||||
|
const failed = {
|
||||||
|
...newAnswerResult("j2", "visual", { oops: true }),
|
||||||
|
isError: true,
|
||||||
|
} as LhMessage;
|
||||||
|
assert.deepEqual(messageViewLines(failed, "r9"), [], "a failed call is retry context, not learning evidence");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -96,7 +146,9 @@ describe("coverageOf — uncovered is DERIVED, never dual-tracked", () => {
|
|||||||
{ chatId: "c2", fp: slot(messages, "m3").fp, tokens: 1, foldedAt: "2026-09-15T00:00:00.000Z" },
|
{ chatId: "c2", fp: slot(messages, "m3").fp, tokens: 1, foldedAt: "2026-09-15T00:00:00.000Z" },
|
||||||
];
|
];
|
||||||
const cov = coverageOf(messages, entries, "c1");
|
const cov = coverageOf(messages, entries, "c1");
|
||||||
assert.deepEqual(cov.uncovered.map((s) => s.message.id), ["m3"]);
|
// `uncovered` is INDEXES (the batcher needs positions for bonding):
|
||||||
|
assert.deepEqual(cov.uncovered, [2]);
|
||||||
|
assert.deepEqual(cov.slots[cov.uncovered[0]!]!.message.id, "m3");
|
||||||
assert.equal(cov.coveredCount, 2);
|
assert.equal(cov.coveredCount, 2);
|
||||||
assert.equal(cov.uncoveredCount, 1);
|
assert.equal(cov.uncoveredCount, 1);
|
||||||
assert.equal(cov.uncoveredTokens, slot(messages, "m3").tokens);
|
assert.equal(cov.uncoveredTokens, slot(messages, "m3").tokens);
|
||||||
@@ -114,7 +166,8 @@ describe("coverageOf — uncovered is DERIVED, never dual-tracked", () => {
|
|||||||
assert.equal(coverageOf(messages, entries, "c1").uncoveredCount, 0);
|
assert.equal(coverageOf(messages, entries, "c1").uncoveredCount, 0);
|
||||||
const mutated = [user("m1", "aaaa!"), messages[1]!];
|
const mutated = [user("m1", "aaaa!"), messages[1]!];
|
||||||
const cov = coverageOf(mutated, entries, "c1");
|
const cov = coverageOf(mutated, entries, "c1");
|
||||||
assert.deepEqual(cov.uncovered.map((s) => s.message.id), ["m1"]);
|
assert.deepEqual(cov.uncovered, [0]);
|
||||||
|
assert.equal(cov.slots[0]!.message.id, "m1");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -128,19 +181,106 @@ describe("batchRuns — token-budgeted, consecutive, never splits a message", ()
|
|||||||
user("m3", "c".repeat(40)),
|
user("m3", "c".repeat(40)),
|
||||||
user("m4", "d".repeat(40)),
|
user("m4", "d".repeat(40)),
|
||||||
]);
|
]);
|
||||||
const runs = batchRuns(slots, 45);
|
const runs = batchRuns(slots, [0, 1, 2, 3], 45);
|
||||||
assert.deepEqual(runs.map((r) => r.map((s) => s.message.id)), [["m1", "m2", "m3"], ["m4"]]);
|
assert.deepEqual(
|
||||||
|
runs.map((r) => r.map((s) => s.message.id)),
|
||||||
|
[["m1", "m2", "m3"], ["m4"]],
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("a single message larger than the budget becomes its own over-budget run", () => {
|
it("a single message larger than the budget becomes its own over-budget run", () => {
|
||||||
const slots = coverageSlots([user("m1", "x".repeat(400)), user("m2", "y".repeat(20))]);
|
const slots = coverageSlots([user("m1", "x".repeat(400)), user("m2", "y".repeat(20))]);
|
||||||
const runs = batchRuns(slots, 10);
|
assert.deepEqual(
|
||||||
assert.deepEqual(runs.map((r) => r.map((s) => s.message.id)), [["m1"], ["m2"]]);
|
batchRuns(slots, [0, 1], 10).map((r) => r.map((s) => s.message.id)),
|
||||||
|
[["m1"], ["m2"]],
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("an empty or non-positive budget never drops messages into a void", () => {
|
it("an empty or non-positive budget never drops messages into a void", () => {
|
||||||
assert.deepEqual(batchRuns(coverageSlots([user("m1", "hi")]), 0), []);
|
assert.deepEqual(batchRuns(coverageSlots([user("m1", "hi")]), [0], 0), []);
|
||||||
assert.deepEqual(batchRuns([], 4096), []);
|
assert.deepEqual(batchRuns([], [], 4096), []);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("THE BOND RULE (owner fork 3): a call and its results never split, even under a tiny budget", () => {
|
||||||
|
// The call's assistant message is COVERED; only the (new) answer is
|
||||||
|
// uncovered — the asking message rides into the chunk anyway, and
|
||||||
|
// the two NEVER split despite a budget of 1.
|
||||||
|
const messages = [
|
||||||
|
user("u1", "topic"),
|
||||||
|
assistantWithCalls("a1", [{ id: "q1", name: "quiz", args: { question: "Q?" } }]),
|
||||||
|
result("r1", "q1", "quiz", { questionText: "Q?", correct: true }),
|
||||||
|
];
|
||||||
|
const slots = coverageSlots(messages);
|
||||||
|
const cov = coverageOf(
|
||||||
|
messages,
|
||||||
|
coverageSlots([messages[0]!, messages[1]!]).map((s) => ({
|
||||||
|
chatId: "c1",
|
||||||
|
fp: s.fp,
|
||||||
|
tokens: s.tokens,
|
||||||
|
foldedAt: "2026-09-26T00:00:00.000Z",
|
||||||
|
})),
|
||||||
|
"c1",
|
||||||
|
);
|
||||||
|
assert.deepEqual(cov.uncovered, [2], "only the answer is uncovered");
|
||||||
|
const runs = batchRuns(slots, cov.uncovered, 1);
|
||||||
|
assert.deepEqual(
|
||||||
|
runs.map((r) => r.map((s) => s.message.id)),
|
||||||
|
[["a1", "r1"]],
|
||||||
|
"the covered asking message rides along as context — one chunk, over budget, never split",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("the reverse bond: an uncovered CALL pulls its covered results into the chunk", () => {
|
||||||
|
const messages = [
|
||||||
|
{
|
||||||
|
id: "a1",
|
||||||
|
role: "assistant",
|
||||||
|
content: [
|
||||||
|
{ type: "text", text: "Let me research that and build a demo." },
|
||||||
|
{ type: "toolCall", id: "j1", name: "research", arguments: { topic: "x" } },
|
||||||
|
{ type: "toolCall", id: "j2", name: "demo", arguments: { title: "T" } },
|
||||||
|
],
|
||||||
|
} as unknown as LhMessage,
|
||||||
|
result("r1", "j1", "research", { summary: "s" }),
|
||||||
|
result("r2", "j2", "demo", { done: true }),
|
||||||
|
];
|
||||||
|
const slots = coverageSlots(messages);
|
||||||
|
const cov = coverageOf(
|
||||||
|
messages,
|
||||||
|
coverageSlots([messages[1]!, messages[2]!]).map((s) => ({
|
||||||
|
chatId: "c1",
|
||||||
|
fp: s.fp,
|
||||||
|
tokens: s.tokens,
|
||||||
|
foldedAt: "2026-09-26T00:00:00.000Z",
|
||||||
|
})),
|
||||||
|
"c1",
|
||||||
|
);
|
||||||
|
assert.deepEqual(cov.uncovered, [0], "only the calling message is uncovered");
|
||||||
|
const runs = batchRuns(slots, cov.uncovered, 10_000);
|
||||||
|
assert.deepEqual(
|
||||||
|
runs.map((r) => r.map((s) => s.message.id)),
|
||||||
|
[["a1", "r1", "r2"]],
|
||||||
|
"all three travel together (checkpoint idempotency makes re-marking the results a no-op)",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("adjacent messages WITHOUT a bond still batch greedily (only calls bond)", () => {
|
||||||
|
// Two plain teacher messages after a bonded pair: the pair is one
|
||||||
|
// range, the plain messages group by budget alone.
|
||||||
|
const messages = [
|
||||||
|
assistantWithCalls("a1", [{ id: "q1", name: "quiz" }]),
|
||||||
|
result("r1", "q1", "quiz", { questionText: "Q?", correct: true }),
|
||||||
|
teacher("t1", "x".repeat(40)),
|
||||||
|
teacher("t2", "y".repeat(40)),
|
||||||
|
];
|
||||||
|
const slots = coverageSlots(messages);
|
||||||
|
assert.equal(slots[0]!.tokens, 0, "a call-only assistant message has no view lines");
|
||||||
|
// budget 30: the pair (~12) + t1 (~14) = 26 fits; t2 overflows.
|
||||||
|
const runs = batchRuns(slots, [0, 2, 3], 30);
|
||||||
|
assert.deepEqual(
|
||||||
|
runs.map((r) => r.map((s) => s.message.id)),
|
||||||
|
[["a1", "r1", "t1"], ["t2"]],
|
||||||
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -158,6 +298,7 @@ describe("batchTag — deterministic, ref-stable, mutation-sensitive", () => {
|
|||||||
assert.notEqual(batchTag(slots), batchTag(mutated));
|
assert.notEqual(batchTag(slots), batchTag(mutated));
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("the all-sessions coverage GET is dead (extraction-UX close-out, 2026-09-24)", () => {
|
describe("the all-sessions coverage GET is dead (extraction-UX close-out, 2026-09-24)", () => {
|
||||||
it("the route file is gone — the only coverage endpoint is the Extract POST", () => {
|
it("the route file is gone — the only coverage endpoint is the Extract POST", () => {
|
||||||
// UI confirmation (ff1ec7a): nothing client-side consumes the
|
// UI confirmation (ff1ec7a): nothing client-side consumes the
|
||||||
|
|||||||
@@ -57,7 +57,7 @@ import { chatStore } from "../src/store/chats.ts";
|
|||||||
import { jobStore } from "../src/store/jobs.ts";
|
import { jobStore } from "../src/store/jobs.ts";
|
||||||
import { handleChatRequest } from "../src/server/chat-handler.ts";
|
import { handleChatRequest } from "../src/server/chat-handler.ts";
|
||||||
import { createTeacherAgent } from "../src/agent/teacher-agent.ts";
|
import { createTeacherAgent } from "../src/agent/teacher-agent.ts";
|
||||||
import { projectNative } from "../src/core/projection.ts";
|
import { projectView as projectNative } from "../src/core/view.ts";
|
||||||
import type { PiEngine } from "../src/agent/models.ts";
|
import type { PiEngine } from "../src/agent/models.ts";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
+16
-10
@@ -26,7 +26,7 @@ import { createFauxCore, fauxAssistantMessage, fauxToolCall, type FauxResponseSt
|
|||||||
import type { PiEngine } from "../src/agent/models.ts";
|
import type { PiEngine } from "../src/agent/models.ts";
|
||||||
import type { LhMessage } from "../src/core/native-messages.ts";
|
import type { LhMessage } from "../src/core/native-messages.ts";
|
||||||
import { newPlanBlockMessage } from "../src/core/native-messages.ts";
|
import { newPlanBlockMessage } from "../src/core/native-messages.ts";
|
||||||
import { projectNative } from "../src/core/projection.ts";
|
import { projectView as projectNative } from "../src/core/view.ts";
|
||||||
import type {
|
import type {
|
||||||
AuditResult,
|
AuditResult,
|
||||||
CoverageEntry,
|
CoverageEntry,
|
||||||
@@ -42,7 +42,7 @@ import type {
|
|||||||
import type { GraphStore } from "../src/core/ports.ts";
|
import type { GraphStore } from "../src/core/ports.ts";
|
||||||
import { abortExtraction, createExtractionPipeline, extractionStatuses, transcriptView } from "../src/agent/subagents/extraction.ts";
|
import { abortExtraction, createExtractionPipeline, extractionStatuses, transcriptView } from "../src/agent/subagents/extraction.ts";
|
||||||
import { ModelsNotConfiguredError, ProviderKeyMissingError, providerKeyMissing, type ModelSlotName } from "../src/core/settings.ts";
|
import { ModelsNotConfiguredError, ProviderKeyMissingError, providerKeyMissing, type ModelSlotName } from "../src/core/settings.ts";
|
||||||
import { batchTag, coverageOf, coverageSlots } from "../src/core/coverage.ts";
|
import { batchTag, coverageOf, coverageSlots, messageViewLines } from "../src/core/coverage.ts";
|
||||||
import { chatStore } from "../src/store/chats.ts";
|
import { chatStore } from "../src/store/chats.ts";
|
||||||
import { PATHS } from "../config.ts";
|
import { PATHS } from "../config.ts";
|
||||||
|
|
||||||
@@ -399,7 +399,7 @@ describe("the extraction pipeline (extractor → auditor → merger)", () => {
|
|||||||
// a new event (vision-extraction-coverage.md).
|
// a new event (vision-extraction-coverage.md).
|
||||||
const events = ledgers.readEvents();
|
const events = ledgers.readEvents();
|
||||||
const record = chatStore.getChat(chatId)!;
|
const record = chatStore.getChat(chatId)!;
|
||||||
const expectedTag = batchTag(coverageSlots(projectNative(record.messages)));
|
const expectedTag = batchTag(coverageSlots(record.messages));
|
||||||
assert.deepEqual(
|
assert.deepEqual(
|
||||||
events.map((e) => [e.kind, e.ref, e.nodeId]),
|
events.map((e) => [e.kind, e.ref, e.nodeId]),
|
||||||
[
|
[
|
||||||
@@ -415,12 +415,18 @@ describe("the extraction pipeline (extractor → auditor → merger)", () => {
|
|||||||
// one entry per transcript message, fingerprinted content each —
|
// one entry per transcript message, fingerprinted content each —
|
||||||
// the same turns are never extracted twice.
|
// the same turns are never extracted twice.
|
||||||
const covered = ledgers.readCoverage();
|
const covered = ledgers.readCoverage();
|
||||||
assert.equal(covered.length, projectNative(record.messages).length);
|
// Wave D: the coverage unit is the VISIBLE native message — bonded
|
||||||
|
// riders with no view lines (a textless call message) are context,
|
||||||
|
// never checkpointed.
|
||||||
|
const visible = record.messages.filter(
|
||||||
|
(m) => messageViewLines(m, m.id).length > 0,
|
||||||
|
).length;
|
||||||
|
assert.equal(covered.length, visible);
|
||||||
assert.ok(covered.every((c) => c.chatId === chatId && c.fp.length === 64 && c.tokens > 0));
|
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(ledgers.readCheckpoints().length, 0, "the chat path writes coverage, not the legacy checkpoint");
|
||||||
|
|
||||||
assert.equal(
|
assert.equal(
|
||||||
coverageOf(projectNative(record.messages), covered, chatId).uncoveredCount,
|
coverageOf(record.messages, covered, chatId).uncoveredCount,
|
||||||
0,
|
0,
|
||||||
"every message is now covered",
|
"every message is now covered",
|
||||||
);
|
);
|
||||||
@@ -1020,9 +1026,9 @@ describe("the chunked pipeline (extraction coverage)", () => {
|
|||||||
|
|
||||||
// Per-batch coverage: every message checkpointed, nothing legacy.
|
// Per-batch coverage: every message checkpointed, nothing legacy.
|
||||||
const coverage = ledgers.readCoverage();
|
const coverage = ledgers.readCoverage();
|
||||||
assert.equal(coverage.length, projectNative(record.messages).length);
|
assert.equal(coverage.length, record.messages.length);
|
||||||
assert.equal(ledgers.readCheckpoints().length, 0);
|
assert.equal(ledgers.readCheckpoints().length, 0);
|
||||||
assert.equal(coverageOf(projectNative(record.messages), coverage, chatId).uncoveredCount, 0);
|
assert.equal(coverageOf(record.messages, coverage, chatId).uncoveredCount, 0);
|
||||||
|
|
||||||
// A second run is a NO-OP: nothing uncovered → no model calls, no
|
// A second run is a NO-OP: nothing uncovered → no model calls, no
|
||||||
// new usage entry (no money spent on covered ground).
|
// new usage entry (no money spent on covered ground).
|
||||||
@@ -1075,7 +1081,7 @@ describe("the chunked pipeline (extraction coverage)", () => {
|
|||||||
// Batch 1's emissions are kept and its messages stay covered.
|
// Batch 1's emissions are kept and its messages stay covered.
|
||||||
assert.deepEqual(stats, { nodes: 1, edges: 0, events: 1 });
|
assert.deepEqual(stats, { nodes: 1, edges: 0, events: 1 });
|
||||||
assert.equal(ledgers.readCoverage().length, 3, "batch 1 checkpointed its three messages");
|
assert.equal(ledgers.readCoverage().length, 3, "batch 1 checkpointed its three messages");
|
||||||
const cov = coverageOf(projectNative(record.messages), ledgers.readCoverage(), chatId);
|
const cov = coverageOf(record.messages, ledgers.readCoverage(), chatId);
|
||||||
assert.equal(cov.uncoveredCount, 2, "the in-flight batch's messages stay uncovered");
|
assert.equal(cov.uncoveredCount, 2, "the in-flight batch's messages stay uncovered");
|
||||||
|
|
||||||
// The re-run — the manual extract button — picks up ONLY the
|
// The re-run — the manual extract button — picks up ONLY the
|
||||||
@@ -1109,9 +1115,9 @@ describe("the chunked pipeline (extraction coverage)", () => {
|
|||||||
auditor: fauxEngine([passThroughAuditStep()]),
|
auditor: fauxEngine([passThroughAuditStep()]),
|
||||||
});
|
});
|
||||||
await pipeline.runExtraction(chatId);
|
await pipeline.runExtraction(chatId);
|
||||||
// Wave B: the record is native — coverage reads the projection.
|
// Wave D: the record IS native history — coverage reads it directly.
|
||||||
const record = () => chatStore.getChat(chatId)!.messages;
|
const record = () => chatStore.getChat(chatId)!.messages;
|
||||||
const messages = () => projectNative(record());
|
const messages = () => record();
|
||||||
assert.equal(coverageOf(messages(), ledgers.readCoverage(), chatId).uncoveredCount, 0);
|
assert.equal(coverageOf(messages(), ledgers.readCoverage(), chatId).uncoveredCount, 0);
|
||||||
|
|
||||||
// Data parts are invisible to the extraction view — a plan-block
|
// Data parts are invisible to the extraction view — a plan-block
|
||||||
|
|||||||
+4
-4
@@ -20,7 +20,7 @@ import { after, describe, it } from "node:test";
|
|||||||
import type { AuditResult } from "../src/core/contracts.ts";
|
import type { AuditResult } from "../src/core/contracts.ts";
|
||||||
import type { QuestionAnswerData } from "../src/core/question-core.ts";
|
import type { QuestionAnswerData } from "../src/core/question-core.ts";
|
||||||
import type { LhMessage } from "../src/core/native-messages.ts";
|
import type { LhMessage } from "../src/core/native-messages.ts";
|
||||||
import { projectNative } from "../src/core/projection.ts";
|
import { projectView as projectNative } from "../src/core/view.ts";
|
||||||
import { getGraph, graphSnapshot, mergeFindings, type GraphDeps } from "../src/core/graph.ts";
|
import { getGraph, graphSnapshot, mergeFindings, type GraphDeps } from "../src/core/graph.ts";
|
||||||
import { coverageSlots } from "../src/core/coverage.ts";
|
import { coverageSlots } from "../src/core/coverage.ts";
|
||||||
import { graphStore } from "../src/store/graph.ts";
|
import { graphStore } from "../src/store/graph.ts";
|
||||||
@@ -33,12 +33,12 @@ const graphDeps = { chatStore, graphStore } as const satisfies GraphDeps;
|
|||||||
/** The session merge source for a chat — what the pipeline passes: the live
|
/** The session merge source for a chat — what the pipeline passes: the live
|
||||||
* transcript + its updatedAt checkpoint stamp. (Notes imports pass
|
* transcript + its updatedAt checkpoint stamp. (Notes imports pass
|
||||||
* messages: null — see the notes-merger test below.) */
|
* messages: null — see the notes-merger test below.) */
|
||||||
function sessionSource(chatId: string): { messages: ReturnType<typeof projectNative>; foldedAt: string } {
|
function sessionSource(chatId: string): { messages: LhMessage[]; foldedAt: string } {
|
||||||
// Wave B: the record is native history — the merger's transcript view
|
// Wave B: the record is native history — the merger's transcript view
|
||||||
// reads through the one-way projection (same shape the extraction
|
// reads through the one-way projection (same shape the extraction
|
||||||
// pipeline passes: its batch slots are projected messages).
|
// pipeline passes: its batch slots are projected messages).
|
||||||
const record = chatStore.getChat(chatId)!;
|
const record = chatStore.getChat(chatId)!;
|
||||||
return { messages: projectNative(record.messages), foldedAt: record.updatedAt };
|
return { messages: record.messages, foldedAt: record.updatedAt };
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── ledger isolation (backup-and-clear of data/graph/*.jsonl) ──────────────
|
// ─── ledger isolation (backup-and-clear of data/graph/*.jsonl) ──────────────
|
||||||
@@ -1036,7 +1036,7 @@ describe("session coverage read (extraction-UX redesign, 2026-09-24)", () => {
|
|||||||
|
|
||||||
// Fold the first two messages: the read derives the split + the
|
// Fold the first two messages: the read derives the split + the
|
||||||
// newest fold timestamp from the ledger rows.
|
// newest fold timestamp from the ledger rows.
|
||||||
const slots = coverageSlots(projectNative(chatStore.getChat(chat.id)!.messages));
|
const slots = coverageSlots(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[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" });
|
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.
|
// Another session's row must not leak into this read.
|
||||||
|
|||||||
+1
-1
@@ -58,7 +58,7 @@ import { providerKeyMissing, type ModelSlotName } from "../src/core/settings.ts"
|
|||||||
import type { PiEngine } from "../src/agent/models.ts";
|
import type { PiEngine } from "../src/agent/models.ts";
|
||||||
import type { SearchProvider } from "../src/core/search.ts";
|
import type { SearchProvider } from "../src/core/search.ts";
|
||||||
import { PATHS } from "../config.ts";
|
import { PATHS } from "../config.ts";
|
||||||
import { projectNative } from "../src/core/projection.ts";
|
import { projectView as projectNative } from "../src/core/view.ts";
|
||||||
import type { LhMessage } from "../src/core/native-messages.ts";
|
import type { LhMessage } from "../src/core/native-messages.ts";
|
||||||
import type { SteerInput, TurnInput } from "../src/core/input-events.ts";
|
import type { SteerInput, TurnInput } from "../src/core/input-events.ts";
|
||||||
|
|
||||||
|
|||||||
@@ -1,74 +0,0 @@
|
|||||||
/**
|
|
||||||
* mergeAdopted (core/ui-message.ts) — the append-only record-adoption seam
|
|
||||||
* (owner ground rules, 2026-09-15): server-added steer messages must become
|
|
||||||
* visible mid-turn without the full snapshot swap that breaks the SDK's
|
|
||||||
* continuation merge. Pure, no React.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import assert from "node:assert/strict";
|
|
||||||
import { describe, it } from "node:test";
|
|
||||||
import { mergeAdopted, type TeacherUIMessage } from "../src/core/index.ts";
|
|
||||||
|
|
||||||
function userText(id: string, text: string): TeacherUIMessage {
|
|
||||||
return { id, role: "user", parts: [{ type: "text", text }] };
|
|
||||||
}
|
|
||||||
function steer(id: string): TeacherUIMessage {
|
|
||||||
// Wave 7 killed the graph-context injection; its marker part is gone.
|
|
||||||
// The steer-shaped user message here exercises mergeAdopted's
|
|
||||||
// non-text user parts path with the handoff marker instead.
|
|
||||||
return { id, role: "user", parts: [{ type: "data-probe-handoff", data: { chatId: "c" } }] };
|
|
||||||
}
|
|
||||||
function assistant(id: string): TeacherUIMessage {
|
|
||||||
return { id, role: "assistant", parts: [{ type: "text", text: "reply" }] };
|
|
||||||
}
|
|
||||||
|
|
||||||
describe("mergeAdopted — append-only adoption", () => {
|
|
||||||
it("inserts a server-added steer between the persisted core and the in-flight tail", () => {
|
|
||||||
const local = [userText("u1", "topic"), userText("u2", "answer"), assistant("a_inflight")];
|
|
||||||
const record = [userText("u1", "topic"), userText("u2", "answer"), steer("steer_ctx_c")];
|
|
||||||
const merged = mergeAdopted(local, record);
|
|
||||||
assert.ok(merged);
|
|
||||||
assert.deepEqual(
|
|
||||||
merged.map((m) => m.id),
|
|
||||||
["u1", "u2", "steer_ctx_c", "a_inflight"],
|
|
||||||
"marker between the answer and the streaming bubble",
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("adopts into an idle client (no in-flight tail)", () => {
|
|
||||||
const local = [userText("u1", "topic")];
|
|
||||||
const record = [userText("u1", "topic"), steer("s1"), assistant("a1")];
|
|
||||||
const merged = mergeAdopted(local, record);
|
|
||||||
assert.deepEqual(
|
|
||||||
merged?.map((m) => m.id),
|
|
||||||
["u1", "s1", "a1"],
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("nothing new → null (no needless re-render)", () => {
|
|
||||||
const local = [userText("u1", "topic")];
|
|
||||||
assert.equal(mergeAdopted(local, [userText("u1", "topic")]), null);
|
|
||||||
// The tail alone is not 'new' — nothing to adopt.
|
|
||||||
assert.equal(mergeAdopted([assistant("a_x")], []), null);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("a missing core id (record rewrite) → null: defer to the full sync at idle", () => {
|
|
||||||
// A local message vanished from the record — mid-turn this means a
|
|
||||||
// rewrite, and the append-only shortcut must not paper over it.
|
|
||||||
const local = [userText("u1", "a"), userText("u2", "b"), assistant("tail")];
|
|
||||||
const record = [userText("u2", "b"), steer("s1")];
|
|
||||||
assert.equal(mergeAdopted(local, record), null);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("only the LAST local message may be the absent in-flight tail", () => {
|
|
||||||
// Two absent local messages: not a simple append — bail.
|
|
||||||
const local = [userText("u1", "a"), userText("u2", "gone"), assistant("tail")];
|
|
||||||
const record = [userText("u1", "a"), steer("s1")];
|
|
||||||
assert.equal(mergeAdopted(local, record), null);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("an empty local list adopts the whole record", () => {
|
|
||||||
const record = [steer("s1"), assistant("a1")];
|
|
||||||
assert.deepEqual(mergeAdopted([], record)?.map((m) => m.id), ["s1", "a1"]);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -10,11 +10,11 @@
|
|||||||
*/
|
*/
|
||||||
import assert from "node:assert";
|
import assert from "node:assert";
|
||||||
import test from "node:test";
|
import test from "node:test";
|
||||||
import type { UIMessage } from "ai";
|
import type { LhViewMessage } from "../src/core/view.ts";
|
||||||
import { phaseAt3, phaseTimeline, type Phase3 } from "../src/core/phases.ts";
|
import { phaseAt3, phaseTimeline, type Phase3 } from "../src/core/phases.ts";
|
||||||
|
|
||||||
/** Text-only user message. */
|
/** Text-only user message. */
|
||||||
function user(text: string): UIMessage {
|
function user(text: string): LhViewMessage {
|
||||||
return { id: `u_${text}`, role: "user", parts: [{ type: "text", text }] };
|
return { id: `u_${text}`, role: "user", parts: [{ type: "text", text }] };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -23,7 +23,7 @@ function assistantTool(
|
|||||||
name: string,
|
name: string,
|
||||||
state: "input-available" | "output-available" | "output-error",
|
state: "input-available" | "output-available" | "output-error",
|
||||||
output?: unknown,
|
output?: unknown,
|
||||||
): UIMessage {
|
): LhViewMessage {
|
||||||
return {
|
return {
|
||||||
id: `a_${name}_${state}_${Math.random().toString(36).slice(2, 6)}`,
|
id: `a_${name}_${state}_${Math.random().toString(36).slice(2, 6)}`,
|
||||||
role: "assistant",
|
role: "assistant",
|
||||||
@@ -37,7 +37,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
|
/** The verdict button's user message (the custom-message scheme): its own
|
||||||
* message carrying ONLY the data-verdict marker part — no human-authored
|
* message carrying ONLY the data-verdict marker part — no human-authored
|
||||||
* text; the model-facing wording is toLlmMessages' (native-messages.ts). */
|
* text; the model-facing wording is toLlmMessages' (native-messages.ts). */
|
||||||
function verdict(accepted: boolean): UIMessage {
|
function verdict(accepted: boolean): LhViewMessage {
|
||||||
return {
|
return {
|
||||||
id: `u_verdict_${Math.random().toString(36).slice(2, 6)}`,
|
id: `u_verdict_${Math.random().toString(36).slice(2, 6)}`,
|
||||||
role: "user",
|
role: "user",
|
||||||
@@ -48,7 +48,7 @@ function verdict(accepted: boolean): UIMessage {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** The accept button's message. */
|
/** The accept button's message. */
|
||||||
function planAccepted(): UIMessage {
|
function planAccepted(): LhViewMessage {
|
||||||
return verdict(true);
|
return verdict(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ import test from "node:test";
|
|||||||
import type { PlanTree } from "../src/core/contracts.ts";
|
import type { PlanTree } from "../src/core/contracts.ts";
|
||||||
import type { LhMessage } from "../src/core/native-messages.ts";
|
import type { LhMessage } from "../src/core/native-messages.ts";
|
||||||
import { planBlocksFor } from "../src/core/plan-blocks.ts";
|
import { planBlocksFor } from "../src/core/plan-blocks.ts";
|
||||||
import { projectNative } from "../src/core/projection.ts";
|
import { projectView as projectNative } from "../src/core/view.ts";
|
||||||
import type { PlanBlockData } from "../src/core/contracts.ts";
|
import type { PlanBlockData } from "../src/core/contracts.ts";
|
||||||
|
|
||||||
// 5-node fixture: a → b → c, b → d, d → e.
|
// 5-node fixture: a → b → c, b → d, d → e.
|
||||||
@@ -83,7 +83,8 @@ function resultOf(callId: string, toolName: string, details: unknown, isError =
|
|||||||
* new messages are `turn`, run over `record`. Returns the projection of
|
* new messages are `turn`, run over `record`. Returns the projection of
|
||||||
* the whole record after the write. */
|
* the whole record after the write. */
|
||||||
function projectAfter(record: LhMessage[], turn: LhMessage[], tree: PlanTree | null) {
|
function projectAfter(record: LhMessage[], turn: LhMessage[], tree: PlanTree | null) {
|
||||||
const blocks = planBlocksFor(record, turn, tree, projectNative([...record, ...turn]));
|
// Wave D: the status derivation reads the NATIVE transcript.
|
||||||
|
const blocks = planBlocksFor(record, turn, tree, [...record, ...turn]);
|
||||||
return projectNative([...record, ...turn, ...blocks]);
|
return projectNative([...record, ...turn, ...blocks]);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -128,8 +129,8 @@ test("idempotent — a call whose block the record already carries is never re-e
|
|||||||
assert.equal(blocksOf(first).length, 1);
|
assert.equal(blocksOf(first).length, 1);
|
||||||
// Persist replay: the same turn's messages land again over a record
|
// Persist replay: the same turn's messages land again over a record
|
||||||
// that already emitted the marker → no duplicate.
|
// that already emitted the marker → no duplicate.
|
||||||
const record: LhMessage[] = [...turn, ...planBlocksFor([], turn, TREE, projectNative(turn))];
|
const record: LhMessage[] = [...turn, ...planBlocksFor([], turn, TREE, turn)];
|
||||||
const blocks2 = planBlocksFor(record, turn, TREE, projectNative(record));
|
const blocks2 = planBlocksFor(record, turn, TREE, record);
|
||||||
assert.equal(blocks2.length, 0, "no duplicate block on persist replay");
|
assert.equal(blocks2.length, 0, "no duplicate block on persist replay");
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -138,7 +139,7 @@ test("a plan REWRITE (a later turn's successful plan_init) gets its own full blo
|
|||||||
assistantWithCall("m1", "plan_init", { tree: TREE }),
|
assistantWithCall("m1", "plan_init", { tree: TREE }),
|
||||||
resultOf("m1", "plan_init", { ok: true, tree: TREE }),
|
resultOf("m1", "plan_init", { ok: true, tree: TREE }),
|
||||||
];
|
];
|
||||||
const record = [...first, ...planBlocksFor([], first, TREE, projectNative(first))];
|
const record = [...first, ...planBlocksFor([], first, TREE, first)];
|
||||||
const second = [
|
const second = [
|
||||||
assistantWithCall("m2", "plan_init", { tree: TREE2 }),
|
assistantWithCall("m2", "plan_init", { tree: TREE2 }),
|
||||||
resultOf("m2", "plan_init", { ok: true, tree: TREE2 }),
|
resultOf("m2", "plan_init", { ok: true, tree: TREE2 }),
|
||||||
|
|||||||
@@ -1,157 +0,0 @@
|
|||||||
/**
|
|
||||||
* Reattach transport tests (components/chat-transport.ts, the
|
|
||||||
* ReattachingChatTransport half): the SDK's native resume path —
|
|
||||||
* reconnectToStream — repointed at GET /api/chats/[id]/stream with the
|
|
||||||
* platform contract's two adapters:
|
|
||||||
*
|
|
||||||
* - turn-settled (the "no turn running" TERMINAL EVENT, not a 204) →
|
|
||||||
* cancel + onSettled + null (the SDK's no-active-stream signal);
|
|
||||||
* - the continuation seam — a start chunk whose messageId matches the
|
|
||||||
* seeded last message is rewritten to a fresh id (the SDK seeds its
|
|
||||||
* resume streaming message EMPTY; a replace-last would drop the seeded
|
|
||||||
* message's parts for the whole stream).
|
|
||||||
*
|
|
||||||
* Plus the framing seam: the first SSE event is peeked byte-wise (a body
|
|
||||||
* that arrives split across reads must still resolve its boundary, and
|
|
||||||
* the leftover bytes must ride the head of the returned stream).
|
|
||||||
*/
|
|
||||||
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(() => {
|
|
||||||
globalThis.fetch = realFetch;
|
|
||||||
});
|
|
||||||
|
|
||||||
/** SSE-frame each object the way the server's subscriber stream does. */
|
|
||||||
function sse(...events: unknown[]): string {
|
|
||||||
return events.map((e) => `data: ${JSON.stringify(e)}\n\n`).join("");
|
|
||||||
}
|
|
||||||
|
|
||||||
/** A Response whose body drips `text` in `slice`-byte reads. */
|
|
||||||
function dripped(text: string, slice: number, status = 200): Response {
|
|
||||||
const bytes = new TextEncoder().encode(text);
|
|
||||||
const body = new ReadableStream<Uint8Array>({
|
|
||||||
start(controller) {
|
|
||||||
for (let i = 0; i < bytes.length; i += slice) {
|
|
||||||
controller.enqueue(bytes.slice(i, i + slice));
|
|
||||||
}
|
|
||||||
controller.close();
|
|
||||||
},
|
|
||||||
});
|
|
||||||
return new Response(body, { status });
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Collect a UIMessageChunk stream to an array. */
|
|
||||||
async function collect(stream: ReadableStream): Promise<unknown[]> {
|
|
||||||
const out: unknown[] = [];
|
|
||||||
const reader = stream.getReader();
|
|
||||||
for (;;) {
|
|
||||||
const { done, value } = await reader.read();
|
|
||||||
if (done) return out;
|
|
||||||
out.push(value);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const RECONNECT_OPTS = {
|
|
||||||
chatId: "chat_x",
|
|
||||||
trigger: "resume-stream" as const,
|
|
||||||
messageId: undefined,
|
|
||||||
};
|
|
||||||
|
|
||||||
describe("reattach transport (reconnectToStream)", () => {
|
|
||||||
it("GETs the stream endpoint for the chat", async () => {
|
|
||||||
const urls: string[] = [];
|
|
||||||
globalThis.fetch = (async (input: RequestInfo | URL) => {
|
|
||||||
urls.push(String(input));
|
|
||||||
return dripped(sse({ type: "start", messageId: "msg_run_1" }), 8);
|
|
||||||
}) as typeof globalThis.fetch;
|
|
||||||
const transport = createReattachingTransport("chat_x", {}, NO_INPUT);
|
|
||||||
await collect((await transport.reconnectToStream(RECONNECT_OPTS))!);
|
|
||||||
assert.deepEqual(urls, ["/api/chats/chat_x/stream"]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("turn-settled: fires onSettled, resolves null (the body is released)", async () => {
|
|
||||||
globalThis.fetch = (async () =>
|
|
||||||
dripped(sse({ type: "turn-settled" }, "[DONE]"), 8)) as typeof globalThis.fetch;
|
|
||||||
let settled = 0;
|
|
||||||
const transport = createReattachingTransport("chat_x", { onSettled: () => settled++ }, NO_INPUT);
|
|
||||||
const stream = await transport.reconnectToStream(RECONNECT_OPTS);
|
|
||||||
assert.equal(stream, null);
|
|
||||||
assert.equal(settled, 1);
|
|
||||||
// (The reader.cancel hygiene is best-effort: undici wraps Response
|
|
||||||
// bodies, so the source-level cancel is not observable here.)
|
|
||||||
});
|
|
||||||
|
|
||||||
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", {}, NO_INPUT);
|
|
||||||
assert.equal(await transport.reconnectToStream(RECONNECT_OPTS), null);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("a fresh start chunk passes through the SDK parse verbatim", async () => {
|
|
||||||
// slice 13: the boundary lands MID-read, so the peek's read-past
|
|
||||||
// remainder must ride the head (the bug this pins: dropped leftovers).
|
|
||||||
globalThis.fetch = (async () =>
|
|
||||||
dripped(
|
|
||||||
sse(
|
|
||||||
{ type: "start", messageId: "msg_run_1" },
|
|
||||||
{ type: "text-start", id: "msg_run_1-t0" },
|
|
||||||
{ type: "text-delta", id: "msg_run_1-t0", delta: "hi" },
|
|
||||||
),
|
|
||||||
13,
|
|
||||||
)) as typeof globalThis.fetch;
|
|
||||||
const transport = createReattachingTransport("chat_x", {}, NO_INPUT);
|
|
||||||
const chunks = (await collect((await transport.reconnectToStream(RECONNECT_OPTS))!)) as Array<
|
|
||||||
Record<string, unknown>
|
|
||||||
>;
|
|
||||||
assert.equal(chunks[0]!.type, "start");
|
|
||||||
assert.equal(chunks[0]!.messageId, "msg_run_1", "no rewrite when the id is fresh");
|
|
||||||
assert.equal(chunks[1]!.type, "text-start");
|
|
||||||
assert.equal(chunks[2]!.delta, "hi", "the leftover bytes after the peek ride the head");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("a CONTINUATION start chunk (id === seeded last message) is rewritten fresh", async () => {
|
|
||||||
globalThis.fetch = (async () =>
|
|
||||||
dripped(
|
|
||||||
sse({ type: "start", messageId: "msg_seed" }, { type: "text-start", id: "msg_seed-t3" }),
|
|
||||||
64,
|
|
||||||
)) as typeof globalThis.fetch;
|
|
||||||
const transport = createReattachingTransport("chat_x", {
|
|
||||||
lastMessageId: () => "msg_seed",
|
|
||||||
}, NO_INPUT);
|
|
||||||
const chunks = (await collect((await transport.reconnectToStream(RECONNECT_OPTS))!)) as Array<
|
|
||||||
Record<string, unknown>
|
|
||||||
>;
|
|
||||||
assert.equal(chunks[0]!.type, "start");
|
|
||||||
assert.notEqual(chunks[0]!.messageId, "msg_seed");
|
|
||||||
assert.match(String(chunks[0]!.messageId), /^msg_[a-z0-9]+_[a-f0-9]{8}$/, "the server's id shape");
|
|
||||||
// Part ids stay server-prefixed — nothing keys parts by message id.
|
|
||||||
assert.equal(chunks[1]!.id, "msg_seed-t3");
|
|
||||||
});
|
|
||||||
|
|
||||||
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", {}, NO_INPUT);
|
|
||||||
const chunks = (await collect((await transport.reconnectToStream(RECONNECT_OPTS))!)) as Array<
|
|
||||||
Record<string, unknown>
|
|
||||||
>;
|
|
||||||
assert.deepEqual(
|
|
||||||
chunks.map((c) => c.type),
|
|
||||||
["start", "finish"],
|
|
||||||
);
|
|
||||||
assert.equal(chunks[0]!.messageId, "msg_run_1");
|
|
||||||
});
|
|
||||||
|
|
||||||
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", {}, NO_INPUT);
|
|
||||||
await assert.rejects(() => transport.reconnectToStream(RECONNECT_OPTS), /boom/);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -0,0 +1,552 @@
|
|||||||
|
/**
|
||||||
|
* SESSION MODEL tests (components/session-core.ts — Wave D): the pure state
|
||||||
|
* machine that replaced useChat + the AI-SDK transport. Everything the old
|
||||||
|
* transport/merge suites pinned survives here on the native wire:
|
||||||
|
*
|
||||||
|
* - the 409 IDENTITY-RETRY (postInputEvent): the event was never persisted
|
||||||
|
* pre-acceptance, so the retry waits out the racing turn and re-POSTs
|
||||||
|
* the SAME body — no merge, no record read;
|
||||||
|
* - the reattach path (resumeStream): record refetch, then the SAME
|
||||||
|
* verbatim-AgentEvent consume as a POST; turn-settled is the terminal;
|
||||||
|
* byte-split SSE frames never corrupt (the framing pin);
|
||||||
|
* - append-only ADOPTION (adoptRecord/absorbed): server-added steers show
|
||||||
|
* mid-turn, the record rewrite bails to the idle full sync, and the
|
||||||
|
* optimistic entries the write-ahead absorbed never double their
|
||||||
|
* bubbles;
|
||||||
|
* - the view application (applyWireEvent → projectView): full-partial
|
||||||
|
* replaces, the fork-2 question-placeholder ignore, execution states,
|
||||||
|
* raw error display (owner ruling);
|
||||||
|
* - the continuation fold (needsContinuation): the mount/sync seam's
|
||||||
|
* native replacement for lastAssistantMessageIsCompleteWithToolCalls.
|
||||||
|
*/
|
||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { afterEach, describe, it } from "node:test";
|
||||||
|
import { absorbed, pendingCallTool, SessionModel } from "../components/session-core.ts";
|
||||||
|
import type { ChatInput } from "../src/core/input-events.ts";
|
||||||
|
import type { LhMessage } from "../src/core/native-messages.ts";
|
||||||
|
import { newUserMessage, newAnswerResult, newVerdictMessage, needsContinuation } from "../src/core/native-messages.ts";
|
||||||
|
|
||||||
|
const realFetch = globalThis.fetch;
|
||||||
|
afterEach(() => {
|
||||||
|
globalThis.fetch = realFetch;
|
||||||
|
});
|
||||||
|
|
||||||
|
/** SSE-frame each object the way the server's subscriber stream does. */
|
||||||
|
function sse(...events: unknown[]): string {
|
||||||
|
return events.map((e) => (e === "[DONE]" ? "data: [DONE]\n\n" : `data: ${JSON.stringify(e)}\n\n`)).join("");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A Response whose body drips `text` in `slice`-byte reads (the framing pin). */
|
||||||
|
function dripped(text: string, slice: number, status = 200): Response {
|
||||||
|
const bytes = new TextEncoder().encode(text);
|
||||||
|
const body = new ReadableStream<Uint8Array>({
|
||||||
|
start(controller) {
|
||||||
|
for (let i = 0; i < bytes.length; i += slice) {
|
||||||
|
controller.enqueue(bytes.slice(i, i + slice));
|
||||||
|
}
|
||||||
|
controller.close();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return new Response(body, { status });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A plain single-read 200 body. */
|
||||||
|
function sseResponse(...events: unknown[]): Response {
|
||||||
|
return new Response(sse(...events), { status: 200 });
|
||||||
|
}
|
||||||
|
|
||||||
|
function assistantMsg(id: string, text: string): LhMessage {
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
role: "assistant",
|
||||||
|
content: [{ type: "text", text }],
|
||||||
|
stopReason: "stop",
|
||||||
|
usage: { input: 0, output: 0, total: 0, cacheReads: 0, cacheWrites: 0 },
|
||||||
|
timestamp: 0,
|
||||||
|
provider: "openrouter",
|
||||||
|
model: "test",
|
||||||
|
} as unknown as LhMessage;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A streaming assistant partial (pi events carry the FULL partial). */
|
||||||
|
function partial(id: string, blocks: unknown[], stopReason?: string): Record<string, unknown> {
|
||||||
|
return {
|
||||||
|
type: "message_update",
|
||||||
|
message: {
|
||||||
|
id,
|
||||||
|
role: "assistant",
|
||||||
|
content: blocks,
|
||||||
|
...(stopReason ? { stopReason } : {}),
|
||||||
|
usage: { input: 0, output: 0, total: 0, cacheReads: 0, cacheWrites: 0 },
|
||||||
|
provider: "openrouter",
|
||||||
|
model: "test",
|
||||||
|
},
|
||||||
|
assistantMessageEvent: { type: "text", textDelta: "" },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function model(opts: Partial<ConstructorParameters<typeof SessionModel>[0]> = {}): SessionModel {
|
||||||
|
return new SessionModel({ chatId: "chat_x", initialMessages: [], ...opts });
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── the 409 identity-retry ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
interface Call {
|
||||||
|
url: string;
|
||||||
|
body: string | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function installFetch(routes: {
|
||||||
|
chatPOST: (call: Call, attempt: number) => Response;
|
||||||
|
jobsGET?: (poll: number) => boolean;
|
||||||
|
}): Call[] {
|
||||||
|
const calls: Call[] = [];
|
||||||
|
let poll = 0;
|
||||||
|
globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => {
|
||||||
|
const url = String(input);
|
||||||
|
calls.push({ url, body: init?.body as string | undefined });
|
||||||
|
if (url === "/api/chat" && init?.method === "POST") {
|
||||||
|
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 });
|
||||||
|
throw new Error(`unexpected fetch: ${url}`);
|
||||||
|
}) as typeof globalThis.fetch;
|
||||||
|
return calls;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("postInputEvent (the 409 identity-retry)", { skip: process.env.NO_SLOW ? true : undefined }, () => {
|
||||||
|
it("passes non-409 responses through untouched", async () => {
|
||||||
|
const calls = installFetch({ chatPOST: () => new Response("stream", { status: 200 }) });
|
||||||
|
const m = model({ pollMs: 10, waitMaxMs: 100 });
|
||||||
|
const res = await m.postInputEvent({ type: "text", text: "hi" });
|
||||||
|
assert.equal(res.status, 200);
|
||||||
|
assert.equal(calls.length, 1, "no polling, single call");
|
||||||
|
assert.deepEqual(JSON.parse(calls[0]!.body!), { chatId: "chat_x", input: { type: "text", text: "hi" } });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("waits out the turn, then re-POSTs the SAME event body (identity retry)", async () => {
|
||||||
|
const event: ChatInput = { type: "text", text: "my question while the wake turn ran" };
|
||||||
|
const calls = installFetch({
|
||||||
|
chatPOST: (_c, n) => (n === 1 ? new Response("busy", { status: 409 }) : new Response("stream", { status: 200 })),
|
||||||
|
jobsGET: (poll) => poll <= 1, // first poll: still running; second: done
|
||||||
|
});
|
||||||
|
const m = model({ pollMs: 10, waitMaxMs: 100 });
|
||||||
|
const res = await m.postInputEvent(event);
|
||||||
|
assert.equal(res.status, 200);
|
||||||
|
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");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("never reads the record — the event is the only payload", async () => {
|
||||||
|
const calls = installFetch({
|
||||||
|
chatPOST: (_c, n) => (n === 1 ? new Response("busy", { status: 409 }) : new Response("stream", { status: 200 })),
|
||||||
|
});
|
||||||
|
const m = model({ pollMs: 10, waitMaxMs: 100 });
|
||||||
|
assert.equal((await m.postInputEvent({ type: "text", text: "q" })).status, 200);
|
||||||
|
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-POSTing the same event each time", async () => {
|
||||||
|
const event: ChatInput = { type: "answer", toolCallId: "call-1", answer: { correct: true } as never };
|
||||||
|
const calls = installFetch({
|
||||||
|
chatPOST: (_c, n) => (n <= 2 ? new Response("busy", { status: 409 }) : new Response("stream", { status: 200 })),
|
||||||
|
});
|
||||||
|
const m = model({ pollMs: 10, waitMaxMs: 100 });
|
||||||
|
assert.equal((await m.postInputEvent(event)).status, 200);
|
||||||
|
const posts = calls.filter((c) => c.url === "/api/chat");
|
||||||
|
assert.equal(posts.length, 3);
|
||||||
|
assert.ok(posts.every((c) => c.body === posts[0]!.body), "every attempt carries the same event");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("gives up after the deadline and surfaces the last 409", async () => {
|
||||||
|
const calls = installFetch({
|
||||||
|
chatPOST: () => new Response("busy", { status: 409 }),
|
||||||
|
jobsGET: () => true,
|
||||||
|
});
|
||||||
|
const m = model({ pollMs: 10, waitMaxMs: 100 });
|
||||||
|
const res = await m.postInputEvent({ type: "text", text: "q" });
|
||||||
|
assert.equal(res.status, 409);
|
||||||
|
const polls = calls.filter((c) => c.url.startsWith("/api/jobs")).length;
|
||||||
|
assert.ok(polls <= 15, `expected bounded polling (~10×10ms), got ${polls}`);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── send: optimistic appends + wire consumption ────────────────────────────
|
||||||
|
|
||||||
|
describe("send (one input event, verbatim consumption)", () => {
|
||||||
|
it("optimistically appends the text, POSTs {chatId, input}, consumes to [DONE]", async () => {
|
||||||
|
const posts: string[] = [];
|
||||||
|
globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => {
|
||||||
|
const url = String(input);
|
||||||
|
if (url === "/api/chat" && init?.method === "POST") {
|
||||||
|
posts.push(init.body as string);
|
||||||
|
return sseResponse(
|
||||||
|
{ type: "agent_start" },
|
||||||
|
{ type: "message_start", message: { role: "assistant", id: "a1", content: [] } },
|
||||||
|
partial("a1", [{ type: "text", text: "hello there" }]),
|
||||||
|
{ type: "message_end", message: { role: "assistant", id: "a1", content: [{ type: "text", text: "hello there" }], stopReason: "stop" } },
|
||||||
|
{ type: "agent_end", messages: [] },
|
||||||
|
"[DONE]",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
throw new Error(`unexpected fetch: ${url}`);
|
||||||
|
}) as typeof globalThis.fetch;
|
||||||
|
|
||||||
|
const m = model();
|
||||||
|
const pending = m.send({ type: "text", text: "my topic" });
|
||||||
|
// The optimistic bubble is INSTANT (the instant-answer UX seam):
|
||||||
|
assert.equal(m.getState().status, "submitted");
|
||||||
|
assert.match((m.getState().nativeMessages.at(-1) as { id?: string }).id ?? "", /^c_/);
|
||||||
|
assert.equal(m.getState().messages.at(-1)!.parts[0]!.text, "my topic");
|
||||||
|
await pending;
|
||||||
|
assert.deepEqual(JSON.parse(posts[0]!), { chatId: "chat_x", input: { type: "text", text: "my topic" } });
|
||||||
|
assert.equal(m.getState().status, "ready");
|
||||||
|
// The streamed assistant REPLACED through full partials (never deltas):
|
||||||
|
const view = m.getState().messages;
|
||||||
|
assert.equal(view.at(-1)!.role, "assistant");
|
||||||
|
assert.equal(view.at(-1)!.parts.at(-1)!.text, "hello there");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("a 503 refusal surfaces the RAW body text (parseModelsRefusal's contract)", async () => {
|
||||||
|
globalThis.fetch = (async () =>
|
||||||
|
new Response(JSON.stringify({ code: "models_not_configured", missing: ["strong"] }), {
|
||||||
|
status: 503,
|
||||||
|
})) as typeof globalThis.fetch;
|
||||||
|
const m = model();
|
||||||
|
await m.send({ type: "text", text: "q" });
|
||||||
|
const state = m.getState();
|
||||||
|
assert.equal(state.status, "error");
|
||||||
|
assert.equal(
|
||||||
|
state.error!.message,
|
||||||
|
JSON.stringify({ code: "models_not_configured", missing: ["strong"] }),
|
||||||
|
"the banner carries the raw envelope — the refusal flow JSON-parses it",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("the optimistic answer result attaches to the pending quiz part at once", async () => {
|
||||||
|
const quizCall = {
|
||||||
|
id: "call-1",
|
||||||
|
role: "assistant",
|
||||||
|
content: [
|
||||||
|
{ type: "text", text: "Q?" },
|
||||||
|
{ type: "toolCall", id: "call-1", name: "quiz", arguments: { question: "Q?", choices: ["a", "b"] } },
|
||||||
|
],
|
||||||
|
stopReason: "toolUse",
|
||||||
|
usage: { input: 0, output: 0, total: 0, cacheReads: 0, cacheWrites: 0 },
|
||||||
|
provider: "openrouter",
|
||||||
|
model: "test",
|
||||||
|
timestamp: 0,
|
||||||
|
} as unknown as LhMessage;
|
||||||
|
let postBody = "";
|
||||||
|
globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => {
|
||||||
|
if (String(input) === "/api/chat") {
|
||||||
|
postBody = init?.body as string;
|
||||||
|
return sseResponse("[DONE]");
|
||||||
|
}
|
||||||
|
throw new Error(`unexpected fetch: ${String(input)}`);
|
||||||
|
}) as typeof globalThis.fetch;
|
||||||
|
const m = model({ initialMessages: [newUserMessage("hi"), quizCall] });
|
||||||
|
const pending = m.submitAnswer("quiz", "call-1", { correct: true } as never);
|
||||||
|
// The dock's AnsweredCard state — before any server round-trip:
|
||||||
|
const state = m.getState();
|
||||||
|
const quizPart = state.messages
|
||||||
|
.flatMap((v) => v.parts)
|
||||||
|
.find((p) => p.type === "tool-quiz")!;
|
||||||
|
assert.equal(quizPart.state, "output-available");
|
||||||
|
assert.deepEqual(quizPart.output, { correct: true });
|
||||||
|
await pending;
|
||||||
|
assert.deepEqual(JSON.parse(postBody), {
|
||||||
|
chatId: "chat_x",
|
||||||
|
input: { type: "answer", toolCallId: "call-1", answer: { correct: true } },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ignores the question placeholder's clean execution end (fork 2) and keeps errors", async () => {
|
||||||
|
// The dock's mount gate keys on the part STATE the live execution
|
||||||
|
// layer drives MID-STREAM: a clean question execution stays
|
||||||
|
// input-available (the in-memory "Question posed" placeholder
|
||||||
|
// never shows); an INVALID call is output-error and NEVER mounts
|
||||||
|
// (the modal-realism crash pin).
|
||||||
|
const enc = new TextEncoder();
|
||||||
|
let push: (e: unknown) => void = () => {};
|
||||||
|
const body = new ReadableStream<Uint8Array>({
|
||||||
|
start(controller) {
|
||||||
|
push = (e) => controller.enqueue(enc.encode(e === "[DONE]" ? "data: [DONE]\n\n" : `data: ${JSON.stringify(e)}\n\n`));
|
||||||
|
},
|
||||||
|
});
|
||||||
|
globalThis.fetch = (async () => new Response(body, { status: 200 })) as typeof globalThis.fetch;
|
||||||
|
const m = model();
|
||||||
|
const done = m.send({ type: "text", text: "q" });
|
||||||
|
const settle = () => new Promise((r) => setTimeout(r, 0));
|
||||||
|
push({
|
||||||
|
type: "message_start",
|
||||||
|
message: {
|
||||||
|
role: "assistant",
|
||||||
|
id: "a1",
|
||||||
|
content: [
|
||||||
|
{ type: "toolCall", id: "q1", name: "quiz", arguments: {} },
|
||||||
|
{ type: "toolCall", id: "bad1", name: "quiz", arguments: {} },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await settle();
|
||||||
|
push({ type: "tool_execution_end", toolCallId: "q1", toolName: "quiz", isError: false, result: { content: [{ type: "text", text: "Question posed" }], details: { placeholder: true } } });
|
||||||
|
push({ type: "tool_execution_end", toolCallId: "bad1", toolName: "quiz", isError: true, result: { content: [{ type: "text", text: "correctAnswer does not match any option value" }], details: undefined } });
|
||||||
|
await settle();
|
||||||
|
const parts = m.getState().messages.flatMap((v) => v.parts).filter((p) => p.type === "tool-quiz");
|
||||||
|
assert.equal(parts.length, 2);
|
||||||
|
assert.equal(parts[0]!.state, "input-available", "the clean placeholder is IGNORED — the dock mounts on it");
|
||||||
|
assert.equal(parts[1]!.state, "output-error");
|
||||||
|
assert.equal(parts[1]!.errorText, "correctAnswer does not match any option value");
|
||||||
|
push({ type: "agent_end", messages: [] });
|
||||||
|
push("[DONE]");
|
||||||
|
await done;
|
||||||
|
});
|
||||||
|
|
||||||
|
it("a provider error rides VERBATIM (owner ruling) — no masking anywhere", async () => {
|
||||||
|
globalThis.fetch = (async () =>
|
||||||
|
sseResponse(
|
||||||
|
partial("a1", [{ type: "text", text: "" }]),
|
||||||
|
{ type: "message_end", message: { role: "assistant", id: "a1", content: [{ type: "text", text: "" }], stopReason: "error", errorMessage: "OpenRouter 429: rate limited" } },
|
||||||
|
{ type: "agent_end", messages: [] },
|
||||||
|
"[DONE]",
|
||||||
|
)) as typeof globalThis.fetch;
|
||||||
|
const m = model();
|
||||||
|
await m.send({ type: "text", text: "q" });
|
||||||
|
assert.equal(m.getState().status, "ready");
|
||||||
|
assert.equal(m.getState().error!.message, "OpenRouter 429: rate limited");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── reattach (resumeStream) ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe("resumeStream (the reattach path)", () => {
|
||||||
|
it("refetches the record, then GETs the stream endpoint", async () => {
|
||||||
|
const urls: string[] = [];
|
||||||
|
globalThis.fetch = (async (input: RequestInfo | URL) => {
|
||||||
|
const url = String(input);
|
||||||
|
urls.push(url);
|
||||||
|
if (url === "/api/chats/chat_x") return Response.json({ messages: [] });
|
||||||
|
if (url === "/api/chats/chat_x/stream")
|
||||||
|
return dripped(sse({ type: "message_start", message: { role: "assistant", id: "a1", content: [{ type: "text", text: "hi" }] } }, "[DONE]"), 8);
|
||||||
|
throw new Error(`unexpected fetch: ${url}`);
|
||||||
|
}) as typeof globalThis.fetch;
|
||||||
|
const m = model();
|
||||||
|
await m.resumeStream();
|
||||||
|
assert.deepEqual(urls, ["/api/chats/chat_x", "/api/chats/chat_x/stream"]);
|
||||||
|
assert.equal(m.getState().status, "ready");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("turn-settled fires the settle hook and ends cleanly", async () => {
|
||||||
|
let settled = 0;
|
||||||
|
globalThis.fetch = (async (input: RequestInfo | URL) => {
|
||||||
|
const url = String(input);
|
||||||
|
if (url === "/api/chats/chat_x") return Response.json({ messages: [] });
|
||||||
|
if (url === "/api/chats/chat_x/stream") return dripped(sse({ type: "turn-settled" }, "[DONE]"), 8);
|
||||||
|
throw new Error(`unexpected fetch: ${url}`);
|
||||||
|
}) as typeof globalThis.fetch;
|
||||||
|
const m = model({ onReattachSettled: () => settled++ });
|
||||||
|
await m.resumeStream();
|
||||||
|
assert.equal(settled, 1);
|
||||||
|
assert.equal(m.getState().status, "ready");
|
||||||
|
assert.equal(m.getState().error, null);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("a replayed CONTINUATION mounts BESIDE the seeded message (the SDK replace-last seam is dead)", async () => {
|
||||||
|
// The seeded record ends with an assistant message; the replayed
|
||||||
|
// turn continues with its OWN assistant message — the view groups
|
||||||
|
// them as one document with a step separator (pixel-identical to a
|
||||||
|
// live continuation; the settle sync lands the canon).
|
||||||
|
globalThis.fetch = (async (input: RequestInfo | URL) => {
|
||||||
|
const url = String(input);
|
||||||
|
if (url === "/api/chats/chat_x") return Response.json({ messages: [assistantMsg("seed", "the question")] });
|
||||||
|
if (url === "/api/chats/chat_x/stream")
|
||||||
|
return sseResponse(
|
||||||
|
{ type: "message_start", message: { role: "assistant", id: "run1", content: [{ type: "text", text: "the reaction" }] } },
|
||||||
|
{ type: "message_end", message: { role: "assistant", id: "run1", content: [{ type: "text", text: "the reaction" }], stopReason: "stop" } },
|
||||||
|
{ type: "agent_end", messages: [] },
|
||||||
|
"[DONE]",
|
||||||
|
);
|
||||||
|
throw new Error(`unexpected fetch: ${url}`);
|
||||||
|
}) as typeof globalThis.fetch;
|
||||||
|
const m = model({ initialMessages: [assistantMsg("seed", "the question")] });
|
||||||
|
await m.resumeStream();
|
||||||
|
const view = m.getState().messages;
|
||||||
|
assert.equal(view.length, 1, "one continuous assistant document");
|
||||||
|
assert.deepEqual(
|
||||||
|
view[0]!.parts.map((p) => p.type),
|
||||||
|
["step-start", "text", "step-start", "text"],
|
||||||
|
"seeded message keeps rendering; the run mounts with a step separator",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("survives a body split byte-by-byte across reads (the framing pin)", async () => {
|
||||||
|
globalThis.fetch = (async (input: RequestInfo | URL) => {
|
||||||
|
const url = String(input);
|
||||||
|
if (url === "/api/chats/chat_x") return Response.json({ messages: [] });
|
||||||
|
if (url === "/api/chats/chat_x/stream")
|
||||||
|
return dripped(
|
||||||
|
sse(
|
||||||
|
{ type: "message_start", message: { role: "assistant", id: "a1", content: [] } },
|
||||||
|
partial("a1", [{ type: "text", text: "hi" }]),
|
||||||
|
{ type: "message_end", message: { role: "assistant", id: "a1", content: [{ type: "text", text: "hi" }], stopReason: "stop" } },
|
||||||
|
) + sse("[DONE]"),
|
||||||
|
1,
|
||||||
|
);
|
||||||
|
throw new Error(`unexpected fetch: ${url}`);
|
||||||
|
}) as typeof globalThis.fetch;
|
||||||
|
const m = model();
|
||||||
|
await m.resumeStream();
|
||||||
|
assert.equal(m.getState().messages.at(-1)!.parts.at(-1)!.text, "hi");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("an unhappy endpoint surfaces the error banner", async () => {
|
||||||
|
globalThis.fetch = (async (input: RequestInfo | URL) => {
|
||||||
|
const url = String(input);
|
||||||
|
if (url === "/api/chats/chat_x") return Response.json({ messages: [] });
|
||||||
|
if (url === "/api/chats/chat_x/stream") return new Response("boom", { status: 500 });
|
||||||
|
throw new Error(`unexpected fetch: ${url}`);
|
||||||
|
}) as typeof globalThis.fetch;
|
||||||
|
const m = model();
|
||||||
|
await m.resumeStream();
|
||||||
|
const state = m.getState();
|
||||||
|
assert.equal(state.status, "error");
|
||||||
|
assert.match(state.error!.message, /boom/);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── adoption (append-only ground rules, owner 2026-09-15) ──────────────────
|
||||||
|
|
||||||
|
function userText(id: string, text: string): LhMessage {
|
||||||
|
return { ...newUserMessage(text), id };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("adoptRecord — append-only adoption", () => {
|
||||||
|
it("splices a server-added steer between the record and the live tail", () => {
|
||||||
|
const m = model({ initialMessages: [userText("u1", "topic")] });
|
||||||
|
m.applyWireEvent({ type: "message_start", message: { role: "assistant", id: "a_live", content: [{ type: "text", text: "streaming" }] } });
|
||||||
|
const steer = { ...newUserMessage("steered content"), id: "steer_1" } as LhMessage & { role: "user" };
|
||||||
|
const record = [userText("u1", "topic"), steer];
|
||||||
|
m.adoptRecord(record);
|
||||||
|
assert.deepEqual(
|
||||||
|
m.getState().nativeMessages.map((x) => x.id),
|
||||||
|
["u1", "steer_1", "a_live"],
|
||||||
|
"the steer shows NOW, the live tail stays last",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("a missing base id (record rewrite) bails — the idle full sync is the net", () => {
|
||||||
|
const m = model({ initialMessages: [userText("u1", "a"), userText("u2", "b")] });
|
||||||
|
m.adoptRecord([userText("u2", "b")]); // u1 vanished: a rewrite, not an append
|
||||||
|
assert.deepEqual(
|
||||||
|
m.getState().nativeMessages.map((x) => x.id),
|
||||||
|
["u1", "u2"],
|
||||||
|
"the adoption is a no-op on a non-append",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("absorbs the optimistic entries the record has taken over (no doubled bubbles)", () => {
|
||||||
|
// A text send's optimistic bubble (local c_ id): the write-ahead's
|
||||||
|
// server copy absorbs it by CONTENT — the bubble never doubles.
|
||||||
|
const optimistic = { ...newUserMessage("my question"), id: "c_local_1" } as LhMessage;
|
||||||
|
const serverCopy = { ...newUserMessage("my question"), id: "srv_9" } as LhMessage;
|
||||||
|
assert.ok(absorbed(optimistic, [userText("u1", "topic"), serverCopy]), "content match absorbs the local copy");
|
||||||
|
assert.ok(!absorbed(serverCopy, [optimistic]), "server stamps are never absorbed");
|
||||||
|
assert.ok(!absorbed({ ...newUserMessage("my question") }, [serverCopy]), "an id-less message is never absorbed");
|
||||||
|
// The answer result absorbs by toolCallId (ids differ, content may too —
|
||||||
|
// the toolCallId is the identity of an answer).
|
||||||
|
const answer = { ...newAnswerResult("call-1", "quiz", { correct: true }), id: "c_local_2" } as LhMessage;
|
||||||
|
assert.ok(
|
||||||
|
absorbed(answer, [{ ...newAnswerResult("call-1", "quiz", { correct: true }), id: "srv_10" } as LhMessage]),
|
||||||
|
"the answer result absorbs by toolCallId",
|
||||||
|
);
|
||||||
|
// A verdict marker absorbs by accepted (the timestamps differ by design).
|
||||||
|
const verdict = { ...newVerdictMessage(true, "2026-09-26T00:00:00.000Z"), id: "c_local_3" } as LhMessage;
|
||||||
|
const serverVerdict = { ...newVerdictMessage(true, "2026-09-26T00:00:01.000Z"), id: "srv_11" } as LhMessage;
|
||||||
|
assert.ok(absorbed(verdict, [serverVerdict]), "the verdict absorbs by accepted");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("setMessages is the full snapshot swap (the idle settle sync)", () => {
|
||||||
|
const m = model({ initialMessages: [userText("u1", "old")] });
|
||||||
|
m.setMessages([userText("u1", "old"), userText("u2", "new"), assistantMsg("a1", "reply")]);
|
||||||
|
assert.deepEqual(
|
||||||
|
m.getState().nativeMessages.map((x) => (x as { id?: string }).id),
|
||||||
|
["u1", "u2", "a1"],
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── the continuation fold (mount/sync seam) ────────────────────────────────
|
||||||
|
|
||||||
|
function assistantWithCall(id: string, callId: string, name = "research"): LhMessage {
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
role: "assistant",
|
||||||
|
content: [{ type: "toolCall", id: callId, name, arguments: { topic: "x" } }],
|
||||||
|
stopReason: "toolUse",
|
||||||
|
usage: { input: 0, output: 0, total: 0, cacheReads: 0, cacheWrites: 0 },
|
||||||
|
provider: "openrouter",
|
||||||
|
model: "test",
|
||||||
|
timestamp: 0,
|
||||||
|
} as unknown as LhMessage;
|
||||||
|
}
|
||||||
|
|
||||||
|
function resultMsg(callId: string, name = "research"): LhMessage {
|
||||||
|
return {
|
||||||
|
...newAnswerResult(callId, name, { ok: true }),
|
||||||
|
id: `r_${callId}`,
|
||||||
|
} as unknown as LhMessage;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("needsContinuation (the native lastAssistantMessageIsCompleteWithToolCalls)", () => {
|
||||||
|
it("a record ending on a fully-answered tool call continues", () => {
|
||||||
|
const messages = [userText("u1", "topic"), assistantWithCall("a1", "call-r1"), resultMsg("call-r1")];
|
||||||
|
assert.equal(needsContinuation(messages), true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("text-tailed records never continue", () => {
|
||||||
|
const messages = [userText("u1", "topic"), assistantMsg("a1", "done talking")];
|
||||||
|
assert.equal(needsContinuation(messages), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("a dangling question never fires a turn (the dock owns it)", () => {
|
||||||
|
const messages = [userText("u1", "topic"), assistantWithCall("a1", "call-q1", "quiz")];
|
||||||
|
assert.equal(needsContinuation(messages), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("parallel quizzes: only ALL-answered continues (the dock still owns the open one)", () => {
|
||||||
|
const messages = [
|
||||||
|
userText("u1", "topic"),
|
||||||
|
{
|
||||||
|
...assistantWithCall("a1", "call-a"),
|
||||||
|
content: [
|
||||||
|
{ type: "toolCall", id: "call-a", name: "quiz", arguments: {} },
|
||||||
|
{ type: "toolCall", id: "call-b", name: "quiz", arguments: {} },
|
||||||
|
],
|
||||||
|
} as unknown as LhMessage,
|
||||||
|
resultMsg("call-a", "quiz"),
|
||||||
|
];
|
||||||
|
assert.equal(needsContinuation(messages), false);
|
||||||
|
const both = [...messages, resultMsg("call-b", "quiz")];
|
||||||
|
assert.equal(needsContinuation(both), true, "once the dock answers B, the agent owes the reaction");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("an orphan result (no asking assistant) never continues", () => {
|
||||||
|
assert.equal(needsContinuation([userText("u1", "topic"), resultMsg("call-x")]), false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── the pending-call tool scan ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe("pendingCallTool", () => {
|
||||||
|
it("finds the dangling call's tool name (the optimistic answer's stamp)", () => {
|
||||||
|
const messages = [assistantWithCall("a1", "call-q1", "ask_user_question")];
|
||||||
|
assert.equal(pendingCallTool(messages, "call-q1"), "ask_user_question");
|
||||||
|
assert.equal(pendingCallTool(messages, "call-zz"), null);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -7,11 +7,11 @@
|
|||||||
*/
|
*/
|
||||||
import assert from "node:assert/strict";
|
import assert from "node:assert/strict";
|
||||||
import { describe, it } from "node:test";
|
import { describe, it } from "node:test";
|
||||||
import type { UIMessage } from "ai";
|
import type { LhViewMessage } from "../src/core/view.ts";
|
||||||
import { streamingToolActivity } from "../src/core/pending.ts";
|
import { streamingToolActivity } from "../src/core/pending.ts";
|
||||||
|
|
||||||
function msg(role: string, parts: unknown[]): UIMessage {
|
function msg(role: string, parts: unknown[]): LhViewMessage {
|
||||||
return { id: "m1", role, parts } as unknown as UIMessage;
|
return { id: "m1", role, parts } as unknown as LhViewMessage;
|
||||||
}
|
}
|
||||||
|
|
||||||
function toolPart(tool: string, state: string) {
|
function toolPart(tool: string, state: string) {
|
||||||
|
|||||||
Reference in New Issue
Block a user