2026-09-26 01:52:47 +03:00
|
|
|
/**
|
|
|
|
|
* THE SESSION MODEL (Wave D): the chat session's state machine, extracted
|
|
|
|
|
* PURE — no React, one injectable fetch — so the attach/replay/settle/
|
|
|
|
|
* error/abort paths are testable in the repo's node:test world. The React
|
|
|
|
|
* hook (components/session.ts) is a thin adapter: it re-renders on change
|
|
|
|
|
* and projects the view (core/view.ts).
|
|
|
|
|
*
|
|
|
|
|
* ONE VIEW PROTOCOL: every turn is a server-side background process (owner
|
|
|
|
|
* ruling 2026-09-25); this model is a view. Sends POST one explicit input
|
|
|
|
|
* event ({chatId, input}); the response and the reattach stream
|
|
|
|
|
* (GET /api/chats/[id]/stream) are the SAME verbatim AgentEvent SSE,
|
|
|
|
|
* applied by the same function. Status vocabulary (pre-Wave-D parity):
|
|
|
|
|
* ready | submitted | streaming | error.
|
|
|
|
|
*/
|
|
|
|
|
|
|
|
|
|
import type { AgentEvent } from "@earendil-works/pi-agent-core";
|
|
|
|
|
import type { ToolResultMessage } from "@earendil-works/pi-ai";
|
|
|
|
|
import {
|
|
|
|
|
needsContinuation,
|
|
|
|
|
newAnswerResult,
|
2026-10-06 03:47:05 +03:00
|
|
|
newNextMessage,
|
2026-10-04 21:55:54 +03:00
|
|
|
newProblemDismissedMarker,
|
|
|
|
|
newThinkingMessage,
|
2026-09-26 01:52:47 +03:00
|
|
|
newUserMessage,
|
|
|
|
|
projectView,
|
|
|
|
|
type ChatInput,
|
2026-10-05 01:02:43 +03:00
|
|
|
type LhMarkerMessage,
|
2026-09-26 01:52:47 +03:00
|
|
|
type LhMessage,
|
|
|
|
|
type LhViewMessage,
|
|
|
|
|
type LiveExecution,
|
|
|
|
|
type QuestionAnswerData,
|
|
|
|
|
} from "../src/core";
|
|
|
|
|
|
|
|
|
|
export type SessionStatus = "ready" | "submitted" | "streaming" | "error";
|
|
|
|
|
|
|
|
|
|
/** How often to poll turnInFlight while a racing turn runs (409 retry). */
|
|
|
|
|
export const TURN_POLL_MS = 500;
|
|
|
|
|
/** Give up waiting (a wake turn can run long) and surface the 409. */
|
|
|
|
|
export const TURN_WAIT_MAX_MS = 30_000;
|
|
|
|
|
|
|
|
|
|
export interface SessionModelDeps {
|
|
|
|
|
/** Injectable fetch (tests); defaults to the global. */
|
|
|
|
|
fetch?: typeof globalThis.fetch;
|
|
|
|
|
/** Injectable retry timing (tests). */
|
|
|
|
|
pollMs?: number;
|
|
|
|
|
waitMaxMs?: number;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/** A local optimistic id (never a server stamp — absorption and the
|
|
|
|
|
* settle swap both key on this prefix). */
|
|
|
|
|
let localSeq = 0;
|
|
|
|
|
function localId(): string {
|
|
|
|
|
return `c_${Date.now().toString(36)}_${(localSeq++).toString(36)}`;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function delay(ms: number, signal?: AbortSignal): Promise<void> {
|
|
|
|
|
return new Promise((resolve, reject) => {
|
|
|
|
|
const t = setTimeout(resolve, ms);
|
|
|
|
|
signal?.addEventListener(
|
|
|
|
|
"abort",
|
|
|
|
|
() => {
|
|
|
|
|
clearTimeout(t);
|
|
|
|
|
reject(new DOMException("Aborted", "AbortError"));
|
|
|
|
|
},
|
|
|
|
|
{ once: true },
|
|
|
|
|
);
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function turnInFlight(model: SessionModel): Promise<boolean | null> {
|
|
|
|
|
try {
|
|
|
|
|
const res = await model.fetch(`/api/jobs?chatId=${encodeURIComponent(model.chatId)}`);
|
|
|
|
|
if (!res.ok) return null;
|
|
|
|
|
return ((await res.json()) as { turnInFlight?: boolean }).turnInFlight === true;
|
|
|
|
|
} catch {
|
|
|
|
|
return null; // network hiccup: keep waiting, the deadline bounds us
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/** One wire event off the SSE body: parsed JSON, or the [DONE] terminal. */
|
|
|
|
|
async function* sseEvents(reader: ReadableStreamDefaultReader<Uint8Array>): AsyncGenerator<Record<string, unknown>> {
|
|
|
|
|
const decoder = new TextDecoder();
|
|
|
|
|
let buf = "";
|
|
|
|
|
for (;;) {
|
|
|
|
|
const { done, value } = await reader.read();
|
|
|
|
|
if (done) return;
|
|
|
|
|
buf += decoder.decode(value, { stream: true });
|
|
|
|
|
for (;;) {
|
|
|
|
|
const end = buf.indexOf("\n\n");
|
|
|
|
|
if (end === -1) break;
|
|
|
|
|
const event = buf.slice(0, end);
|
|
|
|
|
buf = buf.slice(end + 2);
|
|
|
|
|
const dataLine = event.split("\n").find((l) => l.startsWith("data: "));
|
|
|
|
|
if (!dataLine) continue;
|
|
|
|
|
const data = dataLine.slice("data: ".length);
|
|
|
|
|
if (data === "[DONE]") return;
|
|
|
|
|
yield JSON.parse(data) as Record<string, unknown>;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/** The dangling question call's tool name (the optimistic answer result's
|
|
|
|
|
* toolName — the server stamps the same from its own record scan). */
|
|
|
|
|
export function pendingCallTool(messages: readonly LhMessage[], toolCallId: string): string | null {
|
|
|
|
|
for (const m of messages) {
|
|
|
|
|
if ((m as { role?: string }).role !== "assistant") continue;
|
|
|
|
|
const content = (m as { content?: unknown }).content;
|
|
|
|
|
if (!Array.isArray(content)) continue;
|
|
|
|
|
for (const block of content as Array<{ type?: string; id?: string; name?: string }>) {
|
|
|
|
|
if (block.type === "toolCall" && block.id === toolCallId) return block.name ?? null;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Has the record ABSORBED this optimistic tail entry? The write-ahead
|
|
|
|
|
* persists content-identical copies with server-stamped ids — match on the
|
|
|
|
|
* content, not the (local) id; only `c_`-prefixed local entries are ever
|
|
|
|
|
* absorbed (live turn content is never dropped).
|
|
|
|
|
*/
|
|
|
|
|
export function absorbed(m: LhMessage, record: readonly LhMessage[]): boolean {
|
|
|
|
|
if (typeof m.id !== "string" || !m.id.startsWith("c_")) return false;
|
|
|
|
|
const role = (m as { role?: string }).role;
|
|
|
|
|
if (role === "user") {
|
|
|
|
|
const content = (m as { content?: unknown }).content;
|
|
|
|
|
return record.some(
|
|
|
|
|
(r) => (r as { role?: string }).role === "user" && (r as { content?: unknown }).content === content,
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
if (role === "toolResult") {
|
|
|
|
|
const toolCallId = (m as ToolResultMessage).toolCallId;
|
|
|
|
|
return record.some(
|
|
|
|
|
(r) =>
|
|
|
|
|
(r as { role?: string }).role === "toolResult" &&
|
|
|
|
|
(r as ToolResultMessage).toolCallId === toolCallId,
|
|
|
|
|
);
|
|
|
|
|
}
|
2026-10-05 01:02:43 +03:00
|
|
|
if (role === "custom") {
|
|
|
|
|
// Markers (the thinking submission, 2026-10-05 — the doubled-card
|
|
|
|
|
// bug: the optimistic practice-thinking card was never absorbed, so
|
|
|
|
|
// the adoption window rendered it beside the write-ahead's server
|
|
|
|
|
// copy for the whole turn). TOTAL over roles by identity key — a
|
|
|
|
|
// marker is itself when its customType AND structured details match;
|
|
|
|
|
// types that never appear optimistically simply never reach here.
|
|
|
|
|
const marker = m as LhMarkerMessage;
|
|
|
|
|
return record.some(
|
|
|
|
|
(r) =>
|
|
|
|
|
(r as { role?: string }).role === "custom" &&
|
|
|
|
|
(r as LhMarkerMessage).customType === marker.customType &&
|
|
|
|
|
sameDetails((r as LhMarkerMessage).details, marker.details),
|
|
|
|
|
);
|
|
|
|
|
}
|
2026-09-26 01:52:47 +03:00
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-05 01:02:43 +03:00
|
|
|
/** Structural deep-equal for marker details (JSON-stable payloads — the
|
|
|
|
|
* constructors mint plain data). */
|
|
|
|
|
function sameDetails(a: unknown, b: unknown): boolean {
|
|
|
|
|
return JSON.stringify(a) === JSON.stringify(b);
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-26 01:52:47 +03:00
|
|
|
export interface SessionState {
|
|
|
|
|
messages: LhViewMessage[];
|
|
|
|
|
nativeMessages: LhMessage[];
|
|
|
|
|
status: SessionStatus;
|
|
|
|
|
error: { message: string } | null;
|
2026-10-03 03:01:51 +03:00
|
|
|
/** Steers sent but not yet drained (composer steering, 2026-10-03):
|
|
|
|
|
* the queued indicator's truth. An entry dies when its id lands in
|
|
|
|
|
* the messages (the drain's verbatim events upsert the real card) —
|
|
|
|
|
* visibleQueuedSteers is the pure fold. */
|
|
|
|
|
steerQueue: SteerQueueItem[];
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/** One queued composer steer: the client-minted id (the upsert key) + text. */
|
|
|
|
|
export interface SteerQueueItem {
|
|
|
|
|
id: string;
|
|
|
|
|
text: string;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/** The indicator's fold (pure): a queued steer stays visible until its id
|
|
|
|
|
* lands in the message list — from the live stream's upsert, the settle
|
|
|
|
|
* sync, or a reload. A steer parked server-side (stranded behind a
|
|
|
|
|
* question) keeps its line: honest "waiting" until the next run drains it. */
|
|
|
|
|
export function visibleQueuedSteers(
|
|
|
|
|
queue: readonly SteerQueueItem[],
|
|
|
|
|
messages: readonly LhMessage[],
|
|
|
|
|
): SteerQueueItem[] {
|
|
|
|
|
const ids = new Set(messages.map((m) => (m as { id?: string }).id));
|
|
|
|
|
return queue.filter((s) => !ids.has(s.id));
|
2026-09-26 01:52:47 +03:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export interface SessionModelOptions extends SessionModelDeps {
|
|
|
|
|
chatId: string;
|
|
|
|
|
initialMessages: LhMessage[];
|
|
|
|
|
/** Fires on every state change (the hook re-renders). */
|
|
|
|
|
onChange?: () => void;
|
|
|
|
|
/** The reattach settled terminal (turn-settled): no turn running, the
|
|
|
|
|
* record is current — the caller refetches it. */
|
|
|
|
|
onReattachSettled?: () => void;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export class SessionModel {
|
|
|
|
|
readonly chatId: string;
|
|
|
|
|
/** Injectable fetch (tests); defaults to the global. */
|
|
|
|
|
fetch: typeof globalThis.fetch;
|
2026-10-03 03:01:51 +03:00
|
|
|
/** Steers sent but not yet drained (the queued indicator's truth). */
|
|
|
|
|
private steerQueueValue: SteerQueueItem[] = [];
|
2026-09-26 01:52:47 +03:00
|
|
|
private readonly pollMs: number;
|
|
|
|
|
private readonly waitMaxMs: number;
|
|
|
|
|
private onReattachSettled?: () => void;
|
|
|
|
|
/** The hook's re-render trigger (set by the adapter). */
|
|
|
|
|
onChange?: () => void;
|
|
|
|
|
|
|
|
|
|
// THE STATE: the record (server canon) + the live tail (this client's
|
|
|
|
|
// optimistic appends and the streaming turn's messages). The tail dies
|
|
|
|
|
// at every full sync; the write-ahead absorbs the optimistic entries.
|
|
|
|
|
private base: LhMessage[];
|
|
|
|
|
private tail: LhMessage[] = [];
|
|
|
|
|
// The live turn's execution layer (view model mid-turn states):
|
|
|
|
|
private executions = new Map<string, LiveExecution>();
|
|
|
|
|
private streamingIds = new Set<string>();
|
2026-09-26 02:25:04 +03:00
|
|
|
/** The in-flight assistant message's client id (see applyWireEvent). */
|
|
|
|
|
private streamingMessageId: string | null = null;
|
2026-09-26 01:52:47 +03:00
|
|
|
private statusValue: SessionStatus = "ready";
|
|
|
|
|
private errorValue: { message: string } | null = null;
|
|
|
|
|
// The single live consume (POST response or reattach stream): a newer
|
|
|
|
|
// consume cancels the older — one reader at a time.
|
|
|
|
|
private consumeAbort: AbortController | null = null;
|
|
|
|
|
/** Synchronous ready check for the gate (send/read mount logic). */
|
|
|
|
|
readyRef = true;
|
|
|
|
|
|
|
|
|
|
constructor(opts: SessionModelOptions) {
|
|
|
|
|
this.chatId = opts.chatId;
|
|
|
|
|
this.base = opts.initialMessages;
|
2026-09-26 02:13:18 +03:00
|
|
|
// BOUND: the browser's native fetch is a Window method — storing it
|
|
|
|
|
// unbound makes every call throw "Illegal invocation" in the browser
|
|
|
|
|
// (Node's undici doesn't care, so the tests never saw it). Always go
|
|
|
|
|
// through a receiver-preserving wrapper.
|
|
|
|
|
this.fetch = opts.fetch ?? ((...args) => globalThis.fetch(...args));
|
2026-09-26 01:52:47 +03:00
|
|
|
this.pollMs = opts.pollMs ?? TURN_POLL_MS;
|
|
|
|
|
this.waitMaxMs = opts.waitMaxMs ?? TURN_WAIT_MAX_MS;
|
|
|
|
|
this.onChange = opts.onChange;
|
|
|
|
|
this.onReattachSettled = opts.onReattachSettled;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/** Injectable settle hook (the adapter wires its ref). */
|
|
|
|
|
setReattachSettled(fn: () => void): void {
|
|
|
|
|
this.onReattachSettled = fn;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
getState(): SessionState {
|
|
|
|
|
const live =
|
|
|
|
|
this.statusValue === "submitted" || this.statusValue === "streaming"
|
|
|
|
|
? { executions: this.executions, inFlight: true, streamingIds: this.streamingIds }
|
|
|
|
|
: undefined;
|
|
|
|
|
return {
|
|
|
|
|
messages: projectView([...this.base, ...this.tail], live),
|
|
|
|
|
nativeMessages: [...this.base, ...this.tail],
|
|
|
|
|
status: this.statusValue,
|
|
|
|
|
error: this.errorValue,
|
2026-10-03 03:01:51 +03:00
|
|
|
steerQueue: this.steerQueueValue,
|
2026-09-26 01:52:47 +03:00
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
get status(): SessionStatus {
|
|
|
|
|
return this.statusValue;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/** Mutate model state + notify (the hook re-renders). */
|
|
|
|
|
private mutate(fn: () => void): void {
|
|
|
|
|
fn();
|
|
|
|
|
this.onChange?.();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private setStatus(status: SessionStatus): void {
|
|
|
|
|
this.statusValue = status;
|
|
|
|
|
this.readyRef = status === "ready";
|
|
|
|
|
this.onChange?.();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private setError(message: string | null): void {
|
|
|
|
|
this.errorValue = message === null ? null : { message };
|
|
|
|
|
this.onChange?.();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/** The 409 identity-retry fetch (the Wave B contract): the event was
|
|
|
|
|
* never persisted (write-ahead happens only on acceptance), so the
|
|
|
|
|
* retry waits out the running turn and re-POSTs the SAME body. */
|
|
|
|
|
async postInputEvent(input: ChatInput, signal?: AbortSignal): Promise<Response> {
|
|
|
|
|
const body = JSON.stringify({ chatId: this.chatId, input });
|
|
|
|
|
const init: RequestInit = {
|
|
|
|
|
method: "POST",
|
|
|
|
|
headers: { "Content-Type": "application/json" },
|
|
|
|
|
body,
|
|
|
|
|
signal,
|
|
|
|
|
};
|
|
|
|
|
let response = await this.fetch("/api/chat", init);
|
|
|
|
|
let waited = 0;
|
|
|
|
|
while (response.status === 409) {
|
|
|
|
|
await response.body?.cancel().catch(() => {});
|
|
|
|
|
if (signal?.aborted || waited >= this.waitMaxMs) return response;
|
|
|
|
|
await delay(this.pollMs, signal);
|
|
|
|
|
waited += this.pollMs;
|
|
|
|
|
if (signal?.aborted) return response;
|
|
|
|
|
if ((await turnInFlight(this)) !== false) continue;
|
|
|
|
|
response = await this.fetch("/api/chat", init); // may 409 again → loop
|
|
|
|
|
}
|
|
|
|
|
return response;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* ONE event application for both the POST stream and the reattach
|
|
|
|
|
* replay (pi's events carry the FULL partial message — a view REPLACES,
|
2026-10-02 03:00:56 +03:00
|
|
|
* never assembles deltas). Returns whether the event did VIEW WORK:
|
|
|
|
|
* the consume flips submitted→streaming on the first ACTED event, not
|
|
|
|
|
* the first wire byte — agent_start/turn_start release nothing, so
|
|
|
|
|
* the Thinking… placeholder (gated on submitted) covers the whole
|
|
|
|
|
* provider-connect gap (owner, optimistic round 3, 2026-10-02).
|
2026-09-26 01:52:47 +03:00
|
|
|
*/
|
2026-10-02 03:00:56 +03:00
|
|
|
applyWireEvent(event: Record<string, unknown>): boolean {
|
2026-09-26 01:52:47 +03:00
|
|
|
if (event.type === "error") {
|
|
|
|
|
// Loop-level failure (agent.continue threw outside the stream) —
|
|
|
|
|
// raw text (owner ruling, Wave D): the banner shows exactly this.
|
|
|
|
|
this.setError(String(event.errorText ?? "An error occurred."));
|
2026-10-02 03:00:56 +03:00
|
|
|
return false;
|
2026-09-26 01:52:47 +03:00
|
|
|
}
|
|
|
|
|
const agent = event as unknown as AgentEvent;
|
|
|
|
|
switch (agent.type) {
|
|
|
|
|
case "message_start":
|
|
|
|
|
case "message_update":
|
|
|
|
|
case "message_end": {
|
|
|
|
|
const message = agent.message as LhMessage & {
|
|
|
|
|
role?: string;
|
|
|
|
|
id?: string;
|
|
|
|
|
};
|
2026-09-26 03:46:09 +03:00
|
|
|
if ((message as { role?: string }).role !== "assistant") {
|
|
|
|
|
// Wave C: markers injected mid-run (pi's follow-up delivery)
|
|
|
|
|
// arrive as custom-role message_start/message_end events -
|
|
|
|
|
// UPSERT by id (markers always carry steer_<id> ids; id-less
|
|
|
|
|
// non-assistant events never occur - injected messages emit
|
|
|
|
|
// start+end only, no streaming partials). Without this the
|
|
|
|
|
// live stream dropped the marker until the settle sync.
|
|
|
|
|
const mid = message.id;
|
2026-10-02 03:00:56 +03:00
|
|
|
if (mid === undefined) return false;
|
2026-09-26 03:46:09 +03:00
|
|
|
this.mutate(() => {
|
|
|
|
|
const idx = this.tail.findIndex((m) => m.id === mid);
|
|
|
|
|
this.tail =
|
|
|
|
|
idx === -1 ? [...this.tail, message as LhMessage] : this.tail.with(idx, message as LhMessage);
|
|
|
|
|
});
|
2026-10-02 03:00:56 +03:00
|
|
|
return true;
|
2026-09-26 03:46:09 +03:00
|
|
|
}
|
2026-09-26 02:25:04 +03:00
|
|
|
// pi streams AssistantMessages with NO id — the id exists only
|
|
|
|
|
// once the server's persistTurn stamps the record (the live
|
|
|
|
|
// streaming bug, 2026-09-26: minting a fresh id per event made
|
|
|
|
|
// every message_update APPEND a full copy — a divider + thinking
|
|
|
|
|
// block per chunk, cleaned up only by the settle sync). The
|
|
|
|
|
// in-flight message is tracked by SLOT instead: message_start
|
|
|
|
|
// mints and remembers the id; updates and the end REPLACE that
|
|
|
|
|
// same tail entry. Exactly one assistant message streams at a
|
|
|
|
|
// time (pi's strict start → updates → end ordering per step; the
|
|
|
|
|
// reattach replay replays the same sequence), so the slot is
|
|
|
|
|
// unambiguous. An explicit message.id (if one ever appears on
|
|
|
|
|
// the wire) still wins.
|
|
|
|
|
const id = message.id ?? this.streamingMessageId ?? localId();
|
2026-09-26 01:52:47 +03:00
|
|
|
const withId = { ...message, id } as LhMessage;
|
|
|
|
|
if (agent.type === "message_start") {
|
2026-09-26 02:25:04 +03:00
|
|
|
this.streamingMessageId = id;
|
2026-09-26 01:52:47 +03:00
|
|
|
this.streamingIds.add(id);
|
|
|
|
|
this.mutate(() => {
|
|
|
|
|
this.tail = [...this.tail, withId];
|
|
|
|
|
});
|
|
|
|
|
} else {
|
|
|
|
|
this.mutate(() => {
|
|
|
|
|
const idx = this.tail.findIndex((m) => m.id === id);
|
|
|
|
|
this.tail = idx === -1 ? [...this.tail, withId] : this.tail.with(idx, withId);
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
if (agent.type === "message_end") {
|
2026-09-26 02:25:04 +03:00
|
|
|
this.streamingMessageId = null; // slot done — next start mints fresh
|
2026-10-02 03:00:56 +03:00
|
|
|
// (fallthrough: returns true below — the message landed)
|
2026-09-26 01:52:47 +03:00
|
|
|
const stop = (message as { stopReason?: string }).stopReason;
|
|
|
|
|
if (stop === "error") {
|
|
|
|
|
// Provider error, verbatim (owner ruling) — the record
|
|
|
|
|
// persists the same message; no masking anywhere.
|
|
|
|
|
this.setError(
|
|
|
|
|
String((message as { errorMessage?: string }).errorMessage ?? "An error occurred."),
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-10-02 03:00:56 +03:00
|
|
|
return true; // a message event is view work, even empty content
|
2026-09-26 01:52:47 +03:00
|
|
|
}
|
|
|
|
|
case "tool_execution_end": {
|
|
|
|
|
// The execution layer: clean question placeholders are IGNORED
|
|
|
|
|
// downstream (view.ts fork 2); errors keep their validation text.
|
|
|
|
|
const result = agent.result as
|
|
|
|
|
| { content?: Array<{ type?: string; text?: string }>; details?: unknown }
|
|
|
|
|
| null;
|
|
|
|
|
const text = result?.content?.find((c) => c.type === "text")?.text ?? "The tool call failed.";
|
|
|
|
|
this.mutate(() => {
|
|
|
|
|
this.executions = new Map(this.executions).set(agent.toolCallId, {
|
|
|
|
|
isError: agent.isError,
|
|
|
|
|
output: result?.details,
|
|
|
|
|
errorText: text,
|
|
|
|
|
});
|
|
|
|
|
});
|
2026-10-02 03:00:56 +03:00
|
|
|
return true; // the execution layer updated the tool part's card
|
2026-09-26 01:52:47 +03:00
|
|
|
}
|
|
|
|
|
case "turn_end": {
|
|
|
|
|
// The results batch: append as native messages (client-stamped
|
|
|
|
|
// ids — the settle sync lands the server canon).
|
|
|
|
|
this.mutate(() => {
|
|
|
|
|
this.tail = [
|
|
|
|
|
...this.tail,
|
|
|
|
|
...(agent.toolResults as ToolResultMessage[]).map(
|
|
|
|
|
(r) => ({ ...r, id: localId() }) as unknown as LhMessage,
|
|
|
|
|
),
|
|
|
|
|
];
|
|
|
|
|
});
|
2026-10-02 03:00:56 +03:00
|
|
|
return true;
|
2026-09-26 01:52:47 +03:00
|
|
|
}
|
|
|
|
|
default:
|
2026-10-02 03:00:56 +03:00
|
|
|
return false; // agent_start/turn_start/tool_execution_start|update: no view work
|
2026-09-26 01:52:47 +03:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/** Consume one SSE body to its [DONE] terminal. The turn-settled
|
|
|
|
|
* terminal (stream-route only) fires the settle hook and ends. */
|
|
|
|
|
async consumeStream(res: Response): Promise<void> {
|
|
|
|
|
if (!res.body) throw new Error("The turn stream body is empty.");
|
|
|
|
|
const reader = res.body.getReader();
|
|
|
|
|
let sawEvent = false;
|
|
|
|
|
try {
|
|
|
|
|
for await (const event of sseEvents(reader)) {
|
|
|
|
|
if (event.type === "turn-settled") {
|
|
|
|
|
// The raced-settle terminal: no turn to attach to — the
|
|
|
|
|
// record is current; the settle hook refetches.
|
|
|
|
|
await reader.cancel().catch(() => {});
|
|
|
|
|
this.onReattachSettled?.();
|
|
|
|
|
return;
|
|
|
|
|
}
|
2026-10-02 03:00:56 +03:00
|
|
|
const acted = this.applyWireEvent(event);
|
|
|
|
|
if (!sawEvent && acted) {
|
2026-09-26 01:52:47 +03:00
|
|
|
sawEvent = true;
|
|
|
|
|
if (this.statusValue !== "error") this.setStatus("streaming");
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
} finally {
|
|
|
|
|
this.streamingIds = new Set();
|
|
|
|
|
this.executions = new Map();
|
|
|
|
|
if (this.statusValue !== "error") this.setStatus("ready");
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Reattach (the wake/handoff/mount path): a server-side turn with no
|
2026-10-02 02:23:20 +03:00
|
|
|
* live view of ours. ATTACH-FIRST (owner, reattach round 2026-10-02):
|
|
|
|
|
* a fresh-mount reattach passes refetchRecordFirst=false — the page
|
|
|
|
|
* render just read the record, so the blocking record round-trip only
|
|
|
|
|
* added handshake latency before the instant replay; the running
|
|
|
|
|
* turn's partial lives solely in the server event log, and the replay
|
|
|
|
|
* rebuilds it regardless of the refetch. Mid-session callers (the
|
|
|
|
|
* handoff watch) keep the default: their base may trail the record,
|
|
|
|
|
* and the refetch absorbs it (write-ahead adoption) before the replay
|
|
|
|
|
* lands on top. The stream then replays the turn from its first event
|
|
|
|
|
* through the SAME consume path.
|
2026-09-26 01:52:47 +03:00
|
|
|
*/
|
2026-10-02 02:23:20 +03:00
|
|
|
async resumeStream(refetchRecordFirst = true): Promise<void> {
|
2026-09-26 01:52:47 +03:00
|
|
|
this.consumeAbort?.abort();
|
|
|
|
|
const controller = new AbortController();
|
|
|
|
|
this.consumeAbort = controller;
|
2026-10-02 03:00:56 +03:00
|
|
|
// REPLAY ONTO A CLEAN SLATE (owner, optimistic round 3, 2026-10-02):
|
|
|
|
|
// the reattach stream replays the turn from its FIRST event — an
|
|
|
|
|
// aborted earlier attempt (React StrictMode's simulated remount)
|
|
|
|
|
// must not leave partials the replay would DOUBLE onto. The old
|
|
|
|
|
// record refetch provided this reset accidentally; attach-first
|
2026-10-10 09:42:47 +03:00
|
|
|
// made it explicit.
|
|
|
|
|
//
|
|
|
|
|
// THE FLICKER FIX (owner, 2026-10-10 — connection-diet B follow-up):
|
|
|
|
|
// on the REFETCH path the clear must NOT precede the fetch — send()
|
|
|
|
|
// just landed the optimistic answer/note cards in the tail, and any
|
|
|
|
|
// refetch latency (dev-compile stalls measured at 1.7s) re-showed
|
|
|
|
|
// the answered-away question for the whole window: card vanished,
|
|
|
|
|
// dock returned, both snapped back only when the record landed.
|
|
|
|
|
// The refetch block below swaps base+tail ATOMICALLY when the record
|
|
|
|
|
// arrives (and clears on failure — the clean-slate guard holds on
|
|
|
|
|
// every path that actually replays onto a stale tail).
|
|
|
|
|
if (!refetchRecordFirst) {
|
|
|
|
|
this.mutate(() => {
|
|
|
|
|
this.tail = [];
|
|
|
|
|
this.streamingMessageId = null;
|
|
|
|
|
});
|
|
|
|
|
}
|
2026-09-26 01:52:47 +03:00
|
|
|
this.setStatus("submitted");
|
|
|
|
|
try {
|
2026-10-02 02:23:20 +03:00
|
|
|
if (refetchRecordFirst) {
|
|
|
|
|
const recordRes = await this.fetch(`/api/chats/${encodeURIComponent(this.chatId)}`, {
|
|
|
|
|
signal: controller.signal,
|
|
|
|
|
});
|
|
|
|
|
if (recordRes.ok) {
|
|
|
|
|
const record = (await recordRes.json()) as { messages?: LhMessage[] };
|
|
|
|
|
if (Array.isArray(record.messages)) {
|
|
|
|
|
this.mutate(() => {
|
|
|
|
|
this.base = record.messages!;
|
|
|
|
|
this.tail = [];
|
|
|
|
|
});
|
|
|
|
|
}
|
2026-10-10 09:42:47 +03:00
|
|
|
} else {
|
|
|
|
|
// The record did not land — restore the clean-slate guard
|
|
|
|
|
// before the replay (no atomic swap is coming).
|
|
|
|
|
this.mutate(() => {
|
|
|
|
|
this.tail = [];
|
|
|
|
|
this.streamingMessageId = null;
|
|
|
|
|
});
|
2026-09-26 01:52:47 +03:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
const res = await this.fetch(`/api/chats/${encodeURIComponent(this.chatId)}/stream`, {
|
|
|
|
|
signal: controller.signal,
|
|
|
|
|
});
|
|
|
|
|
if (!res.ok) throw new Error((await res.text()) || "Failed to attach to the running turn.");
|
|
|
|
|
await this.consumeStream(res);
|
|
|
|
|
} catch (err) {
|
|
|
|
|
if ((err as Error).name === "AbortError") return; // a newer consume won
|
|
|
|
|
this.setError((err as Error).message);
|
|
|
|
|
this.setStatus("error");
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* SEND: one explicit input event. Optimistic appends first (the instant
|
|
|
|
|
* answer/note UX — the server's write-ahead persists the canon), then
|
|
|
|
|
* POST + consume. Ready-gated: one turn per client at a time (the
|
|
|
|
|
* server's 409 + identity retry covers the wake-race window).
|
|
|
|
|
*/
|
|
|
|
|
async send(input: ChatInput): Promise<void> {
|
|
|
|
|
if (!this.readyRef) return;
|
|
|
|
|
this.consumeAbort?.abort();
|
|
|
|
|
const controller = new AbortController();
|
|
|
|
|
this.consumeAbort = controller;
|
|
|
|
|
this.setError(null);
|
|
|
|
|
this.setStatus("submitted");
|
|
|
|
|
// The optimistic appends — exactly the messages the server's
|
|
|
|
|
// write-ahead will persist (inputAppends), with LOCAL ids so
|
|
|
|
|
// adoption can tell them from the record's absorbed copies.
|
|
|
|
|
const optimistic: LhMessage[] = [];
|
|
|
|
|
if (input.type === "text") optimistic.push({ ...newUserMessage(input.text), id: localId() });
|
2026-10-04 21:55:54 +03:00
|
|
|
else if (input.type === "thinking") {
|
|
|
|
|
// The practice submissions (2026-10-04): the marker lands
|
2026-10-05 03:46:32 +03:00
|
|
|
// optimistically — the card renders from its details the
|
|
|
|
|
// moment the button fires.
|
|
|
|
|
optimistic.push({ ...newThinkingMessage(input.problemCallId, input.text), id: localId() });
|
2026-10-06 03:47:05 +03:00
|
|
|
} else if (input.type === "next") {
|
|
|
|
|
// The move-on signal (owner round 16): the NEXT PROBLEM line
|
|
|
|
|
// renders the instant the chip/Enter fires.
|
|
|
|
|
optimistic.push({ ...newNextMessage(), id: localId() });
|
2026-10-04 21:55:54 +03:00
|
|
|
} else if (input.type === "answer") {
|
2026-09-26 01:52:47 +03:00
|
|
|
const tool = pendingCallTool([...this.base, ...this.tail], input.toolCallId) ?? "quiz";
|
|
|
|
|
optimistic.push({ ...newAnswerResult(input.toolCallId, tool, input.answer), id: localId() });
|
|
|
|
|
}
|
|
|
|
|
if (optimistic.length > 0) this.mutate(() => {
|
|
|
|
|
this.tail = [...this.tail, ...optimistic];
|
|
|
|
|
});
|
|
|
|
|
try {
|
2026-10-10 12:19:31 +03:00
|
|
|
// SUBMITS ARE NOT ABORTABLE BY VIEW TEARDOWN (owner, 2026-10-10 —
|
|
|
|
|
// the dead-session-start postmortem): the POST must NOT ride
|
|
|
|
|
// consumeAbort. Diet A's abort-on-unmount (session.ts's stop())
|
|
|
|
|
// fires on React StrictMode's simulated remount in dev — it killed
|
|
|
|
|
// the ?prompt first send ~8ms in (POST aborted, never retried:
|
|
|
|
|
// the page's sentInitial guard had already flipped), leaving the
|
|
|
|
|
// session dead until a second message, which then persisted
|
|
|
|
|
// WITHOUT the first — the optimistic tail rode a record that never
|
|
|
|
|
// contained it. The POST is a ~100ms ack since Diet B; it cannot
|
|
|
|
|
// starve the connection pool, so it needs no teardown guard. The
|
|
|
|
|
// STREAM consume stays on the controller — releasing that held
|
|
|
|
|
// connection is Diet A's whole job.
|
|
|
|
|
const res = await this.postInputEvent(input);
|
2026-09-26 01:52:47 +03:00
|
|
|
if (res.status === 409) {
|
|
|
|
|
this.setError("A turn is already running — try again in a moment.");
|
|
|
|
|
this.setStatus("error");
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
if (!res.ok) {
|
|
|
|
|
// Refusal envelopes (503 BYOK/key gates) and 400s: the RAW body
|
|
|
|
|
// text rides the error message — the banner shows it, and the
|
|
|
|
|
// page's refusal flow JSON-parses the envelope out of it.
|
|
|
|
|
throw new Error((await res.text()) || `The send failed (${res.status}).`);
|
|
|
|
|
}
|
2026-10-10 09:42:47 +03:00
|
|
|
// CONNECTION DIET B (owner, 2026-10-10 — parallel-session freeze
|
|
|
|
|
// postmortem): the POST acknowledges and closes; there is no stream
|
|
|
|
|
// on this response to consume. Attach through the SAME reattach wire
|
|
|
|
|
// a returning visitor uses (refetch-first: the record refetch absorbs
|
|
|
|
|
// the server's write-ahead copy of our optimistic messages, then the
|
|
|
|
|
// replay lands on top). One live view connection per page, always —
|
|
|
|
|
// the browser's 6-per-origin pool is the scarce resource.
|
|
|
|
|
await res.text().catch(() => {}); // drain the tiny ack; release the socket
|
2026-10-10 12:19:31 +03:00
|
|
|
// A stop() or a newer send/consume won while the ack was in
|
|
|
|
|
// flight: the turn is live server-side either way — do not attach
|
|
|
|
|
// a stream from a view that is gone or superseded.
|
|
|
|
|
if (this.consumeAbort !== controller) return;
|
2026-10-10 09:42:47 +03:00
|
|
|
await this.resumeStream(true);
|
2026-09-26 01:52:47 +03:00
|
|
|
} catch (err) {
|
|
|
|
|
if ((err as Error).name === "AbortError") return; // stop() or a newer consume won
|
|
|
|
|
this.setError((err as Error).message);
|
|
|
|
|
this.setStatus("error");
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-03 03:01:51 +03:00
|
|
|
/**
|
|
|
|
|
* STEER (composer steering, owner arc 2026-10-03): the learner's
|
|
|
|
|
* mid-turn message for the RUNNING turn. NOT a turn — no optimistic
|
|
|
|
|
* transcript card, no status flip: the message joins the queued
|
|
|
|
|
* indicator (the quiet line above the composer) and POSTs to the
|
|
|
|
|
* steer route, which hands it to the live agent's steering queue.
|
|
|
|
|
* When pi drains it at the next response boundary, the verbatim
|
|
|
|
|
* message events upsert the real card (the client-minted id is the
|
|
|
|
|
* key) and the indicator's fold drops the line. A 409 means the turn
|
|
|
|
|
* settled mid-send (the race): fall back to the plain text send —
|
|
|
|
|
* the entry is dropped and send() pushes its own optimistic card.
|
|
|
|
|
*/
|
|
|
|
|
async steer(text: string): Promise<void> {
|
|
|
|
|
const t = text.trim();
|
|
|
|
|
if (!t) return;
|
|
|
|
|
const id = `steer_${localId()}`;
|
|
|
|
|
this.steerQueueValue = [...this.steerQueueValue, { id, text: t }];
|
|
|
|
|
this.onChange?.();
|
|
|
|
|
const dropSteer = () => {
|
|
|
|
|
this.steerQueueValue = this.steerQueueValue.filter((s) => s.id !== id);
|
|
|
|
|
this.onChange?.();
|
|
|
|
|
};
|
|
|
|
|
try {
|
|
|
|
|
const res = await this.fetch(`/api/chats/${this.chatId}/steer`, {
|
|
|
|
|
method: "POST",
|
|
|
|
|
headers: { "Content-Type": "application/json" },
|
|
|
|
|
body: JSON.stringify({ input: { type: "steer", id, text: t } }),
|
|
|
|
|
});
|
|
|
|
|
if (res.ok) return; // parked or delivered — the stream lands the card
|
|
|
|
|
if (res.status === 409) {
|
|
|
|
|
dropSteer();
|
|
|
|
|
await this.send({ type: "text", text: t }); // the settle race: plain send
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
dropSteer();
|
|
|
|
|
this.setError((await res.text()) || `The steer failed (${res.status}).`);
|
|
|
|
|
} catch (err) {
|
|
|
|
|
dropSteer();
|
|
|
|
|
this.setError((err as Error).message);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-26 01:52:47 +03:00
|
|
|
/** The answer path (the dock): the answer IS the question call's tool
|
|
|
|
|
* result (fork 1), riding as its input event. */
|
|
|
|
|
submitAnswer(tool: string, toolCallId: string, output: QuestionAnswerData): Promise<void> {
|
|
|
|
|
void tool;
|
|
|
|
|
return this.send({ type: "answer", toolCallId, answer: output });
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-03 01:51:13 +03:00
|
|
|
/** The dismissal path (owner design 2026-10-03, "A2"): the skipped
|
|
|
|
|
* toolResult lands optimistically, then the SILENT route persists it
|
|
|
|
|
* — no turn, no stream, nothing wakes. The session pauses at the
|
|
|
|
|
* user's choice; the next plain send is the wake. On failure the
|
|
|
|
|
* durable record wins: the error surfaces, the record-changed sync
|
|
|
|
|
* (or any later refetch) replaces the optimistic append with truth. */
|
|
|
|
|
async dismissQuestion(toolCallId: string, answer: QuestionAnswerData): Promise<void> {
|
|
|
|
|
if (!this.readyRef) return;
|
|
|
|
|
const tool = pendingCallTool([...this.base, ...this.tail], toolCallId) ?? "quiz";
|
|
|
|
|
this.mutate(() => {
|
|
|
|
|
this.tail = [...this.tail, { ...newAnswerResult(toolCallId, tool, answer), id: localId() }];
|
|
|
|
|
});
|
|
|
|
|
try {
|
|
|
|
|
const res = await this.fetch(`/api/chats/${this.chatId}/dismiss`, {
|
|
|
|
|
method: "POST",
|
|
|
|
|
headers: { "Content-Type": "application/json" },
|
|
|
|
|
body: JSON.stringify({ input: { type: "dismiss", toolCallId, answer } }),
|
|
|
|
|
});
|
|
|
|
|
if (!res.ok) throw new Error(`The dismissal failed (${res.status}).`);
|
|
|
|
|
} catch (err) {
|
|
|
|
|
this.setError((err as Error).message);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-04 21:55:54 +03:00
|
|
|
/** The problem discard (practice sessions, owner 2026-10-04): the
|
|
|
|
|
* ask-style dismissal — the UI-only marker lands optimistically, then
|
|
|
|
|
* the SILENT route persists it (no turn, no wake). The composer
|
|
|
|
|
* returns; the conclude synthesis rides the user's next input. */
|
|
|
|
|
async dismissProblem(problemCallId: string): Promise<void> {
|
|
|
|
|
if (!this.readyRef) return;
|
|
|
|
|
this.mutate(() => {
|
|
|
|
|
this.tail = [...this.tail, { ...newProblemDismissedMarker(problemCallId), id: localId() }];
|
|
|
|
|
});
|
|
|
|
|
try {
|
|
|
|
|
const res = await this.fetch(`/api/chats/${this.chatId}/dismiss`, {
|
|
|
|
|
method: "POST",
|
|
|
|
|
headers: { "Content-Type": "application/json" },
|
|
|
|
|
body: JSON.stringify({ input: { type: "dismiss-problem", problemCallId } }),
|
|
|
|
|
});
|
|
|
|
|
if (!res.ok) throw new Error(`The dismissal failed (${res.status}).`);
|
|
|
|
|
} catch (err) {
|
|
|
|
|
this.setError((err as Error).message);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-26 01:52:47 +03:00
|
|
|
/** Local detach: cancel the consume, keep the partial tail. The page's
|
|
|
|
|
* settle watch adopts the record when the server turn finishes. */
|
|
|
|
|
stop(): void {
|
|
|
|
|
this.consumeAbort?.abort();
|
|
|
|
|
this.consumeAbort = null;
|
|
|
|
|
this.streamingIds = new Set();
|
2026-09-26 02:25:04 +03:00
|
|
|
this.streamingMessageId = null;
|
2026-09-26 01:52:47 +03:00
|
|
|
this.executions = new Map();
|
|
|
|
|
this.setStatus("ready");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/** Full snapshot swap (the idle settle sync — server canon). */
|
|
|
|
|
setMessages(record: LhMessage[]): void {
|
|
|
|
|
this.mutate(() => {
|
|
|
|
|
this.base = record;
|
|
|
|
|
this.tail = [];
|
2026-09-26 02:25:04 +03:00
|
|
|
this.streamingMessageId = null; // the tail (and its slot) is gone
|
2026-09-26 01:52:47 +03:00
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Append-only adoption (the mid-turn steer/artifact ground rule): swap
|
|
|
|
|
* in the record — every current base message must still be present
|
|
|
|
|
* (the server appends only) — keeping the live tail, minus the
|
|
|
|
|
* optimistic entries the record has absorbed (their server-stamped
|
|
|
|
|
* copies just landed; without this the input bubble would double).
|
|
|
|
|
*/
|
|
|
|
|
adoptRecord(record: LhMessage[]): void {
|
|
|
|
|
const recordIds = new Set(record.map((m) => (m as { id?: string }).id));
|
|
|
|
|
const baseAbsorbed = this.base.some((m) => !recordIds.has((m as { id?: string }).id));
|
|
|
|
|
if (baseAbsorbed) return; // not a pure append — the idle full sync is the net
|
|
|
|
|
this.mutate(() => {
|
|
|
|
|
this.base = record;
|
|
|
|
|
this.tail = this.tail.filter((m) => !absorbed(m, record));
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/** The mount/sync continuation fold (native-messages.ts). */
|
|
|
|
|
static needsContinuation = needsContinuation;
|
|
|
|
|
|
|
|
|
|
clearError(): void {
|
|
|
|
|
this.setError(null);
|
|
|
|
|
}
|
|
|
|
|
}
|