Background turn runner: every turn is a server-side process, clients are views
Owner ruling 2026-09-25. runPiTurn executes a turn decoupled from any HTTP
response: an unbounded memory-only event log (finite by construction; the
30-min liveness TTL bounds the pathological case) plus a subscriber set.
The POST /api/chat stream and the new GET /api/chats/[id]/stream are both
subscribers — identical SSE dialect; late subscribers get FULL replay
(starting at the start chunk) then live until finish + [DONE]. A view
cancelling (reload, navigate) now detaches ONLY — never aborts the turn.
The remote kill is the abort channel: POST /api/activity/abort gains
kind: "turn" (id = chatId; not_running 409 like jobs); handle.abort sets
sawAbort + agent.abort(), every subscriber closes cleanly, the partial
persists through the usual settle (onFinish runs once, then endTurn,
then unregisterTurn LAST so a view that finds nothing finds the record
current). No running turn on reattach: the cheap terminal — one
{"type":"turn-settled"} event + [DONE] (settledTurnResponse).
The live smoke caught what the unit suite had masked: the agent_end
close() had been left behind in the restructure — ReferenceError every
turn, steered into the error path; fixed and pinned (clean turns must
never emit an error chunk; finishReason stop present).
This commit is contained in:
@@ -1,16 +1,23 @@
|
|||||||
/**
|
/**
|
||||||
* POST /api/activity/abort — the dock's END/ABORT control, uniform across
|
* POST /api/activity/abort — the dock's END/ABORT control, uniform across
|
||||||
* process kinds (user-decided contract): body {kind: "job"|"extraction", id}
|
* process kinds (user-decided contract): body {kind: "job"|"extraction"|"turn", id}
|
||||||
* where id is the jobId for sub-agent jobs and the chatId for extraction
|
* where id is the jobId for sub-agent jobs, the chatId for extraction runs
|
||||||
* runs (the pipeline registry is chat-keyed). Mechanisms live in their
|
* (the pipeline registry is chat-keyed), and the chatId for turns
|
||||||
* domains — abortJob (server/jobs.ts, agents stream) and abortExtraction
|
* (background-turn wave, 2026-09-25: a running turn is a background
|
||||||
* (the pipeline module, graph stream); this route is the thin composition.
|
* object — any client may stop it here; a view leaving NEVER kills it).
|
||||||
|
* Mechanisms live in their domains — abortJob (server/jobs.ts, agents
|
||||||
|
* stream), abortExtraction (the pipeline module, graph stream), and
|
||||||
|
* abortTurn (server/turn-runs.ts, the running-turn registry); this route
|
||||||
|
* is the thin composition.
|
||||||
*
|
*
|
||||||
* Outcomes: a job's not_found -> 404, not_running -> 409, aborted -> 200.
|
* Outcomes: a job's not_found -> 404, not_running -> 409, aborted -> 200.
|
||||||
|
* A turn mirrors the job semantics: not_running -> 409 (honest "nothing to
|
||||||
|
* abort" — the caller refetches), aborted -> 200 (partial persists via
|
||||||
|
* the usual settle path, every subscriber closes cleanly).
|
||||||
* Extraction abort is idempotent by design (aborting with nothing in
|
* Extraction abort is idempotent by design (aborting with nothing in
|
||||||
* flight is success — the blob is gone either way) -> 200.
|
* flight is success — the blob is gone either way) -> 200.
|
||||||
*/
|
*/
|
||||||
import { abortExtraction, abortJob } from "@/src/server";
|
import { abortExtraction, abortJob, abortTurn } from "@/src/server";
|
||||||
|
|
||||||
export async function POST(req: Request) {
|
export async function POST(req: Request) {
|
||||||
let body: unknown;
|
let body: unknown;
|
||||||
@@ -20,8 +27,8 @@ export async function POST(req: Request) {
|
|||||||
return Response.json({ error: "Invalid JSON body" }, { status: 400 });
|
return Response.json({ error: "Invalid JSON body" }, { status: 400 });
|
||||||
}
|
}
|
||||||
const { kind, id } = (body ?? {}) as { kind?: string; id?: string };
|
const { kind, id } = (body ?? {}) as { kind?: string; id?: string };
|
||||||
if ((kind !== "job" && kind !== "extraction") || typeof id !== "string" || id === "") {
|
if ((kind !== "job" && kind !== "extraction" && kind !== "turn") || typeof id !== "string" || id === "") {
|
||||||
return Response.json({ error: 'Body must be {kind: "job"|"extraction", id: string}' }, { status: 400 });
|
return Response.json({ error: 'Body must be {kind: "job"|"extraction"|"turn", id: string}' }, { status: 400 });
|
||||||
}
|
}
|
||||||
if (!/^[A-Za-z0-9_-]+$/.test(id)) {
|
if (!/^[A-Za-z0-9_-]+$/.test(id)) {
|
||||||
return Response.json({ error: "Invalid id" }, { status: 400 });
|
return Response.json({ error: "Invalid id" }, { status: 400 });
|
||||||
@@ -35,6 +42,11 @@ export async function POST(req: Request) {
|
|||||||
}
|
}
|
||||||
return Response.json({ ok: true });
|
return Response.json({ ok: true });
|
||||||
}
|
}
|
||||||
|
if (kind === "turn") {
|
||||||
|
const outcome = abortTurn(id);
|
||||||
|
if (!outcome.ok) return Response.json({ ok: false, reason: outcome.reason }, { status: 409 });
|
||||||
|
return Response.json({ ok: true });
|
||||||
|
}
|
||||||
// kind === "extraction": true cancellation; idempotent (nothing in
|
// kind === "extraction": true cancellation; idempotent (nothing in
|
||||||
// flight is success — same dock outcome)
|
// flight is success — same dock outcome)
|
||||||
abortExtraction(id);
|
abortExtraction(id);
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
/**
|
||||||
|
* THE REATTACH STREAM (background-turn runner wave, owner ruling
|
||||||
|
* 2026-09-25): one SSE stream per chat, same wire dialect as the POST
|
||||||
|
* /api/chat stream. A running turn → subscribe to the live turn object:
|
||||||
|
* FULL replay of the event log, then live chunks until settle ([DONE]).
|
||||||
|
* No running turn → the cheap terminal ({"type":"turn-settled"} + [DONE]):
|
||||||
|
* the record is current, the client refetches it.
|
||||||
|
*
|
||||||
|
* 404 mirrors the POST handler's unknown-chat. Framework route is a thin
|
||||||
|
* wrapper over the server barrel (the import law); tests drive the seams
|
||||||
|
* (turn-runs + ui-stream) directly.
|
||||||
|
*/
|
||||||
|
import { getChat, runningTurn, piTurnResponse, settledTurnResponse } from "@/src/server";
|
||||||
|
|
||||||
|
export async function GET(_req: Request, ctx: { params: Promise<{ id: string }> }): Promise<Response> {
|
||||||
|
const { id } = await ctx.params;
|
||||||
|
if (!getChat(id)) return Response.json({ error: "Unknown chat" }, { status: 404 });
|
||||||
|
const handle = runningTurn(id);
|
||||||
|
if (!handle) return settledTurnResponse();
|
||||||
|
return piTurnResponse(handle);
|
||||||
|
}
|
||||||
@@ -7,7 +7,7 @@ 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 { recordToPiMessages } from "./pi/record-adapter.ts";
|
||||||
import { createPiTurnResponse } 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";
|
||||||
import { providerKeyPresent } from "./keys.ts";
|
import { providerKeyPresent } from "./keys.ts";
|
||||||
@@ -19,6 +19,7 @@ import {
|
|||||||
} from "../core/settings-resolution.ts";
|
} from "../core/settings-resolution.ts";
|
||||||
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 { persistTurn, triggerExtraction, withMessageIds } from "./turn-service.ts";
|
import { persistTurn, triggerExtraction, withMessageIds } from "./turn-service.ts";
|
||||||
import type { CallUsage } from "../core/contracts.ts";
|
import type { CallUsage } from "../core/contracts.ts";
|
||||||
|
|
||||||
@@ -310,10 +311,14 @@ export async function handleChatRequest(
|
|||||||
// the factory's subscription (wired above via onUsage).
|
// the factory's subscription (wired above via onUsage).
|
||||||
try {
|
try {
|
||||||
assertValidUiMessages(outgoing);
|
assertValidUiMessages(outgoing);
|
||||||
return createPiTurnResponse({
|
// 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
|
||||||
|
// longer abort the turn; the remote kill is the abort channel, and
|
||||||
|
// late views reattach via GET /api/chats/[id]/stream.
|
||||||
|
const handle = runPiTurn({
|
||||||
agent: constructed,
|
agent: constructed,
|
||||||
uiMessages: outgoing,
|
uiMessages: outgoing,
|
||||||
signal: req.signal,
|
|
||||||
// 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
|
||||||
@@ -352,8 +357,22 @@ export async function handleChatRequest(
|
|||||||
// LAST: a poller that sees not-in-flight must read an already-persisted
|
// LAST: a poller that sees not-in-flight must read an already-persisted
|
||||||
// record — endTurn only after persistTurn has done its job.
|
// record — endTurn only after persistTurn has done its job.
|
||||||
endTurn(chatId);
|
endTurn(chatId);
|
||||||
|
// And only then does the turn stop being a running object
|
||||||
|
// (background-turn wave, 2026-09-25): a reattach that finds
|
||||||
|
// nothing must find the persisted record current — the same
|
||||||
|
// ordering rule as endTurn.
|
||||||
|
unregisterTurn(chatId);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
// The turn is now a live background object: register it so any view
|
||||||
|
// can reattach (GET /api/chats/[id]/stream) or remotely abort it
|
||||||
|
// (POST /api/activity/abort, kind "turn"). Registration after the
|
||||||
|
// runPiTurn call is race-free: the agent loop only ticks in later
|
||||||
|
// microtasks, so settle (and thus unregister) cannot beat us here.
|
||||||
|
registerTurn(chatId, handle);
|
||||||
|
// The POST response: just this caller's view onto the running turn —
|
||||||
|
// identical wire dialect to the reattach stream.
|
||||||
|
return piTurnResponse(handle);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
// The stream never started, so no onFinish will fire — clear the registry
|
// The stream never started, so no onFinish will fire — clear the registry
|
||||||
// or a malformed request would lock the chat until the TTL eviction.
|
// or a malformed request would lock the chat until the TTL eviction.
|
||||||
|
|||||||
@@ -36,5 +36,7 @@ export * from "./search.ts";
|
|||||||
export { authStore } from "../store/auth.ts";
|
export { authStore } from "../store/auth.ts";
|
||||||
export * from "./settings-body.ts";
|
export * from "./settings-body.ts";
|
||||||
export * from "./turn-liveness.ts";
|
export * from "./turn-liveness.ts";
|
||||||
|
export * from "./turn-runs.ts";
|
||||||
|
export { runPiTurn, piTurnResponse, type PiTurnHandle, type PiTurnSpec } from "./pi/ui-stream.ts";
|
||||||
export * from "./observability.ts";
|
export * from "./observability.ts";
|
||||||
export * from "./model-catalog.ts";
|
export * from "./model-catalog.ts";
|
||||||
|
|||||||
+283
-211
@@ -26,6 +26,14 @@ import { QUESTION_TOOLS } from "../../core/pending.ts";
|
|||||||
* - The persisted message is assembled from the SAME parts the client
|
* - The persisted message is assembled from the SAME parts the client
|
||||||
* assembles (the processUIMessageStream shapes), so every downstream
|
* assembles (the processUIMessageStream shapes), so every downstream
|
||||||
* consumer (phases, plan blocks, pending, extraction) is unchanged.
|
* consumer (phases, plan blocks, pending, extraction) is unchanged.
|
||||||
|
*
|
||||||
|
* BACKGROUND-TURN RUNNER (owner ruling, 2026-09-25): a turn executes as a
|
||||||
|
* server-side background object decoupled from any single HTTP response —
|
||||||
|
* clients are VIEWS. runPiTurn returns the handle (event log + subscriber
|
||||||
|
* set); piTurnResponse wraps a subscriber stream as the SSE response, and
|
||||||
|
* the POST /api/chat response and GET /api/chats/[id]/stream are BOTH
|
||||||
|
* exactly that. Cancelling a view detaches it ONLY — never aborts the
|
||||||
|
* turn; the handle's abort() is the one remote kill.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
/** SSE chunk — the flat JSON line shape the client's zod schema accepts. */
|
/** SSE chunk — the flat JSON line shape the client's zod schema accepts. */
|
||||||
@@ -36,14 +44,34 @@ export interface PiTurnSpec {
|
|||||||
agent: Agent;
|
agent: Agent;
|
||||||
/** The outgoing request messages (write-ahead persisted already). */
|
/** The outgoing request messages (write-ahead persisted already). */
|
||||||
uiMessages: UIMessage[];
|
uiMessages: UIMessage[];
|
||||||
/** Request abort signal — aborts the agent mid-run. */
|
|
||||||
signal?: AbortSignal;
|
|
||||||
/** 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;
|
onFinish: (messages: UIMessage[], isAborted: boolean) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** One view onto a running turn: push() replays/streams a chunk, done()
|
||||||
|
* terminates the view ([DONE] + close). Cheap to be a Set member. */
|
||||||
|
export interface PiTurnSubscriber {
|
||||||
|
push(chunk: Chunk): void;
|
||||||
|
done(): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The server-side turn object (background-turn runner wave, 2026-09-25):
|
||||||
|
* runs the agent decoupled from any single HTTP response and owns the
|
||||||
|
* in-memory event log + the live subscriber set. The POST response and
|
||||||
|
* the reattach stream are both subscribers onto it. */
|
||||||
|
export interface PiTurnHandle {
|
||||||
|
/** A fresh subscriber stream: FULL replay of the log, then live events
|
||||||
|
* until the turn settles (ending [DONE]). Cancelling it detaches the
|
||||||
|
* view only — the turn runs on. */
|
||||||
|
subscribe(): ReadableStream<Uint8Array>;
|
||||||
|
/** The remote kill — the ONLY way a running turn dies early: sawAbort +
|
||||||
|
* agent.abort(); the loop's agent_end does the rest (finish chunk to
|
||||||
|
* every subscriber, then onFinish persists the partial transcript). */
|
||||||
|
abort(): void;
|
||||||
|
}
|
||||||
|
|
||||||
/** Map a pi stop reason to the finish chunk's enum. */
|
/** Map a pi stop reason to the finish chunk's enum. */
|
||||||
function finishReasonOf(stopReason: string | undefined): string {
|
function finishReasonOf(stopReason: string | undefined): string {
|
||||||
switch (stopReason) {
|
switch (stopReason) {
|
||||||
@@ -88,7 +116,7 @@ interface ToolPart {
|
|||||||
errorText?: string;
|
errorText?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createPiTurnResponse(spec: PiTurnSpec): Response {
|
export function runPiTurn(spec: PiTurnSpec): PiTurnHandle {
|
||||||
const { agent, uiMessages, onFinish } = spec;
|
const { agent, uiMessages, onFinish } = spec;
|
||||||
|
|
||||||
// The streaming target: continuation (same id, parts appended to a copy
|
// The streaming target: continuation (same id, parts appended to a copy
|
||||||
@@ -117,232 +145,276 @@ export function createPiTurnResponse(spec: PiTurnSpec): Response {
|
|||||||
let finishReason: string | undefined;
|
let finishReason: string | undefined;
|
||||||
let errorMessage: string | undefined;
|
let errorMessage: string | undefined;
|
||||||
|
|
||||||
const stream = new ReadableStream<Uint8Array>({
|
// THE PER-TURN EVENT LOG (memory-only — the settle-only disk writes
|
||||||
start(controller) {
|
// are unchanged): every emitted chunk, in order, so any late subscriber
|
||||||
const encoder = new TextEncoder();
|
// gets a FULL replay. Unbounded BY HONEST DESIGN (owner ruling
|
||||||
// Swallow enqueue on a cancelled stream (the client went away): the
|
// 2026-09-25): a turn's chunk log is finite by construction — the agent
|
||||||
// run must still settle — onFinish persists the partial turn.
|
// loop ends (or the 30-minute liveness TTL bounds the pathological
|
||||||
const send = (chunk: Chunk) => {
|
// never-settling case), and concurrency is human-scale (one turn per
|
||||||
if (settled) return;
|
// chat, a few chats). No cap, no truncation: replay must never
|
||||||
try {
|
// silently drop history. The log dies with the turn at settle.
|
||||||
controller.enqueue(encoder.encode(`data: ${JSON.stringify(chunk)}\n\n`));
|
const log: Chunk[] = [];
|
||||||
} catch {
|
const subscribers = new Set<PiTurnSubscriber>();
|
||||||
/* reader cancelled — stop emitting, keep running */
|
|
||||||
}
|
|
||||||
};
|
|
||||||
const close = () => {
|
|
||||||
if (settled) return;
|
|
||||||
settled = true;
|
|
||||||
try {
|
|
||||||
controller.enqueue(encoder.encode("data: [DONE]\n\n"));
|
|
||||||
controller.close();
|
|
||||||
} catch {
|
|
||||||
/* already cancelled */
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
send({ type: "start", messageId });
|
// Send = append to the log + fan out to every live view. A view's own
|
||||||
|
// enqueue failure (a cancelled reader) is swallowed by the view — the
|
||||||
|
// turn must still settle; onFinish persists regardless of who watches.
|
||||||
|
const send = (chunk: Chunk) => {
|
||||||
|
if (settled) return;
|
||||||
|
log.push(chunk);
|
||||||
|
for (const sub of subscribers) sub.push(chunk);
|
||||||
|
};
|
||||||
|
const closeAll = () => {
|
||||||
|
if (settled) return;
|
||||||
|
settled = true;
|
||||||
|
for (const sub of subscribers) sub.done();
|
||||||
|
subscribers.clear();
|
||||||
|
};
|
||||||
|
|
||||||
const onAbort = () => {
|
send({ type: "start", messageId });
|
||||||
sawAbort = true;
|
|
||||||
agent.abort();
|
|
||||||
};
|
|
||||||
spec.signal?.addEventListener("abort", onAbort, { once: true });
|
|
||||||
|
|
||||||
const settleWith = () => {
|
const settleWith = () => {
|
||||||
const assembled: UIMessage = { id: messageId, role: "assistant", parts };
|
const assembled: UIMessage = { id: messageId, role: "assistant", parts };
|
||||||
// The SDK's onFinish shape: the input copy stays, the extended
|
// The SDK's onFinish shape: the input copy stays, the extended
|
||||||
// response copy appends (persistTurn's withMessageIds dedupe
|
// response copy appends (persistTurn's withMessageIds dedupe
|
||||||
// keeps the last occurrence).
|
// keeps the last occurrence).
|
||||||
onFinish([...uiMessages, assembled], sawAbort || spec.signal?.aborted === true);
|
onFinish([...uiMessages, assembled], sawAbort);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handle = (event: AgentEvent) => {
|
const handle = (event: AgentEvent) => {
|
||||||
switch (event.type) {
|
switch (event.type) {
|
||||||
case "turn_start":
|
case "turn_start":
|
||||||
send({ type: "start-step" });
|
send({ type: "start-step" });
|
||||||
parts.push({ type: "step-start" });
|
parts.push({ type: "step-start" });
|
||||||
break;
|
break;
|
||||||
case "message_update": {
|
case "message_update": {
|
||||||
const streamEvent = event.assistantMessageEvent;
|
const streamEvent = event.assistantMessageEvent;
|
||||||
switch (streamEvent.type) {
|
switch (streamEvent.type) {
|
||||||
case "text_start": {
|
case "text_start": {
|
||||||
const id = `${messageId}-t${textN++}`;
|
const id = `${messageId}-t${textN++}`;
|
||||||
textIds.set(streamEvent.contentIndex, id);
|
textIds.set(streamEvent.contentIndex, id);
|
||||||
const part: TextPart = { type: "text", text: "", state: "streaming" };
|
const part: TextPart = { type: "text", text: "", state: "streaming" };
|
||||||
activeParts.set(id, part);
|
activeParts.set(id, part);
|
||||||
parts.push(part as UIMessage["parts"][number]);
|
parts.push(part as UIMessage["parts"][number]);
|
||||||
send({ type: "text-start", id });
|
send({ type: "text-start", id });
|
||||||
break;
|
|
||||||
}
|
|
||||||
case "text_delta": {
|
|
||||||
const id = textIds.get(streamEvent.contentIndex);
|
|
||||||
const part = id !== undefined ? (activeParts.get(id) as TextPart | undefined) : undefined;
|
|
||||||
if (part) part.text += streamEvent.delta;
|
|
||||||
if (id !== undefined) send({ type: "text-delta", id, delta: streamEvent.delta });
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
case "text_end": {
|
|
||||||
const id = textIds.get(streamEvent.contentIndex);
|
|
||||||
const part = id !== undefined ? (activeParts.get(id) as TextPart | undefined) : undefined;
|
|
||||||
if (part) part.state = "done";
|
|
||||||
activeParts.delete(id ?? "");
|
|
||||||
if (id !== undefined) send({ type: "text-end", id });
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
case "thinking_start": {
|
|
||||||
const id = `${messageId}-r${reasoningN++}`;
|
|
||||||
reasoningIds.set(streamEvent.contentIndex, id);
|
|
||||||
const part: ReasoningPart = { type: "reasoning", id, text: "", state: "streaming" };
|
|
||||||
activeParts.set(id, part);
|
|
||||||
parts.push(part as UIMessage["parts"][number]);
|
|
||||||
send({ type: "reasoning-start", id });
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
case "thinking_delta": {
|
|
||||||
const id = reasoningIds.get(streamEvent.contentIndex);
|
|
||||||
const part = id !== undefined ? (activeParts.get(id) as ReasoningPart | undefined) : undefined;
|
|
||||||
if (part) part.text += streamEvent.delta;
|
|
||||||
if (id !== undefined) send({ type: "reasoning-delta", id, delta: streamEvent.delta });
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
case "thinking_end": {
|
|
||||||
const id = reasoningIds.get(streamEvent.contentIndex);
|
|
||||||
const part = id !== undefined ? (activeParts.get(id) as ReasoningPart | undefined) : undefined;
|
|
||||||
if (part) part.state = "done";
|
|
||||||
activeParts.delete(id ?? "");
|
|
||||||
if (id !== undefined) send({ type: "reasoning-end", id });
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
case "toolcall_start": {
|
|
||||||
const block = (event.message as { content: any[] }).content[streamEvent.contentIndex];
|
|
||||||
if (block?.type !== "toolCall") break;
|
|
||||||
toolInputs.set(block.id, { toolName: block.name, input: undefined });
|
|
||||||
const part: ToolPart = {
|
|
||||||
type: `tool-${block.name}`,
|
|
||||||
toolCallId: block.id,
|
|
||||||
state: "input-streaming",
|
|
||||||
input: undefined,
|
|
||||||
};
|
|
||||||
newToolParts.set(block.id, part);
|
|
||||||
parts.push(part as unknown as UIMessage["parts"][number]);
|
|
||||||
send({ type: "tool-input-start", toolCallId: block.id, toolName: block.name });
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
case "toolcall_delta": {
|
|
||||||
const block = (event.message as { content: any[] }).content[streamEvent.contentIndex];
|
|
||||||
if (block?.type !== "toolCall") break;
|
|
||||||
send({
|
|
||||||
type: "tool-input-delta",
|
|
||||||
toolCallId: block.id,
|
|
||||||
inputTextDelta: streamEvent.delta,
|
|
||||||
});
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
case "toolcall_end": {
|
|
||||||
const toolCall = streamEvent.toolCall;
|
|
||||||
toolInputs.set(toolCall.id, { toolName: toolCall.name, input: toolCall.arguments });
|
|
||||||
// Input stays input-streaming here: it only becomes available
|
|
||||||
// once the call clears validation at the execution boundary
|
|
||||||
// (tool_execution_end) — an invalid input must never reach
|
|
||||||
// input-available, where the dock would mount on it.
|
|
||||||
const part = newToolParts.get(toolCall.id);
|
|
||||||
if (part) part.input = toolCall.arguments;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case "tool_execution_end": {
|
case "text_delta": {
|
||||||
const known = toolInputs.get(event.toolCallId);
|
const id = textIds.get(streamEvent.contentIndex);
|
||||||
const toolName = event.toolName || known?.toolName || "";
|
const part = id !== undefined ? (activeParts.get(id) as TextPart | undefined) : undefined;
|
||||||
const input = known?.input;
|
if (part) part.text += streamEvent.delta;
|
||||||
const part = newToolParts.get(event.toolCallId);
|
if (id !== undefined) send({ type: "text-delta", id, delta: streamEvent.delta });
|
||||||
if (event.isError) {
|
|
||||||
const errorText = resultErrorText(event.result);
|
|
||||||
if (part) {
|
|
||||||
part.state = "output-error";
|
|
||||||
part.rawInput = input;
|
|
||||||
part.errorText = errorText;
|
|
||||||
}
|
|
||||||
send({
|
|
||||||
type: "tool-input-error",
|
|
||||||
toolCallId: event.toolCallId,
|
|
||||||
toolName,
|
|
||||||
input,
|
|
||||||
errorText,
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
// Validation cleared: only NOW does the input become available
|
|
||||||
// to the client (the InvalidToolInputError gating the SDK owned).
|
|
||||||
if (part) {
|
|
||||||
part.state = "input-available";
|
|
||||||
part.input = input;
|
|
||||||
}
|
|
||||||
send({ type: "tool-input-available", toolCallId: event.toolCallId, toolName, input });
|
|
||||||
if ((QUESTION_TOOLS as readonly string[]).includes(toolName)) {
|
|
||||||
// Client-owned: the answer arrives via addToolOutput;
|
|
||||||
// the synthetic placeholder result never reaches wire
|
|
||||||
// or record.
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
const output = (event.result as { details?: unknown } | undefined)?.details;
|
|
||||||
if (part) {
|
|
||||||
part.state = "output-available";
|
|
||||||
part.output = output;
|
|
||||||
}
|
|
||||||
send({ type: "tool-output-available", toolCallId: event.toolCallId, output });
|
|
||||||
}
|
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case "turn_end":
|
case "text_end": {
|
||||||
send({ type: "finish-step" });
|
const id = textIds.get(streamEvent.contentIndex);
|
||||||
|
const part = id !== undefined ? (activeParts.get(id) as TextPart | undefined) : undefined;
|
||||||
|
if (part) part.state = "done";
|
||||||
|
activeParts.delete(id ?? "");
|
||||||
|
if (id !== undefined) send({ type: "text-end", id });
|
||||||
break;
|
break;
|
||||||
case "message_end":
|
}
|
||||||
if (event.message && (event.message as { role?: string }).role === "assistant") {
|
case "thinking_start": {
|
||||||
const m = event.message as { stopReason?: string; errorMessage?: string };
|
const id = `${messageId}-r${reasoningN++}`;
|
||||||
finishReason = m.stopReason;
|
reasoningIds.set(streamEvent.contentIndex, id);
|
||||||
if (m.stopReason === "aborted") sawAbort = true;
|
const part: ReasoningPart = { type: "reasoning", id, text: "", state: "streaming" };
|
||||||
if (m.stopReason === "error") errorMessage = m.errorMessage;
|
activeParts.set(id, part);
|
||||||
}
|
parts.push(part as UIMessage["parts"][number]);
|
||||||
|
send({ type: "reasoning-start", id });
|
||||||
break;
|
break;
|
||||||
case "agent_end": {
|
}
|
||||||
spec.signal?.removeEventListener("abort", onAbort);
|
case "thinking_delta": {
|
||||||
if (errorMessage !== undefined) {
|
const id = reasoningIds.get(streamEvent.contentIndex);
|
||||||
send({ type: "error", errorText: spec.onError?.(errorMessage) ?? errorMessage });
|
const part = id !== undefined ? (activeParts.get(id) as ReasoningPart | undefined) : undefined;
|
||||||
}
|
if (part) part.text += streamEvent.delta;
|
||||||
send({ type: "finish", finishReason: finishReasonOf(finishReason) });
|
if (id !== undefined) send({ type: "reasoning-delta", id, delta: streamEvent.delta });
|
||||||
close();
|
break;
|
||||||
settleWith();
|
}
|
||||||
|
case "thinking_end": {
|
||||||
|
const id = reasoningIds.get(streamEvent.contentIndex);
|
||||||
|
const part = id !== undefined ? (activeParts.get(id) as ReasoningPart | undefined) : undefined;
|
||||||
|
if (part) part.state = "done";
|
||||||
|
activeParts.delete(id ?? "");
|
||||||
|
if (id !== undefined) send({ type: "reasoning-end", id });
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case "toolcall_start": {
|
||||||
|
const block = (event.message as { content: any[] }).content[streamEvent.contentIndex];
|
||||||
|
if (block?.type !== "toolCall") break;
|
||||||
|
toolInputs.set(block.id, { toolName: block.name, input: undefined });
|
||||||
|
const part: ToolPart = {
|
||||||
|
type: `tool-${block.name}`,
|
||||||
|
toolCallId: block.id,
|
||||||
|
state: "input-streaming",
|
||||||
|
input: undefined,
|
||||||
|
};
|
||||||
|
newToolParts.set(block.id, part);
|
||||||
|
parts.push(part as unknown as UIMessage["parts"][number]);
|
||||||
|
send({ type: "tool-input-start", toolCallId: block.id, toolName: block.name });
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case "toolcall_delta": {
|
||||||
|
const block = (event.message as { content: any[] }).content[streamEvent.contentIndex];
|
||||||
|
if (block?.type !== "toolCall") break;
|
||||||
|
send({
|
||||||
|
type: "tool-input-delta",
|
||||||
|
toolCallId: block.id,
|
||||||
|
inputTextDelta: streamEvent.delta,
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case "toolcall_end": {
|
||||||
|
const toolCall = streamEvent.toolCall;
|
||||||
|
toolInputs.set(toolCall.id, { toolName: toolCall.name, input: toolCall.arguments });
|
||||||
|
// Input stays input-streaming here: it only becomes available
|
||||||
|
// once the call clears validation at the execution boundary
|
||||||
|
// (tool_execution_end) — an invalid input must never reach
|
||||||
|
// input-available, where the dock would mount on it.
|
||||||
|
const part = newToolParts.get(toolCall.id);
|
||||||
|
if (part) part.input = toolCall.arguments;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
break;
|
||||||
|
}
|
||||||
agent.subscribe(handle);
|
case "tool_execution_end": {
|
||||||
|
const known = toolInputs.get(event.toolCallId);
|
||||||
void (async () => {
|
const toolName = event.toolName || known?.toolName || "";
|
||||||
try {
|
const input = known?.input;
|
||||||
await agent.continue();
|
const part = newToolParts.get(event.toolCallId);
|
||||||
} catch (err) {
|
if (event.isError) {
|
||||||
// A loop-level failure (not a provider error — those ride the
|
const errorText = resultErrorText(event.result);
|
||||||
// stream as stopReason "error"): surface, persist, close.
|
if (part) {
|
||||||
spec.signal?.removeEventListener("abort", onAbort);
|
part.state = "output-error";
|
||||||
send({ type: "error", errorText: spec.onError?.(err) ?? "An error occurred." });
|
part.rawInput = input;
|
||||||
send({ type: "finish", finishReason: "error" });
|
part.errorText = errorText;
|
||||||
close();
|
}
|
||||||
settleWith();
|
send({
|
||||||
|
type: "tool-input-error",
|
||||||
|
toolCallId: event.toolCallId,
|
||||||
|
toolName,
|
||||||
|
input,
|
||||||
|
errorText,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// Validation cleared: only NOW does the input become available
|
||||||
|
// to the client (the InvalidToolInputError gating the SDK owned).
|
||||||
|
if (part) {
|
||||||
|
part.state = "input-available";
|
||||||
|
part.input = input;
|
||||||
|
}
|
||||||
|
send({ type: "tool-input-available", toolCallId: event.toolCallId, toolName, input });
|
||||||
|
if ((QUESTION_TOOLS as readonly string[]).includes(toolName)) {
|
||||||
|
// Client-owned: the answer arrives via addToolOutput;
|
||||||
|
// the synthetic placeholder result never reaches wire
|
||||||
|
// or record.
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
const output = (event.result as { details?: unknown } | undefined)?.details;
|
||||||
|
if (part) {
|
||||||
|
part.state = "output-available";
|
||||||
|
part.output = output;
|
||||||
|
}
|
||||||
|
send({ type: "tool-output-available", toolCallId: event.toolCallId, output });
|
||||||
}
|
}
|
||||||
})();
|
break;
|
||||||
|
}
|
||||||
|
case "turn_end":
|
||||||
|
send({ type: "finish-step" });
|
||||||
|
break;
|
||||||
|
case "message_end":
|
||||||
|
if (event.message && (event.message as { role?: string }).role === "assistant") {
|
||||||
|
const m = event.message as { stopReason?: string; errorMessage?: string };
|
||||||
|
finishReason = m.stopReason;
|
||||||
|
if (m.stopReason === "aborted") sawAbort = true;
|
||||||
|
if (m.stopReason === "error") errorMessage = m.errorMessage;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case "agent_end": {
|
||||||
|
if (errorMessage !== undefined) {
|
||||||
|
send({ type: "error", errorText: spec.onError?.(errorMessage) ?? errorMessage });
|
||||||
|
}
|
||||||
|
send({ type: "finish", finishReason: finishReasonOf(finishReason) });
|
||||||
|
closeAll();
|
||||||
|
settleWith();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
agent.subscribe(handle);
|
||||||
|
|
||||||
|
void (async () => {
|
||||||
|
try {
|
||||||
|
await agent.continue();
|
||||||
|
} catch (err) {
|
||||||
|
// A loop-level failure (not a provider error — those ride the
|
||||||
|
// stream as stopReason "error"): surface, persist, close.
|
||||||
|
send({ type: "error", errorText: spec.onError?.(err) ?? "An error occurred." });
|
||||||
|
send({ type: "finish", finishReason: "error" });
|
||||||
|
closeAll();
|
||||||
|
settleWith();
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
||||||
|
return {
|
||||||
|
subscribe() {
|
||||||
|
let sub: PiTurnSubscriber | undefined;
|
||||||
|
const stream = new ReadableStream<Uint8Array>({
|
||||||
|
start(controller) {
|
||||||
|
const encoder = new TextEncoder();
|
||||||
|
let closed = false;
|
||||||
|
sub = {
|
||||||
|
push(chunk) {
|
||||||
|
if (closed) return;
|
||||||
|
try {
|
||||||
|
controller.enqueue(encoder.encode(`data: ${JSON.stringify(chunk)}\n\n`));
|
||||||
|
} catch {
|
||||||
|
/* reader cancelled — stop emitting, keep running */
|
||||||
|
closed = true;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
done() {
|
||||||
|
if (closed) return;
|
||||||
|
closed = true;
|
||||||
|
try {
|
||||||
|
controller.enqueue(encoder.encode("data: [DONE]\n\n"));
|
||||||
|
controller.close();
|
||||||
|
} catch {
|
||||||
|
/* already cancelled */
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
// Attach THEN replay — synchronous, so no chunk can fall
|
||||||
|
// between replay and live (single-threaded: the agent's
|
||||||
|
// handle() only runs after this stack returns).
|
||||||
|
subscribers.add(sub);
|
||||||
|
for (const chunk of log) sub.push(chunk);
|
||||||
|
// A subscriber that attached after settle (the unregister
|
||||||
|
// race) still gets the full history + [DONE].
|
||||||
|
if (settled) sub.done();
|
||||||
|
},
|
||||||
|
cancel() {
|
||||||
|
// THE OWNER RULING (2026-09-25): a view leaving NEVER kills
|
||||||
|
// the turn — only the explicit abort channel does. The turn
|
||||||
|
// runs on; the log keeps everything for the next view.
|
||||||
|
if (sub) subscribers.delete(sub);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return stream;
|
||||||
},
|
},
|
||||||
cancel() {
|
abort() {
|
||||||
// The client went away (reload/abort): kill the model call. The
|
if (settled) return;
|
||||||
// run still settles — agent_end fires and onFinish persists.
|
sawAbort = true;
|
||||||
agent.abort();
|
agent.abort();
|
||||||
},
|
},
|
||||||
});
|
};
|
||||||
|
}
|
||||||
|
|
||||||
return new Response(stream, {
|
/** The subscriber-shaped SSE response — the POST /api/chat response and
|
||||||
|
* GET /api/chats/[id]/stream are BOTH this: same headers, same wire
|
||||||
|
* dialect, one protocol for the client to consume. */
|
||||||
|
export function piTurnResponse(handle: PiTurnHandle): Response {
|
||||||
|
return new Response(handle.subscribe(), {
|
||||||
headers: {
|
headers: {
|
||||||
"Content-Type": "text/event-stream",
|
"Content-Type": "text/event-stream",
|
||||||
"Cache-Control": "no-cache, no-transform",
|
"Cache-Control": "no-cache, no-transform",
|
||||||
|
|||||||
@@ -0,0 +1,81 @@
|
|||||||
|
/**
|
||||||
|
* The running-turn registry (background-turn runner wave, owner ruling
|
||||||
|
* 2026-09-25): chatId → the live PiTurnHandle. THE reattach surface —
|
||||||
|
* GET /api/chats/[id]/stream resolves here, and the remote abort
|
||||||
|
* (/api/activity/abort, kind "turn") kills through here.
|
||||||
|
*
|
||||||
|
* Same globalThis discipline as turn-liveness.ts (read its header): Next
|
||||||
|
* dev compiles each route as its own module instance, so a module-level
|
||||||
|
* Map would be a DIFFERENT registry in the POST /api/chat entry and the
|
||||||
|
* GET .../stream entry. globalThis spans module instances within the
|
||||||
|
* process.
|
||||||
|
*
|
||||||
|
* Registration lifecycle: chat-handler registers the handle the moment
|
||||||
|
* the turn starts (after the 409 guard) and its onFinish unregisters LAST
|
||||||
|
* — after persist, usage, extraction, and endTurn — so anything that sees
|
||||||
|
* "no running turn" also sees an already-persisted record. The per-chat
|
||||||
|
* 409 guard means a chatId can hold at most one handle, and beginTurn's
|
||||||
|
* 30-minute TTL bounds the pathological never-settling case the same way
|
||||||
|
* it does for the liveness map: if onFinish never fires, the stale entry
|
||||||
|
* is overwritten by the next turn's registration (Map.set) and garbage
|
||||||
|
* collected — no unbounded leak beyond the same pathology turn-liveness
|
||||||
|
* already accepts.
|
||||||
|
*
|
||||||
|
* App-quit mid-turn (the process dying with an un-persisted turn) is the
|
||||||
|
* SAME residual as before the wave: the registry is memory-only, the
|
||||||
|
* write-ahead record + liveness TTL recovery cover the next mount.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { PiTurnHandle } from "./pi/ui-stream.ts";
|
||||||
|
|
||||||
|
interface LhTurnRunsGlobal {
|
||||||
|
__lhTurnRuns?: Map<string, PiTurnHandle>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function runs(): Map<string, PiTurnHandle> {
|
||||||
|
const g = globalThis as typeof globalThis & LhTurnRunsGlobal;
|
||||||
|
return (g.__lhTurnRuns ??= new Map<string, PiTurnHandle>());
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Register the chat's running turn (chat-handler, right after the turn
|
||||||
|
* starts). One per chat by construction — the 409 guard. */
|
||||||
|
export function registerTurn(chatId: string, handle: PiTurnHandle): void {
|
||||||
|
runs().set(chatId, handle);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Unregister at settle — chat-handler's onFinish, LAST (after endTurn). */
|
||||||
|
export function unregisterTurn(chatId: string): void {
|
||||||
|
runs().delete(chatId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The live turn handle for a chat, or undefined — the subscribe endpoint's
|
||||||
|
* and the abort channel's resolution point. */
|
||||||
|
export function runningTurn(chatId: string): PiTurnHandle | undefined {
|
||||||
|
return runs().get(chatId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The remote kill: any client (original sender, reattached viewer, future
|
||||||
|
* surfaces) may stop a running turn. not_running mirrors abortJob's
|
||||||
|
* honest 409 — there was nothing to abort, the caller refetches. */
|
||||||
|
export function abortTurn(chatId: string): { ok: true } | { ok: false; reason: "not_running" } {
|
||||||
|
const handle = runs().get(chatId);
|
||||||
|
if (!handle) return { ok: false, reason: "not_running" };
|
||||||
|
handle.abort();
|
||||||
|
return { ok: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The cheap terminal when GET /api/chats/[id]/stream finds no running
|
||||||
|
* turn (raced settle, or the client checked turnInFlight a beat too
|
||||||
|
* late): a same-dialect SSE response whose single chunk tells the client
|
||||||
|
* "nothing to attach to — the record is current, refetch it". Chosen as
|
||||||
|
* an EVENT (not a status code) so the reattach path consumes ONE wire
|
||||||
|
* protocol end to end. */
|
||||||
|
export function settledTurnResponse(): Response {
|
||||||
|
return new Response('data: {"type":"turn-settled"}\n\ndata: [DONE]\n\n', {
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "text/event-stream",
|
||||||
|
"Cache-Control": "no-cache, no-transform",
|
||||||
|
"X-Accel-Buffering": "no",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
+179
-8
@@ -27,6 +27,7 @@ import { createTeacherAgent } from "../src/agent/teacher-agent.ts";
|
|||||||
import { handleChatRequest } from "../src/server/chat-handler.ts";
|
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 { recordToPiMessages } from "../src/server/pi/record-adapter.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";
|
||||||
@@ -96,6 +97,46 @@ async function drain(res: Response): Promise<string> {
|
|||||||
return res.text(); // consumes the SSE stream to completion (fires onFinish)
|
return res.text(); // consumes the SSE stream to completion (fires onFinish)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function waitFor(cond: () => boolean, timeoutMs = 8000, what = "condition"): Promise<void> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const started = Date.now();
|
||||||
|
const tick = () => {
|
||||||
|
if (cond()) return resolve();
|
||||||
|
if (Date.now() - started > timeoutMs) return reject(new Error(`timeout waiting for ${what}`));
|
||||||
|
setTimeout(tick, 25);
|
||||||
|
};
|
||||||
|
tick();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A teacher whose step 1 blocks on an external gate — a turn held mid-run
|
||||||
|
* so view-cancel / reattach / abort timing is deterministic. */
|
||||||
|
function gatedTeacher(gate: Promise<void>): PiEngine {
|
||||||
|
const faux = createFauxCore({});
|
||||||
|
faux.setResponses([
|
||||||
|
async () => {
|
||||||
|
await gate;
|
||||||
|
return fauxAssistantMessage([fauxText("Gated reply.")]);
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
return { model: faux.getModel(), streamFn: faux.streamSimple };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A teacher that NEVER resolves except on abort (the jobs abortableFactory
|
||||||
|
* pattern): the only way this turn can end is the remote kill. */
|
||||||
|
function abortableTeacher(): PiEngine {
|
||||||
|
const faux = createFauxCore({});
|
||||||
|
faux.setResponses([
|
||||||
|
((_ctx: unknown, opts: { signal?: AbortSignal } | undefined) =>
|
||||||
|
new Promise((resolve) => {
|
||||||
|
const sig = opts?.signal;
|
||||||
|
if (sig?.aborted) return resolve(fauxAssistantMessage("late"));
|
||||||
|
sig?.addEventListener("abort", () => resolve(fauxAssistantMessage("late")), { once: true });
|
||||||
|
})) as unknown as FauxResponseStep,
|
||||||
|
]);
|
||||||
|
return { model: faux.getModel(), streamFn: faux.streamSimple };
|
||||||
|
}
|
||||||
|
|
||||||
function post(chatId: string, messages: unknown[]): Request {
|
function post(chatId: string, messages: unknown[]): Request {
|
||||||
return new Request("http://test/api/chat", {
|
return new Request("http://test/api/chat", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
@@ -227,22 +268,30 @@ describe("teacher chat flow (scripted model)", () => {
|
|||||||
assert.ok(startIdx < availIdx, "start precedes available");
|
assert.ok(startIdx < availIdx, "start precedes available");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("persists even when the stream is aborted (resume keeps partial state)", async () => {
|
it("a cancelled view NEVER aborts the turn — it runs to completion (owner ruling 2026-09-25)", async () => {
|
||||||
const { engine } = scriptedTeacher(defaultLesson);
|
let release!: () => void;
|
||||||
const chat = createConfiguredChat("Test: abort");
|
const gate = new Promise<void>((r) => (release = r));
|
||||||
|
const engine = gatedTeacher(gate);
|
||||||
|
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: "quiz me" }] }]);
|
const req = post(chat.id, [{ id: "u1", role: "user", parts: [{ type: "text", text: "hold my turn" }] }]);
|
||||||
const res = await handleChatRequest(req, agentFactory(engine));
|
const res = await handleChatRequest(req, agentFactory(engine));
|
||||||
assert.equal(res.status, 200);
|
assert.equal(res.status, 200);
|
||||||
// Read one chunk then cancel — simulates the browser closing mid-stream.
|
// Read one chunk then cancel — the browser view leaves mid-stream.
|
||||||
const reader = res.body!.getReader();
|
const reader = res.body!.getReader();
|
||||||
await reader.read();
|
await reader.read();
|
||||||
await reader.cancel();
|
await reader.cancel();
|
||||||
// Give the aborted run a tick to settle (agent_end → onFinish).
|
// The turn is STILL a running background object — no view killed it.
|
||||||
await new Promise((r) => setTimeout(r, 200));
|
assert.equal(isTurnRunning(chat.id), true, "cancel detached the view only — the turn runs on");
|
||||||
|
assert.ok(runningTurn(chat.id), "the running-turn registry still holds the handle");
|
||||||
|
|
||||||
|
// It runs to FULL completion with nobody watching, then settles alone.
|
||||||
|
release();
|
||||||
|
await waitFor(() => !isTurnRunning(chat.id), 8000, "turn settle after view cancel");
|
||||||
const saved = chatStore.getChat(chat.id)!;
|
const saved = chatStore.getChat(chat.id)!;
|
||||||
assert.ok(saved.messages.length >= 1, "partial transcript persisted");
|
assert.equal(saved.messages.length, 2, "complete turn persisted — not a partial");
|
||||||
|
assert.equal(runningTurn(chat.id), undefined, "handle unregistered at settle");
|
||||||
});
|
});
|
||||||
|
|
||||||
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 () => {
|
||||||
@@ -670,3 +719,125 @@ describe("the key-presence turn gate (provider-key interlude, 2026-09-25)", () =
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("background-turn runner (owner ruling 2026-09-25: every turn is a server-side process, clients are views)", () => {
|
||||||
|
it("subscribe mid-turn: a second view gets the FULL replay, then live, same dialect", async () => {
|
||||||
|
let release!: () => void;
|
||||||
|
const gate = new Promise<void>((r) => (release = r));
|
||||||
|
const engine = gatedTeacher(gate);
|
||||||
|
const chat = createConfiguredChat("Test: reattach replay");
|
||||||
|
createdChats.push(chat.id);
|
||||||
|
|
||||||
|
const req = post(chat.id, [{ id: "u1", role: "user", parts: [{ type: "text", text: "hold my turn" }] }]);
|
||||||
|
const first = await handleChatRequest(req, agentFactory(engine));
|
||||||
|
assert.equal(first.status, 200);
|
||||||
|
// Consume the first view a little (the start chunk is out).
|
||||||
|
const reader = first.body!.getReader();
|
||||||
|
await reader.read();
|
||||||
|
|
||||||
|
// A SECOND view attaches mid-turn — a remount, another device.
|
||||||
|
const handle = runningTurn(chat.id)!;
|
||||||
|
assert.ok(handle, "the registry resolves the live turn");
|
||||||
|
const secondText = new Response(handle.subscribe()).text();
|
||||||
|
|
||||||
|
// Let the turn finish; both views close at settle.
|
||||||
|
release();
|
||||||
|
const text = await secondText;
|
||||||
|
assert.ok(text.startsWith('data: {"type":"start"'), "replay starts from the very first chunk (the start event)");
|
||||||
|
assert.ok(text.includes("Gated reply."), "replay carries the full streamed content");
|
||||||
|
assert.ok(text.endsWith("data: [DONE]\n\n"), "live tail ends with the SSE terminal, same dialect as POST");
|
||||||
|
// Wire honesty: a normal turn settles via agent_end with NO error chunk
|
||||||
|
// (the live smoke caught a close()-throw masked into the error path
|
||||||
|
// — unit asserts never looked; these pins would have).
|
||||||
|
assert.ok(!text.includes('"type":"error"'), "a clean turn never emits an error chunk");
|
||||||
|
assert.ok(text.includes('"finishReason":"stop"'), "the finish chunk carries the stop reason");
|
||||||
|
// And the first view also closes cleanly (no hang, no drop): read its
|
||||||
|
// remaining chunks through the reader we already hold.
|
||||||
|
const decoder = new TextDecoder();
|
||||||
|
let firstTail = "";
|
||||||
|
for (;;) {
|
||||||
|
const { done, value } = await reader.read();
|
||||||
|
if (done) break;
|
||||||
|
firstTail += decoder.decode(value, { stream: true });
|
||||||
|
}
|
||||||
|
assert.ok(firstTail.endsWith("data: [DONE]\n\n"), "first view closes cleanly at settle, unaffected by the reattach");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("remote abort: any client can stop a running turn; the partial persists; subscribers close cleanly", async () => {
|
||||||
|
const engine = abortableTeacher();
|
||||||
|
const chat = createConfiguredChat("Test: remote abort");
|
||||||
|
createdChats.push(chat.id);
|
||||||
|
|
||||||
|
const req = post(chat.id, [{ id: "u1", role: "user", parts: [{ type: "text", text: "wait forever" }] }]);
|
||||||
|
const res = await handleChatRequest(req, agentFactory(engine));
|
||||||
|
assert.equal(res.status, 200);
|
||||||
|
assert.ok(runningTurn(chat.id), "turn registered while in flight");
|
||||||
|
const viewText = res.text(); // the first view, reading to settle
|
||||||
|
|
||||||
|
// The remote kill from a DIFFERENT view (the dock's abort).
|
||||||
|
const outcome = abortTurn(chat.id);
|
||||||
|
assert.deepEqual(outcome, { ok: true }, "abort resolves ok on a live turn");
|
||||||
|
|
||||||
|
const text = await viewText;
|
||||||
|
assert.ok(text.endsWith("data: [DONE]\n\n"), "subscriber closes cleanly at the aborted settle");
|
||||||
|
|
||||||
|
await waitFor(() => !isTurnRunning(chat.id), 8000, "aborted turn settle");
|
||||||
|
const saved = chatStore.getChat(chat.id)!;
|
||||||
|
assert.ok(saved.messages.length >= 2, "the partial transcript persisted via the usual settle path");
|
||||||
|
assert.equal(runningTurn(chat.id), undefined, "handle unregistered after the aborted settle");
|
||||||
|
|
||||||
|
// Honest 409 semantics: there is nothing left to abort.
|
||||||
|
assert.deepEqual(abortTurn(chat.id), { ok: false, reason: "not_running" }, "second abort is not_running");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("subscribe after settle: the cheap terminal tells the client the record is current", async () => {
|
||||||
|
const { engine } = scriptedTeacher(defaultLesson);
|
||||||
|
const chat = createConfiguredChat("Test: settled terminal");
|
||||||
|
createdChats.push(chat.id);
|
||||||
|
|
||||||
|
// A quiet chat: no turn ever ran — the stream is the same terminal.
|
||||||
|
const term = settledTurnResponse();
|
||||||
|
assert.equal(await term.text(), 'data: {"type":"turn-settled"}\n\ndata: [DONE]\n\n', "terminal is one event + [DONE]");
|
||||||
|
assert.equal(runningTurn(chat.id), undefined);
|
||||||
|
|
||||||
|
// And after a settled turn, the registry is empty — the GET would
|
||||||
|
// 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(req, agentFactory(engine)));
|
||||||
|
assert.equal(runningTurn(chat.id), undefined, "no handle survives settle");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("concurrent two-chat reattach: two turns in flight, two views, both settle independently", async () => {
|
||||||
|
const releaseFns: Array<() => void> = [];
|
||||||
|
const chats = [1, 2].map((n) => {
|
||||||
|
const gate = new Promise<void>((r) => (releaseFns.push(r)));
|
||||||
|
const engine = gatedTeacher(gate);
|
||||||
|
const chat = createConfiguredChat(`Test: two-chat ${n}`);
|
||||||
|
createdChats.push(chat.id);
|
||||||
|
return { n, engine, chat };
|
||||||
|
});
|
||||||
|
|
||||||
|
const views = new Map<string, Promise<string>>();
|
||||||
|
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(req, agentFactory(engine));
|
||||||
|
assert.equal(res.status, 200);
|
||||||
|
const handle = runningTurn(chat.id)!;
|
||||||
|
assert.ok(handle, `chat ${chat.id} has its own handle`);
|
||||||
|
views.set(chat.id, new Response(handle.subscribe()).text());
|
||||||
|
// The other chat's handle must not be this one (per-chat keying).
|
||||||
|
const others = chats.filter((c) => c.chat.id !== chat.id).map((c) => runningTurn(c.chat.id));
|
||||||
|
assert.ok(!others.includes(handle), "per-chat handles are distinct objects");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Release both; both settle independently.
|
||||||
|
for (const release of releaseFns) release();
|
||||||
|
for (const { chat } of chats) {
|
||||||
|
const text = await views.get(chat.id)!;
|
||||||
|
assert.ok(text.includes("Gated reply."), `chat ${chat.id} view replayed its own turn`);
|
||||||
|
assert.ok(text.endsWith("data: [DONE]\n\n"), `chat ${chat.id} view closed at settle`);
|
||||||
|
await waitFor(() => !isTurnRunning(chat.id), 8000, `chat ${chat.id} settle`);
|
||||||
|
assert.equal(chatStore.getChat(chat.id)!.messages.length, 2, `chat ${chat.id} persisted its full turn`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user