Wave B: the native message record — input events, projection, adapter death
The chat record is now pi-native agent history (LhMessage[]), the AI SDK's
UIMessage transcript contract is gone from the server, and every client turn
arrives as ONE explicit input event.
Core (new):
- src/core/native-messages.ts — the LhMessage taxonomy (user/assistant/
toolResult + lhMarker customs: verdict, probe-handoff, subagent-steer,
subagent-abort, plan-block), constructors, and toLlmMessages — the single
model-facing seam (convertToLlm): strips ids, flattens markers to user
text, drops UI-only plan-blocks, synthesizes INTERRUPTED results for
dangling aborted calls, never throws.
- src/core/projection.ts — projectNative: the one-way native → UIMessage
projection (consecutive assistant+toolResult runs group into one
assistant view message; toolResults attach backward by toolCallId;
markers project to data parts; plan-blocks splice after the last
successful plan_init). Every existing consumer reads through it.
- src/core/input-events.ts — the ChatInput union (text/answer/verdict/
continue), parseChatInput validation, and inputAppends (answer targets
the dangling question call or 400s; verdict = optional note user
message + native verdict marker).
Server:
- chat-handler.ts — the turn root takes {chatId, input}; write-ahead
persists input appends + claimed steers before the turn; claimSteers
coalesces the whole queue with the handler's steerDelivered filter as
the dedupe seam; teacher-vs-prober branch on hasProbeDone.
- turn-service.ts — persistTurn stamps ids, drops clean question
placeholder toolResults (the record ends at the unanswered call),
emits plan-block markers idempotently (plan-blocks.ts rewrite), and
persists abort partials.
- ui-stream.ts — PiTurnSpec takes the native base; the wire dialect is
unchanged. jobs.ts steers are native markers (steer_<jobId> dedup ids);
waitForIdle reads danglingQuestionOf. teacher-agent.ts wires
convertToLlm: toLlmMessages. record-adapter.ts DELETED wholesale.
- GET /api/chats/[id] serves messages: projectNative(record.messages).
Client:
- chat-transport.ts — the 409 retry is IDENTITY (re-POST the same event
body after the turn settles — the event was never persisted
pre-acceptance; the merge logic is gone). The transport consumes the
stashed input event via prepareSendMessagesRequest and throws loudly on
a missing stash.
- chat.tsx — every send call site stashes exactly one ChatInput event
(text, answer, verdict, continue) before invoking the SDK send.
Tests: full native migration (chat-flow rewritten to input events and
native record pins; jobs/extraction/demos/graph/usage/plan-blocks/
probe-done/reattach-transport converted; record-adapter.test.ts deleted;
chat-transport tests pin the identity retry). 982/982, typecheck, lint,
build all green.
This commit is contained in:
@@ -0,0 +1,299 @@
|
|||||||
|
# Pi-native architecture migration plan
|
||||||
|
|
||||||
|
Recon wave 2026-09-26 · READ-ONLY deliverable · `feat/platform` · untracked until owner review
|
||||||
|
Evidence: wiretap `/tmp/lh-wire/`, 11 controlled OpenRouter replays (`/tmp/rp-*.json/.out`), pi-ai + pi-agent-core dist sources, pi's own session files under `~/.pi/agent/sessions/`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 0. Executive summary
|
||||||
|
|
||||||
|
- **Two banked facts are overturned by the wiretap evidence** (§1). The tool-stripping mystery was model confabulation — the registry was complete on the wire. The truncation is an upstream (OpenInference) bug triggered by multi-tool-result history volume, not by our serialized shape.
|
||||||
|
- Five custom seams mapped against pi-native equivalents (§2). Each has a native replacement; the getApiKey fix (76051d0) is the template: delete the custom seam, use the library path.
|
||||||
|
- What genuinely has no native equivalent is small and enumerable (§3): question-tool UX, tool toggles/phases, jobs, prompt overrides, our reattach/persistence infrastructure.
|
||||||
|
- Proposed risk order (§4) and test strategy (§5). Seams touching `src/agent` are flagged for Teacher's review per band rules.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Corrections to banked facts (evidence first)
|
||||||
|
|
||||||
|
### 1.1 TOOL-SET STRIPPING — SOLVED: the model confabulated; no seam varies the registry
|
||||||
|
|
||||||
|
- Wiretap captures **7 and 9 are chat_muhb8l00's two exact turns** (timestamps 18:44:47 / 18:48:10 match the record created 18:44:41Z). **Both carried the full 11-tool registry** — `quiz, ask_user_question, read, ls, find, grep, research, pdf, list_sessions, list_propositions, probe_done` — including the graph tools the model claimed not to have.
|
||||||
|
- The response-side capture shows the same request streaming the "no graph tools in this session… the harness is stripped to the two question tools" answer with `finish_reason: stop`. The claim is false; z-ai/glm-5.3-flash (relayed via OpenRouter upstreams Relace/OpenInference) confabulated a toolset, likely trusting prose descriptions over the actual `tools` array.
|
||||||
|
- Census of every "missing tool" claim across all master chat records (`data/chats/*.json`):
|
||||||
|
- `chat_mtxsy1lq` (09-12), `chat_mucnl5j3`/`chat_mucnyjvu` (09-22): honest — probe-phase (no graph tools yet) and/or research correctly absent (no search key → `searchAvailable` false).
|
||||||
|
- `chat_mu73galx` (09-18): the closed "no sub-agents" scare (probe-phase prober, teacher tools are teacher-only).
|
||||||
|
- `chat_mufpa030` (09-24): model accurately enumerated its full toolkit; quiz worked.
|
||||||
|
- **"Another session reportedly lacked quiz entirely": not found in any record** (regex census over all chats).
|
||||||
|
- Verdict: `tools.ts` gating and `chat-handler`'s ctx passing work as designed. No `src/agent` change required for this mystery (flagged for Teacher anyway since tools.ts is in that band). Mitigation is model-facing (stronger system-prompt tool enumeration, or dropping flash for tool-heavy phases) — an owner decision, not a seam fix.
|
||||||
|
|
||||||
|
### 1.2 HISTORY-SERIALIZATION TRUNCATION — REFINED: upstream bug on multi-tool-result histories; our shape is not the trigger
|
||||||
|
|
||||||
|
11 controlled replays today against `z-ai/glm-5.3-flash` via OpenRouter (all served by the **OpenInference** upstream; the original truncating request `/tmp/replay.json` = wiretap capture 2, the ML session):
|
||||||
|
|
||||||
|
| variant | change vs. truncating request | prompt_tokens | outcome |
|
||||||
|
|---|---|---|---|
|
||||||
|
| base | verbatim | 7006 | **TRUNC** (4 chunks, ~22 chars reasoning, billed 258 completion, false `finish: stop`) |
|
||||||
|
| A | assistant content cleaned (no glued thinking), parallel calls kept | 6803 | **TRUNC** |
|
||||||
|
| B | parallel calls split into two sequential assistant turns | 7017 | **TRUNC** |
|
||||||
|
| G | `reasoning` param removed | 7006 | **TRUNC** |
|
||||||
|
| H | `max_completion_tokens` 131072→8192 | 7006 | **TRUNC** |
|
||||||
|
| D | both tool results shortened to ~30 chars | 5982 | works (11 tool calls) |
|
||||||
|
| P | two results at 100+100 chars | 6037 | works |
|
||||||
|
| L | two results at 300+300 chars | 6201 | **TRUNC** |
|
||||||
|
| K | two results at 700+700 chars | 6564 | **TRUNC** |
|
||||||
|
| J | big result (1564) + tiny (30) | 6658 | **TRUNC** |
|
||||||
|
| E | single result (1564) | 6626 | works |
|
||||||
|
| F | single result (802) | 6296 | works |
|
||||||
|
| C9 | capture 9 verbatim (the request that streamed the confabulation live) | 6146 | works (332 chars) |
|
||||||
|
| I | single result padded to 2764 chars | 7347 | works (4 tool calls) |
|
||||||
|
|
||||||
|
Readings:
|
||||||
|
- **Not the glued-thinking content** (A truncates clean), **not parallelism** (B truncates sequential), **not prompt size** (I streams 7347 tokens; L truncates at 6201), **not the reasoning param or completion cap** (G, H).
|
||||||
|
- **Trigger: ≥2 tool-result messages with combined content ≥ ~200–600 chars** (P works at 200, L truncates at 600, J truncates with 1564+30). A single tool result of any tested size (≤2764 chars) streams fine. Direct Anthropic/OpenAI and non-flash GLM tolerate everything.
|
||||||
|
- The failure mode is consistent: a sliver of reasoning streams, 150–260 completion tokens are billed, `finish_reason: stop` arrives with ~zero content — a silent upstream truncation, stable and deterministic 4+ hours after the live capture.
|
||||||
|
- **Consequence for the plan:** pi-native serialization would NOT have prevented this — tool results are byte-identical on the wire either way. The live bug needs provider-side action (report to OpenRouter; or pin/avoid the flaky upstream for flash; short-term: route flash traffic elsewhere). The migration below is still correct, for the reasons in §1.3 — but it is not the truncation fix, and saying otherwise would be false comfort.
|
||||||
|
|
||||||
|
### 1.3 What IS wrong with our serialization (the honest indictment of record-adapter)
|
||||||
|
|
||||||
|
- `src/server/pi/record-adapter.ts:161-163` stamps every replayed assistant message `provider: "openrouter", model: "replayed"` → `transformMessages` (`pi-ai/dist/api/transform-messages.js:66`) sees `isSameModel === false` for **every** replayed turn, forever.
|
||||||
|
- Cross-model path converts thinking blocks to plain text (`transform-messages.js:78-81`), which `openai-completions.js` then glues into the assistant content string (no separator — capture 2's content is literally `"<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,4 +1,5 @@
|
|||||||
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;
|
||||||
@@ -10,7 +11,16 @@ export async function GET(_req: Request, { params }: { params: Promise<{ id: str
|
|||||||
// meter + sidebar block — THE coverage surface (the all-sessions GET
|
// 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.
|
||||||
return Response.json({ ...chat, turnInFlight: isTurnRunning(id), coverage: sessionCoverage(id) });
|
// Wave B (owner-approved 2026-09-26): the record's messages are native
|
||||||
|
// agent history — clients receive the one-way projection (the AI-SDK
|
||||||
|
// UIMessage view) until Wave D. turnInFlight lets a returning client
|
||||||
|
// re-attach to a running turn; coverage is the chat view's ring meter.
|
||||||
|
return Response.json({
|
||||||
|
...chat,
|
||||||
|
messages: projectNative(chat.messages),
|
||||||
|
turnInFlight: isTurnRunning(id),
|
||||||
|
coverage: sessionCoverage(id),
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -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 { slotsConfigured, type TeacherUIMessage } from "@/src/core";
|
import { projectNative, slotsConfigured, type TeacherUIMessage } 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,7 +24,9 @@ export default async function ChatPage({
|
|||||||
key={chat.id}
|
key={chat.id}
|
||||||
chatId={chat.id}
|
chatId={chat.id}
|
||||||
title={chat.title}
|
title={chat.title}
|
||||||
initialMessages={chat.messages as TeacherUIMessage[]}
|
// Wave B: the record is native history — the client view is the
|
||||||
|
// one-way projection (GET /api/chats/[id] serves the same shape).
|
||||||
|
initialMessages={projectNative(chat.messages) as TeacherUIMessage[]}
|
||||||
initialConfigured={slotsConfigured(record?.models)}
|
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
|
||||||
|
|||||||
@@ -1,28 +1,31 @@
|
|||||||
/**
|
/**
|
||||||
* Chat transport with 409 merge-retry — the learner's send can race the
|
* Chat transport for the INPUT-EVENTS contract (Wave B, owner ruling
|
||||||
* server's wake pipeline (contracts/jobs.ts "THE ASYNC PROTOCOL"): the wake
|
* 2026-09-26): the client never re-POSTs its transcript — every send
|
||||||
* turn starts server-side and the client only notices via the /api/jobs poll
|
* carries ONE explicit input event ({text} | {answer} | {verdict} |
|
||||||
* up to a second later. A send in that window gets 409'd ("a turn is already
|
* {continue}) stashed by the call site (chat.tsx) before invoking the SDK
|
||||||
* running") and, before this file, surfaced as an error banner.
|
* send. The server owns the record; this transport's ONLY send-time job is
|
||||||
|
* to consume the stash and put it on the wire as {chatId, input}.
|
||||||
*
|
*
|
||||||
* Why NOT a bare retry: every 409 means the server record is AHEAD of the
|
* 409 retry (the wake-pipeline race, contracts/jobs.ts "THE ASYNC
|
||||||
* client — the wake turn persisted the steer message (and, once it finishes,
|
* PROTOCOL"): a send can race the server's wake turn (the client only
|
||||||
* its response) that the client hasn't seen. The chat handler's write-ahead
|
* notices it via the /api/jobs poll up to a second later) and gets
|
||||||
* persist makes the request body the canonical transcript, so re-POSTing the
|
* 409'd. Under input events the retry is IDENTITY: the event was never
|
||||||
* stale client list would clobber the steer + wake messages out of the
|
* persisted (the write-ahead only happens once the turn is accepted), so
|
||||||
* record (and the job already flipped done — steerDelivered makes
|
* waiting for the in-flight turn and re-POSTing the SAME body is safe —
|
||||||
* re-delivery impossible: permanent loss). The retry must therefore MERGE:
|
* no merge, no stale-list clobber. The old UIMessage-era merge logic is
|
||||||
* wait for the in-flight turn to finish, refetch the record, append the
|
* deleted with the UIMessage record (the server now owns the
|
||||||
* client's unsent messages (by id) to the server's list, and re-POST the
|
* record; a lost race means "try again later", not "merge transcripts").
|
||||||
* merged transcript.
|
|
||||||
*
|
*
|
||||||
* Client-side gap after a merged retry: useChat's local list still lacks the
|
* The send path is LOUD about a missing stash: every SDK send must map to
|
||||||
* steer/wake messages until the next syncFromServer (job settle, remount, or
|
* exactly one event — a send with nothing stashed is a call-site bug
|
||||||
* a future "server-ahead" heal in the poller). Rare path; self-heals.
|
* (it would otherwise silently re-send the previous event), so the
|
||||||
|
* transport throws instead of guessing.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { DefaultChatTransport } from "ai";
|
import { DefaultChatTransport } from "ai";
|
||||||
import type { UIMessageChunk } from "ai";
|
import type { UIMessageChunk } from "ai";
|
||||||
import type { ChatTransport } from "ai";
|
import type { ChatTransport } from "ai";
|
||||||
|
import type { ChatInput } from "@/src/core";
|
||||||
import type { TeacherUIMessage } from "@/src/core";
|
import type { TeacherUIMessage } from "@/src/core";
|
||||||
|
|
||||||
/** How often to poll turnInFlight while the racing turn runs. */
|
/** How often to poll turnInFlight while the racing turn runs. */
|
||||||
@@ -56,21 +59,10 @@ async function turnInFlight(chatId: string): Promise<boolean | null> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function serverMessages(chatId: string): Promise<TeacherUIMessage[] | null> {
|
|
||||||
try {
|
|
||||||
const res = await fetch(`/api/chats/${encodeURIComponent(chatId)}`);
|
|
||||||
if (!res.ok) return null;
|
|
||||||
const record = (await res.json()) as { messages?: TeacherUIMessage[] };
|
|
||||||
return Array.isArray(record.messages) ? record.messages : null;
|
|
||||||
} catch {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A fetch that turns a 409 on POST /api/chat into: wait for the running turn,
|
* A fetch that turns a 409 on POST /api/chat into: wait for the running
|
||||||
* merge the outgoing messages onto the fresh server record, re-POST. Non-409
|
* turn, re-POST the SAME event body. Non-409 responses pass through
|
||||||
* responses pass through untouched.
|
* untouched.
|
||||||
*/
|
*/
|
||||||
/** Exported for tests. */
|
/** Exported for tests. */
|
||||||
export function retryingFetch(chatId: string, opts: RetryFetchOptions = {}): typeof globalThis.fetch {
|
export function retryingFetch(chatId: string, opts: RetryFetchOptions = {}): typeof globalThis.fetch {
|
||||||
@@ -90,36 +82,15 @@ export function retryingFetch(chatId: string, opts: RetryFetchOptions = {}): typ
|
|||||||
if (init?.signal?.aborted) return response;
|
if (init?.signal?.aborted) return response;
|
||||||
if ((await turnInFlight(chatId)) !== false) continue;
|
if ((await turnInFlight(chatId)) !== false) continue;
|
||||||
|
|
||||||
// Turn finished: merge onto the fresh record, then re-POST.
|
// Turn finished: re-POST the same input event — nothing merged,
|
||||||
const record = await serverMessages(chatId);
|
// nothing lost (the event was never persisted pre-acceptance).
|
||||||
if (!record || typeof init?.body !== "string") return response;
|
response = await fetch(input, init); // may 409 again → loop
|
||||||
const serverIds = new Set(record.map((m) => m.id));
|
|
||||||
const body = JSON.parse(init.body) as { messages?: TeacherUIMessage[] };
|
|
||||||
if (!Array.isArray(body.messages)) return response;
|
|
||||||
const outgoing = body.messages.filter((m) => m.id && !serverIds.has(m.id));
|
|
||||||
const merged = JSON.stringify({ ...body, messages: [...record, ...outgoing] });
|
|
||||||
|
|
||||||
response = await fetch(input, { ...init, body: merged }); // may 409 again → loop
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return response;
|
return response;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* The plain transport (no reattach adapters): sends keep the 409
|
|
||||||
* merge-retry. chat.tsx uses createReattachingTransport below — a
|
|
||||||
* superset of this — but the plain form stays exported for surfaces
|
|
||||||
* that only ever send (never resume onto a running turn).
|
|
||||||
*/
|
|
||||||
export function createChatTransport(chatId: string): DefaultChatTransport<TeacherUIMessage> {
|
|
||||||
return new DefaultChatTransport<TeacherUIMessage>({
|
|
||||||
api: "/api/chat",
|
|
||||||
body: { chatId },
|
|
||||||
fetch: retryingFetch(chatId),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ────────────────────────────────────────────────────────────────────────
|
/* ────────────────────────────────────────────────────────────────────────
|
||||||
* THE REATTACH TRANSPORT (background-turn runner wave, owner ruling
|
* THE REATTACH TRANSPORT (background-turn runner wave, owner ruling
|
||||||
* 2026-09-25): a turn runs server-side decoupled from every view, and a
|
* 2026-09-25): a turn runs server-side decoupled from every view, and a
|
||||||
@@ -166,6 +137,14 @@ export interface ReattachOptions {
|
|||||||
lastMessageId?: () => string | undefined;
|
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). */
|
/** Concatenate byte payloads (the head stream's event + read-past remainder). */
|
||||||
function concatBytes(a: Uint8Array, b: Uint8Array): Uint8Array {
|
function concatBytes(a: Uint8Array, b: Uint8Array): Uint8Array {
|
||||||
const out = new Uint8Array(a.length + b.length);
|
const out = new Uint8Array(a.length + b.length);
|
||||||
@@ -179,7 +158,7 @@ function freshMessageId(): string {
|
|||||||
return `msg_${Date.now().toString(36)}_${crypto.randomUUID().slice(0, 8)}`;
|
return `msg_${Date.now().toString(36)}_${crypto.randomUUID().slice(0, 8)}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** One parsed SSE event off the wire (data: <json>\\n\\n framing). */
|
/** One parsed SSE event off the wire (data: <json>\n\n framing). */
|
||||||
interface SseEvent {
|
interface SseEvent {
|
||||||
/** The raw bytes of this event, re-injectable into the head stream. */
|
/** The raw bytes of this event, re-injectable into the head stream. */
|
||||||
raw: Uint8Array;
|
raw: Uint8Array;
|
||||||
@@ -193,7 +172,7 @@ interface SseEvent {
|
|||||||
/**
|
/**
|
||||||
* Read the FIRST complete SSE event off a live body (the settled terminal
|
* 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
|
* and the start chunk are both single small events; a long-lived turn's
|
||||||
* log replays after). Resolves as soon as one \\n\\n boundary is seen — the
|
* log replays after). Resolves as soon as one \n\n boundary is seen — the
|
||||||
* caller re-injects the raw bytes (plus any read-past remainder) ahead of
|
* caller re-injects the raw bytes (plus any read-past remainder) ahead of
|
||||||
* the unconsumed tail, so nothing is lost or reordered.
|
* the unconsumed tail, so nothing is lost or reordered.
|
||||||
*/
|
*/
|
||||||
@@ -217,10 +196,11 @@ async function readFirstEvent(reader: ReadableStreamDefaultReader<Uint8Array>):
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The reattach transport: DefaultChatTransport for sends, our seam for resumes. */
|
/** The reattach transport: the input-event POST for sends, our seam for resumes. */
|
||||||
class ReattachingChatTransport extends DefaultChatTransport<TeacherUIMessage> {
|
class ReattachingChatTransport extends DefaultChatTransport<TeacherUIMessage> {
|
||||||
constructor(
|
constructor(
|
||||||
private readonly reattach: ReattachOptions,
|
private readonly reattach: ReattachOptions,
|
||||||
|
private readonly inputEvent: InputEventOptions,
|
||||||
init: ConstructorParameters<typeof DefaultChatTransport<TeacherUIMessage>>[0],
|
init: ConstructorParameters<typeof DefaultChatTransport<TeacherUIMessage>>[0],
|
||||||
) {
|
) {
|
||||||
super(init);
|
super(init);
|
||||||
@@ -300,15 +280,31 @@ class ReattachingChatTransport extends DefaultChatTransport<TeacherUIMessage> {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* The chat.tsx wiring: `transport: createReattachingTransport(chatId, refs)`.
|
* The chat.tsx wiring: `transport: createReattachingTransport(chatId, refs)`.
|
||||||
* Sends keep the 409 merge-retry fetch; resumes take the adapters above.
|
* Sends POST the stashed input event with the 409 identity-retry fetch;
|
||||||
|
* resumes take the adapters above.
|
||||||
*/
|
*/
|
||||||
export function createReattachingTransport(
|
export function createReattachingTransport(
|
||||||
chatId: string,
|
chatId: string,
|
||||||
reattach: ReattachOptions,
|
reattach: ReattachOptions,
|
||||||
|
inputEvent: InputEventOptions,
|
||||||
): DefaultChatTransport<TeacherUIMessage> {
|
): DefaultChatTransport<TeacherUIMessage> {
|
||||||
return new ReattachingChatTransport(reattach, {
|
return new ReattachingChatTransport(reattach, inputEvent, {
|
||||||
api: "/api/chat",
|
api: "/api/chat",
|
||||||
body: { chatId },
|
body: { chatId },
|
||||||
fetch: retryingFetch(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 } };
|
||||||
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
+38
-6
@@ -26,7 +26,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 ChatSettings, type ModelSlotName } from "@/src/core";
|
import { isProviderKeyRefusal, mergeAdopted, type ChatInput, type ChatSettings, type ModelSlotName } from "@/src/core";
|
||||||
import { PlanBlock, PlanPopup, latestTree } from "@/components/plan-block";
|
import { 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";
|
||||||
@@ -181,15 +181,35 @@ export function Chat({
|
|||||||
// the per-render transport recreation can never capture a stale one.
|
// 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);
|
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 } =
|
const { messages, sendMessage, addToolOutput, setMessages, status, error, stop, clearError, resumeStream } =
|
||||||
useChat<TeacherUIMessage>({
|
useChat<TeacherUIMessage>({
|
||||||
id: chatId,
|
id: chatId,
|
||||||
messages: initialMessages,
|
messages: initialMessages,
|
||||||
transport: createReattachingTransport(chatId, {
|
transport: createReattachingTransport(
|
||||||
|
chatId,
|
||||||
|
{
|
||||||
onSettled: () => reattachSettledRef.current(),
|
onSettled: () => reattachSettledRef.current(),
|
||||||
lastMessageId: () => reattachLastIdRef.current,
|
lastMessageId: () => reattachLastIdRef.current,
|
||||||
}),
|
},
|
||||||
|
{ takeInput },
|
||||||
|
),
|
||||||
// After the learner answers (addToolOutput), automatically send the
|
// After the learner answers (addToolOutput), automatically send the
|
||||||
// continuation request so the teacher reacts to the answer.
|
// continuation request so the teacher reacts to the answer.
|
||||||
sendAutomaticallyWhen: lastAssistantMessageIsCompleteWithToolCalls,
|
sendAutomaticallyWhen: lastAssistantMessageIsCompleteWithToolCalls,
|
||||||
@@ -200,6 +220,7 @@ export function Chat({
|
|||||||
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 sendMessage({ text: initialPrompt });
|
void sendMessage({ text: initialPrompt });
|
||||||
}
|
}
|
||||||
}, [initialPrompt, initialMessages.length, sendMessage]);
|
}, [initialPrompt, initialMessages.length, sendMessage]);
|
||||||
@@ -343,6 +364,7 @@ export function Chat({
|
|||||||
messages.length > 0 &&
|
messages.length > 0 &&
|
||||||
lastAssistantMessageIsCompleteWithToolCalls({ messages })
|
lastAssistantMessageIsCompleteWithToolCalls({ messages })
|
||||||
) {
|
) {
|
||||||
|
stashInput({ type: "continue" });
|
||||||
void sendMessage();
|
void sendMessage();
|
||||||
}
|
}
|
||||||
// mount-only by design (turnInFlight read at initial value)
|
// mount-only by design (turnInFlight read at initial value)
|
||||||
@@ -492,6 +514,7 @@ export function Chat({
|
|||||||
status === "ready" &&
|
status === "ready" &&
|
||||||
lastAssistantMessageIsCompleteWithToolCalls({ messages: record.messages })
|
lastAssistantMessageIsCompleteWithToolCalls({ messages: record.messages })
|
||||||
) {
|
) {
|
||||||
|
stashInput({ type: "continue" });
|
||||||
void sendMessage();
|
void sendMessage();
|
||||||
}
|
}
|
||||||
}, [chatId, setMessages, sendMessage, status]);
|
}, [chatId, setMessages, sendMessage, status]);
|
||||||
@@ -612,8 +635,8 @@ export function Chat({
|
|||||||
// TWO REAL MESSAGES, ONE TURN (the custom-message scheme, user-decided
|
// TWO REAL MESSAGES, ONE TURN (the custom-message scheme, user-decided
|
||||||
// 2026-09-12): a typed note is the learner's own user message, and the
|
// 2026-09-12): a typed note is the learner's own user message, and the
|
||||||
// verdict is its own custom message — marker part ONLY, no human-
|
// verdict is its own custom message — marker part ONLY, no human-
|
||||||
// authored text (the model-facing wording lives in the adapter:
|
// authored text (the model-facing wording lives in toLlmMessages,
|
||||||
// record-adapter flattens it; nothing fake is persisted as speech).
|
// the native convertToLlm seam; nothing fake is persisted as speech).
|
||||||
// setMessages + sendMessage() (no args) fires ONE request carrying
|
// setMessages + sendMessage() (no args) fires ONE request carrying
|
||||||
// both — the same single-turn mechanism syncFromServer already uses,
|
// both — the same single-turn mechanism syncFromServer already uses,
|
||||||
// so no 409 dance and no double teacher turn.
|
// so no 409 dance and no double teacher turn.
|
||||||
@@ -639,6 +662,10 @@ export function Chat({
|
|||||||
],
|
],
|
||||||
});
|
});
|
||||||
setMessages([...messages, ...appended]);
|
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();
|
void sendMessage();
|
||||||
},
|
},
|
||||||
[text, messages, setMessages, sendMessage],
|
[text, messages, setMessages, sendMessage],
|
||||||
@@ -964,6 +991,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 sendMessage({ text: t });
|
void sendMessage({ text: t });
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -972,7 +1000,10 @@ export function Chat({
|
|||||||
value={{
|
value={{
|
||||||
chatId,
|
chatId,
|
||||||
syncFromServer,
|
syncFromServer,
|
||||||
sendText: (t: string) => void sendMessage({ text: t }),
|
sendText: (t: string) => {
|
||||||
|
stashInput({ type: "text", text: t });
|
||||||
|
void sendMessage({ text: t });
|
||||||
|
},
|
||||||
openSetup: () => setSetupOpen(true),
|
openSetup: () => setSetupOpen(true),
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@@ -1318,6 +1349,7 @@ export function Chat({
|
|||||||
if (t) {
|
if (t) {
|
||||||
setText("");
|
setText("");
|
||||||
draftRef.current = t;
|
draftRef.current = t;
|
||||||
|
stashInput({ type: "text", text: t });
|
||||||
void sendMessage({ text: t });
|
void sendMessage({ text: t });
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ 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
|
||||||
@@ -92,10 +93,12 @@ import { batchRuns, batchTag, coverageOf, messageViewLines } from "../../core/co
|
|||||||
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.
|
// means — exactly — what the extractor saw. Wave B: the record is native
|
||||||
|
// history — the extraction view reads through the one-way projection.
|
||||||
const lines = [`SESSION: ${record.title}`];
|
const lines = [`SESSION: ${record.title}`];
|
||||||
record.messages.forEach((message, index) => {
|
const projected = projectNative(record.messages);
|
||||||
lines.push(...messageViewLines(message as UIMessage, (message as UIMessage).id || `m${index}`));
|
projected.forEach((message, index) => {
|
||||||
|
lines.push(...messageViewLines(message, message.id || `m${index}`));
|
||||||
});
|
});
|
||||||
return lines.join("\n");
|
return lines.join("\n");
|
||||||
}
|
}
|
||||||
@@ -868,7 +871,8 @@ 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 covered = coverageOf(record.messages as UIMessage[], graphStore.readCoverage(), chatId);
|
const projected = projectNative(record.messages);
|
||||||
|
const covered = coverageOf(projected, graphStore.readCoverage(), chatId);
|
||||||
if (covered.uncovered.length === 0) {
|
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 };
|
||||||
@@ -883,11 +887,11 @@ 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 = record.messages.map((m, i) => ((m as UIMessage).id || `m${i}`) as string);
|
const mids = projected.map((m, i) => (m.id || `m${i}`) as string);
|
||||||
const view = [
|
const view = [
|
||||||
`SESSION: ${record.title}`,
|
`SESSION: ${record.title}`,
|
||||||
...batch.flatMap((slot) => {
|
...batch.flatMap((slot) => {
|
||||||
const idx = record.messages.indexOf(slot.message);
|
const idx = projected.indexOf(slot.message);
|
||||||
return messageViewLines(slot.message, mids[idx] ?? slot.message.id);
|
return messageViewLines(slot.message, mids[idx] ?? slot.message.id);
|
||||||
}),
|
}),
|
||||||
].join("\n");
|
].join("\n");
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { isToolUIPart, type UIMessage } from "ai";
|
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 {
|
||||||
@@ -14,7 +14,7 @@ import {
|
|||||||
type PdfToolInput,
|
type PdfToolInput,
|
||||||
type ResearchResult,
|
type ResearchResult,
|
||||||
type ResearchToolInput,
|
type ResearchToolInput,
|
||||||
type SubagentSteerMessage,
|
type SteerMessage,
|
||||||
type UsageStepLike,
|
type UsageStepLike,
|
||||||
type VisualResult,
|
type VisualResult,
|
||||||
type VisualToolInput,
|
type VisualToolInput,
|
||||||
@@ -23,6 +23,8 @@ 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";
|
||||||
@@ -69,7 +71,7 @@ export interface JobRunnerDeps {
|
|||||||
* steer is delivered (the wake turn completed and the job status
|
* steer is delivered (the wake turn completed and the job status
|
||||||
* flipped); rejects when delivery fails unrecoverably.
|
* flipped); rejects when delivery fails unrecoverably.
|
||||||
*/
|
*/
|
||||||
deliver: (steer: SubagentSteerMessage) => Promise<void>;
|
deliver: (steer: SteerMessage) => Promise<void>;
|
||||||
/** Settings-resolved engine per role (model slots wave, 2026-09-20):
|
/** Settings-resolved engine per role (model slots wave, 2026-09-20):
|
||||||
* the role-override → slot chain, read FRESH per job (the server
|
* the role-override → slot chain, read FRESH per job (the server
|
||||||
* wiring passes effectiveSettings-based resolution). Test overrides
|
* wiring passes effectiveSettings-based resolution). Test overrides
|
||||||
@@ -274,7 +276,11 @@ 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;
|
||||||
return (findToolPart(chat.messages, job.toolCallId)?.input ?? undefined) as JobToolInput | undefined;
|
// Wave B: the record is native history — the transcript scan reads
|
||||||
|
// through the projection (same shape the old record held).
|
||||||
|
return (findToolPart(projectNative(chat.messages), job.toolCallId)?.input ?? undefined) as
|
||||||
|
| JobToolInput
|
||||||
|
| undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── shared plumbing ────────────────────────────────────────────────────
|
// ─── shared plumbing ────────────────────────────────────────────────────
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import {
|
|||||||
} from "./tools.ts";
|
} from "./tools.ts";
|
||||||
import { quizInputObject, quizInputProblems } from "../core/question-core.ts";
|
import { quizInputObject, quizInputProblems } from "../core/question-core.ts";
|
||||||
import { QUESTION_TOOLS, type EnabledTools } from "../core/pending.ts";
|
import { QUESTION_TOOLS, type EnabledTools } from "../core/pending.ts";
|
||||||
|
import { toLlmMessages } from "../core/native-messages.ts";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The two teaching-side agents (docs/probe-teach-split.md): the PROBER and
|
* The two teaching-side agents (docs/probe-teach-split.md): the PROBER and
|
||||||
@@ -89,7 +90,7 @@ export type StepUsageCollector = (usage: Usage) => void;
|
|||||||
export interface AgentBuildOptions {
|
export interface AgentBuildOptions {
|
||||||
/** Model + stream function — tests inject a scripted faux engine. */
|
/** Model + stream function — tests inject a scripted faux engine. */
|
||||||
engine?: PiEngine;
|
engine?: PiEngine;
|
||||||
/** The initial transcript, as pi messages (recordToPiMessages). */
|
/** The initial transcript, as pi messages (the record IS agent state). */
|
||||||
messages?: AgentMessage[];
|
messages?: AgentMessage[];
|
||||||
/** Money channel: one call per model request. */
|
/** Money channel: one call per model request. */
|
||||||
onUsage?: StepUsageCollector;
|
onUsage?: StepUsageCollector;
|
||||||
@@ -164,6 +165,13 @@ function baseAgentOptions(kind: AgentKind, opts: AgentBuildOptions, tools: Agent
|
|||||||
},
|
},
|
||||||
streamFn: engine.streamFn,
|
streamFn: engine.streamFn,
|
||||||
shouldStopAfterTurn: stopper,
|
shouldStopAfterTurn: stopper,
|
||||||
|
// Wave B (owner-approved 2026-09-26): the model-facing conversion is
|
||||||
|
// THE app seam — markers flatten to their canonical user text
|
||||||
|
// (verdictText / HANDOFF_TEXT / steer text, all single-sourced),
|
||||||
|
// UI-only markers (plan-block) drop, the app's id fields strip, and
|
||||||
|
// an abort's dangling question call gets its INTERRUPTED result. It
|
||||||
|
// must never throw (pi's convertToLlm contract).
|
||||||
|
convertToLlm: toLlmMessages,
|
||||||
});
|
});
|
||||||
if (opts.onUsage) {
|
if (opts.onUsage) {
|
||||||
agent.subscribe((event: AgentEvent) => {
|
agent.subscribe((event: AgentEvent) => {
|
||||||
|
|||||||
@@ -1,10 +1,9 @@
|
|||||||
import type { AgentTool } from "@earendil-works/pi-agent-core";
|
import type { AgentTool } from "@earendil-works/pi-agent-core";
|
||||||
import { serverExecute } from "../tool-execute.ts";
|
import { serverExecute } from "../tool-execute.ts";
|
||||||
import { probeDoneInputSchema, type ProbeDoneResult } from "../../core/contracts.ts";
|
import { probeDoneInputSchema, type ProbeDoneResult } from "../../core/contracts.ts";
|
||||||
import type { UIMessage } from "ai";
|
import { danglingQuestionOf, hasProbeDone, type LhMessage } from "../../core/native-messages.ts";
|
||||||
import { findPendingToolCall } from "../../core/pending.ts";
|
|
||||||
import type { ChatStore } from "../../core/ports.ts";
|
import type { ChatStore } from "../../core/ports.ts";
|
||||||
import { buildProbeHandoffSteer, type ProbeHandoffSteerMessage } from "../../core/contracts.ts";
|
import { buildProbeHandoffSteer, type SteerMessage } from "../../core/contracts.ts";
|
||||||
|
|
||||||
/** What the probe_done tool closes over. */
|
/** What the probe_done tool closes over. */
|
||||||
export interface ProbeDoneContext {
|
export interface ProbeDoneContext {
|
||||||
@@ -13,15 +12,14 @@ export interface ProbeDoneContext {
|
|||||||
chatStore: ChatStore;
|
chatStore: ChatStore;
|
||||||
/** Queue the handoff steer (the wake pipeline — src/server/jobs.ts).
|
/** Queue the handoff steer (the wake pipeline — src/server/jobs.ts).
|
||||||
* Injected so this module never imports the server seam. */
|
* Injected so this module never imports the server seam. */
|
||||||
deliver: (steer: ProbeHandoffSteerMessage) => void;
|
deliver: (steer: SteerMessage) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Does the transcript already carry a concluded probe (any state)? */
|
/** Does the transcript already carry a concluded probe (any state)?
|
||||||
function probeConcluded(messages: UIMessage[]): boolean {
|
* Wave B: the native scan (any probe_done tool call, any state) — same
|
||||||
for (const m of messages) {
|
* semantics the UIMessage part scan had. */
|
||||||
if (m.parts.some((p) => p.type === "tool-probe_done")) return true;
|
function probeConcluded(messages: readonly LhMessage[]): boolean {
|
||||||
}
|
return hasProbeDone(messages);
|
||||||
return false;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -64,7 +62,7 @@ export function probeDoneTool(ctx: ProbeDoneContext): Record<string, AgentTool<a
|
|||||||
const record = ctx.chatStore.getChat(ctx.chatId);
|
const record = ctx.chatStore.getChat(ctx.chatId);
|
||||||
if (!record) return { ok: false, error: `Chat not found: ${ctx.chatId}` };
|
if (!record) return { ok: false, error: `Chat not found: ${ctx.chatId}` };
|
||||||
|
|
||||||
if (findPendingToolCall(record.messages) !== null) {
|
if (danglingQuestionOf(record.messages) !== null) {
|
||||||
return {
|
return {
|
||||||
ok: false,
|
ok: false,
|
||||||
error:
|
error:
|
||||||
|
|||||||
@@ -8,22 +8,27 @@
|
|||||||
*/
|
*/
|
||||||
import type { UIMessage } from "ai";
|
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 { Phase3 } from "../phases.ts";
|
import type { Phase3 } from "../phases.ts";
|
||||||
import type { PlanNodeStatus, PlanTree } from "./plan.ts";
|
import type { PlanNodeStatus, PlanTree } from "./plan.ts";
|
||||||
import type { EmissionStats, UnderstandingLevel } from "./graph.ts";
|
import type { EmissionStats, UnderstandingLevel } from "./graph.ts";
|
||||||
|
|
||||||
// ─── Chat records (the session store's on-disk format) ───────────────────────
|
// ─── Chat records (the session store's on-disk format) ───────────────────────
|
||||||
|
|
||||||
/** One persisted session: data/chats/<id>.json. The stored messages are full
|
/** One persisted session: data/chats/<id>.json. Wave B (owner-approved
|
||||||
* AI SDK UIMessages (parts included) — the canonical session record: a
|
* 2026-09-26): the stored messages are pi-NATIVE history (LhMessage — the
|
||||||
* pending quiz is a tool part in `input-available` state, which is exactly
|
* agent loop's own shapes, provider/model/usage/thinkingSignature stamped
|
||||||
* what makes resume-mid-question free. */
|
* by pi) — the canonical session record IS the agent state; replay is
|
||||||
|
* `state.messages = record.messages` + continue(). The client-shaped view
|
||||||
|
* is the one-way projection (core/projection.ts) — a pending quiz is an
|
||||||
|
* unanswered question tool call, which is exactly what makes
|
||||||
|
* resume-mid-question free. */
|
||||||
export interface ChatRecord {
|
export interface ChatRecord {
|
||||||
id: string;
|
id: string;
|
||||||
title: string;
|
title: string;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
messages: UIMessage[];
|
messages: LhMessage[];
|
||||||
/** The session's plan tree (vision §2) — written by plan tools via saveTree. */
|
/** The session's plan tree (vision §2) — written by plan tools via saveTree. */
|
||||||
tree?: PlanTree;
|
tree?: PlanTree;
|
||||||
/** Append-only usage log (observability, 2026-09): one entry per model
|
/** Append-only usage log (observability, 2026-09): one entry per model
|
||||||
@@ -152,8 +157,8 @@ export interface TurnUsage {
|
|||||||
* both verdicts are real transcript events: accept sanctions teaching,
|
* both verdicts are real transcript events: accept sanctions teaching,
|
||||||
* decline asks for revision. The part is the message TYPE (the "custom
|
* decline asks for revision. The part is the message TYPE (the "custom
|
||||||
* message" scheme, 2026-09-12): the message carries no human-authored text,
|
* message" scheme, 2026-09-12): the message carries no human-authored text,
|
||||||
* the model-facing wording is owned by the adapter (record-adapter.ts
|
* the model-facing wording is owned by toLlmMessages (native-messages.ts
|
||||||
* flattens it to VERDICT_TEXT below), and the UI renders the verdict
|
* flattens it to verdictText below), and the UI renders the verdict
|
||||||
* marker — never a user bubble. A learner who also typed a note sends the
|
* marker — never a user bubble. A learner who also typed a note sends the
|
||||||
* note as its own real user message first; the verdict message follows. */
|
* note as its own real user message first; the verdict message follows. */
|
||||||
export interface PlanVerdictData {
|
export interface PlanVerdictData {
|
||||||
|
|||||||
+44
-73
@@ -6,8 +6,9 @@
|
|||||||
* master. Pure types + protocol docs + tiny pure helpers; type-only imports
|
* master. Pure types + protocol docs + tiny pure helpers; type-only imports
|
||||||
* of "ai" (safe everywhere: server, client, tests). See docs/vision.md.
|
* of "ai" (safe everywhere: server, client, tests). See docs/vision.md.
|
||||||
*/
|
*/
|
||||||
import type { UIMessage } from "ai";
|
|
||||||
import type { CallUsage } from "./chat.ts";
|
import type { CallUsage } from "./chat.ts";
|
||||||
|
import type { LhMessage, LhMarkerMessage } from "../native-messages.ts";
|
||||||
|
import { newHandoffMessage } from "../native-messages.ts";
|
||||||
|
|
||||||
// ─── Async sub-agent jobs (vision §2.5; plan.md Wave 0 protocol) ────────────
|
// ─── Async sub-agent jobs (vision §2.5; plan.md Wave 0 protocol) ────────────
|
||||||
|
|
||||||
@@ -141,7 +142,7 @@ export function accumulateJobUsage(
|
|||||||
*
|
*
|
||||||
* 3. On completion the runner builds a STEER MESSAGE (buildSubagentSteer)
|
* 3. On completion the runner builds a STEER MESSAGE (buildSubagentSteer)
|
||||||
* and hands it to the wake pipeline (src/server/jobs.ts): a user-role
|
* and hands it to the wake pipeline (src/server/jobs.ts): a user-role
|
||||||
* UIMessage carrying the result — a text part with what the MODEL needs
|
* result-carrying native marker — the content is what the MODEL needs
|
||||||
* (the research brief, the builder's notes), plus a data-subagent-artifact
|
* (the research brief, the builder's notes), plus a data-subagent-artifact
|
||||||
* part with what the UI renders (the full result: brief + sources /
|
* part with what the UI renders (the full result: brief + sources /
|
||||||
* SVG / demo HTML). Provider conversion drops data parts, so artifact
|
* SVG / demo HTML). Provider conversion drops data parts, so artifact
|
||||||
@@ -245,6 +246,14 @@ export interface PdfResult {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ─── Sub-agent steer messages (THE ASYNC PROTOCOL §3–4) ─────────────────────
|
// ─── Sub-agent steer messages (THE ASYNC PROTOCOL §3–4) ─────────────────────
|
||||||
|
//
|
||||||
|
// Wave B (owner-approved 2026-09-26): steers are NATIVE record messages —
|
||||||
|
// LhMarkerMessage customs (role "custom"), the custom-message taxonomy's
|
||||||
|
// wake members. The model-facing text rides `content` (single-sourced
|
||||||
|
// below); the UI payload (the artifact, the abort badge) rides `details`
|
||||||
|
// and never reaches the model (toLlmMessages maps the marker to a user
|
||||||
|
// message carrying `content` only). The deterministic id (`steer_<jobId>`)
|
||||||
|
// IS the idempotency key — steerDelivered matches on it.
|
||||||
|
|
||||||
/** Message metadata marking a sub-agent steer (contracts.ts §3). */
|
/** Message metadata marking a sub-agent steer (contracts.ts §3). */
|
||||||
export interface SubagentResultMeta {
|
export interface SubagentResultMeta {
|
||||||
@@ -264,40 +273,14 @@ export interface SubagentArtifactData {
|
|||||||
result: ResearchResult | DemoResult | VisualResult | PdfResult;
|
result: ResearchResult | DemoResult | VisualResult | PdfResult;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/** A result steer's marker details: the UI's meta + artifact in one
|
||||||
* The steer message: user-role on the wire (UIMessage roles are fixed), but
|
* payload (the projection splits it into metadata + data part). */
|
||||||
* NEVER a learner interjection — metadata.subagentResult is the marker the
|
export interface SteerDetails extends SubagentResultMeta {
|
||||||
* UI branches on, and the artifact rides the data part (stripped by provider
|
result: ResearchResult | DemoResult | VisualResult | PdfResult;
|
||||||
* conversion, so the model sees only the text part).
|
|
||||||
*/
|
|
||||||
export interface SubagentSteerMessage extends UIMessage {
|
|
||||||
role: "user";
|
|
||||||
metadata: { subagentResult: SubagentResultMeta };
|
|
||||||
parts: [
|
|
||||||
{ type: "text"; text: string },
|
|
||||||
{ type: "data-subagent-artifact"; data: SubagentArtifactData },
|
|
||||||
];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The abort steer: user-role like any steer, but NO artifact part —
|
/** Anything the wake pipeline may queue and deliver — a native marker. */
|
||||||
* there is no result to render, only the teacher-facing text. Shares the
|
export type SteerMessage = LhMarkerMessage;
|
||||||
* result steer's deterministic id space (`steer_${jobId}`): steerDelivered
|
|
||||||
* matches on metadata jobId, so an abort and a (racing) result steer
|
|
||||||
* can never BOTH deliver — exactly one wakes the teacher. */
|
|
||||||
export interface AbortSteerMessage extends UIMessage {
|
|
||||||
role: "user";
|
|
||||||
metadata: { subagentResult: SubagentResultMeta & { aborted: true } };
|
|
||||||
parts: [
|
|
||||||
{ type: "text"; text: string },
|
|
||||||
{ type: "data-subagent-aborted"; data: SubagentResultMeta & { aborted: true } },
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Anything the wake pipeline may queue and deliver. */
|
|
||||||
export type SteerMessage =
|
|
||||||
| SubagentSteerMessage
|
|
||||||
| AbortSteerMessage
|
|
||||||
| ProbeHandoffSteerMessage;
|
|
||||||
|
|
||||||
/** Whether a result is the failure shape (html/svg empty, failure brief). */
|
/** Whether a result is the failure shape (html/svg empty, failure brief). */
|
||||||
export function isSubagentFailure(
|
export function isSubagentFailure(
|
||||||
@@ -354,35 +337,38 @@ function steerText(kind: JobKind, result: ResearchResult | DemoResult | VisualRe
|
|||||||
export function buildSubagentSteer(
|
export function buildSubagentSteer(
|
||||||
job: Pick<JobRecord, "id" | "kind" | "toolCallId">,
|
job: Pick<JobRecord, "id" | "kind" | "toolCallId">,
|
||||||
result: ResearchResult | DemoResult | VisualResult | PdfResult,
|
result: ResearchResult | DemoResult | VisualResult | PdfResult,
|
||||||
): SubagentSteerMessage {
|
): SteerMessage {
|
||||||
return {
|
return {
|
||||||
id: `steer_${job.id}`,
|
id: `steer_${job.id}`,
|
||||||
role: "user",
|
role: "custom",
|
||||||
metadata: { subagentResult: { jobId: job.id, kind: job.kind, toolCallId: job.toolCallId } },
|
customType: "subagent-steer",
|
||||||
parts: [
|
content: steerText(job.kind, result),
|
||||||
{ type: "text", text: steerText(job.kind, result) },
|
display: true,
|
||||||
{ type: "data-subagent-artifact", data: { kind: job.kind, result } },
|
details: {
|
||||||
],
|
jobId: job.id,
|
||||||
|
kind: job.kind,
|
||||||
|
toolCallId: job.toolCallId,
|
||||||
|
result,
|
||||||
|
},
|
||||||
|
timestamp: Date.now(),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Build the abort steer for an aborted job (pure — shared by the
|
/** Build the abort steer for an aborted job (pure — shared by the
|
||||||
* abort path in src/server/jobs.ts and tests). Same id space as the result
|
* abort path in src/server/jobs.ts and tests). Same id space as the result
|
||||||
* steer; see AbortSteerMessage. */
|
* steer (same id space — exactly one delivers). */
|
||||||
export function buildAbortSteer(
|
export function buildAbortSteer(
|
||||||
job: Pick<JobRecord, "id" | "kind" | "toolCallId">,
|
job: Pick<JobRecord, "id" | "kind" | "toolCallId">,
|
||||||
): AbortSteerMessage {
|
): SteerMessage {
|
||||||
const meta = { jobId: job.id, kind: job.kind, toolCallId: job.toolCallId, aborted: true } as const;
|
const meta = { jobId: job.id, kind: job.kind, toolCallId: job.toolCallId, aborted: true } as const;
|
||||||
return {
|
return {
|
||||||
id: `steer_${job.id}`,
|
id: `steer_${job.id}`,
|
||||||
role: "user",
|
role: "custom",
|
||||||
metadata: { subagentResult: meta },
|
customType: "subagent-abort",
|
||||||
parts: [
|
content: abortText(job.kind),
|
||||||
{ type: "text", text: abortText(job.kind) },
|
display: true,
|
||||||
// The message-type marker (the custom-message scheme): the UI
|
details: meta,
|
||||||
// renders the abort badge off this part, not off metadata.
|
timestamp: Date.now(),
|
||||||
{ type: "data-subagent-aborted", data: meta },
|
|
||||||
],
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -403,34 +389,19 @@ function abortText(kind: JobKind): string {
|
|||||||
// probe_done's execute queues this through the same wake pipeline; the
|
// probe_done's execute queues this through the same wake pipeline; the
|
||||||
// pump fires it once the prober's turn ends (gates + grace). It carries no
|
// pump fires it once the prober's turn ends (gates + grace). It carries no
|
||||||
// artifact — the assessment it references is durable in the transcript as
|
// artifact — the assessment it references is durable in the transcript as
|
||||||
// the probe_done tool part. Deterministic id per chat: exactly one
|
// the probe_done tool result. Deterministic id per chat: exactly one
|
||||||
// handoff, idempotent re-delivery.
|
// handoff, idempotent re-delivery. The marker's content is HANDOFF_TEXT
|
||||||
|
// (core/contracts/chat.ts — single source); the UI renders the quiet
|
||||||
|
// system line off the customType. Nothing human-looking is persisted, ever.
|
||||||
|
|
||||||
export interface ProbeHandoffSteerMessage extends UIMessage {
|
export function buildProbeHandoffSteer(chatId: string): SteerMessage {
|
||||||
id: `steer_probe_${string}`;
|
return newHandoffMessage(chatId);
|
||||||
role: "user";
|
|
||||||
metadata: { probeHandoff: { chatId: string } };
|
|
||||||
/** The custom-message scheme (2026-09-12): NO text part — the wake is
|
|
||||||
* scaffolding, not speech. The model-facing instruction is owned by the
|
|
||||||
* adapter (record-adapter.ts flattens this part to HANDOFF_TEXT in
|
|
||||||
* core/contracts/chat.ts); the UI renders the quiet system line off the
|
|
||||||
* part. Nothing human-looking is persisted, ever. */
|
|
||||||
parts: [{ type: "data-probe-handoff"; data: { chatId: string } }];
|
|
||||||
}
|
|
||||||
|
|
||||||
export function buildProbeHandoffSteer(chatId: string): ProbeHandoffSteerMessage {
|
|
||||||
return {
|
|
||||||
id: `steer_probe_${chatId}`,
|
|
||||||
role: "user",
|
|
||||||
metadata: { probeHandoff: { chatId } },
|
|
||||||
parts: [{ type: "data-probe-handoff", data: { chatId } }],
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** True when the transcript already carries this steer — idempotent
|
/** True when the transcript already carries this steer — idempotent
|
||||||
* delivery for ANY steer kind: every steer id is deterministic per its
|
* delivery for ANY steer kind: every steer id is deterministic per its
|
||||||
* event (steer_<jobId> for jobs, steer_probe_<chatId> for the handoff). */
|
* event (steer_<jobId> for jobs, steer_probe_<chatId> for the handoff). */
|
||||||
export function steerDelivered(messages: UIMessage[], steerId: string): boolean {
|
export function steerDelivered(messages: LhMessage[], steerId: string): boolean {
|
||||||
return messages.some((m) => m.id === steerId);
|
return messages.some((m) => m.id === steerId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -32,6 +32,12 @@ 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";
|
export * from "./ui-message.ts";
|
||||||
|
// Wave B (owner-approved 2026-09-26): the native record (LhMessage), the
|
||||||
|
// one-way client-view projection, and the input-events contract — all
|
||||||
|
// pure logic, browser-safe.
|
||||||
|
export * from "./native-messages.ts";
|
||||||
|
export * from "./projection.ts";
|
||||||
|
export * from "./input-events.ts";
|
||||||
export * from "./tool-parts.ts";
|
export * from "./tool-parts.ts";
|
||||||
export * from "./prices.ts";
|
export * from "./prices.ts";
|
||||||
export * from "./cost-summary.ts";
|
export * from "./cost-summary.ts";
|
||||||
|
|||||||
@@ -0,0 +1,121 @@
|
|||||||
|
/**
|
||||||
|
* THE INPUT-EVENTS CONTRACT (Wave B, owner ruling 2026-09-26: "input
|
||||||
|
* events now, in the same pass"): POST /api/chat carries `{chatId, input}`
|
||||||
|
* — ONE explicit event, never a client transcript. The server owns the
|
||||||
|
* record; a send tells it what HAPPENED, not what the client's list looks
|
||||||
|
* like. The 409 merge-retry died with the full-list body (an input event
|
||||||
|
* retries idempotently: nothing was appended when a POST is refused).
|
||||||
|
*
|
||||||
|
* The four events (the complete client-originated input taxonomy):
|
||||||
|
* text — a learner-authored user message (composer send, initial
|
||||||
|
* topic, the demo error-fix loop)
|
||||||
|
* answer — the pending question's graded result (QuestionAnswerData,
|
||||||
|
* built client-side exactly as today; a dismissal is an answer
|
||||||
|
* carrying its flag). Server-side this becomes the call's
|
||||||
|
* native tool result — fork 1's normal agent history.
|
||||||
|
* verdict — the plan verdict (accept/decline); an optional note is the
|
||||||
|
* learner's own real user message, sent first, exactly the
|
||||||
|
* two-real-messages scheme.
|
||||||
|
* continue — run the next turn on the current record (mount
|
||||||
|
* continuations, job-settle re-syncs: the record ends at an
|
||||||
|
* answered question with no teacher reaction yet).
|
||||||
|
*
|
||||||
|
* Server-originated delivery (the wake pipeline's steers) is NOT a client
|
||||||
|
* input — the handler accepts it on the same path (TurnInput) so every
|
||||||
|
* turn rides one composition.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { QuestionAnswerData } from "./question-core.ts";
|
||||||
|
import type { LhMessage } from "./native-messages.ts";
|
||||||
|
import {
|
||||||
|
danglingQuestionOf,
|
||||||
|
newAnswerResult,
|
||||||
|
newUserMessage,
|
||||||
|
newVerdictMessage,
|
||||||
|
} from "./native-messages.ts";
|
||||||
|
import { QUESTION_TOOLS } from "./pending.ts";
|
||||||
|
|
||||||
|
/** One client-originated input event (the POST body's `input`). */
|
||||||
|
export type ChatInput =
|
||||||
|
| { type: "text"; text: string }
|
||||||
|
| { type: "answer"; toolCallId: string; answer: QuestionAnswerData }
|
||||||
|
| { type: "verdict"; accepted: boolean; note?: string }
|
||||||
|
| { type: "continue" };
|
||||||
|
|
||||||
|
/** A wake-pipeline steer delivery — same turn composition, server-origin. */
|
||||||
|
export type SteerInput = { type: "steer"; steer: LhMessage };
|
||||||
|
|
||||||
|
/** Every input the turn composition accepts. */
|
||||||
|
export type TurnInput = ChatInput | SteerInput;
|
||||||
|
|
||||||
|
/** A malformed input event (the honest 400 — the orphan-trap discipline). */
|
||||||
|
export class ChatInputError extends Error {}
|
||||||
|
|
||||||
|
/** Parse and validate an input event off a request body. */
|
||||||
|
export function parseChatInput(input: unknown): ChatInput {
|
||||||
|
if (typeof input !== "object" || input === null) throw new ChatInputError("input must be an object");
|
||||||
|
const i = input as Record<string, unknown>;
|
||||||
|
switch (i.type) {
|
||||||
|
case "text":
|
||||||
|
if (typeof i.text !== "string" || (i.text as string).trim().length === 0) {
|
||||||
|
throw new ChatInputError("text input requires non-empty text");
|
||||||
|
}
|
||||||
|
return { type: "text", text: i.text };
|
||||||
|
case "answer":
|
||||||
|
if (typeof i.toolCallId !== "string" || i.toolCallId.length === 0) {
|
||||||
|
throw new ChatInputError("answer input requires toolCallId");
|
||||||
|
}
|
||||||
|
if (typeof i.answer !== "object" || i.answer === null) {
|
||||||
|
throw new ChatInputError("answer input requires the graded answer object");
|
||||||
|
}
|
||||||
|
return { type: "answer", toolCallId: i.toolCallId, answer: i.answer as QuestionAnswerData };
|
||||||
|
case "verdict":
|
||||||
|
if (typeof i.accepted !== "boolean") throw new ChatInputError("verdict input requires accepted");
|
||||||
|
if (i.note !== undefined && typeof i.note !== "string") {
|
||||||
|
throw new ChatInputError("verdict note must be a string");
|
||||||
|
}
|
||||||
|
return { type: "verdict", accepted: i.accepted, note: i.note as string | undefined };
|
||||||
|
case "continue":
|
||||||
|
return { type: "continue" };
|
||||||
|
default:
|
||||||
|
throw new ChatInputError(`unknown input type: ${String(i.type)}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The native appends an input event makes to the record (pure). The caller
|
||||||
|
* persists them write-ahead; the agent's context is the record plus these.
|
||||||
|
*
|
||||||
|
* Answer validation is the orphan-400 discipline: the call must be a
|
||||||
|
* DANGLING QUESTION call in the record (posed, never answered) — anything
|
||||||
|
* else 400s honestly instead of silently interrupting a question or
|
||||||
|
* double-answering.
|
||||||
|
*/
|
||||||
|
export function inputAppends(record: { messages: LhMessage[] }, input: ChatInput): LhMessage[] {
|
||||||
|
switch (input.type) {
|
||||||
|
case "text":
|
||||||
|
return [newUserMessage(input.text)];
|
||||||
|
case "answer": {
|
||||||
|
const pending = danglingQuestionOf(record.messages);
|
||||||
|
if (!pending || pending.toolCallId !== input.toolCallId) {
|
||||||
|
throw new ChatInputError("answer input targets no pending question call");
|
||||||
|
}
|
||||||
|
return [newAnswerResult(input.toolCallId, pending.tool, input.answer)];
|
||||||
|
}
|
||||||
|
case "verdict": {
|
||||||
|
const appends: LhMessage[] = [];
|
||||||
|
if (input.note && input.note.trim().length > 0) {
|
||||||
|
appends.push(newUserMessage(input.note));
|
||||||
|
}
|
||||||
|
appends.push(newVerdictMessage(input.accepted, new Date().toISOString()));
|
||||||
|
return appends;
|
||||||
|
}
|
||||||
|
case "continue":
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Is this tool name a question tool (dock-owned, answer-gated)? */
|
||||||
|
export function isQuestionTool(name: string): boolean {
|
||||||
|
return (QUESTION_TOOLS as readonly string[]).includes(name);
|
||||||
|
}
|
||||||
@@ -0,0 +1,284 @@
|
|||||||
|
/**
|
||||||
|
* THE NATIVE MESSAGE RECORD (Wave B, owner-approved 2026-09-26): chat
|
||||||
|
* records store pi's own message shapes — the exact history the agent
|
||||||
|
* loop builds — instead of AI-SDK UIMessages. Provider, model, usage,
|
||||||
|
* stopReason, and thinking signatures are stamped by pi itself; replay is
|
||||||
|
* `state.messages = record.messages` + `continue()` — the record IS the
|
||||||
|
* agent state.
|
||||||
|
*
|
||||||
|
* WHY pi MESSAGES LACK OUR `id`: pi's session format gives every ENTRY an
|
||||||
|
* id, never the message. This app needs a per-message id for client
|
||||||
|
* identity (React keys, append-only adoption, the continuation seam), so
|
||||||
|
* every record message carries ONE extra field — `id` — alongside the
|
||||||
|
* native shape. `toLlmMessages` strips it at the wire: the provider never
|
||||||
|
* sees it (verified single site, below).
|
||||||
|
*
|
||||||
|
* UI-ONLY CONTENT (owner ruling, fork 1 + the plan's custom-message
|
||||||
|
* taxonomy): verdict markers, the probe→teach handoff, sub-agent steers,
|
||||||
|
* and plan blocks are LhMarkerMessage — this app's declaration-merged
|
||||||
|
* member of pi-agent-core's CustomAgentMessages extension point, shaped
|
||||||
|
* exactly like pi's own CustomMessage ({role:"custom", customType,
|
||||||
|
* content, display, details}). ONE seam — toLlmMessages, wired as the
|
||||||
|
* agent's convertToLlm — owns every model-facing decision: markers
|
||||||
|
* flatten to their canonical user text (single-sourced in
|
||||||
|
* core/contracts), plan blocks are UI-only and filtered, and dangling
|
||||||
|
* tool calls (an aborted turn) get the interrupted-result synthesis the
|
||||||
|
* provider protocol demands.
|
||||||
|
*
|
||||||
|
* Answers (fork 1, owner 2026-09-26): the learner's answer IS the tool
|
||||||
|
* call's ToolResultMessage — appended natively at answer time. The
|
||||||
|
* synthetic "question posed" placeholder is an in-memory loop device
|
||||||
|
* (Wave A's terminate) and is never persisted: the record ends at the
|
||||||
|
* unanswered call in the pose→answer window, where no turn can run (the
|
||||||
|
* question gate holds steers; a POST would 409).
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { AgentMessage } from "@earendil-works/pi-agent-core";
|
||||||
|
import type { AssistantMessage, Message, ToolResultMessage, UserMessage } from "@earendil-works/pi-ai";
|
||||||
|
import { QUESTION_TOOLS } from "./pending.ts";
|
||||||
|
import { HANDOFF_TEXT, verdictText } from "./contracts/chat.ts";
|
||||||
|
|
||||||
|
/** The hard-invariant wording for an aborted turn's unanswered calls —
|
||||||
|
* carried over verbatim from the pre-native era so the model's view
|
||||||
|
* never drifted. */
|
||||||
|
export const INTERRUPTED_TEXT =
|
||||||
|
"The question was interrupted — the learner never answered; continue without holding a result for it.";
|
||||||
|
|
||||||
|
// ─── The record's message type ───────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The app's custom message (declaration-merged into pi-agent-core's
|
||||||
|
* CustomAgentMessages below). Shaped like pi's own CustomMessage —
|
||||||
|
* `content` is the MODEL-facing text (single-sourced in core/contracts,
|
||||||
|
* never fake human speech), `details` is the UI payload (never
|
||||||
|
* model-visible), `display` marks messages the UI renders (our projection
|
||||||
|
* keys rendering on customType; the flag documents intent).
|
||||||
|
*/
|
||||||
|
export interface LhMarkerMessage {
|
||||||
|
/** Record identity (pi messages carry no id — see the file header). */
|
||||||
|
id: string;
|
||||||
|
role: "custom";
|
||||||
|
customType: "verdict" | "probe-handoff" | "subagent-steer" | "subagent-abort" | "plan-block";
|
||||||
|
/** The model-facing text ("" for plan blocks — UI-only). */
|
||||||
|
content: string;
|
||||||
|
display: boolean;
|
||||||
|
/** The UI payload — per-customType, never model-visible. */
|
||||||
|
details: unknown;
|
||||||
|
timestamp: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
declare module "@earendil-works/pi-agent-core" {
|
||||||
|
interface CustomAgentMessages {
|
||||||
|
lhMarker: LhMarkerMessage;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A record message: a native pi message (or our marker) plus the record id. */
|
||||||
|
export type LhMessage = (UserMessage | AssistantMessage | ToolResultMessage | LhMarkerMessage) & { id: string };
|
||||||
|
|
||||||
|
/** A record message list is assignable to the agent's context — that IS
|
||||||
|
* the replay (ui-stream/chat-handler assign it to initialState.messages). */
|
||||||
|
export function asAgentMessages(messages: readonly LhMessage[]): AgentMessage[] {
|
||||||
|
return messages as unknown as AgentMessage[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── id minting ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** The app-wide message id shape (same as the UIMessage-era mint). */
|
||||||
|
export function newMessageId(): string {
|
||||||
|
return `msg_${Date.now().toString(36)}_${crypto.randomUUID().slice(0, 8)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── constructors (the input-event → native append mapping lives in
|
||||||
|
// core/input-events.ts; steers in core/contracts/jobs.ts) ─────────────────
|
||||||
|
|
||||||
|
/** A learner-authored user message (text input, verdict note). */
|
||||||
|
export function newUserMessage(text: string): LhMessage & UserMessage {
|
||||||
|
return {
|
||||||
|
id: newMessageId(),
|
||||||
|
role: "user",
|
||||||
|
content: text,
|
||||||
|
timestamp: Date.now(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The learner's answer — the question call's tool result (fork 1).
|
||||||
|
* `answer` is the structured QuestionAnswerData the client graded; the
|
||||||
|
* MODEL sees its JSON encoding (the pre-native wire shape),
|
||||||
|
* the UI sees the structured payload via `details`. */
|
||||||
|
export function newAnswerResult(
|
||||||
|
toolCallId: string,
|
||||||
|
toolName: string,
|
||||||
|
answer: unknown,
|
||||||
|
): LhMessage & ToolResultMessage {
|
||||||
|
return {
|
||||||
|
id: newMessageId(),
|
||||||
|
role: "toolResult",
|
||||||
|
toolCallId,
|
||||||
|
toolName,
|
||||||
|
content: [{ type: "text", text: JSON.stringify(answer) }],
|
||||||
|
details: answer,
|
||||||
|
isError: false,
|
||||||
|
timestamp: Date.now(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The plan verdict (the plan→teach seam): marker only — the model-facing
|
||||||
|
* wording is owned by verdictText (core/contracts/chat.ts), the UI
|
||||||
|
* renders the quiet accept/decline line off details. */
|
||||||
|
export function newVerdictMessage(accepted: boolean, at: string): LhMarkerMessage {
|
||||||
|
return {
|
||||||
|
id: newMessageId(),
|
||||||
|
role: "custom",
|
||||||
|
customType: "verdict",
|
||||||
|
content: verdictText(accepted),
|
||||||
|
display: true,
|
||||||
|
details: { accepted, at },
|
||||||
|
timestamp: Date.now(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The probe→teach handoff wake: scaffolding, not speech — the model reads
|
||||||
|
* HANDOFF_TEXT, the UI renders the quiet system line. */
|
||||||
|
export function newHandoffMessage(chatId: string): LhMarkerMessage {
|
||||||
|
return {
|
||||||
|
id: `steer_probe_${chatId}`,
|
||||||
|
role: "custom",
|
||||||
|
customType: "probe-handoff",
|
||||||
|
content: HANDOFF_TEXT,
|
||||||
|
display: true,
|
||||||
|
details: { chatId },
|
||||||
|
timestamp: Date.now(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The plan presentation block: pure UI history (a frozen snapshot), no
|
||||||
|
* model face — toLlmMessages filters it. `planCallId` keys idempotent
|
||||||
|
* emission (one block per successful plan_init call). */
|
||||||
|
export function newPlanBlockMessage(
|
||||||
|
planCallId: string,
|
||||||
|
block: unknown,
|
||||||
|
): LhMarkerMessage {
|
||||||
|
return {
|
||||||
|
id: `pb_${Date.now().toString(36)}_${crypto.randomUUID().slice(0, 8)}`,
|
||||||
|
role: "custom",
|
||||||
|
customType: "plan-block",
|
||||||
|
content: "",
|
||||||
|
display: true,
|
||||||
|
details: { planCallId, block },
|
||||||
|
timestamp: Date.now(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── the one model-facing seam (the agent's convertToLlm) ────────────────────
|
||||||
|
|
||||||
|
/** The ids of every toolResult in the tail (for the dangling-call scan). */
|
||||||
|
function resultIdsFrom(messages: readonly AgentMessage[]): Set<string> {
|
||||||
|
const ids = new Set<string>();
|
||||||
|
for (const m of messages) {
|
||||||
|
if ((m as { role?: string }).role === "toolResult") {
|
||||||
|
ids.add((m as ToolResultMessage).toolCallId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ids;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* AgentMessage[] → the provider wire (the agent's convertToLlm — required
|
||||||
|
* by pi-agent-core's loop config, the ONE place model-facing decisions
|
||||||
|
* live):
|
||||||
|
*
|
||||||
|
* - user / assistant / toolResult pass through VERBATIM — signatures,
|
||||||
|
* usage, provider stamps all native — minus our record-`id` field.
|
||||||
|
* - markers flatten to a user message carrying their content (the
|
||||||
|
* canonical text single-sourced in core/contracts); plan blocks are
|
||||||
|
* UI-only and filtered.
|
||||||
|
* - dangling tool calls (an aborted turn's unanswered questions) get the
|
||||||
|
* synthesized interrupted result — the provider protocol's hard
|
||||||
|
* invariant: a tool call must be followed by its result.
|
||||||
|
*
|
||||||
|
* Contract (pi-agent-core): never throws — an unknown shape passes
|
||||||
|
* through untouched rather than killing the loop.
|
||||||
|
*/
|
||||||
|
export function toLlmMessages(messages: readonly AgentMessage[]): Message[] {
|
||||||
|
const out: Message[] = [];
|
||||||
|
for (let i = 0; i < messages.length; i++) {
|
||||||
|
const m = messages[i]!;
|
||||||
|
const role = (m as { role?: string }).role;
|
||||||
|
if (role === "custom") {
|
||||||
|
const marker = m as LhMarkerMessage;
|
||||||
|
if (marker.customType === "plan-block") continue; // UI-only
|
||||||
|
out.push({
|
||||||
|
role: "user",
|
||||||
|
content: [{ type: "text", text: marker.content }],
|
||||||
|
timestamp: marker.timestamp,
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// Strip the record id: the wire carries the native shape only.
|
||||||
|
const { id: _id, ...native } = m as LhMessage;
|
||||||
|
out.push(native as Message);
|
||||||
|
// Dangling-call synthesis: an assistant message whose toolCalls have
|
||||||
|
// no results in the remaining tail (abort mid-question) — the
|
||||||
|
// provider rejects unanswered calls outright.
|
||||||
|
if (role === "assistant") {
|
||||||
|
const assistant = m as AssistantMessage;
|
||||||
|
const calls = assistant.content.filter((b) => b.type === "toolCall");
|
||||||
|
if (calls.length === 0) continue;
|
||||||
|
const answered = resultIdsFrom(messages.slice(i + 1));
|
||||||
|
for (const call of calls) {
|
||||||
|
if (answered.has(call.id)) continue;
|
||||||
|
out.push({
|
||||||
|
role: "toolResult",
|
||||||
|
toolCallId: call.id,
|
||||||
|
toolName: call.name,
|
||||||
|
content: [{ type: "text", text: INTERRUPTED_TEXT }],
|
||||||
|
isError: false,
|
||||||
|
timestamp: assistant.timestamp,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── native scans (the UIMessage-era pending/phase seams, natively) ──────────
|
||||||
|
|
||||||
|
/** The pending-question gate (findPendingToolCall's native twin): the
|
||||||
|
* NEWEST assistant message's FIRST question-tool call with no result in
|
||||||
|
* the tail — one question at a time, the dock's subject. */
|
||||||
|
export function danglingQuestionOf(
|
||||||
|
messages: readonly LhMessage[],
|
||||||
|
): { toolCallId: string; tool: string; input: unknown } | null {
|
||||||
|
for (let mi = messages.length - 1; mi >= 0; mi--) {
|
||||||
|
const m = messages[mi]!;
|
||||||
|
if ((m as { role?: string }).role !== "assistant") continue;
|
||||||
|
const assistant = m as AssistantMessage & { id: string };
|
||||||
|
const answered = resultIdsFrom(messages.slice(mi + 1));
|
||||||
|
for (const block of assistant.content) {
|
||||||
|
if (block.type !== "toolCall") continue;
|
||||||
|
if (!(QUESTION_TOOLS as readonly string[]).includes(block.name)) continue;
|
||||||
|
if (answered.has(block.id)) continue;
|
||||||
|
return { toolCallId: block.id, tool: block.name, input: block.arguments };
|
||||||
|
}
|
||||||
|
return null; // the newest assistant message's questions are all answered
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The probe→teach construction seam (chat-handler): a probe_done call in
|
||||||
|
* the transcript — any state — means the probe is concluded: teach. */
|
||||||
|
export function hasProbeDone(messages: readonly LhMessage[]): boolean {
|
||||||
|
for (const m of messages) {
|
||||||
|
if ((m as { role?: string }).role !== "assistant") continue;
|
||||||
|
const assistant = m as AssistantMessage;
|
||||||
|
if (assistant.content.some((b) => b.type === "toolCall" && b.name === "probe_done")) return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Every toolResult id in the record — answer-event validation (the call
|
||||||
|
* must be a DANGLING question call, not an already-answered one). */
|
||||||
|
export function answeredCallIds(messages: readonly LhMessage[]): Set<string> {
|
||||||
|
return resultIdsFrom(messages);
|
||||||
|
}
|
||||||
+58
-53
@@ -1,44 +1,38 @@
|
|||||||
/**
|
/**
|
||||||
* Plan-block emission (docs/probe-teach-split.md §6): the INTERIM plan
|
* Plan-block emission (docs/probe-teach-split.md §6): the INTERIM plan
|
||||||
* presentation seam. The plan renders IN FLOW once, at authoring — a full
|
* presentation seam. The plan renders IN FLOW once, at authoring — a
|
||||||
* `data-plan-block` attached to the assistant message that carries the
|
* frozen snapshot attached right after the assistant group carrying the
|
||||||
* successful plan_init call. It is the presentation moment for the plan
|
* successful plan_init call. It is the presentation moment for the plan
|
||||||
* (and the popup's entry point) — statuses on it are accurate at that
|
* (and the popup's entry point) — statuses on it are accurate at that
|
||||||
* instant (current = null, everything upcoming — unless the authoring is a
|
* instant (current = null, everything upcoming — unless the authoring is
|
||||||
* mid-lesson rewrite, in which case the snapshot shows what was true then).
|
* a mid-lesson rewrite, in which case the snapshot shows what was true
|
||||||
|
* then).
|
||||||
*
|
*
|
||||||
* The neighborhood-block machinery (frozen fragments on plan movement) is
|
* Wave B (owner-approved 2026-09-26): the block is a native plan-block
|
||||||
* RETIRED: position derives live from the transcript (derivePlanStatuses —
|
* MARKER appended at the turn's persist (idempotent per plan_init call);
|
||||||
* calls, not results), and frozen snapshots can't represent live movement.
|
* the projection splices its data part into the assistant group IN FLOW,
|
||||||
* The persistent DAG window (the UI wave) is the successor — always
|
* exactly where the UIMessage-era attach put it. The neighborhood-block
|
||||||
* present, auto-moving with the lesson.
|
* machinery stays retired: position derives live from the transcript.
|
||||||
*
|
|
||||||
* Idempotent by construction: a message that already carries a plan-block
|
|
||||||
* part is never given another (persist replays / retry paths are safe).
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import type { PlanBlockData, PlanTree } from "./contracts.ts";
|
import type { PlanBlockData, PlanTree } from "./contracts.ts";
|
||||||
import { derivePlanStatuses } from "./contracts.ts";
|
import { derivePlanStatuses } from "./contracts.ts";
|
||||||
import type { UIMessage } from "ai";
|
import type { UIMessage } from "ai";
|
||||||
|
import type { LhMessage } from "./native-messages.ts";
|
||||||
|
import { newPlanBlockMessage } from "./native-messages.ts";
|
||||||
|
import type { ToolResultMessage } from "@earendil-works/pi-ai";
|
||||||
|
|
||||||
/** The message part the chat renderer consumes (TeacherUIDataParts key
|
/** The message part the chat renderer consumes (TeacherUIDataParts key
|
||||||
* "plan-block" — the SDK strips the `data-` prefix from the name). */
|
* "plan-block" — the SDK strips the `data-` prefix). Kept for the client
|
||||||
|
* renderers; the projection emits this shape. */
|
||||||
export interface PlanBlockPart {
|
export interface PlanBlockPart {
|
||||||
type: "data-plan-block";
|
type: "data-plan-block";
|
||||||
id: string;
|
id: string;
|
||||||
data: PlanBlockData;
|
data: PlanBlockData;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Does this message carry a successful plan_init (the trigger)? */
|
/** The snapshot payload (unchanged since wave 4): statuses derived AT
|
||||||
function hasSuccessfulPlanInit(message: UIMessage): boolean {
|
* EMISSION time from the transcript — the block never re-derives. */
|
||||||
for (const part of message.parts) {
|
|
||||||
if (part.type !== "tool-plan_init") continue;
|
|
||||||
const ok = (part as { output?: { ok?: unknown } }).output?.ok;
|
|
||||||
if (ok === true) return true;
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
function snapshot(tree: PlanTree, messages: readonly UIMessage[]): PlanBlockData {
|
function snapshot(tree: PlanTree, messages: readonly UIMessage[]): PlanBlockData {
|
||||||
const statuses = derivePlanStatuses(tree, messages);
|
const statuses = derivePlanStatuses(tree, messages);
|
||||||
return {
|
return {
|
||||||
@@ -54,36 +48,47 @@ function snapshot(tree: PlanTree, messages: readonly UIMessage[]): PlanBlockData
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/** The record's plan-block markers, keyed by the plan_init call they
|
||||||
* Enrich a message list with plan-presentation blocks — THE emission
|
* present — the idempotency set for emission. */
|
||||||
* seam's pure half. `messages` is the deduped list persistTurn is about to
|
export function emittedPlanBlockCalls(messages: readonly LhMessage[]): Set<string> {
|
||||||
* write; `tree` is the record's current tree (the plan tool saved it during
|
const out = new Set<string>();
|
||||||
* the turn). Messages already carrying a block are never touched.
|
for (const m of messages) {
|
||||||
*/
|
if ((m as { role?: string }).role !== "custom") continue;
|
||||||
export function withPlanBlocks(messages: UIMessage[], tree: PlanTree | null | undefined): UIMessage[] {
|
const marker = m as { customType?: string; details?: { planCallId?: unknown } };
|
||||||
if (!tree || tree.nodes.length === 0 || messages.length === 0) return messages;
|
if (marker.customType === "plan-block" && typeof marker.details?.planCallId === "string") {
|
||||||
const blockId = `pb_${Date.now().toString(36)}_${crypto.randomUUID().slice(0, 8)}`;
|
out.add(marker.details.planCallId);
|
||||||
return messages.map((m) => {
|
}
|
||||||
if (m.role !== "assistant" || m.parts.some((p) => p.type === "data-plan-block")) return m;
|
}
|
||||||
if (!hasSuccessfulPlanInit(m)) return m;
|
return out;
|
||||||
// POSITION, not append: emission runs at persistTurn — after the whole
|
|
||||||
// turn is composed — so appending would bury the block under every
|
|
||||||
// teaching note and quiz of the same turn. The block renders IN FLOW
|
|
||||||
// immediately after the plan_init call it presents (after the LAST
|
|
||||||
// one if a turn carried multiple — a rewrite).
|
|
||||||
const idx = lastIndexOfSuccessfulPlanInit(m.parts);
|
|
||||||
const parts = m.parts.slice();
|
|
||||||
parts.splice(idx + 1, 0, { type: "data-plan-block", id: blockId, data: snapshot(tree, messages) } satisfies PlanBlockPart);
|
|
||||||
return { ...m, parts };
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Index of the last tool-plan_init part with an ok output in this message. */
|
/**
|
||||||
function lastIndexOfSuccessfulPlanInit(parts: ReadonlyArray<{ type: string }>): number {
|
* The native emission seam (pure): ONE plan-block marker per TURN — for
|
||||||
let idx = -1;
|
* the turn's LAST successful plan_init call (a same-turn rewrite presents
|
||||||
parts.forEach((p, i) => {
|
* once; a later turn's rewrite gets its own block — the golden-master
|
||||||
if (p.type !== "tool-plan_init") return;
|
* rule the UIMessage-era attach had) that the record hasn't already
|
||||||
if ((p as { output?: { ok?: unknown } }).output?.ok === true) idx = i;
|
* emitted. `tree` is the record's current tree (the plan tool saved it
|
||||||
});
|
* during the turn); `transcript` is the full PROJECTED transcript the
|
||||||
return idx;
|
* status derivation reads.
|
||||||
|
*/
|
||||||
|
export function planBlocksFor(
|
||||||
|
record: readonly LhMessage[],
|
||||||
|
newMessages: readonly LhMessage[],
|
||||||
|
tree: PlanTree | null | undefined,
|
||||||
|
transcript: readonly UIMessage[],
|
||||||
|
): LhMessage[] {
|
||||||
|
if (!tree || tree.nodes.length === 0) return [];
|
||||||
|
const emitted = emittedPlanBlockCalls(record);
|
||||||
|
let last: { toolCallId: string } | null = null;
|
||||||
|
for (const m of newMessages) {
|
||||||
|
if ((m as { role?: string }).role !== "toolResult") continue;
|
||||||
|
const result = m as ToolResultMessage & { id: string };
|
||||||
|
if (result.toolName !== "plan_init") continue;
|
||||||
|
const ok = (result.details as { ok?: unknown } | undefined)?.ok;
|
||||||
|
if (ok !== true) continue;
|
||||||
|
if (emitted.has(result.toolCallId)) continue;
|
||||||
|
last = { toolCallId: result.toolCallId };
|
||||||
|
}
|
||||||
|
if (!last) return [];
|
||||||
|
return [newPlanBlockMessage(last.toolCallId, snapshot(tree, transcript))];
|
||||||
}
|
}
|
||||||
|
|||||||
+4
-3
@@ -9,7 +9,7 @@
|
|||||||
* adapters may import core; core NEVER imports an adapter.
|
* adapters may import core; core NEVER imports an adapter.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import type { UIMessage } from "ai";
|
import type { LhMessage } from "./native-messages.ts";
|
||||||
import type {
|
import type {
|
||||||
ChatRecord,
|
ChatRecord,
|
||||||
ChatSettings,
|
ChatSettings,
|
||||||
@@ -39,8 +39,9 @@ export interface ChatStore {
|
|||||||
getChat(id: string): ChatRecord | null;
|
getChat(id: string): ChatRecord | null;
|
||||||
/** All sessions, newest activity first (the sidebar's list view). */
|
/** All sessions, newest activity first (the sidebar's list view). */
|
||||||
listChats(): ChatSummary[];
|
listChats(): ChatSummary[];
|
||||||
/** Persist the full message list (client state + new response) after a turn. */
|
/** Persist the full native message list (the record IS the agent
|
||||||
saveMessages(id: string, messages: UIMessage[]): void;
|
* state — Wave B, owner-approved 2026-09-26). */
|
||||||
|
saveMessages(id: string, messages: LhMessage[]): void;
|
||||||
/** Persist the session plan tree (plan tools call this in execute). */
|
/** Persist the session plan tree (plan tools call this in execute). */
|
||||||
saveTree(id: string, tree: PlanTree): void;
|
saveTree(id: string, tree: PlanTree): void;
|
||||||
/** Append one usage entry to the record's turn log (the money channel,
|
/** Append one usage entry to the record's turn log (the money channel,
|
||||||
|
|||||||
@@ -0,0 +1,199 @@
|
|||||||
|
/**
|
||||||
|
* THE ONE-WAY PROJECTION (Wave B, owner-approved 2026-09-26): native record
|
||||||
|
* messages → AI-SDK UIMessages. The record is pi-native history; every
|
||||||
|
* existing consumer — GET /api/chats/[id], the client's renderer, phase/
|
||||||
|
* pending/coverage/extraction scanners — reads THIS projection until Wave
|
||||||
|
* D replaces the client dialect. One direction only: nothing ever converts
|
||||||
|
* UIMessages back into native messages (the lossy record-adapter direction
|
||||||
|
* is deleted wholesale).
|
||||||
|
*
|
||||||
|
* GROUPING RULE (the golden master): a turn's UIMessage shape is one
|
||||||
|
* assistant message per GROUP — consecutive assistant + toolResult
|
||||||
|
* messages, bounded by user/marker messages; toolResults attach to the
|
||||||
|
* tool part of their call (searching backward — an answered quiz's result
|
||||||
|
* lands on the part that asked it, even turns later); a group's id is its
|
||||||
|
* FIRST assistant message's id. This reproduces the client's live view
|
||||||
|
* exactly: the continuation merge (start-chunk id = the group's id) and
|
||||||
|
* the settled record converge to the same shape, so the settle sync never
|
||||||
|
* re-renders what streamed.
|
||||||
|
*
|
||||||
|
* Plan blocks (plan-blocks.ts's splice, moved here): a plan-block marker
|
||||||
|
* has no message of its own — its data part splices into the preceding
|
||||||
|
* assistant group, IN FLOW right after the last successful plan_init call
|
||||||
|
* (the presentation moment).
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { UIMessage } from "ai";
|
||||||
|
import type { AssistantMessage, ToolResultMessage } from "@earendil-works/pi-ai";
|
||||||
|
import type { LhMarkerMessage, LhMessage } from "./native-messages.ts";
|
||||||
|
|
||||||
|
import type { SubagentArtifactData, SubagentResultMeta } from "./contracts/jobs.ts";
|
||||||
|
import type { PlanBlockData, PlanVerdictData } from "./contracts/chat.ts";
|
||||||
|
|
||||||
|
/** Loose part shapes the projection emits (the client's part shapes). */
|
||||||
|
type Part = Record<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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+107
-105
@@ -1,4 +1,3 @@
|
|||||||
import type { UIMessage } from "ai";
|
|
||||||
import type { Usage } from "@earendil-works/pi-ai";
|
import type { Usage } from "@earendil-works/pi-ai";
|
||||||
import { createProberAgent, createTeacherAgent, type AgentBuildOptions, type TeachingAgent } from "../agent/teacher-agent.ts";
|
import { createProberAgent, createTeacherAgent, type AgentBuildOptions, type TeachingAgent } from "../agent/teacher-agent.ts";
|
||||||
import { piEngineForSpec, type PiEngine } from "../agent/models.ts";
|
import { piEngineForSpec, type PiEngine } from "../agent/models.ts";
|
||||||
@@ -6,7 +5,6 @@ import { createJobRunner } from "../agent/subagents/runner.ts";
|
|||||||
import { createExtractionPipeline } from "../agent/subagents/extraction.ts";
|
import { createExtractionPipeline } from "../agent/subagents/extraction.ts";
|
||||||
import type { ModelRole } from "../../config.ts";
|
import type { ModelRole } from "../../config.ts";
|
||||||
import { claimSteers, deliverJobResult, setJobRespawn, setTurnRunner } from "./jobs.ts";
|
import { claimSteers, deliverJobResult, setJobRespawn, setTurnRunner } from "./jobs.ts";
|
||||||
import { recordToPiMessages } from "./pi/record-adapter.ts";
|
|
||||||
import { piTurnResponse, runPiTurn } from "./pi/ui-stream.ts";
|
import { piTurnResponse, runPiTurn } from "./pi/ui-stream.ts";
|
||||||
import { effectiveSettings, stores } from "./stores.ts";
|
import { effectiveSettings, stores } from "./stores.ts";
|
||||||
import { searchAvailable, searchProvider } from "./search.ts";
|
import { searchAvailable, searchProvider } from "./search.ts";
|
||||||
@@ -20,26 +18,35 @@ import {
|
|||||||
import { modelsNotConfigured, providerKeyMissing as providerKeyMissingEnvelope, type ProviderKeyMissing } from "../core/settings.ts";
|
import { modelsNotConfigured, providerKeyMissing as providerKeyMissingEnvelope, type ProviderKeyMissing } from "../core/settings.ts";
|
||||||
import { beginTurn, endTurn } from "./turn-liveness.ts";
|
import { beginTurn, endTurn } from "./turn-liveness.ts";
|
||||||
import { registerTurn, unregisterTurn } from "./turn-runs.ts";
|
import { registerTurn, unregisterTurn } from "./turn-runs.ts";
|
||||||
import { persistTurn, triggerExtraction, withMessageIds } from "./turn-service.ts";
|
import { persistTurn, triggerExtraction } from "./turn-service.ts";
|
||||||
|
import { hasProbeDone } from "../core/native-messages.ts";
|
||||||
|
import { asAgentMessages } from "../core/native-messages.ts";
|
||||||
|
import {
|
||||||
|
ChatInputError,
|
||||||
|
inputAppends,
|
||||||
|
parseChatInput,
|
||||||
|
type TurnInput,
|
||||||
|
} from "../core/input-events.ts";
|
||||||
|
import { steerDelivered } from "../core/contracts.ts";
|
||||||
import type { CallUsage } from "../core/contracts.ts";
|
import type { CallUsage } from "../core/contracts.ts";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* One chat turn: run the teaching agent over the client's message list and
|
* One chat turn over the server-owned native record. Framework-free (plain
|
||||||
* stream the UI message stream back. Framework-free (plain Request/Response)
|
* Request/Response) so tests can drive it directly with a scripted faux
|
||||||
* so tests can drive it directly with a scripted faux engine — the Next route
|
* engine — the Next route handler in app/api/chat is a one-line wrapper.
|
||||||
* handler in app/api/chat is a one-line wrapper.
|
*
|
||||||
|
* THE INPUT-EVENTS CONTRACT (Wave B, owner ruling 2026-09-26): POST /api/chat
|
||||||
|
* carries {chatId, input} — one explicit event (text / answer / verdict /
|
||||||
|
* continue), never a client transcript. The server owns the record; this
|
||||||
|
* module is the TURN COMPOSITION ROOT: it turns the event into native
|
||||||
|
* record appends (core/input-events.ts), builds the agent from the record
|
||||||
|
* (the record IS the agent state — state.messages = record.messages,
|
||||||
|
* continue()), and runs the turn as a background object (pi/ui-stream.ts).
|
||||||
|
* Wake-pipeline steers ride the same composition (TurnInput's steer kind).
|
||||||
*
|
*
|
||||||
* This module is the TURN COMPOSITION ROOT: it wires the storage ports, the
|
|
||||||
* job runner + wake pipeline, the teacher tools, and the turn services
|
|
||||||
* (turn-service.ts) together — all actual work lives behind those seams.
|
|
||||||
* THE SAME COMPOSITION serves both entry points: client requests and the
|
* THE SAME COMPOSITION serves both entry points: client requests and the
|
||||||
* wake pipeline's server-side steers (src/server/jobs.ts) — one code path
|
* wake pipeline's server-side steers (src/server/jobs.ts) — one code path
|
||||||
* for write-ahead persistence, turn liveness, and the turn services.
|
* for write-ahead persistence, turn liveness, and the turn services.
|
||||||
*
|
|
||||||
* Engine note (docs/pi-engine-swap.md): the agent is STATELESS per turn —
|
|
||||||
* built fresh from the persisted record (recordToPiMessages), run by the
|
|
||||||
* bridge (pi/ui-stream.ts), discarded at turn end. The record is the only
|
|
||||||
* source of truth.
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
const jobRunner = createJobRunner({
|
const jobRunner = createJobRunner({
|
||||||
@@ -82,18 +89,12 @@ export const extraction = createExtractionPipeline({
|
|||||||
providerKeyPresent(provider) ? null : providerKeyMissingEnvelope(provider, [tier]),
|
providerKeyPresent(provider) ? null : providerKeyMissingEnvelope(provider, [tier]),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
// Wake-pipeline wiring (contracts.ts "THE ASYNC PROTOCOL" §4): the pipeline
|
// Wake-pipeline wiring (contracts.ts "THE ASYNC PROTOCOL" §4): the pipeline
|
||||||
// fires its server-side teacher turns through THIS composition root, and
|
// delivers its steers through THIS composition root (one event: the steer
|
||||||
// crash recovery re-spawns orphaned sub-agent loops through THIS runner.
|
// marker, appended natively under the turn claim), and crash recovery
|
||||||
setTurnRunner(async (chatId, messages) => {
|
// re-spawns orphaned sub-agent loops through THIS runner.
|
||||||
const res = await handleChatRequest(
|
setTurnRunner(async (chatId, input) => {
|
||||||
new Request("http://localhost/api/chat", {
|
const res = await handleChatInput(chatId, input);
|
||||||
method: "POST",
|
|
||||||
headers: { "Content-Type": "application/json" },
|
|
||||||
body: JSON.stringify({ chatId, messages }),
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
// Drive the stream to completion server-side — nobody else will read it,
|
// Drive the stream to completion server-side — nobody else will read it,
|
||||||
// and onFinish (persist, extraction, endTurn) only runs when it drains.
|
// and onFinish (persist, extraction, endTurn) only runs when it drains.
|
||||||
await res.arrayBuffer().catch(() => undefined);
|
await res.arrayBuffer().catch(() => undefined);
|
||||||
@@ -110,39 +111,44 @@ function resolveEngine(role: ModelRole): PiEngine | undefined {
|
|||||||
|
|
||||||
setJobRespawn((jobId) => void jobRunner.runJob(jobId));
|
setJobRespawn((jobId) => void jobRunner.runJob(jobId));
|
||||||
|
|
||||||
/** The message-shape gate the SDK's UIMessageValidator owned: a malformed
|
/** The route-facing entry: parse {chatId, input} and run one turn. */
|
||||||
* body must fail BEFORE the stream exists — the path where no onFinish will
|
|
||||||
* ever run (the catch clears the turn registry so the chat never locks). */
|
|
||||||
function assertValidUiMessages(messages: UIMessage[]): void {
|
|
||||||
for (const m of messages) {
|
|
||||||
if (m.role !== "user" && m.role !== "assistant" && m.role !== "system") {
|
|
||||||
throw new Error(`invalid message role: ${String((m as { role?: unknown }).role)}`);
|
|
||||||
}
|
|
||||||
if (!Array.isArray(m.parts)) throw new Error("message has no parts array");
|
|
||||||
for (const p of m.parts) {
|
|
||||||
if (p.type === "text" && typeof (p as { text?: unknown }).text !== "string") {
|
|
||||||
throw new Error("text part has no text");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function handleChatRequest(
|
export async function handleChatRequest(
|
||||||
req: Request,
|
req: Request,
|
||||||
buildAgent?: (outgoing: UIMessage[]) => TeachingAgent,
|
buildAgent?: (opts: AgentBuildOptions & { teaching: boolean }) => TeachingAgent,
|
||||||
): Promise<Response> {
|
): Promise<Response> {
|
||||||
let body: { chatId?: unknown; messages?: unknown };
|
let body: { chatId?: unknown; input?: unknown };
|
||||||
try {
|
try {
|
||||||
body = await req.json();
|
body = await req.json();
|
||||||
} catch {
|
} catch {
|
||||||
return Response.json({ error: "Invalid JSON body" }, { status: 400 });
|
return Response.json({ error: "Invalid JSON body" }, { status: 400 });
|
||||||
}
|
}
|
||||||
const chatId = typeof body.chatId === "string" ? body.chatId : "";
|
const chatId = typeof body.chatId === "string" ? body.chatId : "";
|
||||||
if (!chatId || !stores.chatStore.getChat(chatId)) {
|
if (!chatId) return Response.json({ error: "Unknown chat" }, { status: 404 });
|
||||||
return Response.json({ error: "Unknown chat" }, { status: 404 });
|
let input: TurnInput;
|
||||||
|
try {
|
||||||
|
input = parseChatInput(body.input);
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof ChatInputError) {
|
||||||
|
return Response.json({ error: err.message }, { status: 400 });
|
||||||
}
|
}
|
||||||
if (!Array.isArray(body.messages)) {
|
throw err;
|
||||||
return Response.json({ error: "messages must be an array" }, { status: 400 });
|
}
|
||||||
|
return handleChatInput(chatId, input, buildAgent);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The turn composition: one input event → native record appends → one
|
||||||
|
* agent turn as a background object. Also the wake pipeline's entry
|
||||||
|
* (TurnInput's steer kind appends the marker under the same claim).
|
||||||
|
*/
|
||||||
|
export async function handleChatInput(
|
||||||
|
chatId: string,
|
||||||
|
input: TurnInput,
|
||||||
|
buildAgent?: (opts: AgentBuildOptions & { teaching: boolean }) => TeachingAgent,
|
||||||
|
): Promise<Response> {
|
||||||
|
const record = stores.chatStore.getChat(chatId);
|
||||||
|
if (!record) {
|
||||||
|
return Response.json({ error: "Unknown chat" }, { status: 404 });
|
||||||
}
|
}
|
||||||
|
|
||||||
// THE UNIVERSAL TURN GATE (BYOK, owner, 2026-09-22): a turn needs the
|
// THE UNIVERSAL TURN GATE (BYOK, owner, 2026-09-22): a turn needs the
|
||||||
@@ -154,10 +160,7 @@ export async function handleChatRequest(
|
|||||||
// write-ahead persist (nothing durable changes), and every downstream
|
// write-ahead persist (nothing durable changes), and every downstream
|
||||||
// resolution (jobs/extraction gate per-need on their own tiers).
|
// resolution (jobs/extraction gate per-need on their own tiers).
|
||||||
const effective = effectiveSettings();
|
const effective = effectiveSettings();
|
||||||
const conversation = resolveConversationSpec(
|
const conversation = resolveConversationSpec(record.settings, effective.models.strong);
|
||||||
stores.chatStore.getChat(chatId)?.settings,
|
|
||||||
effective.models.strong,
|
|
||||||
);
|
|
||||||
if (!conversation) {
|
if (!conversation) {
|
||||||
const envelope = modelsNotConfigured(["strong"]);
|
const envelope = modelsNotConfigured(["strong"]);
|
||||||
console.info(`[chat ${chatId}] turn refused — ${JSON.stringify(envelope)}`);
|
console.info(`[chat ${chatId}] turn refused — ${JSON.stringify(envelope)}`);
|
||||||
@@ -179,10 +182,27 @@ export async function handleChatRequest(
|
|||||||
return Response.json(envelope, { status: 503 });
|
return Response.json(envelope, { status: 503 });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The input's native appends (pure; the honest 400 for an answer with no
|
||||||
|
// pending question call). Computed BEFORE beginTurn so a refused input
|
||||||
|
// leaves no liveness effects; read against the CURRENT record.
|
||||||
|
let appends;
|
||||||
|
try {
|
||||||
|
appends =
|
||||||
|
input.type === "steer"
|
||||||
|
? steerDelivered(record.messages, input.steer.id)
|
||||||
|
? []
|
||||||
|
: [input.steer]
|
||||||
|
: inputAppends(record, input);
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof ChatInputError) {
|
||||||
|
return Response.json({ error: err.message }, { status: 400 });
|
||||||
|
}
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
|
||||||
// One turn per chat, ever. A second request while a turn runs means a
|
// One turn per chat, ever. A second request while a turn runs means a
|
||||||
// stale/remounted client raced a live one — its message list is outdated,
|
// stale/remounted client raced a live one — letting it through would
|
||||||
// and letting it through would double-run the teacher and clobber the
|
// double-run the teacher and clobber the first turn's persist.
|
||||||
// first turn's persist (saveMessages is last-writer-wins).
|
|
||||||
if (!beginTurn(chatId)) {
|
if (!beginTurn(chatId)) {
|
||||||
console.info(`[chat ${chatId}] turn already in flight — request rejected`);
|
console.info(`[chat ${chatId}] turn already in flight — request rejected`);
|
||||||
return Response.json({ error: "A turn is already running for this session" }, { status: 409 });
|
return Response.json({ error: "A turn is already running for this session" }, { status: 409 });
|
||||||
@@ -190,40 +210,27 @@ export async function handleChatRequest(
|
|||||||
|
|
||||||
// CONTINUATION COALESCING (async protocol §4, the delivery-starvation fix):
|
// CONTINUATION COALESCING (async protocol §4, the delivery-starvation fix):
|
||||||
// steers finished while a quiz chain held the wake gates ride THIS turn —
|
// steers finished while a quiz chain held the wake gates ride THIS turn —
|
||||||
// appended after the learner's answer (conversion-safe: the answer carries
|
// appended after the learner's answer (conversion-safe: the answer is the
|
||||||
// its output). Claiming happens after beginTurn, so this turn owns the
|
// question call's tool result now). Claiming happens after beginTurn, so
|
||||||
// registry and the write-ahead persist below makes the claim durable.
|
// this turn owns the registry and the write-ahead persist below makes the
|
||||||
// The incoming body is passed too: a pump-fired wake turn ALREADY CARRIES
|
// claim durable. Steers already in the transcript (a wake raced in) are
|
||||||
// its steer, so claimSteers finalizes that entry instead of appending a
|
// finalized by the claim itself — the append filter keeps exactly one copy.
|
||||||
// second copy (duplicate-key bug, 2026-09-18).
|
const steers = claimSteers(chatId).filter((s) => !steerDelivered(record.messages, s.id));
|
||||||
const steers = claimSteers(chatId, body.messages as UIMessage[]);
|
|
||||||
const outgoing = (
|
|
||||||
steers.length > 0 ? [...(body.messages as UIMessage[]), ...steers] : body.messages
|
|
||||||
) as UIMessage[];
|
|
||||||
|
|
||||||
// THE SETTINGS VIEW (settings-storage wave, 2026-09-19): resolved fresh
|
// Write-ahead persistence: the input's native appends go on disk BEFORE
|
||||||
// per turn (the gate above owns the read) — graph gates, tool toggles,
|
// the turn starts — the learner's answer is durable the instant the
|
||||||
// and the record's chat defaults all read from ONE small file.
|
// request arrives, and any remount, second tab, or server crash reads
|
||||||
|
// truth instead of the pre-answer record.
|
||||||
// Write-ahead persistence: the request body IS the client's canonical
|
const merged = [...record.messages, ...appends, ...steers];
|
||||||
// state, so it goes on disk BEFORE the stream starts. The learner's answer
|
|
||||||
// is durable the instant the request arrives — any remount, second tab, or
|
|
||||||
// server crash sees truth instead of the pre-answer record. (A client that
|
|
||||||
// returns mid-turn reads turnInFlight from the registry and re-attaches by
|
|
||||||
// polling instead of resurrecting the stale transcript.)
|
|
||||||
try {
|
try {
|
||||||
stores.chatStore.saveMessages(chatId, outgoing);
|
stores.chatStore.saveMessages(chatId, merged);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(`[chat ${chatId}] write-ahead persist failed:`, err);
|
console.error(`[chat ${chatId}] write-ahead persist failed:`, err);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
// MONEY CHANNEL (wave 4): one collector per request, fed by the agent's
|
// MONEY CHANNEL (wave 4): one collector per request, fed by the agent's
|
||||||
// message_end events. Every model call's usage lands in the chat record's
|
// message_end events. Every model call's usage lands in the chat record's
|
||||||
// append-only turns log at onFinish — idempotent per turn id, so a
|
// append-only turns log at onFinish — idempotent per turn id.
|
||||||
// replayed persist appends once while continuation turns (which share a
|
|
||||||
// response message id) each keep their usage.
|
|
||||||
const turnUsageId = crypto.randomUUID();
|
const turnUsageId = crypto.randomUUID();
|
||||||
const steps: CallUsage[] = [];
|
const steps: CallUsage[] = [];
|
||||||
const collectUsage = (usage: Usage) => {
|
const collectUsage = (usage: Usage) => {
|
||||||
@@ -238,13 +245,12 @@ export async function handleChatRequest(
|
|||||||
};
|
};
|
||||||
|
|
||||||
// AGENT CONSTRUCTION BRANCH (docs/probe-teach-split.md §2): the transcript
|
// AGENT CONSTRUCTION BRANCH (docs/probe-teach-split.md §2): the transcript
|
||||||
// is the seam — a probe_done tool part (any state) means the probe is
|
// is the seam — a probe_done call in the record means the probe is
|
||||||
// concluded: build the TEACHER (planning + the full toolset). Otherwise
|
// concluded: build the TEACHER (planning + the full toolset). Otherwise
|
||||||
// the session is still probing: build the PROBER (quiz/ask + probe_done).
|
// the session is still probing: build the PROBER (quiz/ask + probe_done).
|
||||||
// Both share this whole composition (write-ahead persist, liveness,
|
// Both share this whole composition (write-ahead persist, liveness,
|
||||||
// steers, usage, extraction) — only the agent differs.
|
// steers, usage, extraction) — only the agent differs.
|
||||||
const teaching = outgoing.some((m) => m.parts.some((p) => p.type === "tool-probe_done"));
|
const teaching = hasProbeDone(merged);
|
||||||
const role: ModelRole = teaching ? "teacher" : "prober";
|
|
||||||
// BYOK (owner, 2026-09-22): the conversation chain was resolved by the
|
// BYOK (owner, 2026-09-22): the conversation chain was resolved by the
|
||||||
// turn gate above (chat.model override → strong slot → refused) — the
|
// turn gate above (chat.model override → strong slot → refused) — the
|
||||||
// engine build is pure from here; thinking levels clamp natively.
|
// engine build is pure from here; thinking levels clamp natively.
|
||||||
@@ -252,19 +258,18 @@ export async function handleChatRequest(
|
|||||||
// Provider-qualified stamp (model slots wave): matches the price table's
|
// Provider-qualified stamp (model slots wave): matches the price table's
|
||||||
// provider:id keys.
|
// provider:id keys.
|
||||||
const modelId = `${engine.model.provider}:${engine.model.id}`;
|
const modelId = `${engine.model.provider}:${engine.model.id}`;
|
||||||
// Tests inject a factory: the agent is stateless per turn (docs/
|
// Tests inject a factory: the agent is stateless per turn, so each
|
||||||
// pi-engine-swap.md §2), so each request builds a fresh one off the
|
// request builds a fresh one off the record — the factory mirrors that
|
||||||
// transcript — the factory mirrors that seam exactly.
|
// seam exactly. Wave B: the record IS the agent state (native messages;
|
||||||
|
// the record's id field strips at the wire in toLlmMessages).
|
||||||
// CROSS-STREAM SEAM (P2 reconciliation, 2026-09-19): `toolToggles`
|
// CROSS-STREAM SEAM (P2 reconciliation, 2026-09-19): `toolToggles`
|
||||||
// rides the build opts as the user-level DELTA map (absent key =
|
// rides the build opts as the user-level DELTA map (absent key =
|
||||||
// enabled). The definitive toggleable set is TOGGLEABLE_TOOLS — the
|
// enabled). The definitive toggleable set is TOGGLEABLE_TOOLS — the
|
||||||
// sub-agent JOB tools (research/demo/visual/pdf; adjudicated with the
|
// sub-agent JOB tools (research/demo/visual/pdf; adjudicated with the
|
||||||
// user: quiz/ask/fs/plan/probe_done are always-on). First-class on
|
// user: quiz/ask/fs/plan/probe_done are always-on).
|
||||||
// AgentBuildOptions; the factories drop anything explicitly false and
|
|
||||||
// the settings write side rejects unknown keys.
|
|
||||||
const agentOpts: AgentBuildOptions = {
|
const agentOpts: AgentBuildOptions = {
|
||||||
engine,
|
engine,
|
||||||
messages: recordToPiMessages(outgoing),
|
messages: asAgentMessages(merged),
|
||||||
onUsage: collectUsage,
|
onUsage: collectUsage,
|
||||||
toolToggles: effective.agent.tools,
|
toolToggles: effective.agent.tools,
|
||||||
// The prompt-override switches (P2, 2026-09-19): same resolution
|
// The prompt-override switches (P2, 2026-09-19): same resolution
|
||||||
@@ -273,7 +278,7 @@ export async function handleChatRequest(
|
|||||||
promptSwitches: effective.agent.prompts,
|
promptSwitches: effective.agent.prompts,
|
||||||
};
|
};
|
||||||
const constructed: TeachingAgent = buildAgent
|
const constructed: TeachingAgent = buildAgent
|
||||||
? buildAgent(outgoing)
|
? buildAgent({ ...agentOpts, teaching })
|
||||||
: teaching
|
: teaching
|
||||||
? createTeacherAgent(
|
? createTeacherAgent(
|
||||||
{
|
{
|
||||||
@@ -310,7 +315,6 @@ export async function handleChatRequest(
|
|||||||
// transcript, not the money channel); when it's ours, usage flows through
|
// transcript, not the money channel); when it's ours, usage flows through
|
||||||
// the factory's subscription (wired above via onUsage).
|
// the factory's subscription (wired above via onUsage).
|
||||||
try {
|
try {
|
||||||
assertValidUiMessages(outgoing);
|
|
||||||
// THE TURN AS A BACKGROUND OBJECT (owner ruling, 2026-09-25):
|
// THE TURN AS A BACKGROUND OBJECT (owner ruling, 2026-09-25):
|
||||||
// runPiTurn executes decoupled from this response — the POST stream
|
// runPiTurn executes decoupled from this response — the POST stream
|
||||||
// is just the FIRST subscriber (a view). A client disconnect can no
|
// is just the FIRST subscriber (a view). A client disconnect can no
|
||||||
@@ -318,7 +322,7 @@ export async function handleChatRequest(
|
|||||||
// late views reattach via GET /api/chats/[id]/stream.
|
// late views reattach via GET /api/chats/[id]/stream.
|
||||||
const handle = runPiTurn({
|
const handle = runPiTurn({
|
||||||
agent: constructed,
|
agent: constructed,
|
||||||
uiMessages: outgoing,
|
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
|
||||||
@@ -327,14 +331,14 @@ export async function handleChatRequest(
|
|||||||
console.error(`[chat ${chatId}] turn error:`, error);
|
console.error(`[chat ${chatId}] turn error:`, error);
|
||||||
return "An error occurred.";
|
return "An error occurred.";
|
||||||
},
|
},
|
||||||
onFinish: (messages, isAborted) => {
|
onFinish: (turnMessages, isAborted) => {
|
||||||
persistTurn(stores.chatStore, chatId, messages as UIMessage[], isAborted);
|
// Wave B: the record write is the agent's own final state —
|
||||||
|
// the new messages are the slice past the merged base.
|
||||||
|
const stamped = persistTurn(stores.chatStore, chatId, merged.length, turnMessages, isAborted);
|
||||||
// usage log append (idempotent per turn id) — the stats bar
|
// usage log append (idempotent per turn id) — the stats bar
|
||||||
// refolds this on the client after the turn settles. messageId
|
// refolds this on the client after the turn settles. The
|
||||||
// comes from the id-FILLED list: the bridge's response message
|
// provenance message is the turn's FIRST new assistant message.
|
||||||
// already carries its id; the re-fill here is idempotent.
|
const response = stamped.find((m) => (m as { role?: string }).role === "assistant");
|
||||||
const filled = withMessageIds(messages as UIMessage[]);
|
|
||||||
const response = [...filled].reverse().find((m) => m.role === "assistant");
|
|
||||||
if (response && steps.length > 0) {
|
if (response && steps.length > 0) {
|
||||||
stores.chatStore.appendTurnUsage(chatId, {
|
stores.chatStore.appendTurnUsage(chatId, {
|
||||||
id: turnUsageId,
|
id: turnUsageId,
|
||||||
@@ -345,9 +349,7 @@ export async function handleChatRequest(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
// GRAPH GATE (settings-storage wave, 2026-09-19): the write channel —
|
// GRAPH GATE (settings-storage wave, 2026-09-19): the write channel —
|
||||||
// extraction, its auditor — dies with the master switch. (The deeper
|
// extraction, its auditor — dies with the master switch.
|
||||||
// pipeline knobs — embedding/clustering/labeling — gate inside the
|
|
||||||
// pipeline and its consumers, later waves, via the same resolved view.)
|
|
||||||
if (extractionEnabled(effective.graph)) {
|
if (extractionEnabled(effective.graph)) {
|
||||||
// Fresh per-turn read of the graph section's chunk budget
|
// Fresh per-turn read of the graph section's chunk budget
|
||||||
// (extraction-UX redesign, 2026-09-24): the trigger compares
|
// (extraction-UX redesign, 2026-09-24): the trigger compares
|
||||||
|
|||||||
+4
-1
@@ -1,5 +1,6 @@
|
|||||||
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 type { UIMessage } from "ai";
|
||||||
@@ -141,7 +142,9 @@ export function sessionCoverage(chatId: string): SessionCoverage | null {
|
|||||||
const record = stores.chatStore.getChat(chatId);
|
const record = stores.chatStore.getChat(chatId);
|
||||||
if (!record) return null;
|
if (!record) return null;
|
||||||
const entries = stores.graphStore.readCoverage();
|
const entries = stores.graphStore.readCoverage();
|
||||||
const cov = coverageOf(record.messages as UIMessage[], entries, chatId);
|
// Wave B: the record is native history — coverage reads through the
|
||||||
|
// projection (same view the extraction pipeline folds).
|
||||||
|
const cov = coverageOf(projectNative(record.messages), entries, chatId);
|
||||||
let lastFoldedAt: string | null = null;
|
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;
|
||||||
|
|||||||
+36
-39
@@ -31,7 +31,6 @@
|
|||||||
* be a different object in each).
|
* be a different object in each).
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import type { UIMessage } from "ai";
|
|
||||||
import {
|
import {
|
||||||
buildAbortSteer,
|
buildAbortSteer,
|
||||||
buildSubagentSteer,
|
buildSubagentSteer,
|
||||||
@@ -40,13 +39,16 @@ import {
|
|||||||
type JobRecord,
|
type JobRecord,
|
||||||
type SteerMessage,
|
type SteerMessage,
|
||||||
} from "../core/contracts.ts";
|
} from "../core/contracts.ts";
|
||||||
import { findPendingToolCall } from "../core/pending.ts";
|
import type { TurnInput } from "../core/input-events.ts";
|
||||||
|
import { danglingQuestionOf } from "../core/native-messages.ts";
|
||||||
import { fireJobAbort, isJobLive } from "../agent/subagents/aborts.ts";
|
import { fireJobAbort, isJobLive } from "../agent/subagents/aborts.ts";
|
||||||
import { stores } from "./stores.ts";
|
import { stores } from "./stores.ts";
|
||||||
import { isTurnRunning } from "./turn-liveness.ts";
|
import { isTurnRunning } from "./turn-liveness.ts";
|
||||||
|
|
||||||
/** Fires one server-side teacher turn: (chatId, messages) → HTTP-ish status. */
|
/** Fires one server-side teacher turn: (chatId, input) → HTTP-ish status.
|
||||||
export type TurnRunner = (chatId: string, messages: UIMessage[]) => Promise<number>;
|
* Wave B: the input is the STEER EVENT — the handler appends the marker
|
||||||
|
* natively under its own turn claim (one composition for every turn). */
|
||||||
|
export type TurnRunner = (chatId: string, input: TurnInput) => Promise<number>;
|
||||||
|
|
||||||
/** Re-runs a job's sub-agent loop (crash recovery for orphaned jobs). */
|
/** Re-runs a job's sub-agent loop (crash recovery for orphaned jobs). */
|
||||||
export type JobRespawn = (jobId: string) => void;
|
export type JobRespawn = (jobId: string) => void;
|
||||||
@@ -110,10 +112,11 @@ function delay(ms: number): Promise<void> {
|
|||||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The job a steer belongs to, if it is a job steer (result/abort/context);
|
/** The job a steer belongs to, if it is a job steer (result/abort);
|
||||||
* probe-handoff steers have no job — null. */
|
* probe-handoff steers have no job — null. */
|
||||||
function jobIdOf(steer: SteerMessage): string | null {
|
function jobIdOf(steer: SteerMessage): string | null {
|
||||||
return (steer.metadata as { subagentResult?: { jobId: string } }).subagentResult?.jobId ?? null;
|
const details = steer.details as { jobId?: string } | undefined;
|
||||||
|
return typeof details?.jobId === "string" ? details.jobId : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Finalize a delivered steer's job bookkeeping — job steers only. */
|
/** Finalize a delivered steer's job bookkeeping — job steers only. */
|
||||||
@@ -167,32 +170,22 @@ export function deliverJobResult(steer: SteerMessage): Promise<void> {
|
|||||||
* copies of the same steer id (a live React duplicate-key warning the
|
* copies of the same steer id (a live React duplicate-key warning the
|
||||||
* owner hit 2026-09-18; withMessageIds only heals it at onFinish).
|
* owner hit 2026-09-18; withMessageIds only heals it at onFinish).
|
||||||
*/
|
*/
|
||||||
export function claimSteers(
|
export function claimSteers(chatId: string): SteerMessage[] {
|
||||||
chatId: string,
|
|
||||||
incoming?: ReadonlyArray<UIMessage>,
|
|
||||||
): SteerMessage[] {
|
|
||||||
const s = state();
|
const s = state();
|
||||||
const queue = s.queues.get(chatId);
|
const queue = s.queues.get(chatId);
|
||||||
if (!queue || queue.length === 0) return [];
|
if (!queue || queue.length === 0) return [];
|
||||||
const record = stores.chatStore.getChat(chatId);
|
s.queues.delete(chatId);
|
||||||
if (!record) return []; // the handler validated the chat; defensive only
|
// The caller holds the turn registry (chat-handler claims after
|
||||||
const claimed: SteerMessage[] = [];
|
// beginTurn), so the write-ahead persist is imminent and durable:
|
||||||
|
// every queued steer is claimed — its delivery is THIS turn's job.
|
||||||
|
// Steers already in the transcript (a wake raced in) still finalize
|
||||||
|
// here; the handler's append filter (steerDelivered) keeps exactly
|
||||||
|
// one copy in the record.
|
||||||
|
const claimed = queue.map((entry) => entry.steer);
|
||||||
for (const entry of queue) {
|
for (const entry of queue) {
|
||||||
if (
|
|
||||||
steerDelivered(record.messages, entry.steer.id) ||
|
|
||||||
incoming?.some((m) => m.id === entry.steer.id)
|
|
||||||
) {
|
|
||||||
// A wake already landed it — or this turn's own request already
|
|
||||||
// carries it (the pump path): the delivery is done either way,
|
|
||||||
// just finish the bookkeeping. NEVER append twice.
|
|
||||||
finalizeSteer(entry.steer);
|
finalizeSteer(entry.steer);
|
||||||
} else {
|
|
||||||
claimed.push(entry.steer);
|
|
||||||
finalizeSteer(entry.steer); // delivery is now the client turn's job
|
|
||||||
}
|
|
||||||
entry.resolve();
|
entry.resolve();
|
||||||
}
|
}
|
||||||
s.queues.delete(chatId);
|
|
||||||
return claimed;
|
return claimed;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -256,12 +249,13 @@ export function abortJob(jobId: string): AbortOutcome {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function chatIdOf(steer: SteerMessage): string {
|
function chatIdOf(steer: SteerMessage): string {
|
||||||
// The probe handoff carries its chat directly (the job record would
|
// The probe handoff carries its chat directly in the marker details (the
|
||||||
// say it too, but the metadata is the cheap path).
|
// job record would say it too, but the details are the cheap path).
|
||||||
const handoff = (steer.metadata as { probeHandoff?: { chatId: string } }).probeHandoff;
|
if (steer.customType === "probe-handoff") {
|
||||||
if (handoff) return handoff.chatId;
|
return (steer.details as { chatId: string }).chatId;
|
||||||
// The chat is resolvable from the job record; the steer's metadata
|
}
|
||||||
// carries jobId, which indexes the store.
|
// The chat is resolvable from the job record; the steer's details
|
||||||
|
// carry jobId, which indexes the store.
|
||||||
const jobId = jobIdOf(steer);
|
const jobId = jobIdOf(steer);
|
||||||
const job = jobId ? stores.jobStore.getJob(jobId) : undefined;
|
const job = jobId ? stores.jobStore.getJob(jobId) : undefined;
|
||||||
return job?.chatId ?? "";
|
return job?.chatId ?? "";
|
||||||
@@ -334,9 +328,11 @@ async function pump(chatId: string): Promise<void> {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fire the server-side teacher turn. A 409 = a client turn raced
|
// Fire the server-side teacher turn (the steer EVENT: the handler
|
||||||
// in between idle and firing — back off and retry the same steer.
|
// appends the marker natively under its own turn claim). A 409 = a
|
||||||
const status = await s.turnRunner(chatId, [...fresh.messages, entry.steer]).catch((err: unknown) => {
|
// client turn raced in between idle and firing — back off and
|
||||||
|
// retry the same steer.
|
||||||
|
const status = await s.turnRunner(chatId, { type: "steer", steer: entry.steer }).catch((err: unknown) => {
|
||||||
console.error(`[steer ${steerId}] wake turn crashed:`, err);
|
console.error(`[steer ${steerId}] wake turn crashed:`, err);
|
||||||
return -1;
|
return -1;
|
||||||
});
|
});
|
||||||
@@ -387,22 +383,23 @@ function failJob(jobId: string, err: Error): void {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Wait until the chat can take a wake turn: no turn in flight AND no
|
* Wait until the chat can take a wake turn: no turn in flight AND no
|
||||||
* unanswered question part (provider conversion would break). Poll; the
|
* unanswered question call (the record's dangling question — the answer
|
||||||
* learner may take arbitrarily long to answer, and that is correct — the
|
* arrives as its tool result, and no turn may run in the window). Poll;
|
||||||
* steer fires right after their continuation completes.
|
* the learner may take arbitrarily long to answer, and that is correct —
|
||||||
|
* the steer fires right after their answer turn completes.
|
||||||
*/
|
*/
|
||||||
async function waitForIdle(chatId: string): Promise<void> {
|
async function waitForIdle(chatId: string): Promise<void> {
|
||||||
for (;;) {
|
for (;;) {
|
||||||
const record = stores.chatStore.getChat(chatId);
|
const record = stores.chatStore.getChat(chatId);
|
||||||
if (!record) return;
|
if (!record) return;
|
||||||
const blocked = isTurnRunning(chatId) || findPendingToolCall(record.messages) !== null;
|
const blocked = isTurnRunning(chatId) || danglingQuestionOf(record.messages) !== null;
|
||||||
if (!blocked) {
|
if (!blocked) {
|
||||||
await delay(GRACE_MS);
|
await delay(GRACE_MS);
|
||||||
const recheck = stores.chatStore.getChat(chatId);
|
const recheck = stores.chatStore.getChat(chatId);
|
||||||
if (
|
if (
|
||||||
!isTurnRunning(chatId) &&
|
!isTurnRunning(chatId) &&
|
||||||
recheck &&
|
recheck &&
|
||||||
findPendingToolCall(recheck.messages) === null
|
danglingQuestionOf(recheck.messages) === null
|
||||||
) {
|
) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,190 +0,0 @@
|
|||||||
import type { UIMessage } from "ai";
|
|
||||||
import type { AgentMessage } from "@earendil-works/pi-agent-core";
|
|
||||||
import type { AssistantMessage, Message, ToolResultMessage } from "@earendil-works/pi-ai";
|
|
||||||
import { HANDOFF_TEXT, verdictText } from "../../core/contracts/chat.ts";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The record → pi-ai transcript adapter (docs/pi-engine-swap.md §4). The
|
|
||||||
* persisted UIMessage record IS the source of truth; every turn builds a
|
|
||||||
* fresh agent from it (the stateless per-turn decision). This replaces the
|
|
||||||
* SDK's convertToModelMessages with an owned, explicit mapping:
|
|
||||||
*
|
|
||||||
* - user text parts → UserMessage text content.
|
|
||||||
* - SYSTEM DATA PARTS (the custom-message scheme, 2026-09-12): the wake and
|
|
||||||
* verdict messages carry ONLY a marker part — no human text exists to
|
|
||||||
* drop, so the ADAPTER flattens them: data-verdict → verdictText(),
|
|
||||||
* data-probe-handoff → HANDOFF_TEXT (core/contracts/chat.ts owns both —
|
|
||||||
* single source, so the model-facing wording can't drift from the seam).
|
|
||||||
* Other data parts (artifacts, plan blocks) are dropped as before: they
|
|
||||||
* are UI history; the steer briefs ride their text twin (payload, not
|
|
||||||
* boilerplate — those texts are content the sub-agent produced).
|
|
||||||
* - assistant text/reasoning/tool parts → one AssistantMessage whose
|
|
||||||
* content blocks mirror the part order; tool parts become ToolCall
|
|
||||||
* blocks (output-error parts keep their rawInput).
|
|
||||||
* - answered tool parts (output-available) → ToolResultMessage after the
|
|
||||||
* assistant message, JSON-encoded like the model saw it live.
|
|
||||||
* - errored tool parts (output-error) → an isError ToolResultMessage
|
|
||||||
* carrying the validation message (the model's retry context).
|
|
||||||
* - DANGLING calls (input-available, never answered — an aborted turn)
|
|
||||||
* → a synthesized interrupted ToolResultMessage. THE HARD INVARIANT:
|
|
||||||
* an assistant message with tool calls must be followed by tool
|
|
||||||
* results, or providers reject the request outright.
|
|
||||||
*/
|
|
||||||
|
|
||||||
const INTERRUPTED = "The question was interrupted — the learner never answered; continue without holding a result for it.";
|
|
||||||
|
|
||||||
function textOf(part: { text?: string }): string {
|
|
||||||
return typeof part.text === "string" ? part.text : "";
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Encode a tool output the way the model saw it live (string verbatim, object JSON). */
|
|
||||||
function toolResultText(output: unknown): string {
|
|
||||||
return typeof output === "string" ? output : JSON.stringify(output);
|
|
||||||
}
|
|
||||||
|
|
||||||
function zeroUsage() {
|
|
||||||
return {
|
|
||||||
input: 0,
|
|
||||||
output: 0,
|
|
||||||
cacheRead: 0,
|
|
||||||
cacheWrite: 0,
|
|
||||||
totalTokens: 0,
|
|
||||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/** The model-facing text of a marker-only user message, or null when the
|
|
||||||
* message has no model face at all (UI-only data parts). */
|
|
||||||
function systemTextOf(message: { parts: ReadonlyArray<{ type: string; data?: unknown }> }): string | null {
|
|
||||||
for (const part of message.parts) {
|
|
||||||
if (part.type === "data-verdict") {
|
|
||||||
return verdictText((part.data as { accepted?: unknown } | undefined)?.accepted === true);
|
|
||||||
}
|
|
||||||
if (part.type === "data-probe-handoff") return HANDOFF_TEXT;
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function recordToPiMessages(messages: UIMessage[]): Message[] {
|
|
||||||
const out: AgentMessage[] = [];
|
|
||||||
|
|
||||||
for (const message of messages) {
|
|
||||||
if (message.role === "user") {
|
|
||||||
const texts = message.parts
|
|
||||||
.filter((p) => p.type === "text")
|
|
||||||
.map((p) => textOf(p as { text?: string }))
|
|
||||||
.filter((t) => t.length > 0);
|
|
||||||
// The custom-message scheme: a marker-only message (verdict,
|
|
||||||
// handoff) flattens to its canonical model-facing text — the
|
|
||||||
// wording lives in the adapter's reach (core contracts), never
|
|
||||||
// authored as fake human speech in the transcript.
|
|
||||||
if (texts.length === 0) {
|
|
||||||
const flat = systemTextOf(message);
|
|
||||||
if (flat === null) continue; // pure UI data part, no model face
|
|
||||||
texts.push(flat);
|
|
||||||
}
|
|
||||||
out.push({
|
|
||||||
role: "user",
|
|
||||||
content: texts.map((text) => ({ type: "text" as const, text })),
|
|
||||||
timestamp: Date.now(),
|
|
||||||
});
|
|
||||||
} else if (message.role === "assistant") {
|
|
||||||
const content: AssistantMessage["content"] = [];
|
|
||||||
const results: ToolResultMessage[] = [];
|
|
||||||
let answered = false;
|
|
||||||
|
|
||||||
for (const part of message.parts) {
|
|
||||||
if (part.type === "text") {
|
|
||||||
const text = textOf(part);
|
|
||||||
if (text) content.push({ type: "text", text });
|
|
||||||
} else if (part.type === "reasoning") {
|
|
||||||
const text = textOf(part);
|
|
||||||
if (text) content.push({ type: "thinking", thinking: text });
|
|
||||||
} else if (part.type === "step-start") {
|
|
||||||
// presentation-only; the pi loop re-derives turn boundaries
|
|
||||||
} else if (part.type.startsWith("tool-")) {
|
|
||||||
const tool = part.type.slice("tool-".length);
|
|
||||||
const p = part as unknown as {
|
|
||||||
toolCallId: string;
|
|
||||||
state: string;
|
|
||||||
input?: unknown;
|
|
||||||
rawInput?: unknown;
|
|
||||||
output?: unknown;
|
|
||||||
errorText?: string;
|
|
||||||
};
|
|
||||||
// The call block (any state — the model authored it).
|
|
||||||
if (p.state === "input-available" || p.state === "input-streaming" || p.state === "output-available") {
|
|
||||||
content.push({
|
|
||||||
type: "toolCall",
|
|
||||||
id: p.toolCallId,
|
|
||||||
name: tool,
|
|
||||||
arguments: (p.input ?? {}) as Record<string, unknown>,
|
|
||||||
});
|
|
||||||
} else if (p.state === "output-error") {
|
|
||||||
content.push({
|
|
||||||
type: "toolCall",
|
|
||||||
id: p.toolCallId,
|
|
||||||
name: tool,
|
|
||||||
arguments: (p.rawInput ?? p.input ?? {}) as Record<string, unknown>,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
// The result, if any.
|
|
||||||
if (p.state === "output-available") {
|
|
||||||
results.push({
|
|
||||||
role: "toolResult",
|
|
||||||
toolCallId: p.toolCallId,
|
|
||||||
toolName: tool,
|
|
||||||
content: [{ type: "text", text: toolResultText(p.output) }],
|
|
||||||
isError: false,
|
|
||||||
timestamp: Date.now(),
|
|
||||||
});
|
|
||||||
answered = true;
|
|
||||||
} else if (p.state === "output-error") {
|
|
||||||
results.push({
|
|
||||||
role: "toolResult",
|
|
||||||
toolCallId: p.toolCallId,
|
|
||||||
toolName: tool,
|
|
||||||
content: [{ type: "text", text: p.errorText ?? "The tool call failed validation." }],
|
|
||||||
isError: true,
|
|
||||||
timestamp: Date.now(),
|
|
||||||
});
|
|
||||||
answered = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// data-* parts (plan blocks, artifacts, markers) never reach the model.
|
|
||||||
}
|
|
||||||
|
|
||||||
if (content.length === 0) continue; // presentation-only message
|
|
||||||
out.push({
|
|
||||||
role: "assistant",
|
|
||||||
content,
|
|
||||||
api: "openai-completions",
|
|
||||||
provider: "openrouter",
|
|
||||||
model: "replayed",
|
|
||||||
usage: zeroUsage(),
|
|
||||||
stopReason: content[content.length - 1]!.type === "toolCall" ? "toolUse" : "stop",
|
|
||||||
timestamp: Date.now(),
|
|
||||||
});
|
|
||||||
// Dangling calls: synthesize the interrupted result so providers
|
|
||||||
// never see an unanswered tool call (the hard invariant).
|
|
||||||
if (!answered) {
|
|
||||||
for (const block of content) {
|
|
||||||
if (block.type === "toolCall" && !results.some((r) => r.toolCallId === block.id)) {
|
|
||||||
results.push({
|
|
||||||
role: "toolResult",
|
|
||||||
toolCallId: block.id,
|
|
||||||
toolName: block.name,
|
|
||||||
content: [{ type: "text", text: INTERRUPTED }],
|
|
||||||
isError: false,
|
|
||||||
timestamp: Date.now(),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
out.push(...results);
|
|
||||||
}
|
|
||||||
// role "system" never appears in records (system prompt is composed separately).
|
|
||||||
}
|
|
||||||
|
|
||||||
return out as Message[];
|
|
||||||
}
|
|
||||||
+33
-18
@@ -1,6 +1,8 @@
|
|||||||
import type { Agent, AgentEvent } from "@earendil-works/pi-agent-core";
|
import type { Agent, AgentEvent, AgentMessage } from "@earendil-works/pi-agent-core";
|
||||||
import type { UIMessage } from "ai";
|
import type { UIMessage } from "ai";
|
||||||
import { QUESTION_TOOLS } from "../../core/pending.ts";
|
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 UI-stream bridge (docs/pi-engine-swap.md §5): runs one agent turn and
|
||||||
@@ -23,9 +25,10 @@ import { QUESTION_TOOLS } from "../../core/pending.ts";
|
|||||||
* tool-input-error instead and NEVER becomes input-available, so a
|
* tool-input-error instead and NEVER becomes input-available, so a
|
||||||
* malformed question can never mount the dock (the live modal-realism
|
* malformed question can never mount the dock (the live modal-realism
|
||||||
* crash: GLM double-encoded `options` as a JSON string).
|
* crash: GLM double-encoded `options` as a JSON string).
|
||||||
* - The persisted message is assembled from the SAME parts the client
|
* - The WIRE assembly (below) is the client view only (Wave B): the
|
||||||
* assembles (the processUIMessageStream shapes), so every downstream
|
* record write is the agent's own native final state — the projection
|
||||||
* consumer (phases, plan blocks, pending, extraction) is unchanged.
|
* (core/projection.ts) reproduces the client's part shapes for every
|
||||||
|
* consumer (phases, plan blocks, pending, extraction) until Wave D.
|
||||||
*
|
*
|
||||||
* BACKGROUND-TURN RUNNER (owner ruling, 2026-09-25): a turn executes as a
|
* 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 —
|
||||||
@@ -42,12 +45,17 @@ type Chunk = Record<string, unknown> & { type: 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 outgoing request messages (write-ahead persisted already). */
|
/** The turn's base: the native record + this turn's appends
|
||||||
uiMessages: UIMessage[];
|
* (write-ahead persisted already). Wave B: the agent's context and
|
||||||
|
* the record share one shape; onFinish receives the full agent state
|
||||||
|
* and the caller persists the slice past base.length. */
|
||||||
|
base: LhMessage[];
|
||||||
/** The handler's error-unmasking policy (validation messages stay readable). */
|
/** 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.
|
||||||
onFinish: (messages: UIMessage[], isAborted: boolean) => void;
|
* Receives the agent's FULL final state (native messages) — the turn's
|
||||||
|
* new messages are the slice past base.length. */
|
||||||
|
onFinish: (messages: AgentMessage[], isAborted: boolean) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** One view onto a running turn: push() replays/streams a chunk, done()
|
/** One view onto a running turn: push() replays/streams a chunk, done()
|
||||||
@@ -117,15 +125,22 @@ interface ToolPart {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function runPiTurn(spec: PiTurnSpec): PiTurnHandle {
|
export function runPiTurn(spec: PiTurnSpec): PiTurnHandle {
|
||||||
const { agent, uiMessages, onFinish } = spec;
|
const { agent, base, onFinish } = spec;
|
||||||
|
|
||||||
// The streaming target: continuation (same id, parts appended to a copy
|
// The streaming target: continuation (same id, parts appended to a copy
|
||||||
// of the existing message) or a fresh assistant message.
|
// of the existing message) or a fresh assistant message. The continuation
|
||||||
const last = uiMessages[uiMessages.length - 1];
|
// rule is the CLIENT's: it keys on the last UIMessage's id — read through
|
||||||
const continuing = last?.role === "assistant";
|
// 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 =
|
const messageId =
|
||||||
(continuing && last.id) || `msg_${Date.now().toString(36)}_${crypto.randomUUID().slice(0, 8)}`;
|
(continuing && last.id) || `msg_${Date.now().toString(36)}_${crypto.randomUUID().slice(0, 8)}`;
|
||||||
const baseParts: UIMessage["parts"] = continuing ? [...last.parts] : [];
|
const baseParts: UIMessage["parts"] = continuing && last.parts ? [...last.parts] : [];
|
||||||
const parts: UIMessage["parts"] = [...baseParts];
|
const parts: UIMessage["parts"] = [...baseParts];
|
||||||
|
|
||||||
// Stable, collision-free part ids across continuation runs (reasoning
|
// Stable, collision-free part ids across continuation runs (reasoning
|
||||||
@@ -174,11 +189,11 @@ export function runPiTurn(spec: PiTurnSpec): PiTurnHandle {
|
|||||||
send({ type: "start", messageId });
|
send({ type: "start", messageId });
|
||||||
|
|
||||||
const settleWith = () => {
|
const settleWith = () => {
|
||||||
const assembled: UIMessage = { id: messageId, role: "assistant", parts };
|
// Wave B: the record write is the agent's OWN final state (native
|
||||||
// The SDK's onFinish shape: the input copy stays, the extended
|
// history — signatures, usage, provider stamps all real); the wire
|
||||||
// response copy appends (persistTurn's withMessageIds dedupe
|
// assembly above served the client view only. Copy the array: the
|
||||||
// keeps the last occurrence).
|
// agent may outlive this call's read.
|
||||||
onFinish([...uiMessages, assembled], sawAbort);
|
onFinish([...agent.state.messages], sawAbort);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handle = (event: AgentEvent) => {
|
const handle = (event: AgentEvent) => {
|
||||||
|
|||||||
+68
-57
@@ -4,69 +4,85 @@
|
|||||||
* implementing. Each takes its dependencies explicitly (storage ports, the
|
* implementing. Each takes its dependencies explicitly (storage ports, the
|
||||||
* job runner) — no module here imports an adapter.
|
* job runner) — no module here imports an adapter.
|
||||||
*
|
*
|
||||||
* persistTurn — the canonical state write (client + agent messages)
|
* persistTurn — the canonical state write (Wave B: native history;
|
||||||
|
* id stamping, placeholder filtering, plan-block
|
||||||
|
* emission, abort-partial persistence)
|
||||||
* triggerExtraction — the graph write channel: fires the extraction
|
* triggerExtraction — the graph write channel: fires the extraction
|
||||||
* pipeline (the sole graph writer) once a chat
|
* pipeline (the sole graph writer) once a chat
|
||||||
* crosses the v1 quiz-answer threshold. The trigger
|
* crosses the token-budget threshold.
|
||||||
* mechanism is a v1 placeholder — this seam is the
|
|
||||||
* ONLY thing to swap when a better one lands.
|
|
||||||
*
|
*
|
||||||
* Logs are the minimal-observability baseline: one line per failure, tagged
|
* Logs are the minimal-observability baseline: one line per failure, tagged
|
||||||
* [chat <id>] — structured logging lands behind these seams later.
|
* [chat <id>] — structured logging lands behind these seams later.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import type { UIMessage } from "ai";
|
import type { AgentMessage } from "@earendil-works/pi-agent-core";
|
||||||
import { withPlanBlocks } from "../core/plan-blocks.ts";
|
import type { ToolResultMessage } from "@earendil-works/pi-ai";
|
||||||
|
import type { LhMessage } from "../core/native-messages.ts";
|
||||||
|
import { newMessageId } from "../core/native-messages.ts";
|
||||||
|
import { QUESTION_TOOLS } from "../core/pending.ts";
|
||||||
|
import { planBlocksFor } from "../core/plan-blocks.ts";
|
||||||
|
import { projectNative } from "../core/projection.ts";
|
||||||
import { coverageOf } from "../core/coverage.ts";
|
import { 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";
|
||||||
import { ModelsNotConfiguredError, ProviderKeyMissingError } from "../core/settings.ts";
|
import { ModelsNotConfiguredError, ProviderKeyMissingError } from "../core/settings.ts";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The canonical state write: the client owns the message list between turns
|
* The canonical state write (Wave B, owner-approved 2026-09-26): append the
|
||||||
* (useChat); this persists client messages + the agent response. Persists
|
* turn's own messages — the agent loop's native history, provider/model/
|
||||||
* even when aborted — the partial transcript (including any pending
|
* usage/thinkingSignature stamped by pi itself. Persists even when aborted
|
||||||
* question part) is what makes resume work.
|
* — the partial transcript (including a pending question's unanswered
|
||||||
|
* call) is what makes resume work.
|
||||||
|
*
|
||||||
|
* - Id stamping: pi messages carry no id; the record's identity needs one
|
||||||
|
* per message (client keys, append-only adoption, the continuation
|
||||||
|
* seam). Minted here, at the single canonical write.
|
||||||
|
* - Placeholder filtering: a cleanly-posed question's synthetic
|
||||||
|
* "question posed" result is an in-memory loop device (Wave A's
|
||||||
|
* terminate) — NEVER persisted (fork 1: the record ends at the
|
||||||
|
* unanswered call; the learner's answer arrives as the real tool
|
||||||
|
* result). Errored question calls DO persist — the model's retry
|
||||||
|
* context.
|
||||||
|
* - Plan blocks: one frozen snapshot per successful plan_init call,
|
||||||
|
* idempotent (persist replays are safe).
|
||||||
|
*
|
||||||
|
* Returns the stamped new messages (usage provenance reads them).
|
||||||
*/
|
*/
|
||||||
/**
|
export function persistTurn(
|
||||||
* The SDK assigns assistant-message ids CLIENT-side (useChat); the server's
|
chatStore: ChatStore,
|
||||||
* onFinish synthesis leaves them EMPTY. A message persisted only server-side
|
chatId: string,
|
||||||
* (the last turn's, or a wake turn's — nobody re-POSTs it with a client id)
|
baseCount: number,
|
||||||
* would otherwise land in the record with id "" — and two of those in one
|
turnMessages: readonly AgentMessage[],
|
||||||
* transcript break React keys (the chat map) and steer-dedup lookups. Fill any
|
isAborted: boolean,
|
||||||
* missing id here, at the single canonical write: client-authored ids are
|
): LhMessage[] {
|
||||||
* NEVER touched (the client's next POST still carries them verbatim).
|
|
||||||
*/
|
|
||||||
export function withMessageIds(messages: UIMessage[]): UIMessage[] {
|
|
||||||
const filled = messages.map((m) =>
|
|
||||||
m.id ? m : { ...m, id: `msg_${Date.now().toString(36)}_${crypto.randomUUID().slice(0, 8)}` },
|
|
||||||
);
|
|
||||||
// Same id twice = the SDK's continuation merge couldn't happen in place
|
|
||||||
// (continuation coalescing appends a user-role steer AFTER the assistant
|
|
||||||
// message being continued, so onFinish carries the input copy AND the
|
|
||||||
// extended response copy under one id). Keep the LAST occurrence — it is
|
|
||||||
// the SDK's final merge state (a superset of the earlier copy).
|
|
||||||
const seen = new Set<string>();
|
|
||||||
const deduped: UIMessage[] = [];
|
|
||||||
for (let i = filled.length - 1; i >= 0; i--) {
|
|
||||||
const m = filled[i]!;
|
|
||||||
if (seen.has(m.id)) continue;
|
|
||||||
seen.add(m.id);
|
|
||||||
deduped.unshift(m);
|
|
||||||
}
|
|
||||||
return deduped;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function persistTurn(chatStore: ChatStore, chatId: string, messages: UIMessage[], isAborted: boolean): void {
|
|
||||||
try {
|
try {
|
||||||
const deduped = withMessageIds(messages);
|
const record = chatStore.getChat(chatId);
|
||||||
// Plan-block emission (wave 4): blocks are frozen transcript content —
|
if (!record) return [];
|
||||||
// attached here, once, at the canonical state write. The tree was
|
const stamped: LhMessage[] = [];
|
||||||
// saved by the plan tool during the turn; snapshots derive now.
|
for (const m of turnMessages.slice(baseCount)) {
|
||||||
const tree = chatStore.getChat(chatId)?.tree;
|
const message = m as LhMessage;
|
||||||
chatStore.saveMessages(chatId, withPlanBlocks(deduped, tree));
|
// The placeholder question result: loop device, never a record fact.
|
||||||
|
if (
|
||||||
|
(message as { role?: string }).role === "toolResult" &&
|
||||||
|
(QUESTION_TOOLS as readonly string[]).includes((message as ToolResultMessage).toolName) &&
|
||||||
|
!(message as ToolResultMessage).isError
|
||||||
|
) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
stamped.push(message.id ? message : ({ ...message, id: newMessageId() } as LhMessage));
|
||||||
|
}
|
||||||
|
const tree = record.tree;
|
||||||
|
const blocks = planBlocksFor(
|
||||||
|
record.messages,
|
||||||
|
stamped,
|
||||||
|
tree,
|
||||||
|
projectNative([...record.messages, ...stamped]),
|
||||||
|
);
|
||||||
|
chatStore.saveMessages(chatId, [...record.messages, ...stamped, ...blocks]);
|
||||||
|
return stamped;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(`[chat ${chatId}] persist failed:`, err, { isAborted });
|
console.error(`[chat ${chatId}] persist failed:`, err, { isAborted });
|
||||||
|
return [];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -74,15 +90,11 @@ export function persistTurn(chatStore: ChatStore, chatId: string, messages: UIMe
|
|||||||
* The graph write channel (vision §1 construction): the extraction pipeline
|
* The graph write channel (vision §1 construction): the extraction pipeline
|
||||||
* (extractor → auditor → merger — the sole graph writer) runs ASYNC once a
|
* (extractor → auditor → merger — the sole graph writer) runs ASYNC once a
|
||||||
* chat's UNCOVERED transcript tokens reach the chunk budget
|
* chat's UNCOVERED transcript tokens reach the chunk budget
|
||||||
* (vision-extraction-coverage.md, 2026-09-15 — replacing the old
|
* (vision-extraction-coverage.md, 2026-09-15). Coverage reads through the
|
||||||
* every-3-quiz-answers count: content size is the honest signal, and
|
* projection (the record is native history; the extraction view stays
|
||||||
* quiz-less sessions extract too). The budget is the graph section's
|
* client-shaped until Wave D). Fire-and-forget — never blocks the turn;
|
||||||
* extractionChunkTokens setting (extraction-UX redesign, 2026-09-24) —
|
* failures are logged by the pipeline and leave coverage untouched, so the
|
||||||
* the composition root passes the per-turn effective read (chat-handler
|
* next trigger (or the manual extract button) re-runs what's left.
|
||||||
* resolves fresh per turn, same as every other gate). The pipeline
|
|
||||||
* itself extracts only the uncovered batches. Fire-and-forget — never blocks the turn; failures are
|
|
||||||
* logged by the pipeline and leave coverage untouched, so the next trigger
|
|
||||||
* (or the manual extract button) re-runs what's left.
|
|
||||||
*
|
*
|
||||||
* Deliberately isolated in this one seam — the pipeline is
|
* Deliberately isolated in this one seam — the pipeline is
|
||||||
* trigger-agnostic. Skipped under tests: scripted chats are fixtures, not
|
* trigger-agnostic. Skipped under tests: scripted chats are fixtures, not
|
||||||
@@ -98,7 +110,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(record.messages as UIMessage[], deps.graphStore.readCoverage(), chatId);
|
const covered = coverageOf(projectNative(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,
|
||||||
@@ -119,4 +131,3 @@ export function triggerExtraction(
|
|||||||
console.error(`[chat ${chatId}] extraction trigger failed:`, err);
|
console.error(`[chat ${chatId}] extraction trigger failed:`, err);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+25
-9
@@ -12,9 +12,10 @@ import * as path from "node:path";
|
|||||||
import { PATHS } from "../../config.ts";
|
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 { UIMessage } from "ai";
|
import type { LhMessage } from "../core/native-messages.ts";
|
||||||
import { findPendingToolCall } from "../core/pending.ts";
|
import { findPendingToolCall } from "../core/pending.ts";
|
||||||
import { phaseAt3 } from "../core/phases.ts";
|
import { phaseAt3 } 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}`);
|
||||||
@@ -51,7 +52,14 @@ function listChats(): ChatSummary[] {
|
|||||||
.filter((f) => f.endsWith(".json"))
|
.filter((f) => f.endsWith(".json"))
|
||||||
.map((f) => getChat(f.slice(0, -5)))
|
.map((f) => getChat(f.slice(0, -5)))
|
||||||
.filter((r): r is ChatRecord => r !== null)
|
.filter((r): r is ChatRecord => r !== null)
|
||||||
.map(({ messages, tree, turns, extractions, ...summary }) => ({
|
.map(({ messages, tree, turns, extractions, ...summary }) => {
|
||||||
|
// Per-record isolation (Wave B): a record whose messages fail the
|
||||||
|
// projection (e.g. pre-Wave-B UIMessage-format scratch — the
|
||||||
|
// zero-back-compat wipe makes these "corrupt") must not blank the
|
||||||
|
// whole sidebar — same isolation getChat's per-file catch gives.
|
||||||
|
try {
|
||||||
|
const projected = projectNative(messages);
|
||||||
|
return {
|
||||||
// turns and extractions stay out of the list payload (list =
|
// 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
|
||||||
// never needs them).
|
// never needs them).
|
||||||
@@ -60,19 +68,27 @@ function listChats(): ChatSummary[] {
|
|||||||
// 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 markers (probe_done / plan_init — the
|
||||||
// event-driven phases, docs/probe-teach-split.md §4), pending
|
// event-driven phases, docs/probe-teach-split.md §4), pending
|
||||||
// question from the last unanswered quiz/ask part. Liveness (busy)
|
// question from the last unanswered quiz/ask part — both read
|
||||||
// is NOT record-derived — the API route merges it (ChatListItem).
|
// through the one-way projection. Liveness (busy) is NOT
|
||||||
phase: phaseAt3(messages),
|
// record-derived — the API route merges it (ChatListItem).
|
||||||
pendingQuestion: findPendingToolCall(messages) !== null,
|
phase: phaseAt3(projected),
|
||||||
}))
|
pendingQuestion: findPendingToolCall(projected) !== null,
|
||||||
|
};
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`[chat ${summary.id}] summary projection failed:`, err);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.filter((s): s is ChatSummary => s !== null)
|
||||||
.sort((a, b) => b.updatedAt.localeCompare(a.updatedAt));
|
.sort((a, b) => b.updatedAt.localeCompare(a.updatedAt));
|
||||||
} catch {
|
} catch {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Persist the full message list (client state + new response) after a turn. */
|
/** Persist the full native message list (the record IS the agent state —
|
||||||
function saveMessages(id: string, messages: UIMessage[]): void {
|
* Wave B: replay is state.messages = record.messages). */
|
||||||
|
function saveMessages(id: string, messages: LhMessage[]): void {
|
||||||
const record = getChat(id);
|
const record = getChat(id);
|
||||||
if (!record) throw new Error(`Chat not found: ${id}`);
|
if (!record) throw new Error(`Chat not found: ${id}`);
|
||||||
record.messages = messages;
|
record.messages = messages;
|
||||||
|
|||||||
+151
-165
@@ -2,13 +2,15 @@
|
|||||||
* Scripted integration test — the full teacher loop with zero tokens:
|
* Scripted integration test — the full teacher loop with zero tokens:
|
||||||
*
|
*
|
||||||
* learner opens chat → faux model calls `quiz` → server streams + persists
|
* learner opens chat → faux model calls `quiz` → server streams + persists
|
||||||
* → client grades and sends the answer back as the tool output
|
* → client grades and sends the ANSWER EVENT back
|
||||||
* → faux model reacts → transcript persisted.
|
* → faux model reacts → transcript persisted.
|
||||||
*
|
*
|
||||||
* Drives handleChatRequest directly (plain Request/Response — no Next server
|
* Drives handleChatRequest directly (plain Request/Response — no Next server
|
||||||
* needed) with a scripted pi engine (pi-ai's faux provider, docs/
|
* needed) with a scripted pi engine (pi-ai's faux provider, docs/
|
||||||
* pi-engine-swap.md §7): the quiz bridge is gone, the tool part IS the
|
* pi-engine-swap.md §7). Wave B: POSTs carry ONE INPUT EVENT (never a client
|
||||||
* pending state, and the persisted transcript is the resume record.
|
* transcript), the record is pi-native agent history (read through the
|
||||||
|
* projection where a part shape is pinned), and the answer arrives as the
|
||||||
|
* question call's real tool result (fork 1, owner 2026-09-26).
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import assert from "node:assert/strict";
|
import assert from "node:assert/strict";
|
||||||
@@ -28,13 +30,12 @@ import { handleChatRequest } from "../src/server/chat-handler.ts";
|
|||||||
import { effectiveSettings } from "../src/server/stores.ts";
|
import { effectiveSettings } from "../src/server/stores.ts";
|
||||||
import { isTurnRunning } from "../src/server/turn-liveness.ts";
|
import { isTurnRunning } from "../src/server/turn-liveness.ts";
|
||||||
import { abortTurn, runningTurn, settledTurnResponse } from "../src/server/turn-runs.ts";
|
import { abortTurn, runningTurn, settledTurnResponse } from "../src/server/turn-runs.ts";
|
||||||
import { recordToPiMessages } from "../src/server/pi/record-adapter.ts";
|
|
||||||
import { chatStore } from "../src/store/chats.ts";
|
import { 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 { findPendingToolCall } from "../src/core/pending.ts";
|
import { danglingQuestionOf, newAnswerResult } from "../src/core/native-messages.ts";
|
||||||
|
import { projectNative } from "../src/core/projection.ts";
|
||||||
import type { PiEngine } from "../src/agent/models.ts";
|
import type { PiEngine } from "../src/agent/models.ts";
|
||||||
import type { TeachingAgent } from "../src/agent/teacher-agent.ts";
|
import type { LhMessage } from "../src/core/native-messages.ts";
|
||||||
import type { UIMessage } from "ai";
|
|
||||||
import { PATHS } from "../config.ts";
|
import { PATHS } from "../config.ts";
|
||||||
|
|
||||||
// The key-presence turn gate (provider-key interlude, 2026-09-25): scripted
|
// The key-presence turn gate (provider-key interlude, 2026-09-25): scripted
|
||||||
@@ -84,10 +85,27 @@ function defaultLesson(calls: string[][]): FauxResponseStep[] {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** The handler seam (stateless per turn): each request builds a fresh agent
|
/** The handler seam (stateless per turn): each request builds a fresh agent
|
||||||
* off the transcript it is about to run — the factory tests inject. */
|
* off the record it is about to run — the factory tests inject. Wave B: the
|
||||||
function agentFactory(engine: PiEngine) {
|
* handler hands the factory the NATIVE build options (the record IS the
|
||||||
return (outgoing: UIMessage[]): TeachingAgent =>
|
* agent state — no adapter conversion). */
|
||||||
createTeacherAgent(undefined, { engine, messages: recordToPiMessages(outgoing) });
|
function agentFactory(engine: PiEngine): Parameters<typeof handleChatRequest>[1] {
|
||||||
|
return (opts) => createTeacherAgent(undefined, { engine, messages: opts.messages });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The graded answer a client sends for a quiz (the shared grading seam). */
|
||||||
|
function gradedAnswer(input: typeof QUIZ_INPUT, values: string[]) {
|
||||||
|
const options = normalizeOptions(input.options);
|
||||||
|
const qd: QuestionData = {
|
||||||
|
id: "call-1",
|
||||||
|
kind: "quiz",
|
||||||
|
question: input.question,
|
||||||
|
mode: "single-select",
|
||||||
|
options,
|
||||||
|
correctIndices: resolveCorrect(coerceCorrectAnswer(input.correctAnswer), options).indices,
|
||||||
|
explanation: input.explanation,
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
return buildAnswer(qd, { values });
|
||||||
}
|
}
|
||||||
|
|
||||||
async function drain(res: Response): Promise<string> {
|
async function drain(res: Response): Promise<string> {
|
||||||
@@ -137,15 +155,16 @@ function abortableTeacher(): PiEngine {
|
|||||||
return { model: faux.getModel(), streamFn: faux.streamSimple };
|
return { model: faux.getModel(), streamFn: faux.streamSimple };
|
||||||
}
|
}
|
||||||
|
|
||||||
function post(chatId: string, messages: unknown[]): Request {
|
function post(chatId: string, input: unknown): Request {
|
||||||
return new Request("http://test/api/chat", {
|
return new Request("http://test/api/chat", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({ chatId, messages }),
|
// Wave B: the input-events contract — one explicit event per POST,
|
||||||
|
// never a client transcript.
|
||||||
|
body: JSON.stringify({ chatId, input }),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
/** BYOK turn gate (owner, 2026-09-22): no factory floor — a chat needs a
|
/** BYOK turn gate (owner, 2026-09-22): no factory floor — a chat needs a
|
||||||
* conversation model before any turn streams. The scripted tests give
|
* conversation model before any turn streams. The scripted tests give
|
||||||
* each chat a per-chat pin exactly like the setup flow does in
|
* each chat a per-chat pin exactly like the setup flow does in
|
||||||
@@ -174,68 +193,69 @@ describe("teacher chat flow (scripted model)", () => {
|
|||||||
createdChats.push(chat.id);
|
createdChats.push(chat.id);
|
||||||
|
|
||||||
// ── Turn 1: learner opens with a topic; teacher probes with a quiz ──
|
// ── Turn 1: learner opens with a topic; teacher probes with a quiz ──
|
||||||
const firstUser = { id: "u1", role: "user", parts: [{ type: "text", text: "teach me the solar system" }] };
|
await drain(await handleChatRequest(post(chat.id, { type: "text", text: "teach me the solar system" }), agentFactory(engine)));
|
||||||
await drain(await handleChatRequest(post(chat.id, [firstUser]), agentFactory(engine)));
|
|
||||||
|
|
||||||
const saved1 = chatStore.getChat(chat.id)!;
|
const saved1 = chatStore.getChat(chat.id)!;
|
||||||
|
// Native record: user + assistant (fork 1 — the clean question's
|
||||||
|
// placeholder result never persists, so the record ends at the call).
|
||||||
assert.equal(saved1.messages.length, 2, "user message + assistant response persisted");
|
assert.equal(saved1.messages.length, 2, "user message + assistant response persisted");
|
||||||
const assistantMsg = saved1.messages[1]!;
|
const assistantMsg = saved1.messages[1]!;
|
||||||
assert.equal(assistantMsg.role, "assistant");
|
assert.equal(assistantMsg.role, "assistant");
|
||||||
|
|
||||||
const reasoningPart = assistantMsg.parts.find((p) => p.type === "reasoning");
|
const thinking = assistantMsg.content.find((b) => b.type === "thinking");
|
||||||
assert.ok(reasoningPart && reasoningPart.type === "reasoning", "reasoning part streamed + persisted");
|
assert.ok(thinking && thinking.type === "thinking", "thinking block streamed + persisted");
|
||||||
assert.match((reasoningPart as { text?: string }).text!, /Probe the basics/);
|
assert.match((thinking as { thinking?: string }).thinking!, /Probe the basics/);
|
||||||
|
|
||||||
const quizPart = assistantMsg.parts.find((p) => p.type === "tool-quiz");
|
const quizCall = assistantMsg.content.find((b) => b.type === "toolCall" && b.name === "quiz");
|
||||||
assert.ok(quizPart && quizPart.type === "tool-quiz", "quiz tool part persisted");
|
assert.ok(quizCall && quizCall.type === "toolCall", "quiz tool call persisted");
|
||||||
|
assert.equal((quizCall as { id?: string }).id, "call-1");
|
||||||
|
assert.deepEqual((quizCall as { arguments?: unknown }).arguments, QUIZ_INPUT);
|
||||||
|
|
||||||
|
// The projection pins the client view: reasoning part + a pending
|
||||||
|
// (input-available) quiz part — the dock's mount state.
|
||||||
|
const view1 = projectNative(saved1.messages);
|
||||||
|
const reasoningPart = view1[1]!.parts.find((p) => p.type === "reasoning");
|
||||||
|
assert.ok(reasoningPart && reasoningPart.type === "reasoning", "reasoning part projected");
|
||||||
|
const quizPart = view1[1]!.parts.find((p) => p.type === "tool-quiz");
|
||||||
|
assert.ok(quizPart && quizPart.type === "tool-quiz", "quiz tool part projected");
|
||||||
assert.equal((quizPart as { state?: string }).state, "input-available", "unanswered quiz = pending state (resume record)");
|
assert.equal((quizPart as { state?: string }).state, "input-available", "unanswered quiz = pending state (resume record)");
|
||||||
assert.deepEqual((quizPart as { input?: unknown }).input, QUIZ_INPUT);
|
assert.deepEqual((quizPart as { input?: unknown }).input, QUIZ_INPUT);
|
||||||
|
|
||||||
// The pending-question gate sees it.
|
// The pending-question gate sees it (the native seam).
|
||||||
const pending = findPendingToolCall(saved1.messages);
|
const pending = danglingQuestionOf(saved1.messages);
|
||||||
assert.equal(pending?.toolCallId, "call-1");
|
assert.equal(pending?.toolCallId, "call-1");
|
||||||
assert.equal(pending?.tool, "quiz");
|
assert.equal(pending?.tool, "quiz");
|
||||||
assert.deepEqual(pending?.input, QUIZ_INPUT);
|
assert.deepEqual(pending?.input, QUIZ_INPUT);
|
||||||
|
|
||||||
// ── The learner answers: client grades, output goes back on the part ──
|
// ── The learner answers: the client grades and posts the ANSWER
|
||||||
const options = normalizeOptions(QUIZ_INPUT.options);
|
// EVENT — the answer lands as the call's real tool result. ──
|
||||||
const qd: QuestionData = {
|
const answer = gradedAnswer(QUIZ_INPUT, ["Mercury"]);
|
||||||
id: "call-1",
|
|
||||||
kind: "quiz",
|
|
||||||
question: QUIZ_INPUT.question,
|
|
||||||
mode: "single-select",
|
|
||||||
options,
|
|
||||||
correctIndices: resolveCorrect(coerceCorrectAnswer(QUIZ_INPUT.correctAnswer), options).indices,
|
|
||||||
explanation: QUIZ_INPUT.explanation,
|
|
||||||
createdAt: new Date().toISOString(),
|
|
||||||
};
|
|
||||||
const answer = buildAnswer(qd, { values: ["Mercury"] });
|
|
||||||
assert.equal(answer.correct, true);
|
assert.equal(answer.correct, true);
|
||||||
|
await drain(await handleChatRequest(post(chat.id, { type: "answer", toolCallId: "call-1", answer }), agentFactory(engine)));
|
||||||
|
|
||||||
const messages2 = saved1.messages.map((m) =>
|
// ── The teacher reacts — the projection groups the reaction into the
|
||||||
m.id === assistantMsg.id
|
// SAME assistant view message as the answered quiz (the native
|
||||||
? {
|
// grouping canon: consecutive assistant/toolResult messages bounded
|
||||||
...m,
|
// by user messages form one group). ──
|
||||||
parts: m.parts.map((p) =>
|
|
||||||
p.type === "tool-quiz" ? { ...p, state: "output-available" as const, output: answer } : p,
|
|
||||||
),
|
|
||||||
}
|
|
||||||
: m,
|
|
||||||
);
|
|
||||||
await drain(await handleChatRequest(post(chat.id, messages2), agentFactory(engine)));
|
|
||||||
|
|
||||||
// ── The teacher reacts — as a CONTINUATION of the same assistant message
|
|
||||||
// (the last client message is the assistant message carrying the tool
|
|
||||||
// output, so the new text appends to that message's parts). ──
|
|
||||||
const saved2 = chatStore.getChat(chat.id)!;
|
const saved2 = chatStore.getChat(chat.id)!;
|
||||||
assert.equal(saved2.messages.length, 2, "the reaction continues the same assistant message");
|
// Native: user, assistant, answer toolResult, reaction assistant.
|
||||||
const finalParts = saved2.messages[1]!.parts;
|
assert.equal(saved2.messages.length, 4, "the answer + reaction append natively");
|
||||||
|
assert.equal((saved2.messages[2] as { role?: string }).role, "toolResult", "the graded answer is the call's tool result");
|
||||||
|
const view2 = projectNative(saved2.messages);
|
||||||
|
assert.equal(view2.length, 2, "the reaction continues the same assistant message (one group)");
|
||||||
|
const finalParts = view2[1]!.parts;
|
||||||
const textPart = finalParts.find((p) => p.type === "text");
|
const textPart = finalParts.find((p) => p.type === "text");
|
||||||
assert.ok(textPart && textPart.type === "text", "reaction text appended after the tool part");
|
assert.ok(textPart && textPart.type === "text", "reaction text appended after the tool part");
|
||||||
assert.match((textPart as { text?: string }).text!, /Mercury it is/);
|
assert.match((textPart as { text?: string }).text!, /Mercury it is/);
|
||||||
// The answered quiz part is still there, now with its output.
|
// The answered quiz part is still there, now with its output.
|
||||||
const answeredQuiz = finalParts.find((p) => p.type === "tool-quiz");
|
const answeredQuiz = finalParts.find((p) => p.type === "tool-quiz");
|
||||||
assert.ok(answeredQuiz && answeredQuiz.type === "tool-quiz" && (answeredQuiz as { state?: string }).state === "output-available");
|
assert.ok(answeredQuiz && answeredQuiz.type === "tool-quiz" && (answeredQuiz as { state?: string }).state === "output-available");
|
||||||
|
// The graded answer rode the record as the call's tool result and
|
||||||
|
// round-trips through JSON (undefined keys drop) — compare its
|
||||||
|
// SUBSTANCE, not key-for-key against the in-memory build.
|
||||||
|
const out = (answeredQuiz as { output?: { questionId?: string; correct?: boolean } }).output!;
|
||||||
|
assert.equal(out.questionId, "call-1");
|
||||||
|
assert.equal(out.correct, true);
|
||||||
|
|
||||||
// The model actually received the graded answer as a tool result.
|
// The model actually received the graded answer as a tool result.
|
||||||
assert.equal(calls.length, 2, "model saw exactly two prompts");
|
assert.equal(calls.length, 2, "model saw exactly two prompts");
|
||||||
@@ -243,14 +263,41 @@ describe("teacher chat flow (scripted model)", () => {
|
|||||||
assert.ok(toolResultSent, "continuation prompt carries the tool result to the model");
|
assert.ok(toolResultSent, "continuation prompt carries the tool result to the model");
|
||||||
|
|
||||||
// No pending question remains.
|
// No pending question remains.
|
||||||
assert.equal(findPendingToolCall(saved2.messages), null);
|
assert.equal(danglingQuestionOf(saved2.messages), null);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("rejects unknown chats", async () => {
|
it("rejects unknown chats", async () => {
|
||||||
const res = await handleChatRequest(post("chat_nonexistent", []));
|
const res = await handleChatRequest(post("chat_nonexistent", { type: "text", text: "hi" }));
|
||||||
assert.equal(res.status, 404);
|
assert.equal(res.status, 404);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("refuses malformed inputs with a clean 400 — no liveness effects, no lockout", async () => {
|
||||||
|
const chat = createConfiguredChat("Test: malformed input");
|
||||||
|
createdChats.push(chat.id);
|
||||||
|
|
||||||
|
// A text event with no text: refused at the parse boundary.
|
||||||
|
const res = await handleChatRequest(post(chat.id, { type: "text" }), agentFactory(scriptedTeacher(defaultLesson).engine));
|
||||||
|
assert.equal(res.status, 400, "malformed input is a clean 400");
|
||||||
|
assert.equal(isTurnRunning(chat.id), false, "the refusal never registered a turn");
|
||||||
|
|
||||||
|
// An ANSWER with no dangling question call: the orphan-400 discipline
|
||||||
|
// (the server owns the record — it validates the target call).
|
||||||
|
const res2 = await handleChatRequest(
|
||||||
|
post(chat.id, { type: "answer", toolCallId: "call-none", answer: { correct: true } }),
|
||||||
|
agentFactory(scriptedTeacher(defaultLesson).engine),
|
||||||
|
);
|
||||||
|
assert.equal(res2.status, 400, "an answer with no pending question call is refused");
|
||||||
|
assert.equal(isTurnRunning(chat.id), false);
|
||||||
|
|
||||||
|
// And the chat is NOT locked: the next (valid) turn is accepted.
|
||||||
|
const ok = await handleChatRequest(
|
||||||
|
post(chat.id, { type: "text", text: "teach me" }),
|
||||||
|
agentFactory(scriptedTeacher(defaultLesson).engine),
|
||||||
|
);
|
||||||
|
assert.equal(ok.status, 200);
|
||||||
|
await drain(ok);
|
||||||
|
});
|
||||||
|
|
||||||
it("streams tool-input-start before tool-input-available (protocol pin for the 'Writing a question…' indicator)", async () => {
|
it("streams tool-input-start before tool-input-available (protocol pin for the 'Writing a question…' indicator)", async () => {
|
||||||
// The client's writing indicator keys off tool parts in state
|
// The client's writing indicator keys off tool parts in state
|
||||||
// 'input-streaming', which the stream processor sets from the
|
// 'input-streaming', which the stream processor sets from the
|
||||||
@@ -259,8 +306,7 @@ describe("teacher chat flow (scripted model)", () => {
|
|||||||
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 firstUser = { id: "u1", role: "user", parts: [{ type: "text", text: "quiz me" }] };
|
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, [firstUser]), agentFactory(engine)));
|
|
||||||
const startIdx = body.indexOf('"type":"tool-input-start"');
|
const startIdx = body.indexOf('"type":"tool-input-start"');
|
||||||
const availIdx = body.indexOf('"type":"tool-input-available"');
|
const availIdx = body.indexOf('"type":"tool-input-available"');
|
||||||
assert.ok(startIdx !== -1, "tool-input-start chunk present in the stream");
|
assert.ok(startIdx !== -1, "tool-input-start chunk present in the stream");
|
||||||
@@ -275,8 +321,7 @@ describe("teacher chat flow (scripted model)", () => {
|
|||||||
const chat = createConfiguredChat("Test: view cancel");
|
const chat = createConfiguredChat("Test: view cancel");
|
||||||
createdChats.push(chat.id);
|
createdChats.push(chat.id);
|
||||||
|
|
||||||
const req = post(chat.id, [{ id: "u1", role: "user", parts: [{ type: "text", text: "hold my turn" }] }]);
|
const res = await handleChatRequest(post(chat.id, { type: "text", text: "hold my turn" }), agentFactory(engine));
|
||||||
const res = await handleChatRequest(req, agentFactory(engine));
|
|
||||||
assert.equal(res.status, 200);
|
assert.equal(res.status, 200);
|
||||||
// Read one chunk then cancel — the browser view leaves mid-stream.
|
// Read one chunk then cancel — the browser view leaves mid-stream.
|
||||||
const reader = res.body!.getReader();
|
const reader = res.body!.getReader();
|
||||||
@@ -297,7 +342,7 @@ describe("teacher chat flow (scripted model)", () => {
|
|||||||
it("an ungradeable quiz is rejected at the call boundary, the error reaches the model, and the model retries", async () => {
|
it("an ungradeable quiz is rejected at the call boundary, the error reaches the model, and the model retries", async () => {
|
||||||
// The authoring contract (question-core superRefine): a quiz whose
|
// The authoring contract (question-core superRefine): a quiz whose
|
||||||
// correctAnswer matches no option value must NEVER reach the dock —
|
// correctAnswer matches no option value must NEVER reach the dock —
|
||||||
// toPiTool re-runs the zod schema at the call boundary, marks the
|
// the boundary re-runs the schema at the call boundary, marks the
|
||||||
// call invalid, and returns the validation error to the teacher as
|
// call invalid, and returns the validation error to the teacher as
|
||||||
// a tool result. The loop re-prompts the model, which retries in the
|
// a tool result. The loop re-prompts the model, which retries in the
|
||||||
// same turn. This pins that whole mechanism (the live Nietzsche bug).
|
// same turn. This pins that whole mechanism (the live Nietzsche bug).
|
||||||
@@ -328,14 +373,12 @@ describe("teacher chat flow (scripted model)", () => {
|
|||||||
|
|
||||||
const chat = createConfiguredChat("Test: bad quiz retry");
|
const chat = createConfiguredChat("Test: bad quiz retry");
|
||||||
createdChats.push(chat.id);
|
createdChats.push(chat.id);
|
||||||
const firstUser = { id: "u1", role: "user", parts: [{ type: "text", text: "quiz me" }] };
|
const body = await drain(await handleChatRequest(post(chat.id, { type: "text", text: "quiz me" }), agentFactory(engine)));
|
||||||
const body = await drain(await handleChatRequest(post(chat.id, [firstUser]), agentFactory(engine)));
|
|
||||||
|
|
||||||
// WIRE pin (the live modal-realism crash): the invalid call must NEVER
|
// WIRE pin (the live modal-realism crash): the invalid call must NEVER
|
||||||
// become input-available — the dock mounts on input-available parts and
|
// become input-available — the dock mounts on input-available parts and
|
||||||
// crashed on a malformed quiz before tool-input-error could replace it.
|
// crashed on a malformed quiz before tool-input-error could replace it.
|
||||||
// The SDK gated this with InvalidToolInputError; the bridge gates it on
|
// The boundary gates this on clean tool_execution_end.
|
||||||
// clean tool_execution_end.
|
|
||||||
assert.ok(
|
assert.ok(
|
||||||
!body.includes('"type":"tool-input-available","toolCallId":"call-bad"'),
|
!body.includes('"type":"tool-input-available","toolCallId":"call-bad"'),
|
||||||
"the invalid call never reached input-available on the wire",
|
"the invalid call never reached input-available on the wire",
|
||||||
@@ -358,11 +401,12 @@ describe("teacher chat flow (scripted model)", () => {
|
|||||||
"the validation error reached the model",
|
"the validation error reached the model",
|
||||||
);
|
);
|
||||||
|
|
||||||
// The transcript: the failed call persisted as an error part (renders
|
// The transcript: the failed call persisted as an ERROR RESULT (the
|
||||||
// nothing client-side), the retry as the pending quiz.
|
// model's retry context — errored question calls DO persist), the
|
||||||
|
// retry as the dangling quiz. The projection renders the error part.
|
||||||
const saved = chatStore.getChat(chat.id)!;
|
const saved = chatStore.getChat(chat.id)!;
|
||||||
const assistantMsg = saved.messages[1]!;
|
const view = projectNative(saved.messages);
|
||||||
const quizParts = assistantMsg.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",
|
||||||
);
|
);
|
||||||
@@ -371,7 +415,7 @@ describe("teacher chat flow (scripted model)", () => {
|
|||||||
String((failed as { errorText?: string }).errorText),
|
String((failed as { errorText?: string }).errorText),
|
||||||
/does not match any option value/,
|
/does not match any option value/,
|
||||||
);
|
);
|
||||||
const pending = findPendingToolCall(saved.messages);
|
const pending = danglingQuestionOf(saved.messages);
|
||||||
assert.equal(pending?.toolCallId, "call-good", "the RETRIED quiz is the pending one");
|
assert.equal(pending?.toolCallId, "call-good", "the RETRIED quiz is the pending one");
|
||||||
assert.deepEqual(pending?.input, QUIZ_INPUT);
|
assert.deepEqual(pending?.input, QUIZ_INPUT);
|
||||||
});
|
});
|
||||||
@@ -400,8 +444,7 @@ describe("teacher chat flow (scripted model)", () => {
|
|||||||
|
|
||||||
const chat = createConfiguredChat("Test: string options");
|
const chat = createConfiguredChat("Test: string options");
|
||||||
createdChats.push(chat.id);
|
createdChats.push(chat.id);
|
||||||
const firstUser = { id: "u1", role: "user", parts: [{ type: "text", text: "quiz me" }] };
|
const body = await drain(await handleChatRequest(post(chat.id, { type: "text", text: "quiz me" }), agentFactory(engine)));
|
||||||
const body = await drain(await handleChatRequest(post(chat.id, [firstUser]), agentFactory(engine)));
|
|
||||||
|
|
||||||
assert.ok(
|
assert.ok(
|
||||||
!body.includes('"type":"tool-input-available","toolCallId":"call-stringy"'),
|
!body.includes('"type":"tool-input-available","toolCallId":"call-stringy"'),
|
||||||
@@ -413,12 +456,13 @@ describe("teacher chat flow (scripted model)", () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const saved = chatStore.getChat(chat.id)!;
|
const saved = chatStore.getChat(chat.id)!;
|
||||||
const failed = saved.messages[1]!.parts.find(
|
const view = projectNative(saved.messages);
|
||||||
|
const failed = view[1]!.parts.find(
|
||||||
(p) => (p as { toolCallId?: string }).toolCallId === "call-stringy",
|
(p) => (p as { toolCallId?: string }).toolCallId === "call-stringy",
|
||||||
);
|
);
|
||||||
assert.equal((failed as { state?: string }).state, "output-error", "persisted as an error part");
|
assert.equal((failed as { state?: string }).state, "output-error", "persisted as an error part");
|
||||||
assert.match(String((failed as { errorText?: string }).errorText), /options/);
|
assert.match(String((failed as { errorText?: string }).errorText), /options/);
|
||||||
const pending = findPendingToolCall(saved.messages);
|
const pending = danglingQuestionOf(saved.messages);
|
||||||
assert.equal(pending?.toolCallId, "call-clean", "the retried quiz is the pending one");
|
assert.equal(pending?.toolCallId, "call-clean", "the retried quiz is the pending one");
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -426,8 +470,8 @@ describe("teacher chat flow (scripted model)", () => {
|
|||||||
// The live modal-realism observation: the teacher batched two quizzes
|
// The live modal-realism observation: the teacher batched two quizzes
|
||||||
// in one turn, and the dock showed the SECOND one first (the backward
|
// in one turn, and the dock showed the SECOND one first (the backward
|
||||||
// part scan), so the interaction order was reversed vs the record.
|
// part scan), so the interaction order was reversed vs the record.
|
||||||
// Pins: (A) findPendingToolCall picks the FIRST unanswered question in
|
// Pins: (A) danglingQuestionOf picks the FIRST unanswered question in
|
||||||
// part order; (B) question tools execute sequentially, so their
|
// call order; (B) question tools execute sequentially, so their
|
||||||
// tool-input-available chunks arrive in call order on the wire.
|
// tool-input-available chunks arrive in call order on the wire.
|
||||||
const QUIZ_A = { ...QUIZ_INPUT, question: "Quiz A: which planet is closest to the sun?" };
|
const QUIZ_A = { ...QUIZ_INPUT, question: "Quiz A: which planet is closest to the sun?" };
|
||||||
const QUIZ_B = { ...QUIZ_INPUT, question: "Quiz B: which planet is largest?" };
|
const QUIZ_B = { ...QUIZ_INPUT, question: "Quiz B: which planet is largest?" };
|
||||||
@@ -442,8 +486,7 @@ describe("teacher chat flow (scripted model)", () => {
|
|||||||
|
|
||||||
const chat = createConfiguredChat("Test: parallel quizzes");
|
const chat = createConfiguredChat("Test: parallel quizzes");
|
||||||
createdChats.push(chat.id);
|
createdChats.push(chat.id);
|
||||||
const firstUser = { id: "u1", role: "user", parts: [{ type: "text", text: "quiz me twice" }] };
|
const body = await drain(await handleChatRequest(post(chat.id, { type: "text", text: "quiz me twice" }), agentFactory(engine)));
|
||||||
const body = await drain(await handleChatRequest(post(chat.id, [firstUser]), agentFactory(engine)));
|
|
||||||
|
|
||||||
// (B) wire determinism: the batches' input-available chunks in call order.
|
// (B) wire determinism: the batches' input-available chunks in call order.
|
||||||
const availA = body.indexOf('"type":"tool-input-available","toolCallId":"call-a"');
|
const availA = body.indexOf('"type":"tool-input-available","toolCallId":"call-a"');
|
||||||
@@ -451,30 +494,20 @@ describe("teacher chat flow (scripted model)", () => {
|
|||||||
assert.ok(availA !== -1 && availB !== -1, "both quizzes became available");
|
assert.ok(availA !== -1 && availB !== -1, "both quizzes became available");
|
||||||
assert.ok(availA < availB, "call A's input became available before call B's (sequential execution)");
|
assert.ok(availA < availB, "call A's input became available before call B's (sequential execution)");
|
||||||
|
|
||||||
// Saved in call order.
|
// Saved in call order (the projection preserves the content order).
|
||||||
const saved = chatStore.getChat(chat.id)!;
|
const saved = chatStore.getChat(chat.id)!;
|
||||||
const quizIds = saved.messages[1]!.parts
|
const quizIds = projectNative(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");
|
||||||
|
|
||||||
// (A) the dock serializes answering in the same order: A first, then B.
|
// (A) the dock serializes answering in the same order: A first, then B.
|
||||||
const pending = findPendingToolCall(saved.messages);
|
const pending = danglingQuestionOf(saved.messages);
|
||||||
assert.equal(pending?.toolCallId, "call-a", "the FIRST asked question is the dock subject");
|
assert.equal(pending?.toolCallId, "call-a", "the FIRST asked question is the dock subject");
|
||||||
// …the learner answers A (client-side grading seam — state flip only here)…
|
// …the learner answers A: the answer lands as call-a's tool result —
|
||||||
const answered = saved.messages.map((m) =>
|
// the SECOND question becomes the dock subject only after that.
|
||||||
(m as { id?: string }).id === saved.messages[1]!.id
|
const answered = [...saved.messages, newAnswerResult("call-a", "quiz", gradedAnswer(QUIZ_A, ["Mercury"]))];
|
||||||
? {
|
const next = danglingQuestionOf(answered);
|
||||||
...m,
|
|
||||||
parts: (m as { parts: unknown[] }).parts.map((p) =>
|
|
||||||
(p as { toolCallId?: string }).toolCallId === "call-a"
|
|
||||||
? { ...(p as object), state: "output-available", output: { correct: true } }
|
|
||||||
: p,
|
|
||||||
),
|
|
||||||
}
|
|
||||||
: m,
|
|
||||||
);
|
|
||||||
const next = findPendingToolCall(answered as UIMessage[]);
|
|
||||||
assert.equal(next?.toolCallId, "call-b", "the SECOND question becomes the dock subject only after the first is answered");
|
assert.equal(next?.toolCallId, "call-b", "the SECOND question becomes the dock subject only after the first is answered");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -483,7 +516,7 @@ describe("chat store", () => {
|
|||||||
it("round-trips and lists chats", async () => {
|
it("round-trips and lists chats", async () => {
|
||||||
const chat = chatStore.createChat("Store test");
|
const chat = chatStore.createChat("Store test");
|
||||||
createdChats.push(chat.id);
|
createdChats.push(chat.id);
|
||||||
chatStore.saveMessages(chat.id, [{ id: "m1", role: "user", parts: [{ type: "text", text: "hi" }] }]);
|
chatStore.saveMessages(chat.id, [{ id: "m1", role: "user", content: "hi", timestamp: 1 } as never]);
|
||||||
const loaded = chatStore.getChat(chat.id)!;
|
const loaded = chatStore.getChat(chat.id)!;
|
||||||
assert.equal(loaded.messages.length, 1);
|
assert.equal(loaded.messages.length, 1);
|
||||||
assert.equal(loaded.title, "Store test");
|
assert.equal(loaded.title, "Store test");
|
||||||
@@ -510,18 +543,17 @@ describe("mid-turn navigation (turn liveness + write-ahead)", () => {
|
|||||||
const chat = createConfiguredChat("Test: turn guard");
|
const chat = createConfiguredChat("Test: turn guard");
|
||||||
createdChats.push(chat.id);
|
createdChats.push(chat.id);
|
||||||
|
|
||||||
const firstUser = { id: "u1", role: "user", parts: [{ type: "text", text: "teach me the solar system" }] };
|
const res1 = await handleChatRequest(post(chat.id, { type: "text", text: "teach me the solar system" }), agentFactory(engine)); // NOT drained → in flight
|
||||||
const res1 = await handleChatRequest(post(chat.id, [firstUser]), agentFactory(engine)); // NOT drained → in flight
|
|
||||||
assert.equal(isTurnRunning(chat.id), true, "turn registered as in flight");
|
assert.equal(isTurnRunning(chat.id), true, "turn registered as in flight");
|
||||||
|
|
||||||
// A stale client (remount racing a live turn) tries to start a second turn.
|
// A stale client (remount racing a live turn) tries to start a second turn.
|
||||||
const res2 = await handleChatRequest(post(chat.id, [firstUser]), agentFactory(engine));
|
const res2 = await handleChatRequest(post(chat.id, { type: "text", text: "teach me the solar system" }), agentFactory(engine));
|
||||||
assert.equal(res2.status, 409, "concurrent turn rejected — last-writer-wins persist would clobber it");
|
assert.equal(res2.status, 409, "concurrent turn rejected — last-writer-wins persist would clobber it");
|
||||||
|
|
||||||
await drain(res1); // finish → onFinish → endTurn
|
await drain(res1); // finish → onFinish → endTurn
|
||||||
assert.equal(isTurnRunning(chat.id), false, "registry cleared at finish");
|
assert.equal(isTurnRunning(chat.id), false, "registry cleared at finish");
|
||||||
|
|
||||||
const res3 = await handleChatRequest(post(chat.id, [firstUser]), agentFactory(engine));
|
const res3 = await handleChatRequest(post(chat.id, { type: "text", text: "teach me the solar system" }), agentFactory(engine));
|
||||||
assert.equal(res3.status, 200, "the next turn is accepted after the previous finished");
|
assert.equal(res3.status, 200, "the next turn is accepted after the previous finished");
|
||||||
await drain(res3);
|
await drain(res3);
|
||||||
});
|
});
|
||||||
@@ -531,69 +563,31 @@ describe("mid-turn navigation (turn liveness + write-ahead)", () => {
|
|||||||
const chat = createConfiguredChat("Test: write-ahead");
|
const chat = createConfiguredChat("Test: write-ahead");
|
||||||
createdChats.push(chat.id);
|
createdChats.push(chat.id);
|
||||||
|
|
||||||
// Turn 1: the teacher asks the quiz (pending part in the record).
|
// Turn 1: the teacher asks the quiz (dangling call in the record).
|
||||||
const firstUser = { id: "u1", role: "user", parts: [{ type: "text", text: "teach me the solar system" }] };
|
await drain(await handleChatRequest(post(chat.id, { type: "text", text: "teach me the solar system" }), agentFactory(engine)));
|
||||||
await drain(await handleChatRequest(post(chat.id, [firstUser]), agentFactory(engine)));
|
|
||||||
const saved1 = chatStore.getChat(chat.id)!;
|
|
||||||
const assistantMsg = saved1.messages[1]!;
|
|
||||||
|
|
||||||
// The learner answers; the client posts the graded transcript back.
|
// The learner answers; the client posts the ANSWER EVENT.
|
||||||
const options = normalizeOptions(QUIZ_INPUT.options);
|
const answer = gradedAnswer(QUIZ_INPUT, ["Mercury"]);
|
||||||
const qd: QuestionData = {
|
|
||||||
id: "call-1",
|
|
||||||
kind: "quiz",
|
|
||||||
question: QUIZ_INPUT.question,
|
|
||||||
mode: "single-select",
|
|
||||||
options,
|
|
||||||
correctIndices: resolveCorrect(coerceCorrectAnswer(QUIZ_INPUT.correctAnswer), options).indices,
|
|
||||||
explanation: QUIZ_INPUT.explanation,
|
|
||||||
createdAt: new Date().toISOString(),
|
|
||||||
};
|
|
||||||
const answer = buildAnswer(qd, { values: ["Mercury"] });
|
|
||||||
const messages2 = saved1.messages.map((m) =>
|
|
||||||
m.id === assistantMsg.id
|
|
||||||
? {
|
|
||||||
...m,
|
|
||||||
parts: m.parts.map((p) =>
|
|
||||||
p.type === "tool-quiz" ? { ...p, state: "output-available" as const, output: answer } : p,
|
|
||||||
),
|
|
||||||
}
|
|
||||||
: m,
|
|
||||||
);
|
|
||||||
|
|
||||||
// NOT drained: the teacher turn is streaming, but the answer is already
|
// NOT drained: the teacher turn is streaming, but the answer is already
|
||||||
// on disk — a remount (or crash) mid-turn sees the answered quiz, never
|
// on disk — a remount (or crash) mid-turn sees the answered quiz, never
|
||||||
// the stale pre-answer question.
|
// the stale pre-answer question.
|
||||||
const res = await handleChatRequest(post(chat.id, messages2), agentFactory(engine));
|
const res = await handleChatRequest(post(chat.id, { type: "answer", toolCallId: "call-1", answer }), agentFactory(engine));
|
||||||
const midTurn = chatStore.getChat(chat.id)!;
|
const midTurn = chatStore.getChat(chat.id)!;
|
||||||
const midQuiz = midTurn.messages[1]!.parts.find((p) => p.type === "tool-quiz");
|
assert.equal(midTurn.messages.length, 3, "write-ahead appended the answer tool result before finish");
|
||||||
|
assert.equal((midTurn.messages[2] as { role?: string }).role, "toolResult");
|
||||||
|
const midQuiz = projectNative(midTurn.messages)[1]!.parts.find((p) => p.type === "tool-quiz");
|
||||||
assert.ok(midQuiz && midQuiz.type === "tool-quiz");
|
assert.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");
|
||||||
|
|
||||||
await drain(res); // finish → persist the full turn → endTurn
|
await drain(res); // finish → persist the full turn → endTurn
|
||||||
const saved2 = chatStore.getChat(chat.id)!;
|
const saved2 = chatStore.getChat(chat.id)!;
|
||||||
assert.equal(saved2.messages.length, 2, "full turn persisted as usual at finish");
|
// Native: user, assistant, answer result, reaction — one append-only
|
||||||
|
// history; the projection still renders ONE assistant group.
|
||||||
|
assert.equal(saved2.messages.length, 4, "full turn persisted as usual at finish");
|
||||||
|
assert.equal(projectNative(saved2.messages).length, 2, "the projection groups the answered quiz + reaction");
|
||||||
assert.equal(isTurnRunning(chat.id), false, "registry cleared at finish");
|
assert.equal(isTurnRunning(chat.id), false, "registry cleared at finish");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("a turn that fails to start (malformed messages) clears the registry — no lockout", async () => {
|
|
||||||
const chat = createConfiguredChat("Test: turn failed to start");
|
|
||||||
createdChats.push(chat.id);
|
|
||||||
// A text part with no text: fails message validation before the stream
|
|
||||||
// exists — the path where no onFinish will ever run.
|
|
||||||
const bad = [{ id: "m1", role: "user", parts: [{ type: "text" }] }];
|
|
||||||
const res = await handleChatRequest(post(chat.id, bad), agentFactory(scriptedTeacher(defaultLesson).engine));
|
|
||||||
assert.equal(res.status, 500, "turn-start failure is a 500, not a hang");
|
|
||||||
assert.equal(isTurnRunning(chat.id), false, "registry cleared when the stream never started");
|
|
||||||
|
|
||||||
// And the chat is NOT locked: the next (valid) turn is accepted.
|
|
||||||
const ok = await handleChatRequest(
|
|
||||||
post(chat.id, [{ id: "m1", role: "user", parts: [{ type: "text", text: "teach me" }] }]),
|
|
||||||
agentFactory(scriptedTeacher(defaultLesson).engine),
|
|
||||||
);
|
|
||||||
assert.equal(ok.status, 200);
|
|
||||||
await drain(ok);
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("renameChat (the header's click-to-edit title)", () => {
|
describe("renameChat (the header's click-to-edit title)", () => {
|
||||||
@@ -635,8 +629,7 @@ describe("the BYOK turn gate (no factory floor — owner, 2026-09-22)", () => {
|
|||||||
// slot legitimately opens the gate (the chain is honest both ways).
|
// slot legitimately opens the gate (the chain is honest both ways).
|
||||||
const expectRefusal = effectiveSettings().models.strong === null;
|
const expectRefusal = effectiveSettings().models.strong === null;
|
||||||
|
|
||||||
const firstUser = { id: "u1", role: "user", parts: [{ type: "text", text: "teach me the solar system" }] };
|
const res = await handleChatRequest(post(chat.id, { type: "text", text: "teach me the solar system" }), agentFactory(scriptedTeacher(defaultLesson).engine));
|
||||||
const res = await handleChatRequest(post(chat.id, [firstUser]), agentFactory(scriptedTeacher(defaultLesson).engine));
|
|
||||||
|
|
||||||
if (expectRefusal) {
|
if (expectRefusal) {
|
||||||
assert.equal(res.status, 503, "the universal turn gate refuses before beginTurn");
|
assert.equal(res.status, 503, "the universal turn gate refuses before beginTurn");
|
||||||
@@ -660,8 +653,7 @@ describe("the BYOK turn gate (no factory floor — owner, 2026-09-22)", () => {
|
|||||||
it("a per-chat model alone opens the gate (the setup flow's exact write)", async () => {
|
it("a per-chat model alone opens the gate (the setup flow's exact write)", async () => {
|
||||||
const chat = createConfiguredChat("Test: per-chat pin only");
|
const chat = createConfiguredChat("Test: per-chat pin only");
|
||||||
createdChats.push(chat.id);
|
createdChats.push(chat.id);
|
||||||
const firstUser = { id: "u1", role: "user", parts: [{ type: "text", text: "teach me the solar system" }] };
|
const res = await handleChatRequest(post(chat.id, { type: "text", text: "teach me the solar system" }), agentFactory(scriptedTeacher(defaultLesson).engine));
|
||||||
const res = await handleChatRequest(post(chat.id, [firstUser]), agentFactory(scriptedTeacher(defaultLesson).engine));
|
|
||||||
assert.equal(res.status, 200, "chat.settings.model satisfies the chain without any record slot");
|
assert.equal(res.status, 200, "chat.settings.model satisfies the chain without any record slot");
|
||||||
await drain(res);
|
await drain(res);
|
||||||
});
|
});
|
||||||
@@ -683,8 +675,7 @@ describe("the key-presence turn gate (provider-key interlude, 2026-09-25)", () =
|
|||||||
// chain the strong slot rides. The gate fires BEFORE the engine
|
// chain the strong slot rides. The gate fires BEFORE the engine
|
||||||
// build, so the fake model id never hits the catalog.
|
// build, so the fake model id never hits the catalog.
|
||||||
chatStore.saveSettings(chat.id, { model: { provider: "together", id: "no-such-model" } });
|
chatStore.saveSettings(chat.id, { model: { provider: "together", id: "no-such-model" } });
|
||||||
const firstUser = { id: "u1", role: "user", parts: [{ type: "text", text: "teach me something" }] };
|
const res = await handleChatRequest(post(chat.id, { type: "text", text: "teach me something" }), agentFactory(scriptedTeacher(defaultLesson).engine));
|
||||||
const res = await handleChatRequest(post(chat.id, [firstUser]), agentFactory(scriptedTeacher(defaultLesson).engine));
|
|
||||||
|
|
||||||
assert.equal(res.status, 503, "the key-presence gate refuses before beginTurn");
|
assert.equal(res.status, 503, "the key-presence gate refuses before beginTurn");
|
||||||
const body = (await res.json()) as { error: string; code: string; provider: string; slots: string[] };
|
const body = (await res.json()) as { error: string; code: string; provider: string; slots: string[] };
|
||||||
@@ -708,8 +699,7 @@ describe("the key-presence turn gate (provider-key interlude, 2026-09-25)", () =
|
|||||||
// A REAL catalog model: the engine build AFTER the gate must
|
// A REAL catalog model: the engine build AFTER the gate must
|
||||||
// resolve — the injected agent replaces the loop, not the build.
|
// resolve — the injected agent replaces the loop, not the build.
|
||||||
chatStore.saveSettings(chat.id, { model: { provider: "openai", id: "gpt-4" } });
|
chatStore.saveSettings(chat.id, { model: { provider: "openai", id: "gpt-4" } });
|
||||||
const firstUser = { id: "u1", role: "user", parts: [{ type: "text", text: "teach me something" }] };
|
const res = await handleChatRequest(post(chat.id, { type: "text", text: "teach me something" }), agentFactory(scriptedTeacher(defaultLesson).engine));
|
||||||
const res = await handleChatRequest(post(chat.id, [firstUser]), agentFactory(scriptedTeacher(defaultLesson).engine));
|
|
||||||
|
|
||||||
assert.equal(res.status, 200, "the env-shadowed key counts as present — the turn runs");
|
assert.equal(res.status, 200, "the env-shadowed key counts as present — the turn runs");
|
||||||
await drain(res);
|
await drain(res);
|
||||||
@@ -728,8 +718,7 @@ describe("background-turn runner (owner ruling 2026-09-25: every turn is a serve
|
|||||||
const chat = createConfiguredChat("Test: reattach replay");
|
const chat = createConfiguredChat("Test: reattach replay");
|
||||||
createdChats.push(chat.id);
|
createdChats.push(chat.id);
|
||||||
|
|
||||||
const req = post(chat.id, [{ id: "u1", role: "user", parts: [{ type: "text", text: "hold my turn" }] }]);
|
const first = await handleChatRequest(post(chat.id, { type: "text", text: "hold my turn" }), agentFactory(engine));
|
||||||
const first = await handleChatRequest(req, agentFactory(engine));
|
|
||||||
assert.equal(first.status, 200);
|
assert.equal(first.status, 200);
|
||||||
// Consume the first view a little (the start chunk is out).
|
// Consume the first view a little (the start chunk is out).
|
||||||
const reader = first.body!.getReader();
|
const reader = first.body!.getReader();
|
||||||
@@ -768,8 +757,7 @@ describe("background-turn runner (owner ruling 2026-09-25: every turn is a serve
|
|||||||
const chat = createConfiguredChat("Test: remote abort");
|
const chat = createConfiguredChat("Test: remote abort");
|
||||||
createdChats.push(chat.id);
|
createdChats.push(chat.id);
|
||||||
|
|
||||||
const req = post(chat.id, [{ id: "u1", role: "user", parts: [{ type: "text", text: "wait forever" }] }]);
|
const res = await handleChatRequest(post(chat.id, { type: "text", text: "wait forever" }), agentFactory(engine));
|
||||||
const res = await handleChatRequest(req, agentFactory(engine));
|
|
||||||
assert.equal(res.status, 200);
|
assert.equal(res.status, 200);
|
||||||
assert.ok(runningTurn(chat.id), "turn registered while in flight");
|
assert.ok(runningTurn(chat.id), "turn registered while in flight");
|
||||||
const viewText = res.text(); // the first view, reading to settle
|
const viewText = res.text(); // the first view, reading to settle
|
||||||
@@ -802,8 +790,7 @@ describe("background-turn runner (owner ruling 2026-09-25: every turn is a serve
|
|||||||
|
|
||||||
// And after a settled turn, the registry is empty — the GET would
|
// And after a settled turn, the registry is empty — the GET would
|
||||||
// serve the same terminal, not a stale handle.
|
// serve the same terminal, not a stale handle.
|
||||||
const req = post(chat.id, [{ id: "u1", role: "user", parts: [{ type: "text", text: "quiz me" }] }]);
|
await drain(await handleChatRequest(post(chat.id, { type: "text", text: "quiz me" }), agentFactory(engine)));
|
||||||
await drain(await handleChatRequest(req, agentFactory(engine)));
|
|
||||||
assert.equal(runningTurn(chat.id), undefined, "no handle survives settle");
|
assert.equal(runningTurn(chat.id), undefined, "no handle survives settle");
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -819,8 +806,7 @@ describe("background-turn runner (owner ruling 2026-09-25: every turn is a serve
|
|||||||
|
|
||||||
const views = new Map<string, Promise<string>>();
|
const views = new Map<string, Promise<string>>();
|
||||||
for (const { chat, engine } of chats) {
|
for (const { chat, engine } of chats) {
|
||||||
const req = post(chat.id, [{ id: "u1", role: "user", parts: [{ type: "text", text: "hold my turn" }] }]);
|
const res = await handleChatRequest(post(chat.id, { type: "text", text: "hold my turn" }), agentFactory(engine));
|
||||||
const res = await handleChatRequest(req, agentFactory(engine));
|
|
||||||
assert.equal(res.status, 200);
|
assert.equal(res.status, 200);
|
||||||
const handle = runningTurn(chat.id)!;
|
const handle = runningTurn(chat.id)!;
|
||||||
assert.ok(handle, `chat ${chat.id} has its own handle`);
|
assert.ok(handle, `chat ${chat.id} has its own handle`);
|
||||||
|
|||||||
@@ -1,25 +1,20 @@
|
|||||||
/**
|
/**
|
||||||
* 409 merge-retry transport tests (components/chat-transport.ts).
|
* 409 identity-retry transport tests (components/chat-transport.ts).
|
||||||
*
|
*
|
||||||
* Every 409 means the server record is AHEAD (a wake turn persisted the
|
* Wave B (input events): a 409 means a turn is in flight — the event was
|
||||||
* steer): the retry must wait out the turn, merge the outgoing messages onto
|
* NEVER persisted (write-ahead happens only on accept), so the retry waits
|
||||||
* the fresh record, and re-POST the merged list — a bare retry would clobber
|
* out the turn and re-POSTs the SAME event body. Nothing merges, nothing
|
||||||
* the steer/wake messages via the write-ahead persist.
|
* derives: the event is the unit, and re-POSTing it is idempotent-safe.
|
||||||
*/
|
*/
|
||||||
import assert from "node:assert/strict";
|
import assert from "node:assert/strict";
|
||||||
import { afterEach, describe, it } from "node:test";
|
import { afterEach, describe, it } from "node:test";
|
||||||
import { retryingFetch } from "../components/chat-transport.ts";
|
import { retryingFetch } from "../components/chat-transport.ts";
|
||||||
import type { TeacherUIMessage } from "../src/core/ui-message.ts";
|
|
||||||
|
|
||||||
const realFetch = globalThis.fetch;
|
const realFetch = globalThis.fetch;
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
globalThis.fetch = realFetch;
|
globalThis.fetch = realFetch;
|
||||||
});
|
});
|
||||||
|
|
||||||
function msg(id: string, role: "user" | "assistant", text: string): TeacherUIMessage {
|
|
||||||
return { id, role, metadata: {}, parts: [{ type: "text", text }] };
|
|
||||||
}
|
|
||||||
|
|
||||||
interface Call {
|
interface Call {
|
||||||
url: string;
|
url: string;
|
||||||
body: string | undefined;
|
body: string | undefined;
|
||||||
@@ -30,8 +25,6 @@ function installFetch(routes: {
|
|||||||
chatPOST: (call: Call, attempt: number) => Response;
|
chatPOST: (call: Call, attempt: number) => Response;
|
||||||
/** GET /api/jobs?chatId=… → turnInFlight value per poll (poll index). */
|
/** GET /api/jobs?chatId=… → turnInFlight value per poll (poll index). */
|
||||||
jobsGET?: (poll: number) => boolean;
|
jobsGET?: (poll: number) => boolean;
|
||||||
/** GET /api/chats/<id> → messages array. */
|
|
||||||
chatGET?: () => TeacherUIMessage[];
|
|
||||||
}): Call[] {
|
}): Call[] {
|
||||||
const calls: Call[] = [];
|
const calls: Call[] = [];
|
||||||
let poll = 0;
|
let poll = 0;
|
||||||
@@ -42,14 +35,14 @@ function installFetch(routes: {
|
|||||||
return routes.chatPOST(calls.at(-1)!, calls.filter((c) => c.url === "/api/chat").length);
|
return routes.chatPOST(calls.at(-1)!, calls.filter((c) => c.url === "/api/chat").length);
|
||||||
}
|
}
|
||||||
if (url.startsWith("/api/jobs")) return Response.json({ turnInFlight: routes.jobsGET?.(++poll) ?? false });
|
if (url.startsWith("/api/jobs")) return Response.json({ turnInFlight: routes.jobsGET?.(++poll) ?? false });
|
||||||
if (url.startsWith("/api/chats/")) return Response.json({ messages: routes.chatGET?.() ?? [] });
|
|
||||||
throw new Error(`unexpected fetch: ${url}`);
|
throw new Error(`unexpected fetch: ${url}`);
|
||||||
}) as typeof globalThis.fetch;
|
}) as typeof globalThis.fetch;
|
||||||
return calls;
|
return calls;
|
||||||
}
|
}
|
||||||
|
|
||||||
function outgoing(clientOnly: TeacherUIMessage[]): string {
|
/** One input event — the POST body's whole unit. */
|
||||||
return JSON.stringify({ chatId: "chat_x", messages: [...clientOnly] });
|
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 }, () => {
|
describe("retryingFetch (409 merge-retry)", { skip: process.env.NO_SLOW ? true : undefined }, () => {
|
||||||
@@ -58,66 +51,54 @@ describe("retryingFetch (409 merge-retry)", { skip: process.env.NO_SLOW ? true :
|
|||||||
chatPOST: () => new Response("stream", { status: 200 }),
|
chatPOST: () => new Response("stream", { status: 200 }),
|
||||||
});
|
});
|
||||||
const fetch = retryingFetch("chat_x", { pollMs: 10, waitMaxMs: 100 });
|
const fetch = retryingFetch("chat_x", { pollMs: 10, waitMaxMs: 100 });
|
||||||
const res = await fetch("/api/chat", { method: "POST", body: outgoing([msg("c1", "user", "hi")]) });
|
const res = await fetch("/api/chat", { method: "POST", body: outgoing({ type: "text", text: "hi" }) });
|
||||||
assert.equal(res.status, 200);
|
assert.equal(res.status, 200);
|
||||||
assert.equal(calls.length, 1, "no polling, no merge, single call");
|
assert.equal(calls.length, 1, "no polling, no merge, single call");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("waits out the turn, merges onto the fresh record, re-POSTs the merged list", async () => {
|
it("waits out the turn, then re-POSTs the SAME event body (identity retry)", async () => {
|
||||||
const server = [msg("steer_1", "user", "Research complete"), msg("s1", "assistant", "wake turn")];
|
const event = { type: "text", text: "my question while the wake turn ran" };
|
||||||
const fresh = [msg("c1", "user", "my question while the wake turn ran")];
|
|
||||||
const calls = installFetch({
|
const calls = installFetch({
|
||||||
chatPOST: (_call, n) => (n === 1 ? new Response("busy", { status: 409 }) : new Response("stream", { status: 200 })),
|
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
|
jobsGET: (poll) => poll <= 1, // first poll: still running; second: done
|
||||||
chatGET: () => server,
|
|
||||||
});
|
});
|
||||||
const fetch = retryingFetch("chat_x", { pollMs: 10, waitMaxMs: 100 });
|
const fetch = retryingFetch("chat_x", { pollMs: 10, waitMaxMs: 100 });
|
||||||
const res = await fetch("/api/chat", { method: "POST", body: outgoing(fresh) });
|
const res = await fetch("/api/chat", { method: "POST", body: outgoing(event) });
|
||||||
assert.equal(res.status, 200);
|
assert.equal(res.status, 200);
|
||||||
|
|
||||||
// second POST carries server truth + the client's unsent message, in order
|
// The retry carries the IDENTICAL body — the event was never
|
||||||
const retry = JSON.parse(calls.filter((c) => c.url === "/api/chat")[1]!.body!);
|
// persisted pre-accept, so re-sending it is the whole protocol.
|
||||||
assert.deepEqual(
|
const posts = calls.filter((c) => c.url === "/api/chat");
|
||||||
retry.messages.map((m: { id: string }) => m.id),
|
assert.equal(posts.length, 2, "one 409, one retry");
|
||||||
["steer_1", "s1", "c1"],
|
assert.equal(posts[1]!.body, posts[0]!.body, "the event body is byte-identical");
|
||||||
);
|
assert.deepEqual(JSON.parse(posts[1]!.body!), { chatId: "chat_x", input: event });
|
||||||
});
|
});
|
||||||
|
|
||||||
it("drops client messages the server already has (no duplicates in the merge)", async () => {
|
it("does not poll the record or merge — the event is the only payload", async () => {
|
||||||
const server = [msg("steer_1", "user", "Research complete"), msg("c1", "user", "my question")];
|
|
||||||
const calls = installFetch({
|
const calls = installFetch({
|
||||||
chatPOST: (_call, n) => (n === 1 ? new Response("busy", { status: 409 }) : new Response("stream", { status: 200 })),
|
chatPOST: (_call, n) => (n === 1 ? new Response("busy", { status: 409 }) : new Response("stream", { status: 200 })),
|
||||||
chatGET: () => server,
|
|
||||||
});
|
});
|
||||||
const fetch = retryingFetch("chat_x", { pollMs: 10, waitMaxMs: 100 });
|
const fetch = retryingFetch("chat_x", { pollMs: 10, waitMaxMs: 100 });
|
||||||
const res = await fetch("/api/chat", {
|
const res = await fetch("/api/chat", { method: "POST", body: outgoing({ type: "text", text: "q" }) });
|
||||||
method: "POST",
|
|
||||||
body: outgoing([msg("steer_1", "user", "Research complete"), msg("c1", "user", "my question")]),
|
|
||||||
});
|
|
||||||
assert.equal(res.status, 200);
|
assert.equal(res.status, 200);
|
||||||
const retry = JSON.parse(calls.filter((c) => c.url === "/api/chat")[1]!.body!);
|
assert.equal(
|
||||||
assert.deepEqual(
|
calls.filter((c) => c.url.startsWith("/api/chats/")).length,
|
||||||
retry.messages.map((m: { id: string }) => m.id),
|
0,
|
||||||
["steer_1", "c1"],
|
"the retry never reads the record — the server owns it",
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("loops on repeated 409s, re-merging fresh each time", async () => {
|
it("loops on repeated 409s, re-POSTing the same event each time", async () => {
|
||||||
const server = [msg("steer_1", "user", "Research complete")];
|
const event = { type: "answer", toolCallId: "call-1", answer: { correct: true } };
|
||||||
const calls = installFetch({
|
const calls = installFetch({
|
||||||
chatPOST: (_call, n) => (n <= 2 ? new Response("busy", { status: 409 }) : new Response("stream", { status: 200 })),
|
chatPOST: (_call, n) => (n <= 2 ? new Response("busy", { status: 409 }) : new Response("stream", { status: 200 })),
|
||||||
chatGET: () => server,
|
|
||||||
});
|
});
|
||||||
const fetch = retryingFetch("chat_x", { pollMs: 10, waitMaxMs: 100 });
|
const fetch = retryingFetch("chat_x", { pollMs: 10, waitMaxMs: 100 });
|
||||||
const res = await fetch("/api/chat", { method: "POST", body: outgoing([msg("c1", "user", "q")]) });
|
const res = await fetch("/api/chat", { method: "POST", body: outgoing(event) });
|
||||||
assert.equal(res.status, 200);
|
assert.equal(res.status, 200);
|
||||||
const posts = calls.filter((c) => c.url === "/api/chat");
|
const posts = calls.filter((c) => c.url === "/api/chat");
|
||||||
assert.equal(posts.length, 3);
|
assert.equal(posts.length, 3);
|
||||||
const last = JSON.parse(posts[2]!.body!);
|
assert.ok(posts.every((c) => c.body === posts[0]!.body), "every attempt carries the same event");
|
||||||
assert.deepEqual(
|
|
||||||
last.messages.map((m: { id: string }) => m.id),
|
|
||||||
["steer_1", "c1"],
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("gives up after TURN_WAIT_MAX_MS and returns the last 409 (banner as before)", async () => {
|
it("gives up after TURN_WAIT_MAX_MS and returns the last 409 (banner as before)", async () => {
|
||||||
@@ -129,7 +110,7 @@ describe("retryingFetch (409 merge-retry)", { skip: process.env.NO_SLOW ? true :
|
|||||||
jobsGET: () => true,
|
jobsGET: () => true,
|
||||||
});
|
});
|
||||||
const fetch = retryingFetch("chat_x", { pollMs: 10, waitMaxMs: 100 });
|
const fetch = retryingFetch("chat_x", { pollMs: 10, waitMaxMs: 100 });
|
||||||
const res = await fetch("/api/chat", { method: "POST", body: outgoing([msg("c1", "user", "q")]) });
|
const res = await fetch("/api/chat", { method: "POST", body: outgoing({ type: "text", text: "q" }) });
|
||||||
assert.equal(res.status, 409);
|
assert.equal(res.status, 409);
|
||||||
polls = calls.filter((c) => c.url.startsWith("/api/jobs")).length;
|
polls = calls.filter((c) => c.url.startsWith("/api/jobs")).length;
|
||||||
assert.ok(polls <= 15, `expected bounded polling (~10×10ms), got ${polls}`);
|
assert.ok(polls <= 15, `expected bounded polling (~10×10ms), got ${polls}`);
|
||||||
|
|||||||
+78
-40
@@ -50,16 +50,15 @@ import { runDemoBuilder } from "../src/agent/subagents/demo-builder.ts";
|
|||||||
import { headlessOverride, type DemoRunResult, type HeadlessApi } from "../src/agent/subagents/headless.ts";
|
import { headlessOverride, type DemoRunResult, type HeadlessApi } from "../src/agent/subagents/headless.ts";
|
||||||
import { createJobRunner, demoModelOverride, visualModelOverride } from "../src/agent/subagents/runner.ts";
|
import { createJobRunner, demoModelOverride, visualModelOverride } from "../src/agent/subagents/runner.ts";
|
||||||
import { runVisualMaker } from "../src/agent/subagents/visual-maker.ts";
|
import { runVisualMaker } from "../src/agent/subagents/visual-maker.ts";
|
||||||
import { buildAbortSteer, buildSubagentSteer, isSubagentFailure, type SubagentSteerMessage } from "../src/core/contracts.ts";
|
import { buildAbortSteer, buildSubagentSteer, isSubagentFailure, type SteerMessage } from "../src/core/contracts.ts";
|
||||||
import type { JobRecord } from "../src/core/contracts.ts";
|
import type { JobRecord } from "../src/core/contracts.ts";
|
||||||
import { createTeacherTools } from "../src/agent/tools.ts";
|
import { createTeacherTools } from "../src/agent/tools.ts";
|
||||||
import { chatStore } from "../src/store/chats.ts";
|
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, type TeachingAgent } from "../src/agent/teacher-agent.ts";
|
import { createTeacherAgent } from "../src/agent/teacher-agent.ts";
|
||||||
import { recordToPiMessages } from "../src/server/pi/record-adapter.ts";
|
import { projectNative } from "../src/core/projection.ts";
|
||||||
import type { PiEngine } from "../src/agent/models.ts";
|
import type { PiEngine } from "../src/agent/models.ts";
|
||||||
import type { UIMessage } from "ai";
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A fake deliver seam + a runner wired like the chat-handler's, per test:
|
* A fake deliver seam + a runner wired like the chat-handler's, per test:
|
||||||
@@ -67,13 +66,13 @@ import type { UIMessage } from "ai";
|
|||||||
* record reads exactly as production does after the wake turn completes.
|
* record reads exactly as production does after the wake turn completes.
|
||||||
*/
|
*/
|
||||||
function testRunner() {
|
function testRunner() {
|
||||||
const steers: SubagentSteerMessage[] = [];
|
const steers: SteerMessage[] = [];
|
||||||
const runner = createJobRunner({
|
const runner = createJobRunner({
|
||||||
chatStore,
|
chatStore,
|
||||||
jobStore,
|
jobStore,
|
||||||
deliver: async (steer) => {
|
deliver: async (steer) => {
|
||||||
steers.push(steer);
|
steers.push(steer);
|
||||||
const jobId = steer.metadata.subagentResult.jobId;
|
const jobId = (steer.details as { jobId: string }).jobId;
|
||||||
const job = jobStore.getJob(jobId);
|
const job = jobStore.getJob(jobId);
|
||||||
if (job && job.status === "running") {
|
if (job && job.status === "running") {
|
||||||
const failed = job.result ? isSubagentFailure(job.kind, job.result as never) : true;
|
const failed = job.result ? isSubagentFailure(job.kind, job.result as never) : true;
|
||||||
@@ -349,7 +348,7 @@ describe("steer card rendering (the delivered artifact)", () => {
|
|||||||
{ id: "jd", kind: "demo", toolCallId: "cd" },
|
{ id: "jd", kind: "demo", toolCallId: "cd" },
|
||||||
{ title: "Damped oscillator", html: DEMO_HTML, tested: true },
|
{ title: "Damped oscillator", html: DEMO_HTML, tested: true },
|
||||||
);
|
);
|
||||||
const html = renderToStaticMarkup(<SubagentSteerCard message={steer as never} />);
|
const html = renderToStaticMarkup(<SubagentSteerCard message={projectNative([steer])[0] as never} />);
|
||||||
assert.match(html, /sandbox="allow-scripts"/);
|
assert.match(html, /sandbox="allow-scripts"/);
|
||||||
assert.ok(!html.includes("allow-same-origin"), "never allow-same-origin");
|
assert.ok(!html.includes("allow-same-origin"), "never allow-same-origin");
|
||||||
assert.match(html, /demo-resize/);
|
assert.match(html, /demo-resize/);
|
||||||
@@ -365,7 +364,7 @@ describe("steer card rendering (the delivered artifact)", () => {
|
|||||||
{ id: "jd", kind: "demo", toolCallId: "cd" },
|
{ id: "jd", kind: "demo", toolCallId: "cd" },
|
||||||
{ title: "T", html: "", tested: true, notes: "FAILED: never passed verification" },
|
{ title: "T", html: "", tested: true, notes: "FAILED: never passed verification" },
|
||||||
);
|
);
|
||||||
const html = renderToStaticMarkup(<SubagentSteerCard message={steer as never} />);
|
const html = renderToStaticMarkup(<SubagentSteerCard message={projectNative([steer])[0] as never} />);
|
||||||
assert.match(html, /FAILED: never passed verification/);
|
assert.match(html, /FAILED: never passed verification/);
|
||||||
assert.ok(!html.includes("<iframe"), "no iframe for a failed build");
|
assert.ok(!html.includes("<iframe"), "no iframe for a failed build");
|
||||||
});
|
});
|
||||||
@@ -376,7 +375,7 @@ describe("steer card rendering (the delivered artifact)", () => {
|
|||||||
{ id: "jv", kind: "visual", toolCallId: "cv" },
|
{ id: "jv", kind: "visual", toolCallId: "cv" },
|
||||||
{ svg, notes: "Looking at the render, the diagram is clean and accurate." },
|
{ svg, notes: "Looking at the render, the diagram is clean and accurate." },
|
||||||
);
|
);
|
||||||
const html = renderToStaticMarkup(<SubagentSteerCard message={steer as never} />);
|
const html = renderToStaticMarkup(<SubagentSteerCard message={projectNative([steer])[0] as never} />);
|
||||||
assert.ok(html.includes("circle"), "svg content rendered inline");
|
assert.ok(html.includes("circle"), "svg content rendered inline");
|
||||||
assert.match(html, /Figure/);
|
assert.match(html, /Figure/);
|
||||||
assert.match(html, /svg\]:w-full/, "svg scales to the display area's full width");
|
assert.match(html, /svg\]:w-full/, "svg scales to the display area's full width");
|
||||||
@@ -385,16 +384,19 @@ describe("steer card rendering (the delivered artifact)", () => {
|
|||||||
|
|
||||||
it("renders the abort chip for an aborted job's steer (2026-09 dock semantics; the message type is the marker PART)", () => {
|
it("renders the abort chip for an aborted job's steer (2026-09 dock semantics; the message type is the marker PART)", () => {
|
||||||
const steer = buildAbortSteer({ id: "jc", kind: "research", toolCallId: "cc" });
|
const steer = buildAbortSteer({ id: "jc", kind: "research", toolCallId: "cc" });
|
||||||
assert.equal(steer.metadata.subagentResult.aborted, true);
|
// Wave B: the steer is a native marker — the card renders its
|
||||||
assert.equal(steer.parts.length, 2, "text (teacher-facing) + the data-subagent-aborted marker part");
|
// PROJECTION (the same view the transcript renderer hands it).
|
||||||
const html = renderToStaticMarkup(<SubagentSteerCard message={steer as never} />);
|
const view = projectNative([steer])[0]!;
|
||||||
|
assert.equal((steer.details as { aborted: boolean }).aborted, true);
|
||||||
|
assert.equal(view.parts.length, 2, "text (teacher-facing) + the data-subagent-aborted marker part");
|
||||||
|
const html = renderToStaticMarkup(<SubagentSteerCard message={view as never} />);
|
||||||
assert.match(html, /job-aborted-badge/);
|
assert.match(html, /job-aborted-badge/);
|
||||||
assert.match(html, /research/, "the job's kind, lower-cased like the complete-badge idiom");
|
assert.match(html, /research/, "the job's kind, lower-cased like the complete-badge idiom");
|
||||||
assert.match(html, /aborted by the learner/);
|
assert.match(html, /aborted by the learner/);
|
||||||
// the teacher-facing protocol text never renders for the learner
|
// the teacher-facing protocol text never renders for the learner
|
||||||
assert.ok(!html.includes("no brief is coming"), "no protocol glue in the chip");
|
assert.ok(!html.includes("no brief is coming"), "no protocol glue in the chip");
|
||||||
// the badge renders off the PART, not metadata (the custom-message scheme)
|
// the badge renders off the PART, not metadata (the custom-message scheme)
|
||||||
const noMeta = { ...steer, metadata: {} } as typeof steer;
|
const noMeta = { ...view, metadata: {} };
|
||||||
const htmlNoMeta = renderToStaticMarkup(<SubagentSteerCard message={noMeta as never} />);
|
const htmlNoMeta = renderToStaticMarkup(<SubagentSteerCard message={noMeta as never} />);
|
||||||
assert.match(htmlNoMeta, /job-aborted-badge/, "metadata is pump mechanics, never the render seam");
|
assert.match(htmlNoMeta, /job-aborted-badge/, "metadata is pump mechanics, never the render seam");
|
||||||
});
|
});
|
||||||
@@ -410,7 +412,7 @@ describe("steer card rendering (the delivered artifact)", () => {
|
|||||||
],
|
],
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
const html = renderToStaticMarkup(<SubagentSteerCard message={steer as never} />);
|
const html = renderToStaticMarkup(<SubagentSteerCard message={projectNative([steer])[0] as never} />);
|
||||||
assert.match(html, /Research/);
|
assert.match(html, /Research/);
|
||||||
assert.match(html, /complete/);
|
assert.match(html, /complete/);
|
||||||
assert.match(html, /2 sources/);
|
assert.match(html, /2 sources/);
|
||||||
@@ -435,7 +437,7 @@ describe("steer card rendering (the delivered artifact)", () => {
|
|||||||
pagesConsulted: [1, 2],
|
pagesConsulted: [1, 2],
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
const html = renderToStaticMarkup(<SubagentSteerCard message={steer as never} />);
|
const html = renderToStaticMarkup(<SubagentSteerCard message={projectNative([steer])[0] as never} />);
|
||||||
assert.match(html, /PDF/);
|
assert.match(html, /PDF/);
|
||||||
assert.match(html, /reading complete/);
|
assert.match(html, /reading complete/);
|
||||||
assert.match(html, /read 1 page/, "the badge counts READ pages, not search hits");
|
assert.match(html, /read 1 page/, "the badge counts READ pages, not search hits");
|
||||||
@@ -451,7 +453,7 @@ describe("steer card rendering (the delivered artifact)", () => {
|
|||||||
{ id: "jpf2", kind: "pdf", toolCallId: "cpf2" },
|
{ id: "jpf2", kind: "pdf", toolCallId: "cpf2" },
|
||||||
{ path: "/tmp/doc.pdf", question: "q", brief: "PDF failed: the file could not be opened", pagesRead: [], pagesConsulted: [] },
|
{ path: "/tmp/doc.pdf", question: "q", brief: "PDF failed: the file could not be opened", pagesRead: [], pagesConsulted: [] },
|
||||||
);
|
);
|
||||||
const html = renderToStaticMarkup(<SubagentSteerCard message={steer as never} />);
|
const html = renderToStaticMarkup(<SubagentSteerCard message={projectNative([steer])[0] as never} />);
|
||||||
assert.match(html, /pdf-brief-card/);
|
assert.match(html, /pdf-brief-card/);
|
||||||
assert.match(html, /failed/);
|
assert.match(html, /failed/);
|
||||||
assert.match(html, /could not be opened/);
|
assert.match(html, /could not be opened/);
|
||||||
@@ -460,7 +462,7 @@ describe("steer card rendering (the delivered artifact)", () => {
|
|||||||
|
|
||||||
it("renders the visual failure note with no svg when the build FAILED", () => {
|
it("renders the visual failure note with no svg when the build FAILED", () => {
|
||||||
const steer = buildSubagentSteer({ id: "jv", kind: "visual", toolCallId: "cv" }, { svg: "", notes: "FAILED: iteration limit" });
|
const steer = buildSubagentSteer({ id: "jv", kind: "visual", toolCallId: "cv" }, { svg: "", notes: "FAILED: iteration limit" });
|
||||||
const html = renderToStaticMarkup(<SubagentSteerCard message={steer as never} />);
|
const html = renderToStaticMarkup(<SubagentSteerCard message={projectNative([steer])[0] as never} />);
|
||||||
assert.match(html, /FAILED: iteration limit/);
|
assert.match(html, /FAILED: iteration limit/);
|
||||||
assert.ok(!html.includes("circle") && !html.includes("<svg"), "no svg for a failed build");
|
assert.ok(!html.includes("circle") && !html.includes("<svg"), "no svg for a failed build");
|
||||||
});
|
});
|
||||||
@@ -675,14 +677,32 @@ describe("demo/visual job runners", () => {
|
|||||||
it("runJob completes a demo: result on the job, steer via deliver, ack stays on the part", async () => {
|
it("runJob completes a demo: result on the job, steer via deliver, ack stays on the part", async () => {
|
||||||
const chat = chatStore.createChat("Test: demo runner");
|
const chat = chatStore.createChat("Test: demo runner");
|
||||||
createdChats.push(chat.id);
|
createdChats.push(chat.id);
|
||||||
|
// Wave B: a NATIVE fixture (the record holds agent history) — the
|
||||||
|
// runner's transcript scan reads it through the projection.
|
||||||
chatStore.saveMessages(chat.id, [
|
chatStore.saveMessages(chat.id, [
|
||||||
{ id: "u1", role: "user", parts: [{ type: "text", text: "show me compounding" }] },
|
{ id: "u1", role: "user", content: "show me compounding", timestamp: 1 } as never,
|
||||||
{
|
{
|
||||||
id: "a1",
|
id: "a1",
|
||||||
role: "assistant",
|
role: "assistant",
|
||||||
parts: [{ type: "tool-demo", toolCallId: "call-d1", state: "output-available", input: DEMO_INPUT, output: "Dispatched." }],
|
content: [{ type: "toolCall", id: "call-d1", name: "demo", arguments: DEMO_INPUT as never }],
|
||||||
},
|
api: "openai-completions",
|
||||||
] as never);
|
provider: "openrouter",
|
||||||
|
model: "test",
|
||||||
|
usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
||||||
|
stopReason: "stop",
|
||||||
|
timestamp: 1,
|
||||||
|
} as never,
|
||||||
|
{
|
||||||
|
id: "r1",
|
||||||
|
role: "toolResult",
|
||||||
|
toolCallId: "call-d1",
|
||||||
|
toolName: "demo",
|
||||||
|
content: [{ type: "text", text: "Dispatched." }],
|
||||||
|
details: "Dispatched.",
|
||||||
|
isError: false,
|
||||||
|
timestamp: 1,
|
||||||
|
} as never,
|
||||||
|
]);
|
||||||
|
|
||||||
const job = jobStore.createJob({ kind: "demo", chatId: chat.id, toolCallId: "call-d1" });
|
const job = jobStore.createJob({ kind: "demo", chatId: chat.id, toolCallId: "call-d1" });
|
||||||
createdJobs.push(job.id);
|
createdJobs.push(job.id);
|
||||||
@@ -712,15 +732,14 @@ describe("demo/visual job runners", () => {
|
|||||||
|
|
||||||
// The steer carries the artifact for the renderer.
|
// The steer carries the artifact for the renderer.
|
||||||
assert.equal(steers.length, 1, "exactly one steer");
|
assert.equal(steers.length, 1, "exactly one steer");
|
||||||
const data = steers[0]!.parts[1];
|
const data = steers[0]!.details as { kind: string; result: unknown };
|
||||||
assert.ok(data.type === "data-subagent-artifact");
|
assert.equal(data.kind, "demo");
|
||||||
assert.equal(data.data.kind, "demo");
|
assert.deepEqual(data.result, result);
|
||||||
assert.deepEqual(data.data.result, result);
|
|
||||||
|
|
||||||
// The tool part keeps the DISPATCH ACK forever — the result never
|
// The tool part keeps the DISPATCH ACK forever — the result never
|
||||||
// rewrites it (the teacher gets the result once, via the steer).
|
// rewrites it (the teacher gets the result once, via the steer).
|
||||||
const saved = chatStore.getChat(chat.id)!;
|
const saved = projectNative(chatStore.getChat(chat.id)!.messages);
|
||||||
const part = saved.messages[1]!.parts.find((p) => p.type === "tool-demo");
|
const part = saved[1]!.parts.find((p) => p.type === "tool-demo");
|
||||||
assert.ok(part && part.type === "tool-demo", "demo tool part persisted");
|
assert.ok(part && part.type === "tool-demo", "demo tool part persisted");
|
||||||
assert.equal(typeof (part as { output?: unknown }).output, "string", "part output is the ack, not the result");
|
assert.equal(typeof (part as { output?: unknown }).output, "string", "part output is the ack, not the result");
|
||||||
});
|
});
|
||||||
@@ -729,13 +748,29 @@ describe("demo/visual job runners", () => {
|
|||||||
const chat = chatStore.createChat("Test: visual runner failure");
|
const chat = chatStore.createChat("Test: visual runner failure");
|
||||||
createdChats.push(chat.id);
|
createdChats.push(chat.id);
|
||||||
chatStore.saveMessages(chat.id, [
|
chatStore.saveMessages(chat.id, [
|
||||||
{ id: "u1", role: "user", parts: [{ type: "text", text: "draw it" }] },
|
{ id: "u1", role: "user", content: "draw it", timestamp: 1 } as never,
|
||||||
{
|
{
|
||||||
id: "a1",
|
id: "a1",
|
||||||
role: "assistant",
|
role: "assistant",
|
||||||
parts: [{ type: "tool-visual", toolCallId: "call-v1", state: "output-available", input: { brief: "b" }, output: "Dispatched." }],
|
content: [{ type: "toolCall", id: "call-v1", name: "visual", arguments: { brief: "b" } as never }],
|
||||||
},
|
api: "openai-completions",
|
||||||
] as never);
|
provider: "openrouter",
|
||||||
|
model: "test",
|
||||||
|
usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
||||||
|
stopReason: "stop",
|
||||||
|
timestamp: 1,
|
||||||
|
} as never,
|
||||||
|
{
|
||||||
|
id: "r1",
|
||||||
|
role: "toolResult",
|
||||||
|
toolCallId: "call-v1",
|
||||||
|
toolName: "visual",
|
||||||
|
content: [{ type: "text", text: "Dispatched." }],
|
||||||
|
details: "Dispatched.",
|
||||||
|
isError: false,
|
||||||
|
timestamp: 1,
|
||||||
|
} as never,
|
||||||
|
]);
|
||||||
|
|
||||||
const job = jobStore.createJob({ kind: "visual", chatId: chat.id, toolCallId: "call-v1" });
|
const job = jobStore.createJob({ kind: "visual", chatId: chat.id, toolCallId: "call-v1" });
|
||||||
createdJobs.push(job.id);
|
createdJobs.push(job.id);
|
||||||
@@ -753,9 +788,7 @@ describe("demo/visual job runners", () => {
|
|||||||
assert.match(errored.error ?? "", /never wrote an SVG/);
|
assert.match(errored.error ?? "", /never wrote an SVG/);
|
||||||
|
|
||||||
assert.ok(steers[0], "failure still delivers a steer — the teacher learns the outcome");
|
assert.ok(steers[0], "failure still delivers a steer — the teacher learns the outcome");
|
||||||
const data = steers[0]!.parts[1];
|
const result = (steers[0]!.details as { result: unknown }).result as { svg: string; notes?: string };
|
||||||
assert.ok(data.type === "data-subagent-artifact");
|
|
||||||
const result = data.data.result as { svg: string; notes?: string };
|
|
||||||
assert.equal(result.svg, "");
|
assert.equal(result.svg, "");
|
||||||
assert.match(result.notes ?? "", /^FAILED:/);
|
assert.match(result.notes ?? "", /^FAILED:/);
|
||||||
});
|
});
|
||||||
@@ -791,13 +824,18 @@ describe("demo/visual job runners", () => {
|
|||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
chatId: chat.id,
|
chatId: chat.id,
|
||||||
messages: [{ id: "u1", role: "user", parts: [{ type: "text", text: "teach me compounding" }] }],
|
// Wave B: the input-events contract — one event,
|
||||||
|
// never a client transcript.
|
||||||
|
input: { type: "text", text: "teach me compounding" },
|
||||||
}),
|
}),
|
||||||
}),
|
}),
|
||||||
(outgoing: UIMessage[]): TeachingAgent =>
|
// The handler hands the factory the native build options
|
||||||
|
// (messages are the merged record; the record IS the agent
|
||||||
|
// state — no adapter conversion).
|
||||||
|
(opts) =>
|
||||||
createTeacherAgent(undefined, {
|
createTeacherAgent(undefined, {
|
||||||
engine: teacher,
|
engine: teacher,
|
||||||
messages: recordToPiMessages(outgoing),
|
messages: opts.messages,
|
||||||
tools: createTeacherTools({
|
tools: createTeacherTools({
|
||||||
chatId: chat.id,
|
chatId: chat.id,
|
||||||
chatStore,
|
chatStore,
|
||||||
@@ -818,14 +856,14 @@ describe("demo/visual job runners", () => {
|
|||||||
const done = jobStore.getJob(job!.id)!;
|
const done = jobStore.getJob(job!.id)!;
|
||||||
assert.equal(done.status, "done");
|
assert.equal(done.status, "done");
|
||||||
|
|
||||||
const saved = chatStore.getChat(chat.id)!;
|
const saved = projectNative(chatStore.getChat(chat.id)!.messages);
|
||||||
const part = saved.messages[1]!.parts.find((p) => p.type === "tool-demo");
|
const part = saved[1]!.parts.find((p) => p.type === "tool-demo");
|
||||||
assert.ok(part && part.type === "tool-demo", "demo tool part persisted");
|
assert.ok(part && part.type === "tool-demo", "demo tool part persisted");
|
||||||
assert.equal(part.state, "output-available", "the part resolved with the ack at dispatch time");
|
assert.equal(part.state, "output-available", "the part resolved with the ack at dispatch time");
|
||||||
assert.match(String((part as { output?: unknown }).output), /fire-and-forget/);
|
assert.match(String((part as { output?: unknown }).output), /fire-and-forget/);
|
||||||
|
|
||||||
assert.equal(steers.length, 1, "one job, one steer");
|
assert.equal(steers.length, 1, "one job, one steer");
|
||||||
assert.equal(steers[0]!.metadata.subagentResult.jobId, job!.id);
|
assert.equal((steers[0]!.details as { jobId: string }).jobId, job!.id);
|
||||||
|
|
||||||
// Idempotency: dispatch dedupes by toolCallId — no second job.
|
// Idempotency: dispatch dedupes by toolCallId — no second job.
|
||||||
assert.equal(jobStore.listJobs(chat.id).length, 1, "no duplicate job for the same tool part");
|
assert.equal(jobStore.listJobs(chat.id).length, 1, "no duplicate job for the same tool part");
|
||||||
|
|||||||
+93
-41
@@ -23,8 +23,10 @@ import assert from "node:assert/strict";
|
|||||||
import * as fs from "node:fs";
|
import * as fs from "node:fs";
|
||||||
import { after, describe, it } from "node:test";
|
import { after, describe, it } from "node:test";
|
||||||
import { createFauxCore, fauxAssistantMessage, fauxToolCall, type FauxResponseStep } from "@earendil-works/pi-ai";
|
import { createFauxCore, fauxAssistantMessage, fauxToolCall, type FauxResponseStep } from "@earendil-works/pi-ai";
|
||||||
import type { UIMessage } from "ai";
|
|
||||||
import type { PiEngine } from "../src/agent/models.ts";
|
import type { PiEngine } from "../src/agent/models.ts";
|
||||||
|
import type { LhMessage } from "../src/core/native-messages.ts";
|
||||||
|
import { newPlanBlockMessage } from "../src/core/native-messages.ts";
|
||||||
|
import { projectNative } from "../src/core/projection.ts";
|
||||||
import type {
|
import type {
|
||||||
AuditResult,
|
AuditResult,
|
||||||
CoverageEntry,
|
CoverageEntry,
|
||||||
@@ -116,26 +118,67 @@ function memoryLedgers() {
|
|||||||
} satisfies GraphStore;
|
} satisfies GraphStore;
|
||||||
}
|
}
|
||||||
|
|
||||||
function assistant(id: string, parts: unknown[]): UIMessage {
|
// Native fixture builders (Wave B): the record holds pi-native history —
|
||||||
return { id, role: "assistant", parts } as unknown as UIMessage;
|
// an assistant message with its tool calls + one result message per call;
|
||||||
|
// the projection reproduces the part shapes these view tests pin.
|
||||||
|
interface NativeCallDesc {
|
||||||
|
name: string;
|
||||||
|
toolCallId: string;
|
||||||
|
input: Record<string, unknown>;
|
||||||
|
output: unknown;
|
||||||
}
|
}
|
||||||
|
|
||||||
function user(id: string, text: string): UIMessage {
|
function user(id: string, text: string): LhMessage {
|
||||||
return { id, role: "user", parts: [{ type: "text", text }] } as unknown as UIMessage;
|
return { id, role: "user", content: text, timestamp: Date.now() } as never;
|
||||||
}
|
}
|
||||||
|
|
||||||
function quizPart(toolCallId: string, input: Record<string, unknown>, output: Record<string, unknown>): unknown {
|
function assistant(id: string, items: unknown[]): LhMessage[] {
|
||||||
return { type: "tool-quiz", toolCallId, state: "output-available", input, output };
|
const content: unknown[] = [];
|
||||||
|
const calls: NativeCallDesc[] = [];
|
||||||
|
for (const item of items) {
|
||||||
|
const it = item as { type?: string; text?: string };
|
||||||
|
if (it && it.type === "text") {
|
||||||
|
content.push({ type: "text", text: it.text });
|
||||||
|
} else {
|
||||||
|
const call = item as NativeCallDesc;
|
||||||
|
calls.push(call);
|
||||||
|
content.push({ type: "toolCall", id: call.toolCallId, name: call.name, arguments: call.input as never });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const out: LhMessage[] = [
|
||||||
|
{
|
||||||
|
id,
|
||||||
|
role: "assistant",
|
||||||
|
content,
|
||||||
|
api: "openai-completions",
|
||||||
|
provider: "openrouter",
|
||||||
|
model: "test",
|
||||||
|
usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
||||||
|
stopReason: "stop",
|
||||||
|
timestamp: Date.now(),
|
||||||
|
} as never,
|
||||||
|
];
|
||||||
|
for (const call of calls) {
|
||||||
|
out.push({
|
||||||
|
id: `r_${call.toolCallId}`,
|
||||||
|
role: "toolResult",
|
||||||
|
toolCallId: call.toolCallId,
|
||||||
|
toolName: call.name,
|
||||||
|
content: [{ type: "text", text: "ok" }],
|
||||||
|
details: call.output,
|
||||||
|
isError: false,
|
||||||
|
timestamp: Date.now(),
|
||||||
|
} as never);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
function planPart(toolCallId: string, name: string, input: Record<string, unknown>, tree: unknown): unknown {
|
function quizPart(toolCallId: string, input: Record<string, unknown>, output: Record<string, unknown>): NativeCallDesc {
|
||||||
return {
|
return { name: "quiz", toolCallId, input, output };
|
||||||
type: `tool-${name}`,
|
}
|
||||||
toolCallId,
|
|
||||||
state: "output-available",
|
function planPart(toolCallId: string, name: string, input: Record<string, unknown>, tree: unknown): NativeCallDesc {
|
||||||
input,
|
return { name, toolCallId, input, output: { ok: true, tree } };
|
||||||
output: { ok: true, tree },
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** A session-shaped fixture chat: probe → teaching text → check. Tree-blind
|
/** A session-shaped fixture chat: probe → teaching text → check. Tree-blind
|
||||||
@@ -145,23 +188,28 @@ function fixtureChat() {
|
|||||||
createdChats.push(chat.id);
|
createdChats.push(chat.id);
|
||||||
chatStore.saveMessages(chat.id, [
|
chatStore.saveMessages(chat.id, [
|
||||||
user("u1", "teach me the chain rule"),
|
user("u1", "teach me the chain rule"),
|
||||||
assistant("a1", [
|
...assistant("a1", [
|
||||||
quizPart("probe-1", { question: "What is a derivative, roughly?" }, {
|
quizPart("probe-1", { question: "What is a derivative, roughly?" }, {
|
||||||
questionId: "probe-1", kind: "quiz", questionText: "What is a derivative, roughly?",
|
questionId: "probe-1", kind: "quiz", questionText: "What is a derivative, roughly?",
|
||||||
correct: false, dontKnow: false, answeredAt: "2026-08-22T01:00:00.000Z",
|
correct: false, dontKnow: false, answeredAt: "2026-08-22T01:00:00.000Z",
|
||||||
}),
|
}),
|
||||||
]),
|
]),
|
||||||
assistant("a2", [
|
// Distinct TURNS: the native grouping canon merges consecutive
|
||||||
|
// assistant messages into one view group, so the fixture separates
|
||||||
|
// its turns with learner messages (the real record's shape).
|
||||||
|
user("u2", "go on"),
|
||||||
|
...assistant("a2", [
|
||||||
{ type: "text", text: "The derivative measures how fast a function changes." },
|
{ type: "text", text: "The derivative measures how fast a function changes." },
|
||||||
planPart("p1", "plan_init", { nodes: [], edges: [] }, { nodes: [], edges: [], currentNodeId: null, doneNodeIds: [], version: 1 }),
|
planPart("p1", "plan_init", { nodes: [], edges: [] }, { nodes: [], edges: [], currentNodeId: null, doneNodeIds: [], version: 1 }),
|
||||||
]),
|
]),
|
||||||
assistant("a3", [
|
user("u3", "check me"),
|
||||||
|
...assistant("a3", [
|
||||||
quizPart("check-1", { question: "Check" }, {
|
quizPart("check-1", { question: "Check" }, {
|
||||||
questionId: "check-1", kind: "quiz", questionText: "Check", correct: true, dontKnow: false,
|
questionId: "check-1", kind: "quiz", questionText: "Check", correct: true, dontKnow: false,
|
||||||
answeredAt: "2026-08-22T02:00:00.000Z",
|
answeredAt: "2026-08-22T02:00:00.000Z",
|
||||||
}),
|
}),
|
||||||
]),
|
]),
|
||||||
]);
|
].flat());
|
||||||
return { chatId: chat.id, record: chatStore.getChat(chat.id)! };
|
return { chatId: chat.id, record: chatStore.getChat(chat.id)! };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -282,17 +330,16 @@ describe("transcriptView (pure)", () => {
|
|||||||
createdChats.push(chat.id);
|
createdChats.push(chat.id);
|
||||||
chatStore.saveMessages(chat.id, [
|
chatStore.saveMessages(chat.id, [
|
||||||
user("u1", "go"),
|
user("u1", "go"),
|
||||||
assistant("a1", [
|
...assistant("a1", [
|
||||||
{
|
{
|
||||||
type: "tool-research",
|
name: "research",
|
||||||
toolCallId: "r1",
|
toolCallId: "r1",
|
||||||
state: "output-available",
|
|
||||||
input: { question: "q" },
|
input: { question: "q" },
|
||||||
output: { brief: "long brief", sources: [] },
|
output: { brief: "long brief", sources: [] },
|
||||||
},
|
},
|
||||||
{ type: "text", text: " " },
|
{ type: "text", text: " " },
|
||||||
]),
|
]),
|
||||||
]);
|
].flat());
|
||||||
const view = transcriptView(chatStore.getChat(chat.id)!);
|
const view = transcriptView(chatStore.getChat(chat.id)!);
|
||||||
assert.match(view, /\(research tool result delivered\)/);
|
assert.match(view, /\(research tool result delivered\)/);
|
||||||
assert.doesNotMatch(view, /long brief/);
|
assert.doesNotMatch(view, /long brief/);
|
||||||
@@ -352,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(record.messages as UIMessage[]));
|
const expectedTag = batchTag(coverageSlots(projectNative(record.messages)));
|
||||||
assert.deepEqual(
|
assert.deepEqual(
|
||||||
events.map((e) => [e.kind, e.ref, e.nodeId]),
|
events.map((e) => [e.kind, e.ref, e.nodeId]),
|
||||||
[
|
[
|
||||||
@@ -368,12 +415,12 @@ describe("the extraction pipeline (extractor → auditor → merger)", () => {
|
|||||||
// one entry per transcript message, fingerprinted content each —
|
// 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, record.messages.length);
|
assert.equal(covered.length, projectNative(record.messages).length);
|
||||||
assert.ok(covered.every((c) => c.chatId === chatId && c.fp.length === 64 && c.tokens > 0));
|
assert.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(record.messages as UIMessage[], covered, chatId).uncoveredCount,
|
coverageOf(projectNative(record.messages), covered, chatId).uncoveredCount,
|
||||||
0,
|
0,
|
||||||
"every message is now covered",
|
"every message is now covered",
|
||||||
);
|
);
|
||||||
@@ -973,9 +1020,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, record.messages.length);
|
assert.equal(coverage.length, projectNative(record.messages).length);
|
||||||
assert.equal(ledgers.readCheckpoints().length, 0);
|
assert.equal(ledgers.readCheckpoints().length, 0);
|
||||||
assert.equal(coverageOf(record.messages as UIMessage[], coverage, chatId).uncoveredCount, 0);
|
assert.equal(coverageOf(projectNative(record.messages), coverage, chatId).uncoveredCount, 0);
|
||||||
|
|
||||||
// A second run is a NO-OP: nothing uncovered → no model calls, no
|
// 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).
|
||||||
@@ -1028,7 +1075,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(record.messages as UIMessage[], ledgers.readCoverage(), chatId);
|
const cov = coverageOf(projectNative(record.messages), ledgers.readCoverage(), chatId);
|
||||||
assert.equal(cov.uncoveredCount, 2, "the in-flight batch's messages stay uncovered");
|
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
|
||||||
@@ -1062,25 +1109,30 @@ describe("the chunked pipeline (extraction coverage)", () => {
|
|||||||
auditor: fauxEngine([passThroughAuditStep()]),
|
auditor: fauxEngine([passThroughAuditStep()]),
|
||||||
});
|
});
|
||||||
await pipeline.runExtraction(chatId);
|
await pipeline.runExtraction(chatId);
|
||||||
const messages = () => chatStore.getChat(chatId)!.messages as UIMessage[];
|
// Wave B: the record is native — coverage reads the projection.
|
||||||
|
const record = () => chatStore.getChat(chatId)!.messages;
|
||||||
|
const messages = () => projectNative(record());
|
||||||
assert.equal(coverageOf(messages(), ledgers.readCoverage(), chatId).uncoveredCount, 0);
|
assert.equal(coverageOf(messages(), ledgers.readCoverage(), chatId).uncoveredCount, 0);
|
||||||
|
|
||||||
// Data parts are invisible to the extraction view — plan-block
|
// Data parts are invisible to the extraction view — a plan-block
|
||||||
// attach does NOT re-open a covered message.
|
// marker append (whose data part splices into the projected view)
|
||||||
const withDataPart = messages().map((m) =>
|
// does NOT re-open a covered message.
|
||||||
m.id === "u2"
|
chatStore.saveMessages(chatId, [
|
||||||
? { ...m, parts: [...m.parts, { type: "data-plan-blocks", planBlocks: [] } as unknown as UIMessage["parts"][number]] }
|
...record(),
|
||||||
: m,
|
newPlanBlockMessage("call_s2", { kind: "full", at: "2026-09-26T00:00:00.000Z", current: null, nodes: [], edges: [] }),
|
||||||
);
|
]);
|
||||||
chatStore.saveMessages(chatId, withDataPart);
|
|
||||||
const afterAttach = coverageOf(messages(), ledgers.readCoverage(), chatId);
|
const afterAttach = coverageOf(messages(), ledgers.readCoverage(), chatId);
|
||||||
assert.equal(afterAttach.uncoveredCount, 0, "data parts don't change the fingerprint");
|
assert.equal(afterAttach.uncoveredCount, 0, "data parts don't change the fingerprint");
|
||||||
|
|
||||||
// A quiz answer merging into an ASKING message changes its view →
|
// A quiz answer merging into an ASKING message changes its view →
|
||||||
// exactly that message re-opens, and the next run extracts it alone.
|
// exactly that message re-opens, and the next run extracts it alone.
|
||||||
const mutated = messages().map((m) =>
|
// (Native: the answer arrives as the call's tool result — the
|
||||||
m.id === "s1" ? { ...m, parts: [...m.parts, { type: "text", text: "the answer merged in late" }] } : m,
|
// projected group gains the answer line.)
|
||||||
) as unknown as UIMessage[];
|
const mutated = record().map((m) =>
|
||||||
|
(m as { id?: string }).id === "s1"
|
||||||
|
? ({ ...m, content: [...(m as { content: unknown[] }).content, { type: "text", text: "the answer merged in late" }] } as never)
|
||||||
|
: m,
|
||||||
|
);
|
||||||
chatStore.saveMessages(chatId, mutated);
|
chatStore.saveMessages(chatId, mutated);
|
||||||
const afterMerge = coverageOf(messages(), ledgers.readCoverage(), chatId);
|
const afterMerge = coverageOf(messages(), ledgers.readCoverage(), chatId);
|
||||||
assert.equal(afterMerge.uncoveredCount, 1, "only the mutated message re-opens");
|
assert.equal(afterMerge.uncoveredCount, 1, "only the mutated message re-opens");
|
||||||
|
|||||||
+73
-26
@@ -17,9 +17,10 @@
|
|||||||
import assert from "node:assert/strict";
|
import assert from "node:assert/strict";
|
||||||
import * as fs from "node:fs";
|
import * as fs from "node:fs";
|
||||||
import { after, describe, it } from "node:test";
|
import { after, describe, it } from "node:test";
|
||||||
import type { UIMessage } from "ai";
|
|
||||||
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 { projectNative } from "../src/core/projection.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";
|
||||||
@@ -32,9 +33,12 @@ const graphDeps = { chatStore, graphStore } as const satisfies GraphDeps;
|
|||||||
/** The session merge source for a chat — what the pipeline passes: the live
|
/** 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: UIMessage[]; foldedAt: string } {
|
function sessionSource(chatId: string): { messages: ReturnType<typeof projectNative>; foldedAt: string } {
|
||||||
|
// Wave B: the record is native history — the merger's transcript view
|
||||||
|
// reads through the one-way projection (same shape the extraction
|
||||||
|
// pipeline passes: its batch slots are projected messages).
|
||||||
const record = chatStore.getChat(chatId)!;
|
const record = chatStore.getChat(chatId)!;
|
||||||
return { messages: record.messages as UIMessage[], foldedAt: record.updatedAt };
|
return { messages: projectNative(record.messages), foldedAt: record.updatedAt };
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── ledger isolation (backup-and-clear of data/graph/*.jsonl) ──────────────
|
// ─── ledger isolation (backup-and-clear of data/graph/*.jsonl) ──────────────
|
||||||
@@ -72,20 +76,65 @@ after(() => {
|
|||||||
|
|
||||||
// ─── fixture builders ────────────────────────────────────────────────────────
|
// ─── fixture builders ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
function assistant(id: string, parts: unknown[]): UIMessage {
|
// Native fixture builders (Wave B): the record holds pi-native history —
|
||||||
return { id, role: "assistant", parts } as unknown as UIMessage;
|
// an assistant message carrying tool calls + one tool result per call. The
|
||||||
|
// projection reproduces the old part shapes (state output-available, input
|
||||||
|
// = arguments, output = result details).
|
||||||
|
interface NativeCall {
|
||||||
|
name: string;
|
||||||
|
toolCallId: string;
|
||||||
|
input: Record<string, unknown>;
|
||||||
|
output: QuestionAnswerData;
|
||||||
}
|
}
|
||||||
|
|
||||||
function user(id: string, text: string): UIMessage {
|
function user(id: string, text: string): LhMessage[] {
|
||||||
return { id, role: "user", parts: [{ type: "text", text }] } as unknown as UIMessage;
|
return [{ id, role: "user", content: text, timestamp: Date.now() } as never];
|
||||||
|
}
|
||||||
|
|
||||||
|
function assistant(id: string, calls: NativeCall[]): LhMessage[] {
|
||||||
|
const content = calls.map((c) => ({ type: "toolCall", id: c.toolCallId, name: c.name, arguments: c.input as never }));
|
||||||
|
const out: LhMessage[] = [
|
||||||
|
{
|
||||||
|
id,
|
||||||
|
role: "assistant",
|
||||||
|
content,
|
||||||
|
api: "openai-completions",
|
||||||
|
provider: "openrouter",
|
||||||
|
model: "test",
|
||||||
|
usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
||||||
|
stopReason: "stop",
|
||||||
|
timestamp: Date.now(),
|
||||||
|
} as never,
|
||||||
|
];
|
||||||
|
for (const c of calls) {
|
||||||
|
out.push({
|
||||||
|
id: `r_${c.toolCallId}`,
|
||||||
|
role: "toolResult",
|
||||||
|
toolCallId: c.toolCallId,
|
||||||
|
toolName: c.name,
|
||||||
|
content: [{ type: "text", text: "ok" }],
|
||||||
|
details: c.output,
|
||||||
|
isError: false,
|
||||||
|
timestamp: Date.now(),
|
||||||
|
} as never);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
function quizPart(
|
function quizPart(
|
||||||
toolCallId: string,
|
toolCallId: string,
|
||||||
input: Record<string, unknown>,
|
input: Record<string, unknown>,
|
||||||
output: QuestionAnswerData,
|
output: QuestionAnswerData,
|
||||||
): unknown {
|
): NativeCall {
|
||||||
return { type: "tool-quiz", toolCallId, state: "output-available", input, output };
|
return { name: "quiz", toolCallId, input, output };
|
||||||
|
}
|
||||||
|
|
||||||
|
function askPart(
|
||||||
|
toolCallId: string,
|
||||||
|
input: Record<string, unknown>,
|
||||||
|
output: QuestionAnswerData,
|
||||||
|
): NativeCall {
|
||||||
|
return { name: "ask_user_question", toolCallId, input, output };
|
||||||
}
|
}
|
||||||
|
|
||||||
function answer(over: Partial<QuestionAnswerData>): QuestionAnswerData {
|
function answer(over: Partial<QuestionAnswerData>): QuestionAnswerData {
|
||||||
@@ -102,7 +151,7 @@ function answer(over: Partial<QuestionAnswerData>): QuestionAnswerData {
|
|||||||
|
|
||||||
/** Create a chat in the real store with messages; returns the record.
|
/** Create a chat in the real store with messages; returns the record.
|
||||||
* Tree-blind world: the plan tree is irrelevant to the merger — no tree. */
|
* Tree-blind world: the plan tree is irrelevant to the merger — no tree. */
|
||||||
function fixtureChat(title: string, messages: UIMessage[]) {
|
function fixtureChat(title: string, messages: LhMessage[]) {
|
||||||
const chat = chatStore.createChat(title);
|
const chat = chatStore.createChat(title);
|
||||||
createdChats.push(chat.id);
|
createdChats.push(chat.id);
|
||||||
chatStore.saveMessages(chat.id, messages);
|
chatStore.saveMessages(chat.id, messages);
|
||||||
@@ -110,7 +159,7 @@ function fixtureChat(title: string, messages: UIMessage[]) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** The full fixture transcript: five graded quizzes + a dismissed one + an ask. */
|
/** The full fixture transcript: five graded quizzes + a dismissed one + an ask. */
|
||||||
function fixtureMessages(): UIMessage[] {
|
function fixtureMessages(): LhMessage[] {
|
||||||
return [
|
return [
|
||||||
user("u1", "teach me the fixture topic"),
|
user("u1", "teach me the fixture topic"),
|
||||||
assistant("a1", [
|
assistant("a1", [
|
||||||
@@ -157,15 +206,13 @@ function fixtureMessages(): UIMessage[] {
|
|||||||
{ question: "Q6", options: [{ label: "a" }, { label: "b" }], correctAnswer: "a", explanation: "e" },
|
{ question: "Q6", options: [{ label: "a" }, { label: "b" }], correctAnswer: "a", explanation: "e" },
|
||||||
answer({ questionId: "call-6", questionText: "Q6 text", dismissed: true }),
|
answer({ questionId: "call-6", questionText: "Q6 text", dismissed: true }),
|
||||||
),
|
),
|
||||||
{
|
askPart(
|
||||||
type: "tool-ask_user_question",
|
"call-7",
|
||||||
toolCallId: "call-7",
|
{ question: "free text?" },
|
||||||
state: "output-available",
|
answer({ questionId: "call-7", kind: "ask", questionText: "free text?", text: "because" }),
|
||||||
input: { question: "free text?" },
|
),
|
||||||
output: answer({ questionId: "call-7", kind: "ask", questionText: "free text?", text: "because" }),
|
|
||||||
},
|
|
||||||
]),
|
]),
|
||||||
];
|
].flat();
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The auditor's verdict for the fixture chat: three verified propositions
|
/** The auditor's verdict for the fixture chat: three verified propositions
|
||||||
@@ -332,7 +379,7 @@ describe("the merger (mergeFindings)", () => {
|
|||||||
answer({ questionId: "call-9", questionText: "Q9 text", correct: true, answeredAt: "2026-08-22T03:00:00.000Z" }),
|
answer({ questionId: "call-9", questionText: "Q9 text", correct: true, answeredAt: "2026-08-22T03:00:00.000Z" }),
|
||||||
),
|
),
|
||||||
]),
|
]),
|
||||||
]);
|
].flat());
|
||||||
chat2Id = chat2.id;
|
chat2Id = chat2.id;
|
||||||
|
|
||||||
// The auditor's identity verdict: this proposition IS the alpha node.
|
// The auditor's identity verdict: this proposition IS the alpha node.
|
||||||
@@ -473,7 +520,7 @@ describe("the merger (mergeFindings)", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("never string-matches without a verdict — an identical statement without linkTo is a NEW node", () => {
|
it("never string-matches without a verdict — an identical statement without linkTo is a NEW node", () => {
|
||||||
const chat3 = fixtureChat("Graph fixture 3", [user("u1", "third")]);
|
const chat3 = fixtureChat("Graph fixture 3", [user("u1", "third")].flat());
|
||||||
chat3Id = chat3.id;
|
chat3Id = chat3.id;
|
||||||
const stats = mergeFindings(
|
const stats = mergeFindings(
|
||||||
chat3.id,
|
chat3.id,
|
||||||
@@ -623,7 +670,7 @@ describe("session deletion (deleteChatTraces + deleteChatSession, wave 6)", () =
|
|||||||
answer({ questionId: "d-call-1", questionText: "DQ1 text", correct: true, answeredAt: "2026-09-23T00:00:00.000Z" }),
|
answer({ questionId: "d-call-1", questionText: "DQ1 text", correct: true, answeredAt: "2026-09-23T00:00:00.000Z" }),
|
||||||
),
|
),
|
||||||
]),
|
]),
|
||||||
]);
|
].flat());
|
||||||
const auditA: AuditResult = {
|
const auditA: AuditResult = {
|
||||||
propositions: [
|
propositions: [
|
||||||
{ id: "p1", name: "Shared core", statement: "Shared core statement", aliases: ["shared feel"], messages: ["du1"], evidence: ["e"], understanding: "solid" },
|
{ id: "p1", name: "Shared core", statement: "Shared core statement", aliases: ["shared feel"], messages: ["du1"], evidence: ["e"], understanding: "solid" },
|
||||||
@@ -651,7 +698,7 @@ describe("session deletion (deleteChatTraces + deleteChatSession, wave 6)", () =
|
|||||||
answer({ questionId: "e-call-1", questionText: "EQ1 text", correct: false, answeredAt: "2026-09-23T00:01:00.000Z" }),
|
answer({ questionId: "e-call-1", questionText: "EQ1 text", correct: false, answeredAt: "2026-09-23T00:01:00.000Z" }),
|
||||||
),
|
),
|
||||||
]),
|
]),
|
||||||
]);
|
].flat());
|
||||||
const auditB: AuditResult = {
|
const auditB: AuditResult = {
|
||||||
propositions: [
|
propositions: [
|
||||||
{ id: "q1", name: "Shared again", statement: "Shared core, rephrased", aliases: ["B phrasing"], messages: ["eu1"], evidence: ["e"], linkTo: xId, understanding: "shaky" },
|
{ id: "q1", name: "Shared again", statement: "Shared core, rephrased", aliases: ["B phrasing"], messages: ["eu1"], evidence: ["e"], linkTo: xId, understanding: "shaky" },
|
||||||
@@ -976,7 +1023,7 @@ describe("session coverage read (extraction-UX redesign, 2026-09-24)", () => {
|
|||||||
user("cov_u1", "tell me about coverage reads"),
|
user("cov_u1", "tell me about coverage reads"),
|
||||||
user("cov_u2", "and about per-session blocks"),
|
user("cov_u2", "and about per-session blocks"),
|
||||||
user("cov_u3", "a third distinct message"),
|
user("cov_u3", "a third distinct message"),
|
||||||
]);
|
].flat());
|
||||||
const empty = sessionCoverage(chat.id)!;
|
const empty = sessionCoverage(chat.id)!;
|
||||||
assert.ok(empty);
|
assert.ok(empty);
|
||||||
assert.equal(empty.totalMessages, 3);
|
assert.equal(empty.totalMessages, 3);
|
||||||
@@ -989,7 +1036,7 @@ describe("session coverage read (extraction-UX redesign, 2026-09-24)", () => {
|
|||||||
|
|
||||||
// Fold the first two messages: the read derives the split + the
|
// 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(chatStore.getChat(chat.id)!.messages as UIMessage[]);
|
const slots = coverageSlots(projectNative(chatStore.getChat(chat.id)!.messages));
|
||||||
graphStore.appendCoverage({ chatId: chat.id, fp: slots[0]!.fp, tokens: slots[0]!.tokens, foldedAt: "2026-09-24T10:00:00.000Z" });
|
graphStore.appendCoverage({ chatId: chat.id, fp: slots[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.
|
||||||
@@ -1005,7 +1052,7 @@ describe("session coverage read (extraction-UX redesign, 2026-09-24)", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("running: true only for THIS session's chat-kind run (the registry pin)", () => {
|
it("running: true only for THIS session's chat-kind run (the registry pin)", () => {
|
||||||
const chat = fixtureChat("coverage running", [user("cov_r1", "running pin")]);
|
const chat = fixtureChat("coverage running", [user("cov_r1", "running pin")].flat());
|
||||||
const g = globalThis as { __lhExtractionRuns?: unknown };
|
const g = globalThis as { __lhExtractionRuns?: unknown };
|
||||||
const realRuns = g.__lhExtractionRuns;
|
const realRuns = g.__lhExtractionRuns;
|
||||||
try {
|
try {
|
||||||
|
|||||||
+313
-258
@@ -27,7 +27,6 @@ import assert from "node:assert/strict";
|
|||||||
import * as fs from "node:fs";
|
import * as fs from "node:fs";
|
||||||
import * as path from "node:path";
|
import * as path from "node:path";
|
||||||
import { after, describe, it } from "node:test";
|
import { after, describe, it } from "node:test";
|
||||||
import type { UIMessage } from "ai";
|
|
||||||
import { createFauxCore, fauxAssistantMessage, fauxText, fauxToolCall, type AssistantMessage, type FauxResponseStep } from "@earendil-works/pi-ai";
|
import { createFauxCore, fauxAssistantMessage, fauxText, fauxToolCall, type AssistantMessage, type FauxResponseStep } from "@earendil-works/pi-ai";
|
||||||
import {
|
import {
|
||||||
accumulateJobUsage,
|
accumulateJobUsage,
|
||||||
@@ -41,20 +40,17 @@ import {
|
|||||||
type ResearchResult,
|
type ResearchResult,
|
||||||
type SubagentArtifactData,
|
type SubagentArtifactData,
|
||||||
type SteerMessage,
|
type SteerMessage,
|
||||||
type SubagentSteerMessage,
|
|
||||||
type VisualResult,
|
type VisualResult,
|
||||||
buildProbeHandoffSteer,
|
buildProbeHandoffSteer,
|
||||||
type ProbeHandoffSteerMessage,
|
|
||||||
} from "../src/core/contracts.ts";
|
} from "../src/core/contracts.ts";
|
||||||
import { composeInstructions, createTeacherAgent, type TeachingAgent } from "../src/agent/teacher-agent.ts";
|
import { composeInstructions, createTeacherAgent } from "../src/agent/teacher-agent.ts";
|
||||||
import { createTeacherTools } from "../src/agent/tools.ts";
|
import { createTeacherTools } from "../src/agent/tools.ts";
|
||||||
import { webTools } from "../src/agent/tools/web.ts";
|
import { webTools } from "../src/agent/tools/web.ts";
|
||||||
import { handleChatRequest } from "../src/server/chat-handler.ts";
|
import { handleChatRequest } from "../src/server/chat-handler.ts";
|
||||||
import { recordToPiMessages } from "../src/server/pi/record-adapter.ts";
|
|
||||||
import { abortJob, claimSteers, deliverJobResult, recoverStaleJobs, setJobRespawn, setTurnRunner } from "../src/server/jobs.ts";
|
import { abortJob, claimSteers, deliverJobResult, recoverStaleJobs, setJobRespawn, setTurnRunner } from "../src/server/jobs.ts";
|
||||||
import { registerJobAbort, unregisterJobAbort } from "../src/agent/subagents/aborts.ts";
|
import { registerJobAbort, unregisterJobAbort } from "../src/agent/subagents/aborts.ts";
|
||||||
import { beginTurn, endTurn } from "../src/server/turn-liveness.ts";
|
import { beginTurn, endTurn } from "../src/server/turn-liveness.ts";
|
||||||
import { persistTurn, withMessageIds } from "../src/server/turn-service.ts";
|
import { persistTurn } from "../src/server/turn-service.ts";
|
||||||
import { chatStore } from "../src/store/chats.ts";
|
import { chatStore } from "../src/store/chats.ts";
|
||||||
import { jobStore } from "../src/store/jobs.ts";
|
import { jobStore } from "../src/store/jobs.ts";
|
||||||
import { createJobRunner, pdfModelOverride, researchModelOverride } from "../src/agent/subagents/runner.ts";
|
import { createJobRunner, pdfModelOverride, researchModelOverride } from "../src/agent/subagents/runner.ts";
|
||||||
@@ -62,6 +58,9 @@ import { providerKeyMissing, type ModelSlotName } from "../src/core/settings.ts"
|
|||||||
import type { PiEngine } from "../src/agent/models.ts";
|
import type { 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 type { LhMessage } from "../src/core/native-messages.ts";
|
||||||
|
import type { SteerInput, TurnInput } from "../src/core/input-events.ts";
|
||||||
|
|
||||||
const JOBS_DIR = path.join(PATHS.data, "jobs");
|
const JOBS_DIR = path.join(PATHS.data, "jobs");
|
||||||
|
|
||||||
@@ -77,6 +76,36 @@ function trackChat(id: string) {
|
|||||||
createdChats.push(id);
|
createdChats.push(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Wave B native fixture: user text + an assistant tool call + its ack —
|
||||||
|
* the record is agent history; the runner's transcript scan (input
|
||||||
|
* resolution) reads it through the projection. */
|
||||||
|
function nativeToolMessages(text: string, tool: string, toolCallId: string, input: unknown): LhMessage[] {
|
||||||
|
return [
|
||||||
|
{ id: "u1", role: "user", content: text, timestamp: 1 } as never,
|
||||||
|
{
|
||||||
|
id: "a1",
|
||||||
|
role: "assistant",
|
||||||
|
content: [{ type: "toolCall", id: toolCallId, name: tool, arguments: input as never }],
|
||||||
|
api: "openai-completions",
|
||||||
|
provider: "openrouter",
|
||||||
|
model: "test",
|
||||||
|
usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
||||||
|
stopReason: "toolUse",
|
||||||
|
timestamp: 1,
|
||||||
|
} as never,
|
||||||
|
{
|
||||||
|
id: "r1",
|
||||||
|
role: "toolResult",
|
||||||
|
toolCallId,
|
||||||
|
toolName: tool,
|
||||||
|
content: [{ type: "text", text: "Dispatched." }],
|
||||||
|
details: "Dispatched.",
|
||||||
|
isError: false,
|
||||||
|
timestamp: 1,
|
||||||
|
} as never,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
function trackJob(id: string) {
|
function trackJob(id: string) {
|
||||||
createdJobs.push(id);
|
createdJobs.push(id);
|
||||||
}
|
}
|
||||||
@@ -132,11 +161,13 @@ async function drain(res: Response): Promise<string> {
|
|||||||
return res.text();
|
return res.text();
|
||||||
}
|
}
|
||||||
|
|
||||||
function post(chatId: string, messages: unknown[]): Request {
|
function post(chatId: string, input: unknown): Request {
|
||||||
return new Request("http://test/api/chat", {
|
return new Request("http://test/api/chat", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({ chatId, messages }),
|
// Wave B: the input-events contract — one explicit event, never a
|
||||||
|
// client transcript.
|
||||||
|
body: JSON.stringify({ chatId, input }),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -192,19 +223,21 @@ describe("steer helpers (contracts.ts)", () => {
|
|||||||
const demo: DemoResult = { title: "T", html: "<html></html>", tested: true, notes: "ok" };
|
const demo: DemoResult = { title: "T", html: "<html></html>", tested: true, notes: "ok" };
|
||||||
const visual: VisualResult = { svg: "<svg/>", notes: "ok" };
|
const visual: VisualResult = { svg: "<svg/>", notes: "ok" };
|
||||||
|
|
||||||
it("buildSubagentSteer: user-role, metadata-marked, artifact on the data part, brief on the text part", () => {
|
it("buildSubagentSteer: a native marker — brief on the model-facing content, artifact payload in details", () => {
|
||||||
const job = { id: "job_x", kind: "research" as const, toolCallId: "call-x" };
|
const job = { id: "job_x", kind: "research" as const, toolCallId: "call-x" };
|
||||||
const steer = buildSubagentSteer(job, research);
|
const steer = buildSubagentSteer(job, research);
|
||||||
assert.equal(steer.id, "steer_job_x", "deterministic id per job — idempotent delivery");
|
assert.equal(steer.id, "steer_job_x", "deterministic id per job — idempotent delivery");
|
||||||
assert.equal(steer.role, "user");
|
// Wave B: steers are LhMarkerMessage customs — role "custom", the
|
||||||
assert.deepEqual(steer.metadata, { subagentResult: { jobId: "job_x", kind: "research", toolCallId: "call-x" } });
|
// model reads `content`, the UI reads `details` (never model-visible).
|
||||||
const [text, data] = steer.parts;
|
assert.equal(steer.role, "custom");
|
||||||
assert.equal(text.type, "text");
|
assert.equal(steer.customType, "subagent-steer");
|
||||||
assert.ok(text.type === "text" && text.text.includes("BRIEF"), "the model-facing text carries the brief");
|
assert.equal(steer.display, true);
|
||||||
assert.equal(data.type, "data-subagent-artifact");
|
assert.ok(steer.content.includes("BRIEF"), "the model-facing text carries the brief");
|
||||||
assert.ok(data.type === "data-subagent-artifact");
|
const data = steer.details as { jobId: string; kind: string; toolCallId: string; result: ResearchResult };
|
||||||
assert.equal(data.data.kind, "research");
|
assert.equal(data.jobId, "job_x");
|
||||||
assert.deepEqual(data.data.result, research);
|
assert.equal(data.kind, "research");
|
||||||
|
assert.equal(data.toolCallId, "call-x");
|
||||||
|
assert.deepEqual(data.result, research);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("accumulateJobUsage: sums per model call; fields NO call reported stay undefined (absent is not zero)", () => {
|
it("accumulateJobUsage: sums per model call; fields NO call reported stay undefined (absent is not zero)", () => {
|
||||||
@@ -248,19 +281,15 @@ describe("steer helpers (contracts.ts)", () => {
|
|||||||
}, "lastStep always mirrors the LATEST call, never the sums");
|
}, "lastStep always mirrors the LATEST call, never the sums");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("buildAbortSteer: teacher-facing text + the data-subagent-aborted marker part (the message type), same deterministic id space as the result steer", () => {
|
it("buildAbortSteer: a subagent-abort marker — teacher-facing text on content, the abort payload in details", () => {
|
||||||
const steer = buildAbortSteer({ id: "job_c", kind: "demo", toolCallId: "call-c" });
|
const steer = buildAbortSteer({ id: "job_c", kind: "demo", toolCallId: "call-c" });
|
||||||
assert.equal(steer.id, "steer_job_c", "same id space — steerDelivered treats either as delivered");
|
assert.equal(steer.id, "steer_job_c", "same id space — steerDelivered treats either as delivered");
|
||||||
assert.equal(steer.role, "user");
|
assert.equal(steer.role, "custom");
|
||||||
assert.equal(steer.metadata.subagentResult.aborted, true);
|
assert.equal(steer.customType, "subagent-abort");
|
||||||
assert.equal(steer.metadata.subagentResult.jobId, "job_c");
|
assert.ok(/aborted/.test(steer.content), "the teacher-facing text names the abort");
|
||||||
assert.equal(steer.parts.length, 2);
|
// The marker's customType is the message type: the UI renders the
|
||||||
assert.ok(steer.parts[0]!.type === "text" && /aborted/.test(steer.parts[0].text));
|
// abort badge off the projection (demos.test.tsx pins the card).
|
||||||
// The marker PART is the message type (the custom-message scheme): the
|
assert.deepEqual(steer.details, { jobId: "job_c", kind: "demo", toolCallId: "call-c", aborted: true });
|
||||||
// UI renders the abort badge off it — metadata stays for pump mechanics.
|
|
||||||
const marker = steer.parts[1]!;
|
|
||||||
assert.equal(marker.type, "data-subagent-aborted");
|
|
||||||
assert.deepEqual(marker.data, { jobId: "job_c", kind: "demo", toolCallId: "call-c", aborted: true });
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("isSubagentFailure: failure shapes only (empty html/svg, failure brief)", () => {
|
it("isSubagentFailure: failure shapes only (empty html/svg, failure brief)", () => {
|
||||||
@@ -292,28 +321,26 @@ describe("steer helpers (contracts.ts)", () => {
|
|||||||
const failDemo = buildSubagentSteer({ id: "j2", kind: "demo", toolCallId: "c" }, { title: "T", html: "", tested: true, notes: "FAILED: boom" });
|
const failDemo = buildSubagentSteer({ id: "j2", kind: "demo", toolCallId: "c" }, { title: "T", html: "", tested: true, notes: "FAILED: boom" });
|
||||||
const failVisual = buildSubagentSteer({ id: "j3", kind: "visual", toolCallId: "c" }, { svg: "", notes: "FAILED: boom" });
|
const failVisual = buildSubagentSteer({ id: "j3", kind: "visual", toolCallId: "c" }, { svg: "", notes: "FAILED: boom" });
|
||||||
for (const steer of [failResearch, failDemo, failVisual]) {
|
for (const steer of [failResearch, failDemo, failVisual]) {
|
||||||
const text = steer.parts[0];
|
assert.ok(!steer.content.includes("rendered in the lesson"), "failure text never claims the artifact landed");
|
||||||
assert.ok(text.type === "text" && !text.text.includes("rendered in the lesson"), "failure text never claims the artifact landed");
|
|
||||||
}
|
}
|
||||||
assert.ok(failResearch.parts[0].type === "text" && failResearch.parts[0].text.includes("flag the uncertainty"));
|
assert.ok(failResearch.content.includes("flag the uncertainty"));
|
||||||
});
|
});
|
||||||
|
|
||||||
it("pdf steers (sources v0): brief + page citations on success, PDF failed: shape on failure, abort wording per kind", () => {
|
it("pdf steers (sources v0): brief + page citations on success, PDF failed: shape on failure, abort wording per kind", () => {
|
||||||
const pdf: PdfResult = { path: "/tmp/doc.pdf", question: "q?", brief: "The formula is on p. 2.", pagesRead: [2], pagesConsulted: [1, 2] };
|
const pdf: PdfResult = { path: "/tmp/doc.pdf", question: "q?", brief: "The formula is on p. 2.", pagesRead: [2], pagesConsulted: [1, 2] };
|
||||||
const ok = buildSubagentSteer({ id: "jp", kind: "pdf", toolCallId: "cp" }, pdf);
|
const ok = buildSubagentSteer({ id: "jp", kind: "pdf", toolCallId: "cp" }, pdf);
|
||||||
assert.ok(ok.parts[0].type === "text" && ok.parts[0].text.includes("page citations"), "the teacher is told to cite pages");
|
assert.ok(ok.content.includes("page citations"), "the teacher is told to cite pages");
|
||||||
assert.ok(ok.parts[0].type === "text" && ok.parts[0].text.includes("The formula is on p. 2."));
|
assert.ok(ok.content.includes("The formula is on p. 2."));
|
||||||
assert.equal(ok.parts[1].type, "data-subagent-artifact");
|
assert.equal((ok.details as { kind: string }).kind, "pdf");
|
||||||
assert.equal((ok.parts[1] as { data: SubagentArtifactData }).data.kind, "pdf");
|
assert.deepEqual((ok.details as { result: unknown }).result, pdf);
|
||||||
assert.deepEqual((ok.parts[1] as { data: SubagentArtifactData }).data.result, pdf);
|
|
||||||
// failure shape mirrors research
|
// failure shape mirrors research
|
||||||
assert.equal(isSubagentFailure("pdf", pdf), false);
|
assert.equal(isSubagentFailure("pdf", pdf), false);
|
||||||
assert.equal(isSubagentFailure("pdf", { ...pdf, brief: "PDF failed: unreadable" }), true);
|
assert.equal(isSubagentFailure("pdf", { ...pdf, brief: "PDF failed: unreadable" }), true);
|
||||||
const fail = buildSubagentSteer({ id: "jf", kind: "pdf", toolCallId: "cf" }, { ...pdf, brief: "PDF failed: unreadable" });
|
const fail = buildSubagentSteer({ id: "jf", kind: "pdf", toolCallId: "cf" }, { ...pdf, brief: "PDF failed: unreadable" });
|
||||||
assert.ok(fail.parts[0].type === "text" && fail.parts[0].text.includes("flag the gap"));
|
assert.ok(fail.content.includes("flag the gap"));
|
||||||
// abort wording names the PDF job
|
// abort wording names the PDF job
|
||||||
const aborted = buildAbortSteer({ id: "ja", kind: "pdf", toolCallId: "ca" });
|
const aborted = buildAbortSteer({ id: "ja", kind: "pdf", toolCallId: "ca" });
|
||||||
assert.ok(aborted.parts[0].type === "text" && aborted.parts[0].text.includes("PDF reading job"));
|
assert.ok(aborted.content.includes("PDF reading job"));
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -325,12 +352,12 @@ describe("steer helpers (contracts.ts)", () => {
|
|||||||
* exactly as it will in production after the wake turn completes.
|
* exactly as it will in production after the wake turn completes.
|
||||||
*/
|
*/
|
||||||
function fakeDeliver() {
|
function fakeDeliver() {
|
||||||
const steers: SubagentSteerMessage[] = [];
|
const steers: SteerMessage[] = [];
|
||||||
return {
|
return {
|
||||||
steers,
|
steers,
|
||||||
deliver: async (steer: SubagentSteerMessage) => {
|
deliver: async (steer: SteerMessage) => {
|
||||||
steers.push(steer);
|
steers.push(steer);
|
||||||
const jobId = steer.metadata.subagentResult.jobId;
|
const jobId = (steer.details as { jobId: string }).jobId;
|
||||||
const job = jobStore.getJob(jobId);
|
const job = jobStore.getJob(jobId);
|
||||||
if (job && job.status === "running") {
|
if (job && job.status === "running") {
|
||||||
const failed = job.result ? isSubagentFailure(job.kind, job.result as never) : true;
|
const failed = job.result ? isSubagentFailure(job.kind, job.result as never) : true;
|
||||||
@@ -376,18 +403,21 @@ describe("dispatch (async protocol §2)", () => {
|
|||||||
jobStore,
|
jobStore,
|
||||||
spawn: (job, input) => void testRunner.runJob(job.id, input),
|
spawn: (job, input) => void testRunner.runJob(job.id, input),
|
||||||
});
|
});
|
||||||
const agent = (outgoing: UIMessage[]): TeachingAgent =>
|
// The handler hands the factory the native build options — the
|
||||||
createTeacherAgent(undefined, { engine, tools, messages: recordToPiMessages(outgoing) });
|
// record IS the agent state (Wave B: no adapter conversion).
|
||||||
|
const agent: Parameters<typeof handleChatRequest>[1] = (opts) =>
|
||||||
|
createTeacherAgent(undefined, { engine, tools, messages: opts.messages });
|
||||||
|
|
||||||
researchModelOverride.current = researcherModel();
|
researchModelOverride.current = researcherModel();
|
||||||
const firstUser = { id: "u1", role: "user", parts: [{ type: "text", text: "teach me graph rendering" }] };
|
const body = await drain(
|
||||||
const body = await drain(await handleChatRequest(post(chat.id, [firstUser]), agent));
|
await handleChatRequest(post(chat.id, { type: "text", text: "teach me graph rendering" }), agent),
|
||||||
|
);
|
||||||
|
|
||||||
// The teacher's SAME turn carried on past the tool call.
|
// The teacher's SAME turn carried on past the tool call.
|
||||||
assert.ok(body.includes("Back with the brief."), "the turn continued after the dispatch");
|
assert.ok(body.includes("Back with the brief."), "the turn continued after the dispatch");
|
||||||
|
|
||||||
const saved = chatStore.getChat(chat.id)!;
|
const saved = projectNative(chatStore.getChat(chat.id)!.messages);
|
||||||
const part = saved.messages[1]!.parts.find((p) => p.type === "tool-research");
|
const part = saved[1]!.parts.find((p) => p.type === "tool-research");
|
||||||
assert.ok(part && part.type === "tool-research", "research tool part persisted");
|
assert.ok(part && part.type === "tool-research", "research tool part persisted");
|
||||||
assert.equal(part.state, "output-available", "the part resolved at dispatch time (not held pending)");
|
assert.equal(part.state, "output-available", "the part resolved at dispatch time (not held pending)");
|
||||||
assert.equal(typeof (part as { output?: unknown }).output, "string", "the output is the ack, not the result");
|
assert.equal(typeof (part as { output?: unknown }).output, "string", "the output is the ack, not the result");
|
||||||
@@ -403,12 +433,12 @@ describe("dispatch (async protocol §2)", () => {
|
|||||||
assert.equal(done.status, "done");
|
assert.equal(done.status, "done");
|
||||||
assert.equal(done.activity, "delivered");
|
assert.equal(done.activity, "delivered");
|
||||||
|
|
||||||
// The steer carries the brief + artifact for the (UI) renderer.
|
// The steer carries the brief on its model-facing content; the
|
||||||
|
// artifact payload rides details for the (UI) renderer.
|
||||||
assert.equal(delivery.steers.length, 1, "exactly one steer — one job, one result");
|
assert.equal(delivery.steers.length, 1, "exactly one steer — one job, one result");
|
||||||
const steer = delivery.steers[0]!;
|
const steer = delivery.steers[0]!;
|
||||||
assert.equal(steer.metadata.subagentResult.jobId, job!.id);
|
assert.equal((steer.details as { jobId: string }).jobId, job!.id);
|
||||||
const text = steer.parts[0];
|
assert.ok(steer.content.includes("sigma.js v3 is current"), "steer text carries the brief");
|
||||||
assert.ok(text.type === "text" && text.text.includes("sigma.js v3 is current"), "steer text carries the brief");
|
|
||||||
|
|
||||||
// Search threading (2026-09-20): the researcher's web_search ran on
|
// Search threading (2026-09-20): the researcher's web_search ran on
|
||||||
// the INJECTED port — the stub saw the scripted query, no network.
|
// the INJECTED port — the stub saw the scripted query, no network.
|
||||||
@@ -460,17 +490,7 @@ describe("research runner (async protocol §3)", () => {
|
|||||||
|
|
||||||
// A dispatched research part in the stored chat (the input is also
|
// A dispatched research part in the stored chat (the input is also
|
||||||
// what the runner falls back to on a crash-recovery re-spawn).
|
// what the runner falls back to on a crash-recovery re-spawn).
|
||||||
chatStore.saveMessages(chat.id, [
|
chatStore.saveMessages(chat.id, nativeToolMessages("which sigma version?", "research", "call-run1", RESEARCH_INPUT));
|
||||||
{ id: "u1", role: "user", parts: [{ type: "text", text: "which sigma version?" }] },
|
|
||||||
{
|
|
||||||
id: "a1",
|
|
||||||
role: "assistant",
|
|
||||||
parts: [
|
|
||||||
{ type: "text", text: "Checking." },
|
|
||||||
{ type: "tool-research", toolCallId: "call-run1", state: "output-available", input: RESEARCH_INPUT, output: "Dispatched." },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
] as never);
|
|
||||||
|
|
||||||
const job = jobStore.createJob({ kind: "research", chatId: chat.id, toolCallId: "call-run1" });
|
const job = jobStore.createJob({ kind: "research", chatId: chat.id, toolCallId: "call-run1" });
|
||||||
trackJob(job.id);
|
trackJob(job.id);
|
||||||
@@ -503,14 +523,7 @@ describe("research runner (async protocol §3)", () => {
|
|||||||
it("a failing runner still steers (the teacher learns the outcome) and the job lands error", async () => {
|
it("a failing runner still steers (the teacher learns the outcome) and the job lands error", async () => {
|
||||||
const chat = chatStore.createChat("Test: runner failure");
|
const chat = chatStore.createChat("Test: runner failure");
|
||||||
trackChat(chat.id);
|
trackChat(chat.id);
|
||||||
chatStore.saveMessages(chat.id, [
|
chatStore.saveMessages(chat.id, nativeToolMessages("hi", "research", "call-err1", RESEARCH_INPUT));
|
||||||
{ id: "u1", role: "user", parts: [{ type: "text", text: "hi" }] },
|
|
||||||
{
|
|
||||||
id: "a1",
|
|
||||||
role: "assistant",
|
|
||||||
parts: [{ type: "tool-research", toolCallId: "call-err1", state: "output-available", input: RESEARCH_INPUT, output: "Dispatched." }],
|
|
||||||
},
|
|
||||||
] as never);
|
|
||||||
|
|
||||||
const job = jobStore.createJob({ kind: "research", chatId: chat.id, toolCallId: "call-err1" });
|
const job = jobStore.createJob({ kind: "research", chatId: chat.id, toolCallId: "call-err1" });
|
||||||
trackJob(job.id);
|
trackJob(job.id);
|
||||||
@@ -534,24 +547,14 @@ describe("research runner (async protocol §3)", () => {
|
|||||||
|
|
||||||
const steer = delivery.steers[0]!;
|
const steer = delivery.steers[0]!;
|
||||||
assert.ok(steer, "failure still delivers a steer");
|
assert.ok(steer, "failure still delivers a steer");
|
||||||
const text = steer.parts[0];
|
assert.ok(steer.content.includes("Research failed: model exploded"), "failure brief on the steer text");
|
||||||
assert.ok(text.type === "text" && text.text.includes("Research failed: model exploded"), "failure brief on the steer text");
|
assert.deepEqual(((steer.details as { result: ResearchResult }).result).sources, []);
|
||||||
const data = steer.parts[1];
|
|
||||||
assert.ok(data.type === "data-subagent-artifact");
|
|
||||||
assert.deepEqual((data.data.result as ResearchResult).sources, []);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("runJob resolves the input from the transcript when dispatch-time input is absent (crash recovery)", async () => {
|
it("runJob resolves the input from the transcript when dispatch-time input is absent (crash recovery)", async () => {
|
||||||
const chat = chatStore.createChat("Test: transcript input fallback");
|
const chat = chatStore.createChat("Test: transcript input fallback");
|
||||||
trackChat(chat.id);
|
trackChat(chat.id);
|
||||||
chatStore.saveMessages(chat.id, [
|
chatStore.saveMessages(chat.id, nativeToolMessages("go", "research", "call-fb1", RESEARCH_INPUT));
|
||||||
{ id: "u1", role: "user", parts: [{ type: "text", text: "go" }] },
|
|
||||||
{
|
|
||||||
id: "a1",
|
|
||||||
role: "assistant",
|
|
||||||
parts: [{ type: "tool-research", toolCallId: "call-fb1", state: "output-available", input: RESEARCH_INPUT, output: "Dispatched." }],
|
|
||||||
},
|
|
||||||
] as never);
|
|
||||||
const job = jobStore.createJob({ kind: "research", chatId: chat.id, toolCallId: "call-fb1" });
|
const job = jobStore.createJob({ kind: "research", chatId: chat.id, toolCallId: "call-fb1" });
|
||||||
trackJob(job.id);
|
trackJob(job.id);
|
||||||
|
|
||||||
@@ -595,14 +598,7 @@ describe("pdf runner (sources v0)", () => {
|
|||||||
it("runJob completes a pdf job: brief + pagesConsulted land on the record, citation steer delivered", async () => {
|
it("runJob completes a pdf job: brief + pagesConsulted land on the record, citation steer delivered", async () => {
|
||||||
const chat = chatStore.createChat("Test: pdf runner");
|
const chat = chatStore.createChat("Test: pdf runner");
|
||||||
trackChat(chat.id);
|
trackChat(chat.id);
|
||||||
chatStore.saveMessages(chat.id, [
|
chatStore.saveMessages(chat.id, nativeToolMessages("read this pdf", "pdf", "call-pdf1", PDF_INPUT));
|
||||||
{ id: "u1", role: "user", parts: [{ type: "text", text: "read this pdf" }] },
|
|
||||||
{
|
|
||||||
id: "a1",
|
|
||||||
role: "assistant",
|
|
||||||
parts: [{ type: "tool-pdf", toolCallId: "call-pdf1", state: "output-available", input: PDF_INPUT, output: "Dispatched." }],
|
|
||||||
},
|
|
||||||
] as never);
|
|
||||||
|
|
||||||
const job = jobStore.createJob({ kind: "pdf", chatId: chat.id, toolCallId: "call-pdf1" });
|
const job = jobStore.createJob({ kind: "pdf", chatId: chat.id, toolCallId: "call-pdf1" });
|
||||||
trackJob(job.id);
|
trackJob(job.id);
|
||||||
@@ -627,9 +623,8 @@ describe("pdf runner (sources v0)", () => {
|
|||||||
|
|
||||||
assert.equal(delivery.steers.length, 1, "one job, one steer");
|
assert.equal(delivery.steers.length, 1, "one job, one steer");
|
||||||
const steer = delivery.steers[0]!;
|
const steer = delivery.steers[0]!;
|
||||||
assert.equal(steer.metadata.subagentResult.kind, "pdf");
|
assert.equal((steer.details as { kind: string }).kind, "pdf");
|
||||||
const text = steer.parts[0];
|
assert.ok(steer.content.includes("integral rule"), "steer text carries the brief");
|
||||||
assert.ok(text.type === "text" && text.text.includes("integral rule"), "steer text carries the brief");
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("an unreadable source: the job lands error with the PDF failed: brief and the steer flags the gap", async () => {
|
it("an unreadable source: the job lands error with the PDF failed: brief and the steer flags the gap", async () => {
|
||||||
@@ -656,8 +651,7 @@ describe("pdf runner (sources v0)", () => {
|
|||||||
assert.deepEqual(result.pagesConsulted, []);
|
assert.deepEqual(result.pagesConsulted, []);
|
||||||
|
|
||||||
const steer = delivery.steers[0]!;
|
const steer = delivery.steers[0]!;
|
||||||
const text = steer.parts[0];
|
assert.ok(steer.content.includes("flag the gap"), "the teacher learns to flag the gap");
|
||||||
assert.ok(text.type === "text" && text.text.includes("flag the gap"), "the teacher learns to flag the gap");
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -693,10 +687,10 @@ describe("researcher agent", () => {
|
|||||||
*/
|
*/
|
||||||
describe("wake pipeline (src/server/jobs.ts)", () => {
|
describe("wake pipeline (src/server/jobs.ts)", () => {
|
||||||
/** A chat with a finished job ready for delivery. */
|
/** A chat with a finished job ready for delivery. */
|
||||||
function setup(kind: "research" | "demo" = "research"): { chatId: string; jobId: string; steer: SubagentSteerMessage } {
|
function setup(kind: "research" | "demo" = "research"): { chatId: string; jobId: string; steer: SteerMessage } {
|
||||||
const chat = chatStore.createChat("Test: wake pipeline");
|
const chat = chatStore.createChat("Test: wake pipeline");
|
||||||
trackChat(chat.id);
|
trackChat(chat.id);
|
||||||
chatStore.saveMessages(chat.id, [{ id: "u1", role: "user", parts: [{ type: "text", text: "hello" }] }] as never);
|
chatStore.saveMessages(chat.id, [{ id: "u1", role: "user", content: "hello", timestamp: 1 } as never]);
|
||||||
|
|
||||||
const job = jobStore.createJob({ kind, chatId: chat.id, toolCallId: `call-${jobStore.listJobs(chat.id).length}` });
|
const job = jobStore.createJob({ kind, chatId: chat.id, toolCallId: `call-${jobStore.listJobs(chat.id).length}` });
|
||||||
trackJob(job.id);
|
trackJob(job.id);
|
||||||
@@ -709,29 +703,22 @@ describe("wake pipeline (src/server/jobs.ts)", () => {
|
|||||||
it("delivers a PROBE HANDOFF steer the same way — the teacher's wake turn", async () => {
|
it("delivers a PROBE HANDOFF steer the same way — the teacher's wake turn", async () => {
|
||||||
const chat = chatStore.createChat("Test: probe handoff wake");
|
const chat = chatStore.createChat("Test: probe handoff wake");
|
||||||
trackChat(chat.id);
|
trackChat(chat.id);
|
||||||
// The concluded probe: a persisted probe_done part (the seam the
|
// The concluded probe: a persisted probe_done call + result (the
|
||||||
// chat-handler's construction branch keys on) and no open question.
|
// native seam the chat-handler's construction branch keys on) and no
|
||||||
chatStore.saveMessages(chat.id, [
|
// open question.
|
||||||
{ id: "u1", role: "user", parts: [{ type: "text", text: "teach me calc" }] },
|
chatStore.saveMessages(chat.id, nativeToolMessages("teach me calc", "probe_done", "call-pd", { ok: true }));
|
||||||
{
|
|
||||||
id: "a1",
|
|
||||||
role: "assistant",
|
|
||||||
parts: [
|
|
||||||
{ type: "tool-probe_done", state: "output-available", output: { ok: true } },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
] as never);
|
|
||||||
const handoff = buildProbeHandoffSteer(chat.id);
|
const handoff = buildProbeHandoffSteer(chat.id);
|
||||||
let seen: { chatId: string; lastMessage: ProbeHandoffSteerMessage } | null = null;
|
let seen: { chatId: string; steer: SteerMessage } | undefined;
|
||||||
setTurnRunner(async (cid, messages) => {
|
setTurnRunner(async (cid, input) => {
|
||||||
seen = { chatId: cid, lastMessage: messages[messages.length - 1] as ProbeHandoffSteerMessage };
|
seen = { chatId: cid, steer: (input as SteerInput).steer as SteerMessage };
|
||||||
return 200;
|
return 200;
|
||||||
});
|
});
|
||||||
|
|
||||||
await deliverJobResult(handoff);
|
await deliverJobResult(handoff);
|
||||||
await waitFor(() => seen !== null, 8000, "handoff wake fired");
|
await waitFor(() => seen !== undefined, 8000, "handoff wake fired");
|
||||||
assert.equal(seen!.chatId, chat.id);
|
const firedHandoff = seen!;
|
||||||
assert.equal(seen!.lastMessage.id, `steer_probe_${chat.id}`, "the handoff is the wake turn's final input message");
|
assert.equal(firedHandoff.chatId, chat.id);
|
||||||
|
assert.equal(firedHandoff.steer.id, `steer_probe_${chat.id}`, "the handoff IS the wake turn's input");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("a redelivered handoff dedupes via the transcript (idempotent by steer id)", async () => {
|
it("a redelivered handoff dedupes via the transcript (idempotent by steer id)", async () => {
|
||||||
@@ -752,9 +739,9 @@ describe("wake pipeline (src/server/jobs.ts)", () => {
|
|||||||
|
|
||||||
it("delivers: fires the turn runner with the record + steer, then flips the job done", async () => {
|
it("delivers: fires the turn runner with the record + steer, then flips the job done", async () => {
|
||||||
const { chatId, jobId, steer } = setup();
|
const { chatId, jobId, steer } = setup();
|
||||||
let seen: { chatId: string; lastMessage: unknown } | null = null;
|
let seen: { chatId: string; steer: SteerMessage } | undefined;
|
||||||
setTurnRunner(async (cid, messages) => {
|
setTurnRunner(async (cid, input) => {
|
||||||
seen = { chatId: cid, lastMessage: messages[messages.length - 1] };
|
seen = { chatId: cid, steer: (input as SteerInput).steer as SteerMessage };
|
||||||
return 200;
|
return 200;
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -764,9 +751,8 @@ describe("wake pipeline (src/server/jobs.ts)", () => {
|
|||||||
assert.equal(jobStore.getJob(jobId)!.status, "done");
|
assert.equal(jobStore.getJob(jobId)!.status, "done");
|
||||||
assert.equal(jobStore.getJob(jobId)!.activity, "delivered");
|
assert.equal(jobStore.getJob(jobId)!.activity, "delivered");
|
||||||
assert.ok(seen, "the turn runner fired");
|
assert.ok(seen, "the turn runner fired");
|
||||||
assert.equal((seen as { chatId: string }).chatId, chatId);
|
assert.equal(seen.chatId, chatId);
|
||||||
const last = (seen as { lastMessage: SubagentSteerMessage }).lastMessage;
|
assert.equal(seen.steer.id, steer.id, "the steer IS the wake turn's input");
|
||||||
assert.equal(last.id, steer.id, "the steer is the wake turn's final input message");
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("waits while a question part is pending, and fires once it clears", async () => {
|
it("waits while a question part is pending, and fires once it clears", async () => {
|
||||||
@@ -777,16 +763,22 @@ describe("wake pipeline (src/server/jobs.ts)", () => {
|
|||||||
return 200;
|
return 200;
|
||||||
});
|
});
|
||||||
|
|
||||||
// An unanswered quiz part blocks the wake (provider conversion would
|
// A dangling question call blocks the wake (the model would reject an
|
||||||
// break on an input-available tool call).
|
// unanswered tool call — fork 1: the record ends at the call).
|
||||||
chatStore.saveMessages(chatId, [
|
chatStore.saveMessages(chatId, [
|
||||||
{ id: "u1", role: "user", parts: [{ type: "text", text: "hello" }] },
|
{ id: "u1", role: "user", content: "hello", timestamp: 1 } as never,
|
||||||
{
|
{
|
||||||
id: "a1",
|
id: "a1",
|
||||||
role: "assistant",
|
role: "assistant",
|
||||||
parts: [{ type: "tool-quiz", toolCallId: "quiz-call", state: "input-available", input: { question: "q", choices: ["a", "b"] } }],
|
content: [{ type: "toolCall", id: "quiz-call", name: "quiz", arguments: { question: "q", choices: ["a", "b"] } as never }],
|
||||||
},
|
api: "openai-completions",
|
||||||
] as never);
|
provider: "openrouter",
|
||||||
|
model: "test",
|
||||||
|
usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
||||||
|
stopReason: "toolUse",
|
||||||
|
timestamp: 1,
|
||||||
|
} as never,
|
||||||
|
]);
|
||||||
|
|
||||||
// The delivery promise floats while the quiz blocks — awaiting it now
|
// The delivery promise floats while the quiz blocks — awaiting it now
|
||||||
// would deadlock the test (the pump defers until the gate opens).
|
// would deadlock the test (the pump defers until the gate opens).
|
||||||
@@ -796,17 +788,21 @@ describe("wake pipeline (src/server/jobs.ts)", () => {
|
|||||||
assert.equal(fired, 0, "no wake while the quiz is pending");
|
assert.equal(fired, 0, "no wake while the quiz is pending");
|
||||||
assert.equal(jobStore.getJob(jobId)!.status, "running", "job stays running while blocked");
|
assert.equal(jobStore.getJob(jobId)!.status, "running", "job stays running while blocked");
|
||||||
|
|
||||||
// The learner answers: the pending part clears (client adds output).
|
// The learner answers: the real tool result clears the dangling call.
|
||||||
const record = chatStore.getChat(chatId)!;
|
const record = chatStore.getChat(chatId)!;
|
||||||
const answered = record.messages.map((m) =>
|
chatStore.saveMessages(chatId, [
|
||||||
m.id === "a1"
|
...record.messages,
|
||||||
? {
|
{
|
||||||
...m,
|
id: "r1",
|
||||||
parts: [{ type: "tool-quiz", toolCallId: "quiz-call", state: "output-available", input: { question: "q", choices: ["a", "b"] }, output: { answer: "a", correct: true, explanation: "" } }],
|
role: "toolResult",
|
||||||
}
|
toolCallId: "quiz-call",
|
||||||
: m,
|
toolName: "quiz",
|
||||||
);
|
content: [{ type: "text", text: "answered" }],
|
||||||
chatStore.saveMessages(chatId, answered as never);
|
details: { answer: "a", correct: true, explanation: "" },
|
||||||
|
isError: false,
|
||||||
|
timestamp: 1,
|
||||||
|
} as never,
|
||||||
|
]);
|
||||||
|
|
||||||
await waitFor(() => fired > 0, 8000, "wake after the quiz answer");
|
await waitFor(() => fired > 0, 8000, "wake after the quiz answer");
|
||||||
await delivery; // now it resolves — the steer was delivered
|
await delivery; // now it resolves — the steer was delivered
|
||||||
@@ -873,15 +869,20 @@ describe("claimSteers + pump duplicate-guard (continuation coalescing)", () => {
|
|||||||
assert.equal(fired, 0, "the pump never fires a wake for a claimed steer");
|
assert.equal(fired, 0, "the pump never fires a wake for a claimed steer");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("finalizes already-delivered steers without returning them (a wake raced in)", async () => {
|
it("finalizes already-delivered steers; the handler's steerDelivered filter keeps one copy (a wake raced in)", async () => {
|
||||||
const { chatId, jobId, steer } = setup();
|
const { chatId, jobId, steer } = setup();
|
||||||
const record = chatStore.getChat(chatId)!;
|
const record = chatStore.getChat(chatId)!;
|
||||||
chatStore.saveMessages(chatId, [...record.messages, steer] as never);
|
chatStore.saveMessages(chatId, [...record.messages, steer] as never);
|
||||||
|
|
||||||
const delivery = deliverJobResult(steer);
|
const delivery = deliverJobResult(steer);
|
||||||
await delay(100); // let the pump see it first (its top-of-loop idempotent skip)
|
await delay(100); // let the pump's top-of-loop idempotent skip run
|
||||||
const claimed = claimSteers(chatId);
|
const claimed = claimSteers(chatId);
|
||||||
assert.equal(claimed.length, 0, "delivered steers are not re-claimed");
|
// The pump itself finalizes already-delivered steers at the top of
|
||||||
|
// its loop — the queue is empty by the time a racing turn claims.
|
||||||
|
// (A steer that DID ride the queue gets claimed whole; the HANDLER's
|
||||||
|
// steerDelivered filter keeps exactly one copy at append.)
|
||||||
|
assert.equal(claimed.length, 0, "the pump finalized the delivered steer without firing a wake");
|
||||||
|
assert.ok(steerDelivered(chatStore.getChat(chatId)!.messages, steer.id), "the record already holds it");
|
||||||
assert.equal(jobStore.getJob(jobId)!.status, "done");
|
assert.equal(jobStore.getJob(jobId)!.status, "done");
|
||||||
await delivery;
|
await delivery;
|
||||||
});
|
});
|
||||||
@@ -890,7 +891,7 @@ describe("claimSteers + pump duplicate-guard (continuation coalescing)", () => {
|
|||||||
assert.deepEqual(claimSteers("chat_never_existed"), []);
|
assert.deepEqual(claimSteers("chat_never_existed"), []);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("pump path: a steer already carried by the incoming turn body is finalized, never appended again (duplicate-key bug, 2026-09-18)", async () => {
|
it("pump path: a steer coalesced into the record mid-flight is claimed, filtered at append, never double-fired (duplicate-key bug, 2026-09-18)", async () => {
|
||||||
const { chatId, jobId, steer } = setup();
|
const { chatId, jobId, steer } = setup();
|
||||||
// Own runner: this chat's gates are OPEN, so the pump would otherwise
|
// Own runner: this chat's gates are OPEN, so the pump would otherwise
|
||||||
// fire whatever runner the NEXT test registers (leaked counter).
|
// fire whatever runner the NEXT test registers (leaked counter).
|
||||||
@@ -899,29 +900,29 @@ describe("claimSteers + pump duplicate-guard (continuation coalescing)", () => {
|
|||||||
fired++;
|
fired++;
|
||||||
return 200;
|
return 200;
|
||||||
});
|
});
|
||||||
// The wake pump's request ALREADY carries the steer (it dequeues only
|
// The racing turn's write-ahead ALREADY landed the steer in the record
|
||||||
// after the turn returns), and the record does not — the exact window
|
// while the entry is still queued — the exact window the handler
|
||||||
// the chat handler claims in.
|
// claims in (the pump dequeues only after the turn returns 200).
|
||||||
const delivery = deliverJobResult(steer);
|
const delivery = deliverJobResult(steer);
|
||||||
await delay(300); // the pump is inside waitForIdle's grace (750ms)
|
await delay(300); // the pump is inside waitForIdle's grace (750ms)
|
||||||
const incoming = [...(chatStore.getChat(chatId)!.messages as never[]), steer];
|
const incoming = [...chatStore.getChat(chatId)!.messages, steer];
|
||||||
const claimed = claimSteers(chatId, incoming);
|
chatStore.saveMessages(chatId, incoming); // the racing turn's write-ahead persist
|
||||||
assert.equal(claimed.length, 0, "the already-carried steer is not re-claimed");
|
const claimed = claimSteers(chatId);
|
||||||
assert.equal(jobStore.getJob(jobId)!.status, "done", "job still finalizes — the turn delivers it");
|
assert.equal(claimed.length, 1, "the queued steer is claimed — its delivery rides the turn");
|
||||||
|
assert.equal(jobStore.getJob(jobId)!.status, "done", "job still finalizes — the claim resolves the delivery");
|
||||||
await delivery;
|
await delivery;
|
||||||
assert.deepEqual(claimSteers(chatId), [], "queue is drained");
|
assert.deepEqual(claimSteers(chatId), [], "queue is drained");
|
||||||
// The handler persists the outgoing write-ahead immediately after the
|
// The handler appends only steers NOT already in the record
|
||||||
// claim — the pump's post-idle re-check reads the RECORD (not the
|
// (steerDelivered) — the write-ahead keeps exactly ONE copy of the
|
||||||
// queue), so without this the wake would double-fire at grace-end.
|
// steer id, and the pump's post-idle re-check reads the RECORD and
|
||||||
const record2 = chatStore.getChat(chatId)!;
|
// skips the delivered id.
|
||||||
chatStore.saveMessages(chatId, [...record2.messages, ...incoming] as never);
|
const appends = claimed.filter((c) => !steerDelivered(chatStore.getChat(chatId)!.messages, c.id));
|
||||||
await delay(800); // the pump's grace elapses — the delivered steer is skipped
|
assert.equal(appends.length, 0, "the already-persisted steer never appends again");
|
||||||
assert.equal(fired, 0, "the claimed-and-carried steer never double-fires a wake");
|
const outgoing = [...chatStore.getChat(chatId)!.messages, ...appends];
|
||||||
// The handler's outgoing = incoming + claimed: with claimed empty the
|
const ids = outgoing.map((m) => m.id);
|
||||||
// write-ahead holds exactly ONE copy of the steer id.
|
|
||||||
const outgoing = [...incoming, ...claimed] as never[];
|
|
||||||
const ids = outgoing.map((m: { id: string }) => m.id);
|
|
||||||
assert.equal(ids.filter((id) => id === steer.id).length, 1, "exactly one steer copy persists");
|
assert.equal(ids.filter((id) => id === steer.id).length, 1, "exactly one steer copy persists");
|
||||||
|
await delay(800); // the pump's grace elapses — its re-check sees the delivered id
|
||||||
|
assert.equal(fired, 0, "the claimed-and-persisted steer never double-fires a wake");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("pump duplicate-guard: a steer coalesced into the record while the gates were closed is never double-fired", async () => {
|
it("pump duplicate-guard: a steer coalesced into the record while the gates were closed is never double-fired", async () => {
|
||||||
@@ -932,15 +933,21 @@ describe("claimSteers + pump duplicate-guard (continuation coalescing)", () => {
|
|||||||
return 200;
|
return 200;
|
||||||
});
|
});
|
||||||
|
|
||||||
// Gates closed: a pending quiz parks the pump in waitForIdle.
|
// Gates closed: a dangling question call parks the pump in waitForIdle.
|
||||||
chatStore.saveMessages(chatId, [
|
chatStore.saveMessages(chatId, [
|
||||||
{ id: "u1", role: "user", parts: [{ type: "text", text: "hello" }] },
|
{ id: "u1", role: "user", content: "hello", timestamp: 1 } as never,
|
||||||
{
|
{
|
||||||
id: "a1",
|
id: "a1",
|
||||||
role: "assistant",
|
role: "assistant",
|
||||||
parts: [{ type: "tool-quiz", toolCallId: "quiz-call", state: "input-available", input: { question: "q", choices: ["a", "b"] } }],
|
content: [{ type: "toolCall", id: "quiz-call", name: "quiz", arguments: { question: "q", choices: ["a", "b"] } as never }],
|
||||||
},
|
api: "openai-completions",
|
||||||
] as never);
|
provider: "openrouter",
|
||||||
|
model: "test",
|
||||||
|
usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
||||||
|
stopReason: "toolUse",
|
||||||
|
timestamp: 1,
|
||||||
|
} as never,
|
||||||
|
]);
|
||||||
const delivery = deliverJobResult(steer);
|
const delivery = deliverJobResult(steer);
|
||||||
await delay(1200);
|
await delay(1200);
|
||||||
assert.equal(fired, 0, "parked behind the pending quiz");
|
assert.equal(fired, 0, "parked behind the pending quiz");
|
||||||
@@ -953,15 +960,20 @@ describe("claimSteers + pump duplicate-guard (continuation coalescing)", () => {
|
|||||||
|
|
||||||
// The learner answers: the gates open — the parked pump must see the
|
// The learner answers: the gates open — the parked pump must see the
|
||||||
// delivered steer via its post-idle re-check, NOT fire a second turn.
|
// delivered steer via its post-idle re-check, NOT fire a second turn.
|
||||||
const answered = chatStore.getChat(chatId)!.messages.map((m) =>
|
const answeredRecord = chatStore.getChat(chatId)!;
|
||||||
m.id === "a1"
|
chatStore.saveMessages(chatId, [
|
||||||
? {
|
...answeredRecord.messages,
|
||||||
...m,
|
{
|
||||||
parts: [{ type: "tool-quiz", toolCallId: "quiz-call", state: "output-available", input: { question: "q", choices: ["a", "b"] }, output: { answer: "a", correct: true, explanation: "" } }],
|
id: "r1",
|
||||||
}
|
role: "toolResult",
|
||||||
: m,
|
toolCallId: "quiz-call",
|
||||||
);
|
toolName: "quiz",
|
||||||
chatStore.saveMessages(chatId, answered as never);
|
content: [{ type: "text", text: "answered" }],
|
||||||
|
details: { answer: "a", correct: true, explanation: "" },
|
||||||
|
isError: false,
|
||||||
|
timestamp: 1,
|
||||||
|
} as never,
|
||||||
|
]);
|
||||||
|
|
||||||
await delivery;
|
await delivery;
|
||||||
await delay(1500);
|
await delay(1500);
|
||||||
@@ -990,57 +1002,104 @@ function delay(ms: number): Promise<void> {
|
|||||||
|
|
||||||
// ─── persist id hygiene (the duplicate-empty-key bug) ────────────────────────
|
// ─── persist id hygiene (the duplicate-empty-key bug) ────────────────────────
|
||||||
|
|
||||||
describe("persistTurn id hygiene", () => {
|
describe("persistTurn (native history, Wave B)", () => {
|
||||||
it("withMessageIds fills EMPTY ids (server-only synthesized messages) and preserves existing ones", () => {
|
it("stamps EMPTY ids (pi minted no id) at the canonical write and preserves existing ones", () => {
|
||||||
const filled = withMessageIds([
|
const chat = chatStore.createChat("Test: persist stamps ids");
|
||||||
{ id: "", role: "assistant", parts: [{ type: "text", text: "wake turn" }] },
|
|
||||||
{ id: "client-id", role: "user", parts: [{ type: "text", text: "hi" }] },
|
|
||||||
]);
|
|
||||||
assert.notEqual(filled[0]!.id, "", "empty id replaced");
|
|
||||||
assert.match(filled[0]!.id, /^msg_/);
|
|
||||||
assert.equal(filled[1]!.id, "client-id", "client-authored id untouched");
|
|
||||||
assert.notEqual(filled[0]!.id, filled[1]!.id);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("persistTurn dedupes same-id continuation copies, keeping the LAST (the SDK's merged superset)", () => {
|
|
||||||
// The coalescing shape: a steer (user-role) rides a quiz-answer
|
|
||||||
// continuation AFTER the assistant message being continued, so the
|
|
||||||
// SDK's onFinish list carries the input copy AND the extended
|
|
||||||
// response copy under one id — the record must keep only the merge.
|
|
||||||
const chat = chatStore.createChat("Test: dedupe continuation copies");
|
|
||||||
trackChat(chat.id);
|
trackChat(chat.id);
|
||||||
const short: UIMessage = { id: "msg_dup_1", role: "assistant", parts: [{ type: "text", text: "step 1" }] };
|
const stamped = persistTurn(chatStore, chat.id, 0, [
|
||||||
const long: UIMessage = { id: "msg_dup_1", role: "assistant", parts: [{ type: "text", text: "step 1" }, { type: "text", text: "step 2 (continued)" }] };
|
{ id: "", role: "user", content: "wake turn", timestamp: 1 } as never,
|
||||||
persistTurn(chatStore, chat.id, [
|
{
|
||||||
{ id: "u1", role: "user", parts: [{ type: "text", text: "hi" }] },
|
id: "msg_kept",
|
||||||
short,
|
role: "assistant",
|
||||||
{ id: "steer_x", role: "user", parts: [{ type: "text", text: "research complete" }] },
|
content: [{ type: "text", text: "hi" }],
|
||||||
long,
|
api: "openai-completions",
|
||||||
|
provider: "openrouter",
|
||||||
|
model: "test",
|
||||||
|
usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
||||||
|
stopReason: "stop",
|
||||||
|
timestamp: 1,
|
||||||
|
} as never,
|
||||||
], false);
|
], false);
|
||||||
|
assert.notEqual(stamped[0]!.id, "", "empty id replaced");
|
||||||
|
assert.match(stamped[0]!.id, /^msg_/);
|
||||||
|
assert.equal(stamped[1]!.id, "msg_kept", "server-authored id untouched");
|
||||||
const record = chatStore.getChat(chat.id)!;
|
const record = chatStore.getChat(chat.id)!;
|
||||||
assert.equal(record.messages.length, 3, "the duplicate entry is dropped");
|
assert.equal(record.messages.length, 2, "both messages persisted");
|
||||||
assert.deepEqual(
|
assert.ok(record.messages.every((m) => m.id && m.id.length > 0), "no empty ids survive persist");
|
||||||
record.messages.map((m) => m.id),
|
|
||||||
["u1", "steer_x", "msg_dup_1"],
|
|
||||||
"order preserved, steer before the merged assistant copy",
|
|
||||||
);
|
|
||||||
const merged = record.messages[2]!;
|
|
||||||
assert.equal(merged.parts.length, 2, "the KEPT copy is the extended one");
|
|
||||||
assert.equal((merged.parts[1] as { text: string }).text, "step 2 (continued)");
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("persistTurn saves every message with a unique non-empty id (wake turns never re-POSTed)", () => {
|
it("baseCount slices the turn's own messages (the agent's state is append-only across turns)", () => {
|
||||||
const chat = chatStore.createChat("Test: persist ids");
|
const chat = chatStore.createChat("Test: persist slices the turn tail");
|
||||||
trackChat(chat.id);
|
trackChat(chat.id);
|
||||||
persistTurn(chatStore, chat.id, [
|
chatStore.saveMessages(chat.id, [
|
||||||
{ id: "", role: "user", parts: [{ type: "text", text: "q" }] },
|
{ id: "base_1", role: "user", content: "first", timestamp: 1 } as never,
|
||||||
{ id: "", role: "assistant", parts: [{ type: "text", text: "a1" }] },
|
]);
|
||||||
{ id: "", role: "assistant", parts: [{ type: "text", text: "a2" }] },
|
// The agent's state at onFinish: the base record + the turn's OWN new
|
||||||
|
// messages (Wave B: onFinish receives the full native history — the
|
||||||
|
// slice keeps persist append-only).
|
||||||
|
const state = [
|
||||||
|
{ id: "base_1", role: "user", content: "first", timestamp: 1 } as never,
|
||||||
|
{
|
||||||
|
id: "",
|
||||||
|
role: "assistant",
|
||||||
|
content: [{ type: "text", text: "wake turn" }],
|
||||||
|
api: "openai-completions",
|
||||||
|
provider: "openrouter",
|
||||||
|
model: "test",
|
||||||
|
usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
||||||
|
stopReason: "stop",
|
||||||
|
timestamp: 1,
|
||||||
|
} as never,
|
||||||
|
];
|
||||||
|
const stamped = persistTurn(chatStore, chat.id, 1, state, false);
|
||||||
|
assert.equal(stamped.length, 1, "only the turn's new tail persists");
|
||||||
|
const record = chatStore.getChat(chat.id)!;
|
||||||
|
assert.equal(record.messages.length, 2, "the base record is untouched — append-only");
|
||||||
|
assert.match(record.messages[1]!.id, /^msg_/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("drops clean question placeholders, keeps errored ones (fork 1: the record ends at the unanswered call)", () => {
|
||||||
|
const chat = chatStore.createChat("Test: persist placeholder filtering");
|
||||||
|
trackChat(chat.id);
|
||||||
|
const stamped = persistTurn(chatStore, chat.id, 0, [
|
||||||
|
{
|
||||||
|
id: "",
|
||||||
|
role: "assistant",
|
||||||
|
content: [{ type: "toolCall", id: "q1", name: "quiz", arguments: { question: "q?" } as never }],
|
||||||
|
api: "openai-completions",
|
||||||
|
provider: "openrouter",
|
||||||
|
model: "test",
|
||||||
|
usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
||||||
|
stopReason: "toolUse",
|
||||||
|
timestamp: 1,
|
||||||
|
} as never,
|
||||||
|
// The loop's synthetic "question posed" result — never a fact.
|
||||||
|
{
|
||||||
|
id: "",
|
||||||
|
role: "toolResult",
|
||||||
|
toolCallId: "q1",
|
||||||
|
toolName: "quiz",
|
||||||
|
content: [{ type: "text", text: "question posed" }],
|
||||||
|
details: "question posed",
|
||||||
|
isError: false,
|
||||||
|
timestamp: 1,
|
||||||
|
} as never,
|
||||||
|
// An ERRORED authoring call DOES persist — the model's retry context.
|
||||||
|
{
|
||||||
|
id: "",
|
||||||
|
role: "toolResult",
|
||||||
|
toolCallId: "q1",
|
||||||
|
toolName: "quiz",
|
||||||
|
content: [{ type: "text", text: "bad options" }],
|
||||||
|
details: "bad options",
|
||||||
|
isError: true,
|
||||||
|
timestamp: 1,
|
||||||
|
} as never,
|
||||||
], false);
|
], false);
|
||||||
const record = chatStore.getChat(chat.id);
|
assert.equal(stamped.length, 2, "the clean placeholder is filtered, the errored result stays");
|
||||||
const ids = record!.messages.map((m) => m.id);
|
const record = chatStore.getChat(chat.id)!;
|
||||||
assert.ok(ids.every((id) => id && id.length > 0), "no empty ids survive persist");
|
assert.equal(record.messages.length, 2);
|
||||||
assert.equal(new Set(ids).size, ids.length, "ids unique — React keys safe");
|
assert.equal((record.messages[1] as { isError: boolean }).isError, true);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1142,17 +1201,34 @@ describe("abort (src/server/jobs.ts abortJob)", () => {
|
|||||||
function newChatWithResearchJob(label: string, toolCallId: string): { chatId: string; jobId: string } {
|
function newChatWithResearchJob(label: string, toolCallId: string): { chatId: string; jobId: string } {
|
||||||
const chat = chatStore.createChat(`Test: ${label}`);
|
const chat = chatStore.createChat(`Test: ${label}`);
|
||||||
trackChat(chat.id);
|
trackChat(chat.id);
|
||||||
|
// Native fixture — the record is agent history (Wave B).
|
||||||
chatStore.saveMessages(chat.id, [
|
chatStore.saveMessages(chat.id, [
|
||||||
{ id: "u1", role: "user", parts: [{ type: "text", text: "hi" }] },
|
{ id: "u1", role: "user", content: "hi", timestamp: 1 } as never,
|
||||||
{
|
{
|
||||||
id: "a1",
|
id: "a1",
|
||||||
role: "assistant",
|
role: "assistant",
|
||||||
parts: [
|
content: [
|
||||||
{ type: "text", text: "Checking." },
|
{ type: "text", text: "Checking." },
|
||||||
{ type: "tool-research", toolCallId, state: "output-available", input: RESEARCH_INPUT, output: "Dispatched." },
|
{ type: "toolCall", id: toolCallId, name: "research", arguments: RESEARCH_INPUT as never },
|
||||||
],
|
],
|
||||||
},
|
api: "openai-completions",
|
||||||
] as never);
|
provider: "openrouter",
|
||||||
|
model: "test",
|
||||||
|
usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
||||||
|
stopReason: "toolUse",
|
||||||
|
timestamp: 1,
|
||||||
|
} as never,
|
||||||
|
{
|
||||||
|
id: "r1",
|
||||||
|
role: "toolResult",
|
||||||
|
toolCallId,
|
||||||
|
toolName: "research",
|
||||||
|
content: [{ type: "text", text: "Dispatched." }],
|
||||||
|
details: "Dispatched.",
|
||||||
|
isError: false,
|
||||||
|
timestamp: 1,
|
||||||
|
} as never,
|
||||||
|
]);
|
||||||
const job = jobStore.createJob({ kind: "research", chatId: chat.id, toolCallId });
|
const job = jobStore.createJob({ kind: "research", chatId: chat.id, toolCallId });
|
||||||
trackJob(job.id);
|
trackJob(job.id);
|
||||||
return { chatId: chat.id, jobId: job.id };
|
return { chatId: chat.id, jobId: job.id };
|
||||||
@@ -1161,8 +1237,8 @@ describe("abort (src/server/jobs.ts abortJob)", () => {
|
|||||||
it("kills the running loop: error \"Aborted by learner\" immediately, loop unwinds silently, teacher gets the abort steer", async () => {
|
it("kills the running loop: error \"Aborted by learner\" immediately, loop unwinds silently, teacher gets the abort steer", async () => {
|
||||||
const { jobId } = newChatWithResearchJob("abort running job", "call-abort1");
|
const { jobId } = newChatWithResearchJob("abort running job", "call-abort1");
|
||||||
const fired: SteerMessage[] = [];
|
const fired: SteerMessage[] = [];
|
||||||
setTurnRunner(async (_cid, messages) => {
|
setTurnRunner(async (_cid, input) => {
|
||||||
fired.push(messages[messages.length - 1] as SteerMessage);
|
fired.push((input as SteerInput).steer as SteerMessage);
|
||||||
return 200;
|
return 200;
|
||||||
});
|
});
|
||||||
researchModelOverride.current = hangingModel();
|
researchModelOverride.current = hangingModel();
|
||||||
@@ -1191,10 +1267,9 @@ describe("abort (src/server/jobs.ts abortJob)", () => {
|
|||||||
|
|
||||||
await waitFor(() => fired.length > 0, 8000, "abort wake fired");
|
await waitFor(() => fired.length > 0, 8000, "abort wake fired");
|
||||||
const last = fired[0]!;
|
const last = fired[0]!;
|
||||||
assert.equal((last.metadata as { subagentResult: { aborted: boolean } }).subagentResult.aborted, true);
|
assert.equal((last.details as { aborted: boolean }).aborted, true);
|
||||||
assert.equal(last.id, `steer_${jobId}`);
|
assert.equal(last.id, `steer_${jobId}`);
|
||||||
assert.equal(last.parts.length, 2, "teacher-facing text + the data-subagent-aborted marker part — no artifact");
|
assert.equal(last.customType, "subagent-abort", "the abort marker — no artifact");
|
||||||
assert.equal(last.parts[1]!.type, "data-subagent-aborted");
|
|
||||||
// aborted is terminal: the wake completing must not flip it
|
// aborted is terminal: the wake completing must not flip it
|
||||||
assert.equal(jobStore.getJob(jobId)!.status, "error", "terminal after the wake");
|
assert.equal(jobStore.getJob(jobId)!.status, "error", "terminal after the wake");
|
||||||
} finally {
|
} finally {
|
||||||
@@ -1231,7 +1306,7 @@ describe("abort (src/server/jobs.ts abortJob)", () => {
|
|||||||
it("aborting a delivering job drops the queued result steer and wakes with the abort note instead", async () => {
|
it("aborting a delivering job drops the queued result steer and wakes with the abort note instead", async () => {
|
||||||
const chat = chatStore.createChat("Test: abort delivering job");
|
const chat = chatStore.createChat("Test: abort delivering job");
|
||||||
trackChat(chat.id);
|
trackChat(chat.id);
|
||||||
chatStore.saveMessages(chat.id, [{ id: "u1", role: "user", parts: [{ type: "text", text: "hello" }] }] as never);
|
chatStore.saveMessages(chat.id, [{ id: "u1", role: "user", content: "hello", timestamp: 1 } as never]);
|
||||||
const job = jobStore.createJob({ kind: "research", chatId: chat.id, toolCallId: "call-abort3" });
|
const job = jobStore.createJob({ kind: "research", chatId: chat.id, toolCallId: "call-abort3" });
|
||||||
trackJob(job.id);
|
trackJob(job.id);
|
||||||
const result: ResearchResult = { brief: "Done: it works.", sources: [{ title: "s", url: "https://x.test" }] };
|
const result: ResearchResult = { brief: "Done: it works.", sources: [{ title: "s", url: "https://x.test" }] };
|
||||||
@@ -1240,8 +1315,8 @@ describe("abort (src/server/jobs.ts abortJob)", () => {
|
|||||||
// Block the pump's idle gates so the result steer sits QUEUED, not in flight.
|
// Block the pump's idle gates so the result steer sits QUEUED, not in flight.
|
||||||
assert.ok(beginTurn(chat.id));
|
assert.ok(beginTurn(chat.id));
|
||||||
const fired: SteerMessage[] = [];
|
const fired: SteerMessage[] = [];
|
||||||
setTurnRunner(async (_cid, messages) => {
|
setTurnRunner(async (_cid, input) => {
|
||||||
fired.push(messages[messages.length - 1] as SteerMessage);
|
fired.push((input as SteerInput).steer as SteerMessage);
|
||||||
return 200;
|
return 200;
|
||||||
});
|
});
|
||||||
void deliverJobResult(buildSubagentSteer(job, result)).catch(() => {
|
void deliverJobResult(buildSubagentSteer(job, result)).catch(() => {
|
||||||
@@ -1257,11 +1332,7 @@ describe("abort (src/server/jobs.ts abortJob)", () => {
|
|||||||
endTurn(chat.id); // gates open — whatever is left in the queue fires
|
endTurn(chat.id); // gates open — whatever is left in the queue fires
|
||||||
await waitFor(() => fired.length > 0, 8000, "wake fired");
|
await waitFor(() => fired.length > 0, 8000, "wake fired");
|
||||||
assert.equal(fired.length, 1, "exactly one delivery — the result steer was dropped");
|
assert.equal(fired.length, 1, "exactly one delivery — the result steer was dropped");
|
||||||
assert.equal(
|
assert.equal((fired[0]!.details as { aborted: boolean }).aborted, true, "the abort steer, not the result");
|
||||||
(fired[0]!.metadata as { subagentResult: { aborted: boolean } }).subagentResult.aborted,
|
|
||||||
true,
|
|
||||||
"the abort steer, not the result",
|
|
||||||
);
|
|
||||||
assert.equal(jobStore.getJob(job.id)!.status, "error", "terminal after the wake");
|
assert.equal(jobStore.getJob(job.id)!.status, "error", "terminal after the wake");
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1297,7 +1368,7 @@ describe("stale-job recovery sweep (liveness-gated)", () => {
|
|||||||
function orphanJob(withResult?: ResearchResult): { chatId: string; jobId: string } {
|
function orphanJob(withResult?: ResearchResult): { chatId: string; jobId: string } {
|
||||||
const chat = chatStore.createChat("Test: stale sweep");
|
const chat = chatStore.createChat("Test: stale sweep");
|
||||||
trackChat(chat.id);
|
trackChat(chat.id);
|
||||||
chatStore.saveMessages(chat.id, [{ id: "u1", role: "user", parts: [{ type: "text", text: "hello" }] }] as never);
|
chatStore.saveMessages(chat.id, [{ id: "u1", role: "user", content: "hello", timestamp: 1 } as never]);
|
||||||
const job = jobStore.createJob({ kind: "research", chatId: chat.id, toolCallId: `call-sweep-${createdJobs.length}` });
|
const job = jobStore.createJob({ kind: "research", chatId: chat.id, toolCallId: `call-sweep-${createdJobs.length}` });
|
||||||
trackJob(job.id);
|
trackJob(job.id);
|
||||||
if (withResult) jobStore.updateJob(job.id, { result: withResult, activity: "delivering result" });
|
if (withResult) jobStore.updateJob(job.id, { result: withResult, activity: "delivering result" });
|
||||||
@@ -1360,14 +1431,7 @@ describe("BYOK job refusal (no factory floor)", () => {
|
|||||||
it("runJob with NO resolvable engine lands the job as error — the envelope wording, no phantom run", async () => {
|
it("runJob with NO resolvable engine lands the job as error — the envelope wording, no phantom run", async () => {
|
||||||
const chat = chatStore.createChat("Test: unconfigured spawn");
|
const chat = chatStore.createChat("Test: unconfigured spawn");
|
||||||
trackChat(chat.id);
|
trackChat(chat.id);
|
||||||
chatStore.saveMessages(chat.id, [
|
chatStore.saveMessages(chat.id, nativeToolMessages("hi", "research", "call-nc1", RESEARCH_INPUT));
|
||||||
{ id: "u1", role: "user", parts: [{ type: "text", text: "hi" }] },
|
|
||||||
{
|
|
||||||
id: "a1",
|
|
||||||
role: "assistant",
|
|
||||||
parts: [{ type: "tool-research", toolCallId: "call-nc1", state: "output-available", input: RESEARCH_INPUT, output: "Dispatched." }],
|
|
||||||
},
|
|
||||||
] as never);
|
|
||||||
|
|
||||||
const job = jobStore.createJob({ kind: "research", chatId: chat.id, toolCallId: "call-nc1" });
|
const job = jobStore.createJob({ kind: "research", chatId: chat.id, toolCallId: "call-nc1" });
|
||||||
trackJob(job.id);
|
trackJob(job.id);
|
||||||
@@ -1388,9 +1452,8 @@ describe("BYOK job refusal (no factory floor)", () => {
|
|||||||
assert.match(done.error ?? "", /No model configured/);
|
assert.match(done.error ?? "", /No model configured/);
|
||||||
assert.match(done.error ?? "", /missing: cheap/);
|
assert.match(done.error ?? "", /missing: cheap/);
|
||||||
assert.equal(delivery.steers.length, 1, "the refusal STEERED — the teacher is not stranded");
|
assert.equal(delivery.steers.length, 1, "the refusal STEERED — the teacher is not stranded");
|
||||||
const text = delivery.steers[0]!.parts[0];
|
|
||||||
assert.ok(
|
assert.ok(
|
||||||
text.type === "text" && text.text.includes("Research failed: No model configured"),
|
delivery.steers[0]!.content.includes("Research failed: No model configured"),
|
||||||
"the steer carries the kind's failure shape",
|
"the steer carries the kind's failure shape",
|
||||||
);
|
);
|
||||||
const result = done.result as ResearchResult;
|
const result = done.result as ResearchResult;
|
||||||
@@ -1404,14 +1467,7 @@ describe("provider-key job refusal (key-presence interlude)", () => {
|
|||||||
it("an engine whose provider key is gone refuses the spawn — envelope wording, no phantom run", async () => {
|
it("an engine whose provider key is gone refuses the spawn — envelope wording, no phantom run", async () => {
|
||||||
const chat = chatStore.createChat("Test: keyless spawn");
|
const chat = chatStore.createChat("Test: keyless spawn");
|
||||||
trackChat(chat.id);
|
trackChat(chat.id);
|
||||||
chatStore.saveMessages(chat.id, [
|
chatStore.saveMessages(chat.id, nativeToolMessages("hi", "research", "call-km1", RESEARCH_INPUT));
|
||||||
{ id: "u1", role: "user", parts: [{ type: "text", text: "hi" }] },
|
|
||||||
{
|
|
||||||
id: "a1",
|
|
||||||
role: "assistant",
|
|
||||||
parts: [{ type: "tool-research", toolCallId: "call-km1", state: "output-available", input: RESEARCH_INPUT, output: "Dispatched." }],
|
|
||||||
},
|
|
||||||
] as never);
|
|
||||||
|
|
||||||
const job = jobStore.createJob({ kind: "research", chatId: chat.id, toolCallId: "call-km1" });
|
const job = jobStore.createJob({ kind: "research", chatId: chat.id, toolCallId: "call-km1" });
|
||||||
trackJob(job.id);
|
trackJob(job.id);
|
||||||
@@ -1441,9 +1497,8 @@ describe("provider-key job refusal (key-presence interlude)", () => {
|
|||||||
assert.equal(done.error, providerKeyMissing(engine.model.provider, ["cheap"]).error, "the envelope's error line verbatim");
|
assert.equal(done.error, providerKeyMissing(engine.model.provider, ["cheap"]).error, "the envelope's error line verbatim");
|
||||||
assert.deepEqual(seen, [[engine.model.provider, "cheap"]], "the check saw the engine's provider + the researcher's tier");
|
assert.deepEqual(seen, [[engine.model.provider, "cheap"]], "the check saw the engine's provider + the researcher's tier");
|
||||||
assert.equal(delivery.steers.length, 1, "the refusal STEERED — the teacher is not stranded");
|
assert.equal(delivery.steers.length, 1, "the refusal STEERED — the teacher is not stranded");
|
||||||
const text = delivery.steers[0]!.parts[0];
|
|
||||||
assert.ok(
|
assert.ok(
|
||||||
text.type === "text" && text.text.includes(`Research failed: No ${engine.model.provider} key is set`),
|
delivery.steers[0]!.content.includes(`Research failed: No ${engine.model.provider} key is set`),
|
||||||
"the steer carries the kind's failure shape",
|
"the steer carries the kind's failure shape",
|
||||||
);
|
);
|
||||||
const result = done.result as ResearchResult;
|
const result = done.result as ResearchResult;
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ const PLAN_OK = { ok: true, tree: { nodes: [{ id: "n1", title: "N" }], edges: []
|
|||||||
|
|
||||||
/** The verdict button's user message (the custom-message scheme): its own
|
/** 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 the adapter's (record-adapter.ts). */
|
* text; the model-facing wording is toLlmMessages' (native-messages.ts). */
|
||||||
function verdict(accepted: boolean): UIMessage {
|
function verdict(accepted: boolean): UIMessage {
|
||||||
return {
|
return {
|
||||||
id: `u_verdict_${Math.random().toString(36).slice(2, 6)}`,
|
id: `u_verdict_${Math.random().toString(36).slice(2, 6)}`,
|
||||||
|
|||||||
+153
-94
@@ -1,15 +1,20 @@
|
|||||||
/**
|
/**
|
||||||
* plan-blocks — the INTERIM plan presentation seam (docs/probe-teach-split.md
|
* plan-blocks — the INTERIM plan presentation seam (docs/probe-teach-split.md
|
||||||
* §6): the full block renders IN FLOW exactly once, attached to the
|
* §6): the full block renders IN FLOW exactly once, attached to the
|
||||||
* assistant message carrying the successful plan_init call. The
|
* assistant group carrying the successful plan_init call. Wave B: emission
|
||||||
* neighborhood-block machinery is retired (position derives live from
|
* is a NATIVE plan-block marker (one per TURN, for the last successful
|
||||||
* derivePlanStatuses); the persistent DAG window (UI wave) succeeds this.
|
* call), and the one-way projection splices its data part right after the
|
||||||
|
* last successful plan_init tool part. The neighborhood-block machinery is
|
||||||
|
* retired (position derives live from derivePlanStatuses); the persistent
|
||||||
|
* DAG window (UI wave) succeeds this.
|
||||||
*/
|
*/
|
||||||
import assert from "node:assert";
|
import assert from "node:assert";
|
||||||
import test from "node:test";
|
import test from "node:test";
|
||||||
import type { UIMessage } from "ai";
|
|
||||||
import { withPlanBlocks, type PlanBlockPart } from "../src/core/plan-blocks.ts";
|
|
||||||
import type { PlanTree } from "../src/core/contracts.ts";
|
import type { PlanTree } from "../src/core/contracts.ts";
|
||||||
|
import type { LhMessage } from "../src/core/native-messages.ts";
|
||||||
|
import { planBlocksFor } from "../src/core/plan-blocks.ts";
|
||||||
|
import { projectNative } from "../src/core/projection.ts";
|
||||||
|
import type { PlanBlockData } from "../src/core/contracts.ts";
|
||||||
|
|
||||||
// 5-node fixture: a → b → c, b → d, d → e.
|
// 5-node fixture: a → b → c, b → d, d → e.
|
||||||
const TREE: PlanTree = {
|
const TREE: PlanTree = {
|
||||||
@@ -40,132 +45,186 @@ const TREE2: PlanTree = {
|
|||||||
version: 2,
|
version: 2,
|
||||||
};
|
};
|
||||||
|
|
||||||
/** An assistant message carrying a successful plan_init with the fixture tree. */
|
/** A native assistant message with one tool call (the projection's tool
|
||||||
function planInitMessage(id: string, tree: PlanTree = TREE): UIMessage {
|
* part source) — minimal fixture shape, same as the record holds. */
|
||||||
|
function assistantWithCall(id: string, name: string, args: unknown, ...text: string[]): LhMessage {
|
||||||
|
const content: unknown[] = [];
|
||||||
|
for (const t of text) content.push({ type: "text", text: t });
|
||||||
|
content.push({ type: "toolCall", id: `call_${id}`, name, arguments: args as never });
|
||||||
return {
|
return {
|
||||||
id,
|
id,
|
||||||
role: "assistant",
|
role: "assistant",
|
||||||
parts: [
|
content,
|
||||||
{ type: "text", text: "Here is the plan." },
|
api: "openai-completions",
|
||||||
{ type: "tool-plan_init", state: "output-available", output: { ok: true, tree } },
|
provider: "openrouter",
|
||||||
],
|
model: "test",
|
||||||
} as unknown as UIMessage;
|
usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
||||||
|
stopReason: "stop",
|
||||||
|
timestamp: Date.now(),
|
||||||
|
} as never;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** A message built from raw parts (positional tests need exact part arrays). */
|
/** A native tool result for a call (details = what the record + projection
|
||||||
function msg(role: string, parts: unknown[]): UIMessage {
|
* read; the plan tool persists {ok, tree} here). */
|
||||||
return { id: `m_${Math.random().toString(36).slice(2, 8)}`, role, parts } as unknown as UIMessage;
|
function resultOf(callId: string, toolName: string, details: unknown, isError = false): LhMessage {
|
||||||
|
return {
|
||||||
|
id: `r_${callId}${isError ? "_e" : ""}`,
|
||||||
|
role: "toolResult",
|
||||||
|
toolCallId: `call_${callId}`,
|
||||||
|
toolName,
|
||||||
|
content: [{ type: "text", text: isError ? "failed" : "ok" }],
|
||||||
|
details,
|
||||||
|
isError,
|
||||||
|
timestamp: Date.now(),
|
||||||
|
} as never;
|
||||||
}
|
}
|
||||||
|
|
||||||
function blocksOf(messages: UIMessage[]): PlanBlockPart[] {
|
/** Emit + persist + project: the full persistTurn slice for a turn whose
|
||||||
return messages.flatMap((m) => m.parts.filter((p) => p.type === "data-plan-block") as PlanBlockPart[]);
|
* new messages are `turn`, run over `record`. Returns the projection of
|
||||||
|
* the whole record after the write. */
|
||||||
|
function projectAfter(record: LhMessage[], turn: LhMessage[], tree: PlanTree | null) {
|
||||||
|
const blocks = planBlocksFor(record, turn, tree, projectNative([...record, ...turn]));
|
||||||
|
return projectNative([...record, ...turn, ...blocks]);
|
||||||
}
|
}
|
||||||
|
|
||||||
test("withPlanBlocks: a successful plan_init message gains ONE full block (all nodes, statuses from the transcript)", () => {
|
function blocksOf(messages: ReturnType<typeof projectNative>): PlanBlockData[] {
|
||||||
const out = withPlanBlocks([planInitMessage("m1")], TREE);
|
return messages.flatMap((m) =>
|
||||||
|
(m.parts as { type: string; data?: PlanBlockData }[])
|
||||||
|
.filter((p) => p.type === "data-plan-block")
|
||||||
|
.map((p) => p.data!),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
test("a successful plan_init turn gains ONE full block (all nodes, statuses from the transcript)", () => {
|
||||||
|
const turn = [
|
||||||
|
assistantWithCall("m1", "plan_init", { tree: TREE }, "Here is the plan."),
|
||||||
|
resultOf("m1", "plan_init", { ok: true, tree: TREE }),
|
||||||
|
];
|
||||||
|
const out = projectAfter([], turn, TREE);
|
||||||
const blocks = blocksOf(out);
|
const blocks = blocksOf(out);
|
||||||
assert.equal(blocks.length, 1);
|
assert.equal(blocks.length, 1);
|
||||||
const data = blocks[0]!.data;
|
const data = blocks[0]!;
|
||||||
assert.equal(data.kind, "full");
|
assert.equal(data.kind, "full");
|
||||||
assert.equal(data.current, null, "at authoring nothing is current");
|
assert.equal(data.current, null, "at authoring nothing is current");
|
||||||
assert.equal(data.nodes.length, 5);
|
assert.equal(data.nodes.length, 5);
|
||||||
assert.ok(data.nodes.every((n) => n.status === "upcoming"));
|
assert.ok(data.nodes.every((n) => n.status === "upcoming"));
|
||||||
assert.equal(data.edges.length, 4);
|
assert.equal(data.edges.length, 4);
|
||||||
// attached to the plan_init message itself
|
|
||||||
assert.equal(blocksOf([out[0]!]).length, 1);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test("withPlanBlocks: a FAILED plan_init gains nothing", () => {
|
test("a FAILED plan_init gains nothing", () => {
|
||||||
const failed: UIMessage = {
|
const turn = [
|
||||||
id: "m1",
|
assistantWithCall("m1", "plan_init", { tree: TREE }, "oops"),
|
||||||
role: "assistant",
|
resultOf("m1", "plan_init", { ok: false, error: "bad" }, true),
|
||||||
parts: [
|
];
|
||||||
{ type: "text", text: "oops" },
|
assert.equal(blocksOf(projectAfter([], turn, TREE)).length, 0);
|
||||||
{ type: "tool-plan_init", state: "output-error", output: { ok: false, error: "bad" } },
|
|
||||||
],
|
|
||||||
} as unknown as UIMessage;
|
|
||||||
assert.equal(blocksOf(withPlanBlocks([failed], TREE)).length, 0);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test("withPlanBlocks: idempotent — a message that already carries a block is never given another", () => {
|
test("idempotent — a call whose block the record already carries is never re-emitted", () => {
|
||||||
const once = withPlanBlocks([planInitMessage("m1")], TREE);
|
const turn = [
|
||||||
const twice = withPlanBlocks(once, TREE);
|
assistantWithCall("m1", "plan_init", { tree: TREE }),
|
||||||
assert.equal(blocksOf(twice).length, 1, "no duplicate block on persist replay");
|
resultOf("m1", "plan_init", { ok: true, tree: TREE }),
|
||||||
|
];
|
||||||
|
const first = projectAfter([], turn, TREE);
|
||||||
|
assert.equal(blocksOf(first).length, 1);
|
||||||
|
// Persist replay: the same turn's messages land again over a record
|
||||||
|
// that already emitted the marker → no duplicate.
|
||||||
|
const record: LhMessage[] = [...turn, ...planBlocksFor([], turn, TREE, projectNative(turn))];
|
||||||
|
const blocks2 = planBlocksFor(record, turn, TREE, projectNative(record));
|
||||||
|
assert.equal(blocks2.length, 0, "no duplicate block on persist replay");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("withPlanBlocks: a plan REWRITE (second successful plan_init) gets its own full block; the old stays frozen", () => {
|
test("a plan REWRITE (a later turn's successful plan_init) gets its own full block; the old stays frozen", () => {
|
||||||
const rewrittenTree: PlanTree = { nodes: TREE.nodes.slice(0, 3), edges: [{ from: "a", to: "b" }], version: 2 };
|
const first = [
|
||||||
const out = withPlanBlocks([planInitMessage("m1"), planInitMessage("m2", rewrittenTree)], rewrittenTree);
|
assistantWithCall("m1", "plan_init", { tree: TREE }),
|
||||||
|
resultOf("m1", "plan_init", { ok: true, tree: TREE }),
|
||||||
|
];
|
||||||
|
const record = [...first, ...planBlocksFor([], first, TREE, projectNative(first))];
|
||||||
|
const second = [
|
||||||
|
assistantWithCall("m2", "plan_init", { tree: TREE2 }),
|
||||||
|
resultOf("m2", "plan_init", { ok: true, tree: TREE2 }),
|
||||||
|
];
|
||||||
|
const out = projectAfter(record, second, TREE2);
|
||||||
const blocks = blocksOf(out);
|
const blocks = blocksOf(out);
|
||||||
assert.equal(blocks.length, 2, "one full block per authoring event");
|
assert.equal(blocks.length, 2, "one full block per authoring turn");
|
||||||
assert.equal(blocks[1]!.data.nodes.length, 3);
|
assert.equal(blocks[1]!.nodes.length, 3);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("withPlanBlocks: the block renders IN FLOW immediately after the plan_init part — not appended under the turn's later parts", () => {
|
test("the block renders IN FLOW immediately after the plan_init part — not appended under the turn's later parts", () => {
|
||||||
// Real-world shape (user session chat_mtwgb9lj): the authoring turn
|
// Real-world shape (user session chat_mtwgb9lj): the authoring turn
|
||||||
// carries plan_init, then presentation prose, then quiz calls — emission
|
// carries plan_init + prose + a quiz call — emission runs at persistTurn
|
||||||
// runs at persistTurn AFTER all of that exists.
|
// AFTER all of that exists.
|
||||||
const turn = msg("assistant", [
|
const turn = [
|
||||||
{ type: "step-start" },
|
assistantWithCall("m1", "plan_init", { tree: TREE }),
|
||||||
{ type: "tool-plan_init", state: "output-available", output: { ok: true, tree: TREE } },
|
resultOf("m1", "plan_init", { ok: true, tree: TREE }),
|
||||||
{ type: "step-start" },
|
assistantWithCall("m1b", "quiz", { question: "q?" }, "Here's the plan."),
|
||||||
{ type: "text", state: "done", text: "Here's the plan." },
|
resultOf("m1", "plan_init", { ok: true, tree: TREE }), // (kept out via emitted set — see next test)
|
||||||
{ type: "tool-quiz", state: "input-available", input: { question: "q?" } },
|
];
|
||||||
]);
|
turn.pop(); // drop the stray duplicate — build the real shape below
|
||||||
const out = withPlanBlocks([turn], TREE);
|
const real = [
|
||||||
const types = out[0]!.parts.map((p) => p.type);
|
assistantWithCall("m1", "plan_init", { tree: TREE }),
|
||||||
assert.equal(types[2], "data-plan-block", "the block directly follows the plan_init part");
|
resultOf("m1", "plan_init", { ok: true, tree: TREE }),
|
||||||
assert.equal(types.indexOf("data-plan-block"), 2);
|
assistantWithCall("m1b", "quiz", { question: "q?" }, "Here's the plan."),
|
||||||
assert.ok(types.indexOf("data-plan-block") < types.indexOf("text"), "block before the prose");
|
];
|
||||||
assert.ok(types.indexOf("data-plan-block") < types.indexOf("tool-quiz"), "block before the first check");
|
const out = projectAfter([], real, TREE);
|
||||||
|
const group = out.find((m) => m.role === "assistant")!;
|
||||||
|
const types = (group.parts as unknown[]).map((p) => (p as { type: string }).type);
|
||||||
|
const blockAt = types.indexOf("data-plan-block");
|
||||||
|
assert.ok(blockAt > types.indexOf("tool-plan_init"), "the block directly follows the plan_init part");
|
||||||
|
assert.ok(blockAt < types.indexOf("text"), "block before the prose");
|
||||||
|
assert.ok(blockAt < types.indexOf("tool-quiz"), "block before the first check");
|
||||||
assert.equal(types.at(-1), "tool-quiz", "the quiz is still the turn's last part");
|
assert.equal(types.at(-1), "tool-quiz", "the quiz is still the turn's last part");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("withPlanBlocks: with multiple plan_inits in one turn (a rewrite), the block follows the LAST one", () => {
|
test("with multiple plan_inits in one turn (a same-turn rewrite), ONE block follows the LAST one", () => {
|
||||||
const turn = msg("assistant", [
|
const turn = [
|
||||||
{ type: "tool-plan_init", state: "output-available", output: { ok: true, tree: TREE } },
|
assistantWithCall("m1", "plan_init", { tree: TREE }, "v1"),
|
||||||
{ type: "text", state: "done", text: "v1" },
|
resultOf("m1", "plan_init", { ok: true, tree: TREE }),
|
||||||
{ type: "tool-plan_init", state: "output-available", output: { ok: true, tree: TREE2 } },
|
assistantWithCall("m2", "plan_init", { tree: TREE2 }, "v2"),
|
||||||
{ type: "text", state: "done", text: "v2" },
|
resultOf("m2", "plan_init", { ok: true, tree: TREE2 }),
|
||||||
]);
|
];
|
||||||
const out = withPlanBlocks([turn], TREE2);
|
const out = projectAfter([], turn, TREE2);
|
||||||
const types = out[0]!.parts.map((p) => p.type);
|
const group = out.find((m) => m.role === "assistant")!;
|
||||||
assert.equal(types[3], "data-plan-block", "block directly after the last successful init");
|
const parts = group.parts as unknown[];
|
||||||
assert.equal(types[4], "text", "the trailing prose stays after the block");
|
const blocks = parts.filter((p) => (p as { type: string }).type === "data-plan-block");
|
||||||
assert.equal(out[0]!.parts.filter((p) => p.type === "data-plan-block").length, 1, "one block per message even on a same-turn rewrite");
|
assert.equal(blocks.length, 1, "one block per turn even on a same-turn rewrite");
|
||||||
|
const types = parts.map((p) => (p as { type: string }).type);
|
||||||
|
assert.ok(
|
||||||
|
types.indexOf("data-plan-block") > types.indexOf("tool-plan_init"),
|
||||||
|
"the block follows the last successful init",
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("withPlanBlocks: quizzes alone emit nothing (no neighborhood blocks anymore)", () => {
|
test("quizzes alone emit nothing (no neighborhood blocks anymore)", () => {
|
||||||
const quizTurn: UIMessage = {
|
const turn = [
|
||||||
id: "m1",
|
assistantWithCall("m1", "quiz", { nodeId: "b", question: "q?" }),
|
||||||
role: "assistant",
|
resultOf("m1", "quiz", { correct: true }),
|
||||||
parts: [
|
];
|
||||||
{ type: "text", text: "check" },
|
assert.equal(blocksOf(projectAfter([], turn, TREE)).length, 0);
|
||||||
{ type: "tool-quiz", state: "output-available", input: { nodeId: "b" }, output: { correct: true } },
|
|
||||||
],
|
|
||||||
} as unknown as UIMessage;
|
|
||||||
assert.equal(blocksOf(withPlanBlocks([quizTurn], TREE)).length, 0);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test("withPlanBlocks: no tree / empty tree / no messages are no-ops", () => {
|
test("no tree / empty tree / no messages are no-ops", () => {
|
||||||
assert.equal(withPlanBlocks([planInitMessage("m1")], null).length, 1, "passthrough");
|
const turn = [
|
||||||
|
assistantWithCall("m1", "plan_init", { tree: TREE }),
|
||||||
|
resultOf("m1", "plan_init", { ok: true, tree: TREE }),
|
||||||
|
];
|
||||||
|
assert.equal(planBlocksFor([], turn, null, []).length, 0, "no tree → nothing");
|
||||||
const emptyTree: PlanTree = { nodes: [], edges: [], version: 0 };
|
const emptyTree: PlanTree = { nodes: [], edges: [], version: 0 };
|
||||||
assert.equal(blocksOf(withPlanBlocks([planInitMessage("m1")], emptyTree)).length, 0);
|
assert.equal(planBlocksFor([], turn, emptyTree, []).length, 0, "empty tree → nothing");
|
||||||
assert.equal(withPlanBlocks([], TREE).length, 0);
|
assert.equal(planBlocksFor([], [], TREE, []).length, 0, "no messages → nothing");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("withPlanBlocks: mid-lesson rewrite snapshot derives statuses from the whole transcript", () => {
|
test("mid-lesson rewrite snapshot derives statuses from the whole transcript", () => {
|
||||||
// A tagged quiz call on node b happened before the rewrite: b is current.
|
// A tagged quiz call on node b happened before the rewrite: b is current.
|
||||||
const quizBefore: UIMessage = {
|
const before = [
|
||||||
id: "m0",
|
assistantWithCall("m0", "quiz", { nodeId: "b", question: "q?" }),
|
||||||
role: "assistant",
|
resultOf("m0", "quiz", { correct: true }),
|
||||||
parts: [
|
];
|
||||||
{ type: "text", text: "about b" },
|
const rewrite = [
|
||||||
{ type: "tool-quiz", state: "output-available", input: { nodeId: "b" }, output: { correct: true } },
|
assistantWithCall("m1", "plan_init", { tree: TREE }),
|
||||||
],
|
resultOf("m1", "plan_init", { ok: true, tree: TREE }),
|
||||||
} as unknown as UIMessage;
|
];
|
||||||
const out = withPlanBlocks([quizBefore, planInitMessage("m1")], TREE);
|
const out = projectAfter(before, rewrite, TREE);
|
||||||
const data = blocksOf(out)[0]!.data;
|
const data = blocksOf(out)[0]!;
|
||||||
assert.equal(data.current, "b");
|
assert.equal(data.current, "b");
|
||||||
const b = data.nodes.find((n) => n.id === "b")!;
|
const b = data.nodes.find((n) => n.id === "b")!;
|
||||||
assert.equal(b.status, "current");
|
assert.equal(b.status, "current");
|
||||||
|
|||||||
+39
-13
@@ -3,13 +3,18 @@
|
|||||||
* Server-side; three outcomes: refusal while a question is open, refusal
|
* Server-side; three outcomes: refusal while a question is open, refusal
|
||||||
* after the probe is already concluded, ok + handoff queued. The handoff
|
* after the probe is already concluded, ok + handoff queued. The handoff
|
||||||
* rides the wake pipeline (deliver injected here — no server seam import).
|
* rides the wake pipeline (deliver injected here — no server seam import).
|
||||||
|
* Wave B: the record is native history — fixtures are pi messages, the
|
||||||
|
* pending-question gate is the dangling-question scan, and the handoff is
|
||||||
|
* a native marker (customType probe-handoff, HANDOFF_TEXT content).
|
||||||
*/
|
*/
|
||||||
import assert from "node:assert/strict";
|
import assert from "node:assert/strict";
|
||||||
import * as fs from "node:fs";
|
import * as fs from "node:fs";
|
||||||
import * as path from "node:path";
|
import * as path from "node:path";
|
||||||
import { after, describe, it } from "node:test";
|
import { after, describe, it } from "node:test";
|
||||||
import { probeDoneTool } from "../src/agent/tools/probe.ts";
|
import { probeDoneTool } from "../src/agent/tools/probe.ts";
|
||||||
import type { ProbeHandoffSteerMessage } from "../src/core/contracts.ts";
|
import type { SteerMessage } from "../src/core/contracts.ts";
|
||||||
|
import type { LhMessage } from "../src/core/native-messages.ts";
|
||||||
|
import { HANDOFF_TEXT } from "../src/core/contracts.ts";
|
||||||
import { chatStore } from "../src/store/chats.ts";
|
import { chatStore } from "../src/store/chats.ts";
|
||||||
import { PATHS } from "../config.ts";
|
import { PATHS } from "../config.ts";
|
||||||
|
|
||||||
@@ -40,6 +45,22 @@ const ASSESSMENT = {
|
|||||||
summary: "Enter at the formal definition.",
|
summary: "Enter at the formal definition.",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/** A native assistant message carrying one tool call (the minimal fixture
|
||||||
|
* shape the scans read — role assistant, content blocks). */
|
||||||
|
function assistantWithCall(id: string, toolName: string, args: unknown): LhMessage {
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
role: "assistant",
|
||||||
|
content: [{ type: "toolCall", id: `call_${id}`, name: toolName, arguments: args as never }],
|
||||||
|
api: "openai-completions",
|
||||||
|
provider: "openrouter",
|
||||||
|
model: "test",
|
||||||
|
usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
||||||
|
stopReason: "stop",
|
||||||
|
timestamp: Date.now(),
|
||||||
|
} as never;
|
||||||
|
}
|
||||||
|
|
||||||
async function execute(tools: ReturnType<typeof probeDoneTool>, input: unknown) {
|
async function execute(tools: ReturnType<typeof probeDoneTool>, input: unknown) {
|
||||||
const t = tools.probe_done!;
|
const t = tools.probe_done!;
|
||||||
assert.ok(typeof t.execute === "function");
|
assert.ok(typeof t.execute === "function");
|
||||||
@@ -49,7 +70,7 @@ async function execute(tools: ReturnType<typeof probeDoneTool>, input: unknown)
|
|||||||
describe("probe_done: the probe→planning seam", () => {
|
describe("probe_done: the probe→planning seam", () => {
|
||||||
it("ok path: records the assessment and queues the handoff steer (deterministic id)", async () => {
|
it("ok path: records the assessment and queues the handoff steer (deterministic id)", async () => {
|
||||||
const chatId = newChat("Probe: ok");
|
const chatId = newChat("Probe: ok");
|
||||||
const delivered: ProbeHandoffSteerMessage[] = [];
|
const delivered: SteerMessage[] = [];
|
||||||
const tools = probeDoneTool({ chatId, chatStore, deliver: (s) => delivered.push(s) });
|
const tools = probeDoneTool({ chatId, chatStore, deliver: (s) => delivered.push(s) });
|
||||||
|
|
||||||
const res = await execute(tools, { assessment: ASSESSMENT });
|
const res = await execute(tools, { assessment: ASSESSMENT });
|
||||||
@@ -57,19 +78,19 @@ describe("probe_done: the probe→planning seam", () => {
|
|||||||
assert.equal(res.error, undefined);
|
assert.equal(res.error, undefined);
|
||||||
assert.equal(delivered.length, 1, "the handoff is queued exactly once");
|
assert.equal(delivered.length, 1, "the handoff is queued exactly once");
|
||||||
assert.equal(delivered[0]!.id, `steer_probe_${chatId}`);
|
assert.equal(delivered[0]!.id, `steer_probe_${chatId}`);
|
||||||
assert.equal((delivered[0]!.metadata as { probeHandoff: { chatId: string } }).probeHandoff.chatId, chatId);
|
// Native marker (Wave B): the model-facing HANDOFF_TEXT rides content;
|
||||||
|
// the UI keys off customType; the chat id rides details.
|
||||||
|
assert.equal(delivered[0]!.customType, "probe-handoff");
|
||||||
|
assert.equal(delivered[0]!.content, HANDOFF_TEXT);
|
||||||
|
assert.equal((delivered[0]!.details as { chatId: string }).chatId, chatId);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("REFUSES while a question is still open — resolve it first", async () => {
|
it("REFUSES while a question is still open — resolve it first", async () => {
|
||||||
const chatId = newChat("Probe: open question");
|
const chatId = newChat("Probe: open question");
|
||||||
chatStore.saveMessages(chatId, [
|
chatStore.saveMessages(chatId, [
|
||||||
{
|
assistantWithCall("m1", "quiz", { question: "q?", options: [{ label: "a" }], correctAnswer: "a" }),
|
||||||
id: "m1",
|
|
||||||
role: "assistant",
|
|
||||||
parts: [{ type: "tool-quiz", state: "input-available", input: { question: "q?" } }],
|
|
||||||
} as never,
|
|
||||||
]);
|
]);
|
||||||
const delivered: ProbeHandoffSteerMessage[] = [];
|
const delivered: SteerMessage[] = [];
|
||||||
const tools = probeDoneTool({ chatId, chatStore, deliver: (s) => delivered.push(s) });
|
const tools = probeDoneTool({ chatId, chatStore, deliver: (s) => delivered.push(s) });
|
||||||
|
|
||||||
const res = await execute(tools, { assessment: ASSESSMENT });
|
const res = await execute(tools, { assessment: ASSESSMENT });
|
||||||
@@ -81,13 +102,18 @@ describe("probe_done: the probe→planning seam", () => {
|
|||||||
it("REFUSES a second conclusion — probe_done fires exactly once", async () => {
|
it("REFUSES a second conclusion — probe_done fires exactly once", async () => {
|
||||||
const chatId = newChat("Probe: twice");
|
const chatId = newChat("Probe: twice");
|
||||||
chatStore.saveMessages(chatId, [
|
chatStore.saveMessages(chatId, [
|
||||||
|
assistantWithCall("m1", "probe_done", { assessment: ASSESSMENT }),
|
||||||
{
|
{
|
||||||
id: "m1",
|
id: "m1r",
|
||||||
role: "assistant",
|
role: "toolResult",
|
||||||
parts: [{ type: "tool-probe_done", state: "output-available", input: { assessment: ASSESSMENT }, output: { ok: true } }],
|
toolCallId: "call_m1",
|
||||||
|
toolName: "probe_done",
|
||||||
|
content: [{ type: "text", text: "ok" }],
|
||||||
|
isError: false,
|
||||||
|
timestamp: Date.now(),
|
||||||
} as never,
|
} as never,
|
||||||
]);
|
]);
|
||||||
const delivered: ProbeHandoffSteerMessage[] = [];
|
const delivered: SteerMessage[] = [];
|
||||||
const tools = probeDoneTool({ chatId, chatStore, deliver: (s) => delivered.push(s) });
|
const tools = probeDoneTool({ chatId, chatStore, deliver: (s) => delivered.push(s) });
|
||||||
|
|
||||||
const res = await execute(tools, { assessment: ASSESSMENT });
|
const res = await execute(tools, { assessment: ASSESSMENT });
|
||||||
|
|||||||
@@ -18,6 +18,10 @@
|
|||||||
import assert from "node:assert/strict";
|
import assert from "node:assert/strict";
|
||||||
import { afterEach, describe, it } from "node:test";
|
import { afterEach, describe, it } from "node:test";
|
||||||
import { createReattachingTransport } from "../components/chat-transport.ts";
|
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;
|
const realFetch = globalThis.fetch;
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
@@ -67,7 +71,7 @@ describe("reattach transport (reconnectToStream)", () => {
|
|||||||
urls.push(String(input));
|
urls.push(String(input));
|
||||||
return dripped(sse({ type: "start", messageId: "msg_run_1" }), 8);
|
return dripped(sse({ type: "start", messageId: "msg_run_1" }), 8);
|
||||||
}) as typeof globalThis.fetch;
|
}) as typeof globalThis.fetch;
|
||||||
const transport = createReattachingTransport("chat_x", {});
|
const transport = createReattachingTransport("chat_x", {}, NO_INPUT);
|
||||||
await collect((await transport.reconnectToStream(RECONNECT_OPTS))!);
|
await collect((await transport.reconnectToStream(RECONNECT_OPTS))!);
|
||||||
assert.deepEqual(urls, ["/api/chats/chat_x/stream"]);
|
assert.deepEqual(urls, ["/api/chats/chat_x/stream"]);
|
||||||
});
|
});
|
||||||
@@ -76,7 +80,7 @@ describe("reattach transport (reconnectToStream)", () => {
|
|||||||
globalThis.fetch = (async () =>
|
globalThis.fetch = (async () =>
|
||||||
dripped(sse({ type: "turn-settled" }, "[DONE]"), 8)) as typeof globalThis.fetch;
|
dripped(sse({ type: "turn-settled" }, "[DONE]"), 8)) as typeof globalThis.fetch;
|
||||||
let settled = 0;
|
let settled = 0;
|
||||||
const transport = createReattachingTransport("chat_x", { onSettled: () => settled++ });
|
const transport = createReattachingTransport("chat_x", { onSettled: () => settled++ }, NO_INPUT);
|
||||||
const stream = await transport.reconnectToStream(RECONNECT_OPTS);
|
const stream = await transport.reconnectToStream(RECONNECT_OPTS);
|
||||||
assert.equal(stream, null);
|
assert.equal(stream, null);
|
||||||
assert.equal(settled, 1);
|
assert.equal(settled, 1);
|
||||||
@@ -86,7 +90,7 @@ describe("reattach transport (reconnectToStream)", () => {
|
|||||||
|
|
||||||
it("204 (the SDK's native no-stream signal) resolves null untouched", async () => {
|
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;
|
globalThis.fetch = (async () => new Response(null, { status: 204 })) as typeof globalThis.fetch;
|
||||||
const transport = createReattachingTransport("chat_x", {});
|
const transport = createReattachingTransport("chat_x", {}, NO_INPUT);
|
||||||
assert.equal(await transport.reconnectToStream(RECONNECT_OPTS), null);
|
assert.equal(await transport.reconnectToStream(RECONNECT_OPTS), null);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -102,7 +106,7 @@ describe("reattach transport (reconnectToStream)", () => {
|
|||||||
),
|
),
|
||||||
13,
|
13,
|
||||||
)) as typeof globalThis.fetch;
|
)) as typeof globalThis.fetch;
|
||||||
const transport = createReattachingTransport("chat_x", {});
|
const transport = createReattachingTransport("chat_x", {}, NO_INPUT);
|
||||||
const chunks = (await collect((await transport.reconnectToStream(RECONNECT_OPTS))!)) as Array<
|
const chunks = (await collect((await transport.reconnectToStream(RECONNECT_OPTS))!)) as Array<
|
||||||
Record<string, unknown>
|
Record<string, unknown>
|
||||||
>;
|
>;
|
||||||
@@ -120,7 +124,7 @@ describe("reattach transport (reconnectToStream)", () => {
|
|||||||
)) as typeof globalThis.fetch;
|
)) as typeof globalThis.fetch;
|
||||||
const transport = createReattachingTransport("chat_x", {
|
const transport = createReattachingTransport("chat_x", {
|
||||||
lastMessageId: () => "msg_seed",
|
lastMessageId: () => "msg_seed",
|
||||||
});
|
}, NO_INPUT);
|
||||||
const chunks = (await collect((await transport.reconnectToStream(RECONNECT_OPTS))!)) as Array<
|
const chunks = (await collect((await transport.reconnectToStream(RECONNECT_OPTS))!)) as Array<
|
||||||
Record<string, unknown>
|
Record<string, unknown>
|
||||||
>;
|
>;
|
||||||
@@ -134,7 +138,7 @@ describe("reattach transport (reconnectToStream)", () => {
|
|||||||
it("survives a body split byte-by-byte across reads", async () => {
|
it("survives a body split byte-by-byte across reads", async () => {
|
||||||
globalThis.fetch = (async () =>
|
globalThis.fetch = (async () =>
|
||||||
dripped(sse({ type: "start", messageId: "msg_run_1" }, { type: "finish", finishReason: "stop" }), 1)) as typeof globalThis.fetch;
|
dripped(sse({ type: "start", messageId: "msg_run_1" }, { type: "finish", finishReason: "stop" }), 1)) as typeof globalThis.fetch;
|
||||||
const transport = createReattachingTransport("chat_x", {});
|
const transport = createReattachingTransport("chat_x", {}, NO_INPUT);
|
||||||
const chunks = (await collect((await transport.reconnectToStream(RECONNECT_OPTS))!)) as Array<
|
const chunks = (await collect((await transport.reconnectToStream(RECONNECT_OPTS))!)) as Array<
|
||||||
Record<string, unknown>
|
Record<string, unknown>
|
||||||
>;
|
>;
|
||||||
@@ -147,7 +151,7 @@ describe("reattach transport (reconnectToStream)", () => {
|
|||||||
|
|
||||||
it("an unhappy endpoint throws (the SDK surfaces the error banner)", async () => {
|
it("an unhappy endpoint throws (the SDK surfaces the error banner)", async () => {
|
||||||
globalThis.fetch = (async () => new Response("boom", { status: 500 })) as typeof globalThis.fetch;
|
globalThis.fetch = (async () => new Response("boom", { status: 500 })) as typeof globalThis.fetch;
|
||||||
const transport = createReattachingTransport("chat_x", {});
|
const transport = createReattachingTransport("chat_x", {}, NO_INPUT);
|
||||||
await assert.rejects(() => transport.reconnectToStream(RECONNECT_OPTS), /boom/);
|
await assert.rejects(() => transport.reconnectToStream(RECONNECT_OPTS), /boom/);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,89 +0,0 @@
|
|||||||
/**
|
|
||||||
* Unit tests — the record→pi adapter's SYSTEM MESSAGE flattening (the
|
|
||||||
* custom-message scheme, user-decided 2026-09-12).
|
|
||||||
*
|
|
||||||
* Wake and verdict messages are user-role messages carrying ONLY a marker
|
|
||||||
* data part — no human text exists. The adapter owns their model-facing
|
|
||||||
* wording (flattens the marker to the canonical text from core contracts):
|
|
||||||
* the transcript never persists fake human speech, and the wording can't
|
|
||||||
* drift between the seam, the tests, and reality.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import assert from "node:assert/strict";
|
|
||||||
import { describe, it } from "node:test";
|
|
||||||
import type { UIMessage } from "ai";
|
|
||||||
import { recordToPiMessages } from "../src/server/pi/record-adapter.ts";
|
|
||||||
import { HANDOFF_TEXT, verdictText } from "../src/core/contracts/chat.ts";
|
|
||||||
import { buildAbortSteer, buildProbeHandoffSteer } from "../src/core/contracts/jobs.ts";
|
|
||||||
|
|
||||||
function userText(text: string): UIMessage {
|
|
||||||
return { id: `u_${text.length}`, role: "user", parts: [{ type: "text", text }] };
|
|
||||||
}
|
|
||||||
|
|
||||||
function userParts(parts: unknown[]): UIMessage {
|
|
||||||
return { id: "u_sys", role: "user", parts: parts as UIMessage["parts"] };
|
|
||||||
}
|
|
||||||
|
|
||||||
const texts = (msgs: ReturnType<typeof recordToPiMessages>): string[] => {
|
|
||||||
const out: string[] = [];
|
|
||||||
for (const m of msgs) {
|
|
||||||
if (m.role === "user") {
|
|
||||||
for (const c of m.content) {
|
|
||||||
if ((c as { type?: string }).type === "text") out.push((c as { text: string }).text);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return out;
|
|
||||||
};
|
|
||||||
|
|
||||||
describe("record-adapter: system message flattening", () => {
|
|
||||||
it("an ACCEPT verdict flattens to the canonical model-facing text (no human speech persisted)", () => {
|
|
||||||
const out = recordToPiMessages([
|
|
||||||
userText("teach me"),
|
|
||||||
userParts([{ type: "data-verdict", id: "vd_1", data: { accepted: true, at: "…" } }]),
|
|
||||||
]);
|
|
||||||
assert.deepEqual(texts(out), ["teach me", verdictText(true)]);
|
|
||||||
assert.equal(texts(out)[1], "The learner accepted the plan — begin teaching.");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("a DECLINE verdict flattens to the canonical revision text", () => {
|
|
||||||
const out = recordToPiMessages([
|
|
||||||
userParts([{ type: "data-verdict", id: "vd_2", data: { accepted: false, at: "…" } }]),
|
|
||||||
]);
|
|
||||||
assert.deepEqual(texts(out), [verdictText(false)]);
|
|
||||||
assert.match(texts(out)[0]!, /revised/);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("the probe handoff steer flattens to the canonical plan-authoring instruction", () => {
|
|
||||||
const handoff = buildProbeHandoffSteer("chat_x");
|
|
||||||
assert.equal(handoff.parts.length, 1, "the handoff is marker-ONLY — no text part");
|
|
||||||
assert.equal(handoff.parts[0]!.type, "data-probe-handoff");
|
|
||||||
const out = recordToPiMessages([handoff]);
|
|
||||||
assert.deepEqual(texts(out), [HANDOFF_TEXT]);
|
|
||||||
assert.match(texts(out)[0]!, /plan_init/);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("note + verdict (the two-message accept) reach the model as two user texts, in order", () => {
|
|
||||||
const out = recordToPiMessages([
|
|
||||||
userText("Start with the basics, skip the history."),
|
|
||||||
userParts([{ type: "data-verdict", id: "vd_3", data: { accepted: true, at: "…" } }]),
|
|
||||||
]);
|
|
||||||
assert.deepEqual(texts(out), ["Start with the basics, skip the history.", verdictText(true)]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("sub-agent steers keep their text (payload, not boilerplate): the artifact part adds nothing", () => {
|
|
||||||
const abort = buildAbortSteer({ id: "j1", kind: "research", toolCallId: "c1" });
|
|
||||||
const out = recordToPiMessages([abort]);
|
|
||||||
const got = texts(out);
|
|
||||||
assert.equal(got.length, 1, "one user text — the marker part is UI-only");
|
|
||||||
assert.match(got[0]!, /aborted/i);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("a pure UI data part with no system meaning still never reaches the model", () => {
|
|
||||||
const out = recordToPiMessages([
|
|
||||||
userParts([{ type: "data-plan-block", id: "pb", data: { kind: "full", at: "…", current: null, nodes: [], edges: [] } }]),
|
|
||||||
]);
|
|
||||||
assert.equal(out.length, 0, "no model face at all — skipped");
|
|
||||||
});
|
|
||||||
|
|
||||||
});
|
|
||||||
+5
-2
@@ -221,8 +221,11 @@ test("appendTurnUsage: appends, idempotent per turn id, continuation turns (same
|
|||||||
let reread = chatStore.getChat(record.id);
|
let reread = chatStore.getChat(record.id);
|
||||||
assert.equal(reread?.turns?.length, 2);
|
assert.equal(reread?.turns?.length, 2);
|
||||||
|
|
||||||
// saveMessages must not clobber the log (read-mutate-write record)
|
// saveMessages must not clobber the log (read-mutate-write record) —
|
||||||
chatStore.saveMessages(record.id, [{ id: "msg_a", role: "user", parts: [] }]);
|
// Wave B: a NATIVE message list (the record holds agent history).
|
||||||
|
chatStore.saveMessages(record.id, [
|
||||||
|
{ id: "msg_a", role: "user", content: "native", timestamp: Date.now() } as never,
|
||||||
|
]);
|
||||||
reread = chatStore.getChat(record.id);
|
reread = chatStore.getChat(record.id);
|
||||||
assert.equal(reread?.turns?.length, 2);
|
assert.equal(reread?.turns?.length, 2);
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user