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:
+108
-106
@@ -1,4 +1,3 @@
|
||||
import type { UIMessage } from "ai";
|
||||
import type { Usage } from "@earendil-works/pi-ai";
|
||||
import { createProberAgent, createTeacherAgent, type AgentBuildOptions, type TeachingAgent } from "../agent/teacher-agent.ts";
|
||||
import { piEngineForSpec, type PiEngine } from "../agent/models.ts";
|
||||
@@ -6,7 +5,6 @@ import { createJobRunner } from "../agent/subagents/runner.ts";
|
||||
import { createExtractionPipeline } from "../agent/subagents/extraction.ts";
|
||||
import type { ModelRole } from "../../config.ts";
|
||||
import { claimSteers, deliverJobResult, setJobRespawn, setTurnRunner } from "./jobs.ts";
|
||||
import { recordToPiMessages } from "./pi/record-adapter.ts";
|
||||
import { piTurnResponse, runPiTurn } from "./pi/ui-stream.ts";
|
||||
import { effectiveSettings, stores } from "./stores.ts";
|
||||
import { searchAvailable, searchProvider } from "./search.ts";
|
||||
@@ -20,26 +18,35 @@ import {
|
||||
import { modelsNotConfigured, providerKeyMissing as providerKeyMissingEnvelope, type ProviderKeyMissing } from "../core/settings.ts";
|
||||
import { beginTurn, endTurn } from "./turn-liveness.ts";
|
||||
import { registerTurn, unregisterTurn } from "./turn-runs.ts";
|
||||
import { persistTurn, triggerExtraction, withMessageIds } from "./turn-service.ts";
|
||||
import { persistTurn, triggerExtraction } from "./turn-service.ts";
|
||||
import { hasProbeDone } from "../core/native-messages.ts";
|
||||
import { asAgentMessages } from "../core/native-messages.ts";
|
||||
import {
|
||||
ChatInputError,
|
||||
inputAppends,
|
||||
parseChatInput,
|
||||
type TurnInput,
|
||||
} from "../core/input-events.ts";
|
||||
import { steerDelivered } from "../core/contracts.ts";
|
||||
import type { CallUsage } from "../core/contracts.ts";
|
||||
|
||||
/**
|
||||
* One chat turn: run the teaching agent over the client's message list and
|
||||
* stream the UI message stream back. Framework-free (plain Request/Response)
|
||||
* so tests can drive it directly with a scripted faux engine — the Next route
|
||||
* handler in app/api/chat is a one-line wrapper.
|
||||
* One chat turn over the server-owned native record. Framework-free (plain
|
||||
* Request/Response) so tests can drive it directly with a scripted faux
|
||||
* engine — the Next route handler in app/api/chat is a one-line wrapper.
|
||||
*
|
||||
* THE INPUT-EVENTS CONTRACT (Wave B, owner ruling 2026-09-26): POST /api/chat
|
||||
* carries {chatId, input} — one explicit event (text / answer / verdict /
|
||||
* continue), never a client transcript. The server owns the record; this
|
||||
* module is the TURN COMPOSITION ROOT: it turns the event into native
|
||||
* record appends (core/input-events.ts), builds the agent from the record
|
||||
* (the record IS the agent state — state.messages = record.messages,
|
||||
* continue()), and runs the turn as a background object (pi/ui-stream.ts).
|
||||
* Wake-pipeline steers ride the same composition (TurnInput's steer kind).
|
||||
*
|
||||
* This module is the TURN COMPOSITION ROOT: it wires the storage ports, the
|
||||
* job runner + wake pipeline, the teacher tools, and the turn services
|
||||
* (turn-service.ts) together — all actual work lives behind those seams.
|
||||
* THE SAME COMPOSITION serves both entry points: client requests and the
|
||||
* wake pipeline's server-side steers (src/server/jobs.ts) — one code path
|
||||
* for write-ahead persistence, turn liveness, and the turn services.
|
||||
*
|
||||
* Engine note (docs/pi-engine-swap.md): the agent is STATELESS per turn —
|
||||
* built fresh from the persisted record (recordToPiMessages), run by the
|
||||
* bridge (pi/ui-stream.ts), discarded at turn end. The record is the only
|
||||
* source of truth.
|
||||
*/
|
||||
|
||||
const jobRunner = createJobRunner({
|
||||
@@ -82,18 +89,12 @@ export const extraction = createExtractionPipeline({
|
||||
providerKeyPresent(provider) ? null : providerKeyMissingEnvelope(provider, [tier]),
|
||||
});
|
||||
|
||||
|
||||
// Wake-pipeline wiring (contracts.ts "THE ASYNC PROTOCOL" §4): the pipeline
|
||||
// fires its server-side teacher turns through THIS composition root, and
|
||||
// crash recovery re-spawns orphaned sub-agent loops through THIS runner.
|
||||
setTurnRunner(async (chatId, messages) => {
|
||||
const res = await handleChatRequest(
|
||||
new Request("http://localhost/api/chat", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ chatId, messages }),
|
||||
}),
|
||||
);
|
||||
// delivers its steers through THIS composition root (one event: the steer
|
||||
// marker, appended natively under the turn claim), and crash recovery
|
||||
// re-spawns orphaned sub-agent loops through THIS runner.
|
||||
setTurnRunner(async (chatId, input) => {
|
||||
const res = await handleChatInput(chatId, input);
|
||||
// Drive the stream to completion server-side — nobody else will read it,
|
||||
// and onFinish (persist, extraction, endTurn) only runs when it drains.
|
||||
await res.arrayBuffer().catch(() => undefined);
|
||||
@@ -110,39 +111,44 @@ function resolveEngine(role: ModelRole): PiEngine | undefined {
|
||||
|
||||
setJobRespawn((jobId) => void jobRunner.runJob(jobId));
|
||||
|
||||
/** The message-shape gate the SDK's UIMessageValidator owned: a malformed
|
||||
* body must fail BEFORE the stream exists — the path where no onFinish will
|
||||
* ever run (the catch clears the turn registry so the chat never locks). */
|
||||
function assertValidUiMessages(messages: UIMessage[]): void {
|
||||
for (const m of messages) {
|
||||
if (m.role !== "user" && m.role !== "assistant" && m.role !== "system") {
|
||||
throw new Error(`invalid message role: ${String((m as { role?: unknown }).role)}`);
|
||||
}
|
||||
if (!Array.isArray(m.parts)) throw new Error("message has no parts array");
|
||||
for (const p of m.parts) {
|
||||
if (p.type === "text" && typeof (p as { text?: unknown }).text !== "string") {
|
||||
throw new Error("text part has no text");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** The route-facing entry: parse {chatId, input} and run one turn. */
|
||||
export async function handleChatRequest(
|
||||
req: Request,
|
||||
buildAgent?: (outgoing: UIMessage[]) => TeachingAgent,
|
||||
buildAgent?: (opts: AgentBuildOptions & { teaching: boolean }) => TeachingAgent,
|
||||
): Promise<Response> {
|
||||
let body: { chatId?: unknown; messages?: unknown };
|
||||
let body: { chatId?: unknown; input?: unknown };
|
||||
try {
|
||||
body = await req.json();
|
||||
} catch {
|
||||
return Response.json({ error: "Invalid JSON body" }, { status: 400 });
|
||||
}
|
||||
const chatId = typeof body.chatId === "string" ? body.chatId : "";
|
||||
if (!chatId || !stores.chatStore.getChat(chatId)) {
|
||||
return Response.json({ error: "Unknown chat" }, { status: 404 });
|
||||
if (!chatId) return Response.json({ error: "Unknown chat" }, { status: 404 });
|
||||
let input: TurnInput;
|
||||
try {
|
||||
input = parseChatInput(body.input);
|
||||
} catch (err) {
|
||||
if (err instanceof ChatInputError) {
|
||||
return Response.json({ error: err.message }, { status: 400 });
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
if (!Array.isArray(body.messages)) {
|
||||
return Response.json({ error: "messages must be an array" }, { status: 400 });
|
||||
return handleChatInput(chatId, input, buildAgent);
|
||||
}
|
||||
|
||||
/**
|
||||
* The turn composition: one input event → native record appends → one
|
||||
* agent turn as a background object. Also the wake pipeline's entry
|
||||
* (TurnInput's steer kind appends the marker under the same claim).
|
||||
*/
|
||||
export async function handleChatInput(
|
||||
chatId: string,
|
||||
input: TurnInput,
|
||||
buildAgent?: (opts: AgentBuildOptions & { teaching: boolean }) => TeachingAgent,
|
||||
): Promise<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
|
||||
@@ -154,10 +160,7 @@ export async function handleChatRequest(
|
||||
// write-ahead persist (nothing durable changes), and every downstream
|
||||
// resolution (jobs/extraction gate per-need on their own tiers).
|
||||
const effective = effectiveSettings();
|
||||
const conversation = resolveConversationSpec(
|
||||
stores.chatStore.getChat(chatId)?.settings,
|
||||
effective.models.strong,
|
||||
);
|
||||
const conversation = resolveConversationSpec(record.settings, effective.models.strong);
|
||||
if (!conversation) {
|
||||
const envelope = modelsNotConfigured(["strong"]);
|
||||
console.info(`[chat ${chatId}] turn refused — ${JSON.stringify(envelope)}`);
|
||||
@@ -179,10 +182,27 @@ export async function handleChatRequest(
|
||||
return Response.json(envelope, { status: 503 });
|
||||
}
|
||||
|
||||
// The input's native appends (pure; the honest 400 for an answer with no
|
||||
// pending question call). Computed BEFORE beginTurn so a refused input
|
||||
// leaves no liveness effects; read against the CURRENT record.
|
||||
let appends;
|
||||
try {
|
||||
appends =
|
||||
input.type === "steer"
|
||||
? steerDelivered(record.messages, input.steer.id)
|
||||
? []
|
||||
: [input.steer]
|
||||
: inputAppends(record, input);
|
||||
} catch (err) {
|
||||
if (err instanceof ChatInputError) {
|
||||
return Response.json({ error: err.message }, { status: 400 });
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
|
||||
// One turn per chat, ever. A second request while a turn runs means a
|
||||
// stale/remounted client raced a live one — its message list is outdated,
|
||||
// and letting it through would double-run the teacher and clobber the
|
||||
// first turn's persist (saveMessages is last-writer-wins).
|
||||
// stale/remounted client raced a live one — letting it through would
|
||||
// double-run the teacher and clobber the first turn's persist.
|
||||
if (!beginTurn(chatId)) {
|
||||
console.info(`[chat ${chatId}] turn already in flight — request rejected`);
|
||||
return Response.json({ error: "A turn is already running for this session" }, { status: 409 });
|
||||
@@ -190,40 +210,27 @@ export async function handleChatRequest(
|
||||
|
||||
// CONTINUATION COALESCING (async protocol §4, the delivery-starvation fix):
|
||||
// steers finished while a quiz chain held the wake gates ride THIS turn —
|
||||
// appended after the learner's answer (conversion-safe: the answer carries
|
||||
// its output). Claiming happens after beginTurn, so this turn owns the
|
||||
// registry and the write-ahead persist below makes the claim durable.
|
||||
// The incoming body is passed too: a pump-fired wake turn ALREADY CARRIES
|
||||
// its steer, so claimSteers finalizes that entry instead of appending a
|
||||
// second copy (duplicate-key bug, 2026-09-18).
|
||||
const steers = claimSteers(chatId, body.messages as UIMessage[]);
|
||||
const outgoing = (
|
||||
steers.length > 0 ? [...(body.messages as UIMessage[]), ...steers] : body.messages
|
||||
) as UIMessage[];
|
||||
// appended after the learner's answer (conversion-safe: the answer is the
|
||||
// question call's tool result now). Claiming happens after beginTurn, so
|
||||
// this turn owns the registry and the write-ahead persist below makes the
|
||||
// claim durable. Steers already in the transcript (a wake raced in) are
|
||||
// finalized by the claim itself — the append filter keeps exactly one copy.
|
||||
const steers = claimSteers(chatId).filter((s) => !steerDelivered(record.messages, s.id));
|
||||
|
||||
// THE SETTINGS VIEW (settings-storage wave, 2026-09-19): resolved fresh
|
||||
// per turn (the gate above owns the read) — graph gates, tool toggles,
|
||||
// and the record's chat defaults all read from ONE small file.
|
||||
|
||||
// Write-ahead persistence: the request body IS the client's canonical
|
||||
// state, so it goes on disk BEFORE the stream starts. The learner's answer
|
||||
// is durable the instant the request arrives — any remount, second tab, or
|
||||
// server crash sees truth instead of the pre-answer record. (A client that
|
||||
// returns mid-turn reads turnInFlight from the registry and re-attaches by
|
||||
// polling instead of resurrecting the stale transcript.)
|
||||
// Write-ahead persistence: the input's native appends go on disk BEFORE
|
||||
// the turn starts — the learner's answer is durable the instant the
|
||||
// request arrives, and any remount, second tab, or server crash reads
|
||||
// truth instead of the pre-answer record.
|
||||
const merged = [...record.messages, ...appends, ...steers];
|
||||
try {
|
||||
stores.chatStore.saveMessages(chatId, outgoing);
|
||||
stores.chatStore.saveMessages(chatId, merged);
|
||||
} catch (err) {
|
||||
console.error(`[chat ${chatId}] write-ahead persist failed:`, err);
|
||||
}
|
||||
|
||||
|
||||
|
||||
// MONEY CHANNEL (wave 4): one collector per request, fed by the agent's
|
||||
// message_end events. Every model call's usage lands in the chat record's
|
||||
// append-only turns log at onFinish — idempotent per turn id, so a
|
||||
// replayed persist appends once while continuation turns (which share a
|
||||
// response message id) each keep their usage.
|
||||
// append-only turns log at onFinish — idempotent per turn id.
|
||||
const turnUsageId = crypto.randomUUID();
|
||||
const steps: CallUsage[] = [];
|
||||
const collectUsage = (usage: Usage) => {
|
||||
@@ -238,13 +245,12 @@ export async function handleChatRequest(
|
||||
};
|
||||
|
||||
// AGENT CONSTRUCTION BRANCH (docs/probe-teach-split.md §2): the transcript
|
||||
// is the seam — a probe_done tool part (any state) means the probe is
|
||||
// is the seam — a probe_done call in the record means the probe is
|
||||
// concluded: build the TEACHER (planning + the full toolset). Otherwise
|
||||
// the session is still probing: build the PROBER (quiz/ask + probe_done).
|
||||
// Both share this whole composition (write-ahead persist, liveness,
|
||||
// steers, usage, extraction) — only the agent differs.
|
||||
const teaching = outgoing.some((m) => m.parts.some((p) => p.type === "tool-probe_done"));
|
||||
const role: ModelRole = teaching ? "teacher" : "prober";
|
||||
const teaching = hasProbeDone(merged);
|
||||
// BYOK (owner, 2026-09-22): the conversation chain was resolved by the
|
||||
// turn gate above (chat.model override → strong slot → refused) — the
|
||||
// engine build is pure from here; thinking levels clamp natively.
|
||||
@@ -252,19 +258,18 @@ export async function handleChatRequest(
|
||||
// Provider-qualified stamp (model slots wave): matches the price table's
|
||||
// provider:id keys.
|
||||
const modelId = `${engine.model.provider}:${engine.model.id}`;
|
||||
// Tests inject a factory: the agent is stateless per turn (docs/
|
||||
// pi-engine-swap.md §2), so each request builds a fresh one off the
|
||||
// transcript — the factory mirrors that seam exactly.
|
||||
// Tests inject a factory: the agent is stateless per turn, so each
|
||||
// request builds a fresh one off the record — the factory mirrors that
|
||||
// seam exactly. Wave B: the record IS the agent state (native messages;
|
||||
// the record's id field strips at the wire in toLlmMessages).
|
||||
// CROSS-STREAM SEAM (P2 reconciliation, 2026-09-19): `toolToggles`
|
||||
// rides the build opts as the user-level DELTA map (absent key =
|
||||
// enabled). The definitive toggleable set is TOGGLEABLE_TOOLS — the
|
||||
// sub-agent JOB tools (research/demo/visual/pdf; adjudicated with the
|
||||
// user: quiz/ask/fs/plan/probe_done are always-on). First-class on
|
||||
// AgentBuildOptions; the factories drop anything explicitly false and
|
||||
// the settings write side rejects unknown keys.
|
||||
// user: quiz/ask/fs/plan/probe_done are always-on).
|
||||
const agentOpts: AgentBuildOptions = {
|
||||
engine,
|
||||
messages: recordToPiMessages(outgoing),
|
||||
messages: asAgentMessages(merged),
|
||||
onUsage: collectUsage,
|
||||
toolToggles: effective.agent.tools,
|
||||
// The prompt-override switches (P2, 2026-09-19): same resolution
|
||||
@@ -273,7 +278,7 @@ export async function handleChatRequest(
|
||||
promptSwitches: effective.agent.prompts,
|
||||
};
|
||||
const constructed: TeachingAgent = buildAgent
|
||||
? buildAgent(outgoing)
|
||||
? buildAgent({ ...agentOpts, teaching })
|
||||
: teaching
|
||||
? createTeacherAgent(
|
||||
{
|
||||
@@ -310,7 +315,6 @@ export async function handleChatRequest(
|
||||
// transcript, not the money channel); when it's ours, usage flows through
|
||||
// the factory's subscription (wired above via onUsage).
|
||||
try {
|
||||
assertValidUiMessages(outgoing);
|
||||
// THE TURN AS A BACKGROUND OBJECT (owner ruling, 2026-09-25):
|
||||
// runPiTurn executes decoupled from this response — the POST stream
|
||||
// is just the FIRST subscriber (a view). A client disconnect can no
|
||||
@@ -318,7 +322,7 @@ export async function handleChatRequest(
|
||||
// late views reattach via GET /api/chats/[id]/stream.
|
||||
const handle = runPiTurn({
|
||||
agent: constructed,
|
||||
uiMessages: outgoing,
|
||||
base: merged,
|
||||
// Stream-level failures are provider/transport internals — masked,
|
||||
// like the SDK defaults. Tool-input validation messages never
|
||||
// route through here: they ride the error-result path and reach the
|
||||
@@ -327,14 +331,14 @@ export async function handleChatRequest(
|
||||
console.error(`[chat ${chatId}] turn error:`, error);
|
||||
return "An error occurred.";
|
||||
},
|
||||
onFinish: (messages, isAborted) => {
|
||||
persistTurn(stores.chatStore, chatId, messages as UIMessage[], isAborted);
|
||||
onFinish: (turnMessages, isAborted) => {
|
||||
// Wave B: the record write is the agent's own final state —
|
||||
// the new messages are the slice past the merged base.
|
||||
const stamped = persistTurn(stores.chatStore, chatId, merged.length, turnMessages, isAborted);
|
||||
// usage log append (idempotent per turn id) — the stats bar
|
||||
// refolds this on the client after the turn settles. messageId
|
||||
// comes from the id-FILLED list: the bridge's response message
|
||||
// already carries its id; the re-fill here is idempotent.
|
||||
const filled = withMessageIds(messages as UIMessage[]);
|
||||
const response = [...filled].reverse().find((m) => m.role === "assistant");
|
||||
// refolds this on the client after the turn settles. The
|
||||
// provenance message is the turn's FIRST new assistant message.
|
||||
const response = stamped.find((m) => (m as { role?: string }).role === "assistant");
|
||||
if (response && steps.length > 0) {
|
||||
stores.chatStore.appendTurnUsage(chatId, {
|
||||
id: turnUsageId,
|
||||
@@ -345,9 +349,7 @@ export async function handleChatRequest(
|
||||
});
|
||||
}
|
||||
// GRAPH GATE (settings-storage wave, 2026-09-19): the write channel —
|
||||
// extraction, its auditor — dies with the master switch. (The deeper
|
||||
// pipeline knobs — embedding/clustering/labeling — gate inside the
|
||||
// pipeline and its consumers, later waves, via the same resolved view.)
|
||||
// extraction, its auditor — dies with the master switch.
|
||||
if (extractionEnabled(effective.graph)) {
|
||||
// Fresh per-turn read of the graph section's chunk budget
|
||||
// (extraction-UX redesign, 2026-09-24): the trigger compares
|
||||
@@ -380,4 +382,4 @@ export async function handleChatRequest(
|
||||
console.error(`[chat ${chatId}] turn failed to start:`, err);
|
||||
return Response.json({ error: "Failed to start the turn" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+4
-1
@@ -1,5 +1,6 @@
|
||||
import { abortExtraction, extractionStatuses } from "../agent/subagents/extraction.ts";
|
||||
import { coverageOf } from "../core/coverage.ts";
|
||||
import { projectNative } from "../core/projection.ts";
|
||||
import { deleteChatTraces, type ChatDeleteMode, type ChatDeletionStats } from "../core/graph-deletion.ts";
|
||||
import { titleFromPrompt } from "../core/contracts.ts";
|
||||
import type { UIMessage } from "ai";
|
||||
@@ -141,7 +142,9 @@ export function sessionCoverage(chatId: string): SessionCoverage | null {
|
||||
const record = stores.chatStore.getChat(chatId);
|
||||
if (!record) return null;
|
||||
const entries = stores.graphStore.readCoverage();
|
||||
const cov = coverageOf(record.messages as UIMessage[], entries, chatId);
|
||||
// Wave B: the record is native history — coverage reads through the
|
||||
// projection (same view the extraction pipeline folds).
|
||||
const cov = coverageOf(projectNative(record.messages), entries, chatId);
|
||||
let lastFoldedAt: string | null = null;
|
||||
for (const entry of entries) {
|
||||
if (entry.chatId !== chatId) continue;
|
||||
|
||||
+37
-40
@@ -31,7 +31,6 @@
|
||||
* be a different object in each).
|
||||
*/
|
||||
|
||||
import type { UIMessage } from "ai";
|
||||
import {
|
||||
buildAbortSteer,
|
||||
buildSubagentSteer,
|
||||
@@ -40,13 +39,16 @@ import {
|
||||
type JobRecord,
|
||||
type SteerMessage,
|
||||
} from "../core/contracts.ts";
|
||||
import { findPendingToolCall } from "../core/pending.ts";
|
||||
import type { TurnInput } from "../core/input-events.ts";
|
||||
import { danglingQuestionOf } from "../core/native-messages.ts";
|
||||
import { fireJobAbort, isJobLive } from "../agent/subagents/aborts.ts";
|
||||
import { stores } from "./stores.ts";
|
||||
import { isTurnRunning } from "./turn-liveness.ts";
|
||||
|
||||
/** Fires one server-side teacher turn: (chatId, messages) → HTTP-ish status. */
|
||||
export type TurnRunner = (chatId: string, messages: UIMessage[]) => Promise<number>;
|
||||
/** Fires one server-side teacher turn: (chatId, input) → HTTP-ish status.
|
||||
* Wave B: the input is the STEER EVENT — the handler appends the marker
|
||||
* natively under its own turn claim (one composition for every turn). */
|
||||
export type TurnRunner = (chatId: string, input: TurnInput) => Promise<number>;
|
||||
|
||||
/** Re-runs a job's sub-agent loop (crash recovery for orphaned jobs). */
|
||||
export type JobRespawn = (jobId: string) => void;
|
||||
@@ -110,10 +112,11 @@ function delay(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
/** The job a steer belongs to, if it is a job steer (result/abort/context);
|
||||
/** The job a steer belongs to, if it is a job steer (result/abort);
|
||||
* probe-handoff steers have no job — null. */
|
||||
function jobIdOf(steer: SteerMessage): string | null {
|
||||
return (steer.metadata as { subagentResult?: { jobId: string } }).subagentResult?.jobId ?? null;
|
||||
const details = steer.details as { jobId?: string } | undefined;
|
||||
return typeof details?.jobId === "string" ? details.jobId : null;
|
||||
}
|
||||
|
||||
/** Finalize a delivered steer's job bookkeeping — job steers only. */
|
||||
@@ -167,32 +170,22 @@ export function deliverJobResult(steer: SteerMessage): Promise<void> {
|
||||
* copies of the same steer id (a live React duplicate-key warning the
|
||||
* owner hit 2026-09-18; withMessageIds only heals it at onFinish).
|
||||
*/
|
||||
export function claimSteers(
|
||||
chatId: string,
|
||||
incoming?: ReadonlyArray<UIMessage>,
|
||||
): SteerMessage[] {
|
||||
export function claimSteers(chatId: string): SteerMessage[] {
|
||||
const s = state();
|
||||
const queue = s.queues.get(chatId);
|
||||
if (!queue || queue.length === 0) return [];
|
||||
const record = stores.chatStore.getChat(chatId);
|
||||
if (!record) return []; // the handler validated the chat; defensive only
|
||||
const claimed: SteerMessage[] = [];
|
||||
s.queues.delete(chatId);
|
||||
// The caller holds the turn registry (chat-handler claims after
|
||||
// beginTurn), so the write-ahead persist is imminent and durable:
|
||||
// every queued steer is claimed — its delivery is THIS turn's job.
|
||||
// Steers already in the transcript (a wake raced in) still finalize
|
||||
// here; the handler's append filter (steerDelivered) keeps exactly
|
||||
// one copy in the record.
|
||||
const claimed = queue.map((entry) => entry.steer);
|
||||
for (const entry of queue) {
|
||||
if (
|
||||
steerDelivered(record.messages, entry.steer.id) ||
|
||||
incoming?.some((m) => m.id === entry.steer.id)
|
||||
) {
|
||||
// A wake already landed it — or this turn's own request already
|
||||
// carries it (the pump path): the delivery is done either way,
|
||||
// just finish the bookkeeping. NEVER append twice.
|
||||
finalizeSteer(entry.steer);
|
||||
} else {
|
||||
claimed.push(entry.steer);
|
||||
finalizeSteer(entry.steer); // delivery is now the client turn's job
|
||||
}
|
||||
finalizeSteer(entry.steer);
|
||||
entry.resolve();
|
||||
}
|
||||
s.queues.delete(chatId);
|
||||
return claimed;
|
||||
}
|
||||
|
||||
@@ -256,12 +249,13 @@ export function abortJob(jobId: string): AbortOutcome {
|
||||
}
|
||||
|
||||
function chatIdOf(steer: SteerMessage): string {
|
||||
// The probe handoff carries its chat directly (the job record would
|
||||
// say it too, but the metadata is the cheap path).
|
||||
const handoff = (steer.metadata as { probeHandoff?: { chatId: string } }).probeHandoff;
|
||||
if (handoff) return handoff.chatId;
|
||||
// The chat is resolvable from the job record; the steer's metadata
|
||||
// carries jobId, which indexes the store.
|
||||
// The probe handoff carries its chat directly in the marker details (the
|
||||
// job record would say it too, but the details are the cheap path).
|
||||
if (steer.customType === "probe-handoff") {
|
||||
return (steer.details as { chatId: string }).chatId;
|
||||
}
|
||||
// The chat is resolvable from the job record; the steer's details
|
||||
// carry jobId, which indexes the store.
|
||||
const jobId = jobIdOf(steer);
|
||||
const job = jobId ? stores.jobStore.getJob(jobId) : undefined;
|
||||
return job?.chatId ?? "";
|
||||
@@ -334,9 +328,11 @@ async function pump(chatId: string): Promise<void> {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Fire the server-side teacher turn. A 409 = a client turn raced
|
||||
// in between idle and firing — back off and retry the same steer.
|
||||
const status = await s.turnRunner(chatId, [...fresh.messages, entry.steer]).catch((err: unknown) => {
|
||||
// Fire the server-side teacher turn (the steer EVENT: the handler
|
||||
// appends the marker natively under its own turn claim). A 409 = a
|
||||
// client turn raced in between idle and firing — back off and
|
||||
// retry the same steer.
|
||||
const status = await s.turnRunner(chatId, { type: "steer", steer: entry.steer }).catch((err: unknown) => {
|
||||
console.error(`[steer ${steerId}] wake turn crashed:`, err);
|
||||
return -1;
|
||||
});
|
||||
@@ -387,22 +383,23 @@ function failJob(jobId: string, err: Error): void {
|
||||
|
||||
/**
|
||||
* Wait until the chat can take a wake turn: no turn in flight AND no
|
||||
* unanswered question part (provider conversion would break). Poll; the
|
||||
* learner may take arbitrarily long to answer, and that is correct — the
|
||||
* steer fires right after their continuation completes.
|
||||
* unanswered question call (the record's dangling question — the answer
|
||||
* arrives as its tool result, and no turn may run in the window). Poll;
|
||||
* the learner may take arbitrarily long to answer, and that is correct —
|
||||
* the steer fires right after their answer turn completes.
|
||||
*/
|
||||
async function waitForIdle(chatId: string): Promise<void> {
|
||||
for (;;) {
|
||||
const record = stores.chatStore.getChat(chatId);
|
||||
if (!record) return;
|
||||
const blocked = isTurnRunning(chatId) || findPendingToolCall(record.messages) !== null;
|
||||
const blocked = isTurnRunning(chatId) || danglingQuestionOf(record.messages) !== null;
|
||||
if (!blocked) {
|
||||
await delay(GRACE_MS);
|
||||
const recheck = stores.chatStore.getChat(chatId);
|
||||
if (
|
||||
!isTurnRunning(chatId) &&
|
||||
recheck &&
|
||||
findPendingToolCall(recheck.messages) === null
|
||||
danglingQuestionOf(recheck.messages) === null
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -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 { QUESTION_TOOLS } from "../../core/pending.ts";
|
||||
import type { LhMessage } from "../../core/native-messages.ts";
|
||||
import { projectNative } from "../../core/projection.ts";
|
||||
|
||||
/**
|
||||
* The UI-stream bridge (docs/pi-engine-swap.md §5): runs one agent turn and
|
||||
@@ -23,9 +25,10 @@ import { QUESTION_TOOLS } from "../../core/pending.ts";
|
||||
* tool-input-error instead and NEVER becomes input-available, so a
|
||||
* malformed question can never mount the dock (the live modal-realism
|
||||
* crash: GLM double-encoded `options` as a JSON string).
|
||||
* - The persisted message is assembled from the SAME parts the client
|
||||
* assembles (the processUIMessageStream shapes), so every downstream
|
||||
* consumer (phases, plan blocks, pending, extraction) is unchanged.
|
||||
* - The WIRE assembly (below) is the client view only (Wave B): the
|
||||
* record write is the agent's own native final state — the projection
|
||||
* (core/projection.ts) reproduces the client's part shapes for every
|
||||
* consumer (phases, plan blocks, pending, extraction) until Wave D.
|
||||
*
|
||||
* BACKGROUND-TURN RUNNER (owner ruling, 2026-09-25): a turn executes as a
|
||||
* server-side background object decoupled from any single HTTP response —
|
||||
@@ -42,12 +45,17 @@ type Chunk = Record<string, unknown> & { type: string };
|
||||
export interface PiTurnSpec {
|
||||
/** The per-turn agent (stateless engine — built from the record). */
|
||||
agent: Agent;
|
||||
/** The outgoing request messages (write-ahead persisted already). */
|
||||
uiMessages: UIMessage[];
|
||||
/** The turn's base: the native record + this turn's appends
|
||||
* (write-ahead persisted already). Wave B: the agent's context and
|
||||
* the record share one shape; onFinish receives the full agent state
|
||||
* and the caller persists the slice past base.length. */
|
||||
base: LhMessage[];
|
||||
/** The handler's error-unmasking policy (validation messages stay readable). */
|
||||
onError?: (error: unknown) => string;
|
||||
/** The canonical end-of-turn seam: persist + usage + extraction + endTurn. */
|
||||
onFinish: (messages: UIMessage[], isAborted: boolean) => void;
|
||||
/** The canonical end-of-turn seam: persist + usage + extraction + endTurn.
|
||||
* Receives the agent's FULL final state (native messages) — the turn's
|
||||
* new messages are the slice past base.length. */
|
||||
onFinish: (messages: AgentMessage[], isAborted: boolean) => void;
|
||||
}
|
||||
|
||||
/** One view onto a running turn: push() replays/streams a chunk, done()
|
||||
@@ -117,15 +125,22 @@ interface ToolPart {
|
||||
}
|
||||
|
||||
export function runPiTurn(spec: PiTurnSpec): PiTurnHandle {
|
||||
const { agent, uiMessages, onFinish } = spec;
|
||||
const { agent, base, onFinish } = spec;
|
||||
|
||||
// The streaming target: continuation (same id, parts appended to a copy
|
||||
// of the existing message) or a fresh assistant message.
|
||||
const last = uiMessages[uiMessages.length - 1];
|
||||
const continuing = last?.role === "assistant";
|
||||
// of the existing message) or a fresh assistant message. The continuation
|
||||
// rule is the CLIENT's: it keys on the last UIMessage's id — read through
|
||||
// the projection (the record is native; a turn after an answer continues
|
||||
// the group that carries the answered quiz, which is the projection's
|
||||
// last message).
|
||||
const projected = projectNative(base);
|
||||
const last = projected[projected.length - 1] as
|
||||
| { id?: string; role?: string; parts?: UIMessage["parts"] }
|
||||
| undefined;
|
||||
const continuing = last?.role === "assistant" && typeof last.id === "string";
|
||||
const messageId =
|
||||
(continuing && last.id) || `msg_${Date.now().toString(36)}_${crypto.randomUUID().slice(0, 8)}`;
|
||||
const baseParts: UIMessage["parts"] = continuing ? [...last.parts] : [];
|
||||
const baseParts: UIMessage["parts"] = continuing && last.parts ? [...last.parts] : [];
|
||||
const parts: UIMessage["parts"] = [...baseParts];
|
||||
|
||||
// Stable, collision-free part ids across continuation runs (reasoning
|
||||
@@ -174,11 +189,11 @@ export function runPiTurn(spec: PiTurnSpec): PiTurnHandle {
|
||||
send({ type: "start", messageId });
|
||||
|
||||
const settleWith = () => {
|
||||
const assembled: UIMessage = { id: messageId, role: "assistant", parts };
|
||||
// The SDK's onFinish shape: the input copy stays, the extended
|
||||
// response copy appends (persistTurn's withMessageIds dedupe
|
||||
// keeps the last occurrence).
|
||||
onFinish([...uiMessages, assembled], sawAbort);
|
||||
// Wave B: the record write is the agent's OWN final state (native
|
||||
// history — signatures, usage, provider stamps all real); the wire
|
||||
// assembly above served the client view only. Copy the array: the
|
||||
// agent may outlive this call's read.
|
||||
onFinish([...agent.state.messages], sawAbort);
|
||||
};
|
||||
|
||||
const handle = (event: AgentEvent) => {
|
||||
|
||||
+68
-57
@@ -4,69 +4,85 @@
|
||||
* implementing. Each takes its dependencies explicitly (storage ports, the
|
||||
* job runner) — no module here imports an adapter.
|
||||
*
|
||||
* persistTurn — the canonical state write (client + agent messages)
|
||||
* persistTurn — the canonical state write (Wave B: native history;
|
||||
* id stamping, placeholder filtering, plan-block
|
||||
* emission, abort-partial persistence)
|
||||
* triggerExtraction — the graph write channel: fires the extraction
|
||||
* pipeline (the sole graph writer) once a chat
|
||||
* crosses the v1 quiz-answer threshold. The trigger
|
||||
* mechanism is a v1 placeholder — this seam is the
|
||||
* ONLY thing to swap when a better one lands.
|
||||
* crosses the token-budget threshold.
|
||||
*
|
||||
* Logs are the minimal-observability baseline: one line per failure, tagged
|
||||
* [chat <id>] — structured logging lands behind these seams later.
|
||||
*/
|
||||
|
||||
import type { UIMessage } from "ai";
|
||||
import { withPlanBlocks } from "../core/plan-blocks.ts";
|
||||
import type { AgentMessage } from "@earendil-works/pi-agent-core";
|
||||
import type { ToolResultMessage } from "@earendil-works/pi-ai";
|
||||
import type { LhMessage } from "../core/native-messages.ts";
|
||||
import { newMessageId } from "../core/native-messages.ts";
|
||||
import { QUESTION_TOOLS } from "../core/pending.ts";
|
||||
import { planBlocksFor } from "../core/plan-blocks.ts";
|
||||
import { projectNative } from "../core/projection.ts";
|
||||
import { coverageOf } from "../core/coverage.ts";
|
||||
import type { ChatStore, GraphStore } from "../core/ports.ts";
|
||||
import type { ExtractionPipeline } from "../agent/subagents/extraction.ts";
|
||||
import { ModelsNotConfiguredError, ProviderKeyMissingError } from "../core/settings.ts";
|
||||
|
||||
/**
|
||||
* The canonical state write: the client owns the message list between turns
|
||||
* (useChat); this persists client messages + the agent response. Persists
|
||||
* even when aborted — the partial transcript (including any pending
|
||||
* question part) is what makes resume work.
|
||||
* The canonical state write (Wave B, owner-approved 2026-09-26): append the
|
||||
* turn's own messages — the agent loop's native history, provider/model/
|
||||
* usage/thinkingSignature stamped by pi itself. Persists even when aborted
|
||||
* — the partial transcript (including a pending question's unanswered
|
||||
* call) is what makes resume work.
|
||||
*
|
||||
* - Id stamping: pi messages carry no id; the record's identity needs one
|
||||
* per message (client keys, append-only adoption, the continuation
|
||||
* seam). Minted here, at the single canonical write.
|
||||
* - Placeholder filtering: a cleanly-posed question's synthetic
|
||||
* "question posed" result is an in-memory loop device (Wave A's
|
||||
* terminate) — NEVER persisted (fork 1: the record ends at the
|
||||
* unanswered call; the learner's answer arrives as the real tool
|
||||
* result). Errored question calls DO persist — the model's retry
|
||||
* context.
|
||||
* - Plan blocks: one frozen snapshot per successful plan_init call,
|
||||
* idempotent (persist replays are safe).
|
||||
*
|
||||
* Returns the stamped new messages (usage provenance reads them).
|
||||
*/
|
||||
/**
|
||||
* The SDK assigns assistant-message ids CLIENT-side (useChat); the server's
|
||||
* onFinish synthesis leaves them EMPTY. A message persisted only server-side
|
||||
* (the last turn's, or a wake turn's — nobody re-POSTs it with a client id)
|
||||
* would otherwise land in the record with id "" — and two of those in one
|
||||
* transcript break React keys (the chat map) and steer-dedup lookups. Fill any
|
||||
* missing id here, at the single canonical write: client-authored ids are
|
||||
* NEVER touched (the client's next POST still carries them verbatim).
|
||||
*/
|
||||
export function withMessageIds(messages: UIMessage[]): UIMessage[] {
|
||||
const filled = messages.map((m) =>
|
||||
m.id ? m : { ...m, id: `msg_${Date.now().toString(36)}_${crypto.randomUUID().slice(0, 8)}` },
|
||||
);
|
||||
// Same id twice = the SDK's continuation merge couldn't happen in place
|
||||
// (continuation coalescing appends a user-role steer AFTER the assistant
|
||||
// message being continued, so onFinish carries the input copy AND the
|
||||
// extended response copy under one id). Keep the LAST occurrence — it is
|
||||
// the SDK's final merge state (a superset of the earlier copy).
|
||||
const seen = new Set<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 {
|
||||
export function persistTurn(
|
||||
chatStore: ChatStore,
|
||||
chatId: string,
|
||||
baseCount: number,
|
||||
turnMessages: readonly AgentMessage[],
|
||||
isAborted: boolean,
|
||||
): LhMessage[] {
|
||||
try {
|
||||
const deduped = withMessageIds(messages);
|
||||
// Plan-block emission (wave 4): blocks are frozen transcript content —
|
||||
// attached here, once, at the canonical state write. The tree was
|
||||
// saved by the plan tool during the turn; snapshots derive now.
|
||||
const tree = chatStore.getChat(chatId)?.tree;
|
||||
chatStore.saveMessages(chatId, withPlanBlocks(deduped, tree));
|
||||
const record = chatStore.getChat(chatId);
|
||||
if (!record) return [];
|
||||
const stamped: LhMessage[] = [];
|
||||
for (const m of turnMessages.slice(baseCount)) {
|
||||
const message = m as LhMessage;
|
||||
// The placeholder question result: loop device, never a record fact.
|
||||
if (
|
||||
(message as { role?: string }).role === "toolResult" &&
|
||||
(QUESTION_TOOLS as readonly string[]).includes((message as ToolResultMessage).toolName) &&
|
||||
!(message as ToolResultMessage).isError
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
stamped.push(message.id ? message : ({ ...message, id: newMessageId() } as LhMessage));
|
||||
}
|
||||
const tree = record.tree;
|
||||
const blocks = planBlocksFor(
|
||||
record.messages,
|
||||
stamped,
|
||||
tree,
|
||||
projectNative([...record.messages, ...stamped]),
|
||||
);
|
||||
chatStore.saveMessages(chatId, [...record.messages, ...stamped, ...blocks]);
|
||||
return stamped;
|
||||
} catch (err) {
|
||||
console.error(`[chat ${chatId}] persist failed:`, err, { isAborted });
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -74,15 +90,11 @@ export function persistTurn(chatStore: ChatStore, chatId: string, messages: UIMe
|
||||
* The graph write channel (vision §1 construction): the extraction pipeline
|
||||
* (extractor → auditor → merger — the sole graph writer) runs ASYNC once a
|
||||
* chat's UNCOVERED transcript tokens reach the chunk budget
|
||||
* (vision-extraction-coverage.md, 2026-09-15 — replacing the old
|
||||
* every-3-quiz-answers count: content size is the honest signal, and
|
||||
* quiz-less sessions extract too). The budget is the graph section's
|
||||
* extractionChunkTokens setting (extraction-UX redesign, 2026-09-24) —
|
||||
* the composition root passes the per-turn effective read (chat-handler
|
||||
* resolves fresh per turn, same as every other gate). The pipeline
|
||||
* itself extracts only the uncovered batches. Fire-and-forget — never blocks the turn; failures are
|
||||
* logged by the pipeline and leave coverage untouched, so the next trigger
|
||||
* (or the manual extract button) re-runs what's left.
|
||||
* (vision-extraction-coverage.md, 2026-09-15). Coverage reads through the
|
||||
* projection (the record is native history; the extraction view stays
|
||||
* client-shaped until Wave D). Fire-and-forget — never blocks the turn;
|
||||
* failures are logged by the pipeline and leave coverage untouched, so the
|
||||
* next trigger (or the manual extract button) re-runs what's left.
|
||||
*
|
||||
* Deliberately isolated in this one seam — the pipeline is
|
||||
* trigger-agnostic. Skipped under tests: scripted chats are fixtures, not
|
||||
@@ -98,7 +110,7 @@ export function triggerExtraction(
|
||||
try {
|
||||
const record = deps.chatStore.getChat(chatId);
|
||||
if (!record) return;
|
||||
const covered = coverageOf(record.messages as UIMessage[], deps.graphStore.readCoverage(), chatId);
|
||||
const covered = coverageOf(projectNative(record.messages), deps.graphStore.readCoverage(), chatId);
|
||||
if (covered.uncoveredTokens < chunkTokens) return;
|
||||
void extraction.runExtraction(chatId).catch((err) => {
|
||||
// BYOK (owner, 2026-09-22) + key presence (provider-key interlude,
|
||||
@@ -119,4 +131,3 @@ export function triggerExtraction(
|
||||
console.error(`[chat ${chatId}] extraction trigger failed:`, err);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user