Wave D: native AgentEvent wire + client session rewrite (SDK purged)
Server: ui-stream.ts is verbatim pi AgentEvent passthrough (SSE data lines + [DONE]); only app-level events are the loop-level error and turn-settled terminal; onError carries raw provider text (owner ruling). Client: useChat + the AI-SDK transport replaced by components/session-core.ts (SessionModel: 409 identity-retry, optimistic c_ appends, absorbed() adoption, needsContinuation, shared consume for POST + reattach) and the session.ts hook; the golden-master grouping canon moved client-side into core/view.ts (projectView); scanners read the native record (phaseAt3Lh, danglingQuestionOf, graph toolParts, plan-blocks, runner, extraction). Coverage: the native message is the unit (fork 3) with the call+result inseparability bond rule; view-line-less messages (markers, error results) are invisible to coverage and never checkpointed. Deletes: chat-transport, projection, ui-message, their tests (ported to tests/session-core.test.ts), and the ai + @ai-sdk/react dependencies — the Vercel SDK is fully out of the tree. Wire pins in chat-flow rewritten onto the AgentEvent dialect (eventsOf parser). Gates: tsc clean, 994/994 tests, eslint clean, build green; live smoke on PORT=3004 (verbatim wire → native record).
This commit is contained in:
+78
-302
@@ -1,67 +1,66 @@
|
||||
import type { Agent, AgentEvent, AgentMessage } from "@earendil-works/pi-agent-core";
|
||||
import type { UIMessage } from "ai";
|
||||
import { QUESTION_TOOLS } from "../../core/pending.ts";
|
||||
import type { LhMessage } from "../../core/native-messages.ts";
|
||||
import { projectNative } from "../../core/projection.ts";
|
||||
|
||||
/**
|
||||
* The UI-stream bridge (docs/pi-engine-swap.md §5): runs one agent turn and
|
||||
* re-emits it as an AI-SDK-shaped UIMessageStream SSE response — the wire
|
||||
* protocol `useChat` (Band D, unchanged) parses:
|
||||
* THE NATIVE WIRE (Wave D, owner-approved 2026-09-26): one agent turn, streamed
|
||||
* to every view as VERBATIM pi AgentEvents — no translation, no remapping, no
|
||||
* part assembly. The SSE body is the agent's own event stream:
|
||||
*
|
||||
* data: {"type":"text-delta", …}\n\n … data: [DONE]
|
||||
* data: {"type":"agent_start"}\n\n
|
||||
* data: {"type":"message_update","message":{…},"assistantMessageEvent":{…}}\n\n
|
||||
* …
|
||||
* data: {"type":"agent_end","messages":[…]}\n\n
|
||||
* data: [DONE]\n\n
|
||||
*
|
||||
* Load-bearing protocol rules (verified against ai@7's client):
|
||||
* - The `start` chunk's messageId MUST be the transcript's last message id
|
||||
* when it is an assistant message (the continuation id rule) — a fresh id
|
||||
* there duplicates every prior part client-side.
|
||||
* - Question tools (quiz / ask_user_question): emit tool-input-available and
|
||||
* NEVER tool-output-available (the client throws on an output for a call it
|
||||
* owns). The synthetic "question posed" result lives in memory only; the
|
||||
* record's part stays output-less until the learner answers.
|
||||
* - tool-input-available is emitted ONLY after the call cleared validation
|
||||
* at the boundary (clean tool_execution_end) — the SDK's
|
||||
* InvalidToolInputError gating. An invalid call surfaces as
|
||||
* tool-input-error instead and NEVER becomes input-available, so a
|
||||
* malformed question can never mount the dock (the live modal-realism
|
||||
* crash: GLM double-encoded `options` as a JSON string).
|
||||
* - The WIRE assembly (below) is the client view only (Wave B): the
|
||||
* record write is the agent's own native final state — the projection
|
||||
* (core/projection.ts) reproduces the client's part shapes for every
|
||||
* consumer (phases, plan blocks, pending, extraction) until Wave D.
|
||||
* Load-bearing protocol facts (verified against pi-agent-core):
|
||||
* - Every `message_update` carries the FULL partial assistant message (each
|
||||
* AssistantMessageEvent ships `partial`) — a view REPLACES its streaming
|
||||
* assistant with `event.message`; no delta assembly exists anywhere.
|
||||
* - Question tools (quiz / ask_user_question): NO suppression (owner ruling,
|
||||
* Wave D fork 2) — the closing `tool_execution_end` with the in-memory
|
||||
* "Question posed" placeholder flows to the client, which ignores it
|
||||
* (question tools are client-owned; the answer arrives as the input event).
|
||||
* The placeholder still never persists and never reaches the model.
|
||||
* - Tool-call validation gating survives by construction: `tool_execution_end`
|
||||
* only fires after the call cleared the boundary (an invalid call arrives
|
||||
* as isError — never a clean end), so the dock's mount rule keys on it.
|
||||
* - App-level events beyond pi's taxonomy: the reattach terminal
|
||||
* {"type":"turn-settled"} (served by turn-runs when no turn is running) and
|
||||
* the loop-level {"type":"error","errorText"} (agent.continue() threw
|
||||
* outside the event stream — mapped through the handler's masking policy).
|
||||
* - Stream-level provider errors ride their message verbatim
|
||||
* (message_end with stopReason "error" + errorMessage).
|
||||
*
|
||||
* 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.
|
||||
* 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. */
|
||||
type Chunk = Record<string, unknown> & { type: string };
|
||||
/** The wire event: a verbatim pi AgentEvent, or one of the two app events. */
|
||||
export type TurnWireEvent =
|
||||
| AgentEvent
|
||||
| { type: "error"; errorText: string };
|
||||
|
||||
export interface PiTurnSpec {
|
||||
/** The per-turn agent (stateless engine — built from the record). */
|
||||
agent: Agent;
|
||||
/** The turn's base: the native record + this turn's appends
|
||||
* (write-ahead persisted already). Wave B: the agent's context and
|
||||
* the record share one shape; onFinish receives the full agent state
|
||||
* and the caller persists the slice past base.length. */
|
||||
base: LhMessage[];
|
||||
/** The handler's error-unmasking policy (validation messages stay readable). */
|
||||
/** The handler's error-masking policy for LOOP-level failures (the
|
||||
* event stream itself is verbatim — provider errors ride their
|
||||
* message's stopReason/errorMessage through untouched). */
|
||||
onError?: (error: unknown) => string;
|
||||
/** The canonical end-of-turn seam: persist + usage + extraction + endTurn.
|
||||
* Receives the agent's FULL final state (native messages) — the turn's
|
||||
* new messages are the slice past base.length. */
|
||||
* Receives the agent's FULL final state (native messages) — the caller
|
||||
* persists the slice past its own base count. */
|
||||
onFinish: (messages: AgentMessage[], isAborted: boolean) => void;
|
||||
}
|
||||
|
||||
/** One view onto a running turn: push() replays/streams a chunk, done()
|
||||
/** One view onto a running turn: push() replays/streams an event, done()
|
||||
* terminates the view ([DONE] + close). Cheap to be a Set member. */
|
||||
export interface PiTurnSubscriber {
|
||||
push(chunk: Chunk): void;
|
||||
push(event: TurnWireEvent): void;
|
||||
done(): void;
|
||||
}
|
||||
|
||||
@@ -75,109 +74,35 @@ export interface PiTurnHandle {
|
||||
* 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). */
|
||||
* agent.abort(); the loop's agent_end does the rest (the settle seam
|
||||
* persists the partial transcript to every listener). */
|
||||
abort(): void;
|
||||
}
|
||||
|
||||
/** Map a pi stop reason to the finish chunk's enum. */
|
||||
function finishReasonOf(stopReason: string | undefined): string {
|
||||
switch (stopReason) {
|
||||
case "stop":
|
||||
return "stop";
|
||||
case "length":
|
||||
return "length";
|
||||
case "toolUse":
|
||||
return "tool-calls";
|
||||
case "error":
|
||||
return "error";
|
||||
default:
|
||||
return "other";
|
||||
}
|
||||
}
|
||||
|
||||
function resultErrorText(result: unknown): string {
|
||||
const content = (result as { content?: Array<{ type: string; text?: string }> } | undefined)?.content;
|
||||
const text = content?.find((c) => c.type === "text")?.text;
|
||||
return text ?? "The tool call failed.";
|
||||
}
|
||||
|
||||
/** Loose part shapes the bridge assembles (the client's part shapes). */
|
||||
interface TextPart {
|
||||
type: "text";
|
||||
text: string;
|
||||
state: string;
|
||||
}
|
||||
interface ReasoningPart {
|
||||
type: "reasoning";
|
||||
id: string;
|
||||
text: string;
|
||||
state: string;
|
||||
}
|
||||
interface ToolPart {
|
||||
type: string;
|
||||
toolCallId: string;
|
||||
state: string;
|
||||
input?: unknown;
|
||||
rawInput?: unknown;
|
||||
output?: unknown;
|
||||
errorText?: string;
|
||||
}
|
||||
|
||||
export function runPiTurn(spec: PiTurnSpec): PiTurnHandle {
|
||||
const { agent, base, onFinish } = spec;
|
||||
|
||||
// The streaming target: continuation (same id, parts appended to a copy
|
||||
// of the existing message) or a fresh assistant message. The continuation
|
||||
// rule is the CLIENT's: it keys on the last UIMessage's id — read through
|
||||
// the projection (the record is native; a turn after an answer continues
|
||||
// the group that carries the answered quiz, which is the projection's
|
||||
// last message).
|
||||
const projected = projectNative(base);
|
||||
const last = projected[projected.length - 1] as
|
||||
| { id?: string; role?: string; parts?: UIMessage["parts"] }
|
||||
| undefined;
|
||||
const continuing = last?.role === "assistant" && typeof last.id === "string";
|
||||
const messageId =
|
||||
(continuing && last.id) || `msg_${Date.now().toString(36)}_${crypto.randomUUID().slice(0, 8)}`;
|
||||
const baseParts: UIMessage["parts"] = continuing && last.parts ? [...last.parts] : [];
|
||||
const parts: UIMessage["parts"] = [...baseParts];
|
||||
|
||||
// Stable, collision-free part ids across continuation runs (reasoning
|
||||
// parts persist their id; text ids only need per-run uniqueness).
|
||||
let textN = baseParts.filter((p) => p.type === "text").length;
|
||||
let reasoningN = baseParts.filter((p) => p.type === "reasoning").length;
|
||||
const textIds = new Map<number, string>(); // contentIndex → chunk id
|
||||
const reasoningIds = new Map<number, string>();
|
||||
const toolInputs = new Map<string, { toolName: string; input: unknown }>();
|
||||
/** The streaming text/reasoning parts of THIS run, keyed by chunk id. */
|
||||
const activeParts = new Map<string, TextPart | ReasoningPart>();
|
||||
/** Tool parts created this run, keyed by toolCallId. */
|
||||
const newToolParts = new Map<string, ToolPart>();
|
||||
const { agent, onError, onFinish } = spec;
|
||||
|
||||
let settled = false;
|
||||
let sawAbort = false;
|
||||
let finishReason: string | undefined;
|
||||
let errorMessage: string | undefined;
|
||||
|
||||
// THE PER-TURN EVENT LOG (memory-only — the settle-only disk writes
|
||||
// are unchanged): every emitted chunk, in order, so any late subscriber
|
||||
// gets a FULL replay. Unbounded BY HONEST DESIGN (owner ruling
|
||||
// 2026-09-25): a turn's chunk log is finite by construction — the agent
|
||||
// loop ends (or the 30-minute liveness TTL bounds the pathological
|
||||
// never-settling case), and concurrency is human-scale (one turn per
|
||||
// chat, a few chats). No cap, no truncation: replay must never
|
||||
// silently drop history. The log dies with the turn at settle.
|
||||
const log: Chunk[] = [];
|
||||
// are unchanged): every forwarded event, in order, so any late
|
||||
// subscriber gets a FULL replay. Unbounded BY HONEST DESIGN (owner
|
||||
// ruling 2026-09-25): a turn's event log is finite by construction —
|
||||
// the agent loop ends (or the 30-minute liveness TTL bounds the
|
||||
// pathological never-settling case), and concurrency is human-scale
|
||||
// (one turn per chat, a few chats). No cap, no truncation: replay must
|
||||
// never silently drop history. The log dies with the turn at settle.
|
||||
const log: TurnWireEvent[] = [];
|
||||
const subscribers = new Set<PiTurnSubscriber>();
|
||||
|
||||
// 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) => {
|
||||
const send = (event: TurnWireEvent) => {
|
||||
if (settled) return;
|
||||
log.push(chunk);
|
||||
for (const sub of subscribers) sub.push(chunk);
|
||||
log.push(event);
|
||||
for (const sub of subscribers) sub.push(event);
|
||||
};
|
||||
const closeAll = () => {
|
||||
if (settled) return;
|
||||
@@ -185,175 +110,23 @@ export function runPiTurn(spec: PiTurnSpec): PiTurnHandle {
|
||||
for (const sub of subscribers) sub.done();
|
||||
subscribers.clear();
|
||||
};
|
||||
|
||||
send({ type: "start", messageId });
|
||||
|
||||
// The settle seam: the record write is the agent's OWN final state
|
||||
// (native history — signatures, usage, provider stamps all real).
|
||||
// Copy the array: the agent may outlive this call's read.
|
||||
const settleWith = () => {
|
||||
// Wave B: the record write is the agent's OWN final state (native
|
||||
// history — signatures, usage, provider stamps all real); the wire
|
||||
// assembly above served the client view only. Copy the array: the
|
||||
// agent may outlive this call's read.
|
||||
onFinish([...agent.state.messages], sawAbort);
|
||||
};
|
||||
|
||||
const handle = (event: AgentEvent) => {
|
||||
switch (event.type) {
|
||||
case "turn_start":
|
||||
send({ type: "start-step" });
|
||||
parts.push({ type: "step-start" });
|
||||
break;
|
||||
case "message_update": {
|
||||
const streamEvent = event.assistantMessageEvent;
|
||||
switch (streamEvent.type) {
|
||||
case "text_start": {
|
||||
const id = `${messageId}-t${textN++}`;
|
||||
textIds.set(streamEvent.contentIndex, id);
|
||||
const part: TextPart = { type: "text", text: "", state: "streaming" };
|
||||
activeParts.set(id, part);
|
||||
parts.push(part as UIMessage["parts"][number]);
|
||||
send({ type: "text-start", id });
|
||||
break;
|
||||
}
|
||||
case "text_delta": {
|
||||
const id = textIds.get(streamEvent.contentIndex);
|
||||
const part = id !== undefined ? (activeParts.get(id) as TextPart | undefined) : undefined;
|
||||
if (part) part.text += streamEvent.delta;
|
||||
if (id !== undefined) send({ type: "text-delta", id, delta: streamEvent.delta });
|
||||
break;
|
||||
}
|
||||
case "text_end": {
|
||||
const id = textIds.get(streamEvent.contentIndex);
|
||||
const part = id !== undefined ? (activeParts.get(id) as TextPart | undefined) : undefined;
|
||||
if (part) part.state = "done";
|
||||
activeParts.delete(id ?? "");
|
||||
if (id !== undefined) send({ type: "text-end", id });
|
||||
break;
|
||||
}
|
||||
case "thinking_start": {
|
||||
const id = `${messageId}-r${reasoningN++}`;
|
||||
reasoningIds.set(streamEvent.contentIndex, id);
|
||||
const part: ReasoningPart = { type: "reasoning", id, text: "", state: "streaming" };
|
||||
activeParts.set(id, part);
|
||||
parts.push(part as UIMessage["parts"][number]);
|
||||
send({ type: "reasoning-start", id });
|
||||
break;
|
||||
}
|
||||
case "thinking_delta": {
|
||||
const id = reasoningIds.get(streamEvent.contentIndex);
|
||||
const part = id !== undefined ? (activeParts.get(id) as ReasoningPart | undefined) : undefined;
|
||||
if (part) part.text += streamEvent.delta;
|
||||
if (id !== undefined) send({ type: "reasoning-delta", id, delta: streamEvent.delta });
|
||||
break;
|
||||
}
|
||||
case "thinking_end": {
|
||||
const id = reasoningIds.get(streamEvent.contentIndex);
|
||||
const part = id !== undefined ? (activeParts.get(id) as ReasoningPart | undefined) : undefined;
|
||||
if (part) part.state = "done";
|
||||
activeParts.delete(id ?? "");
|
||||
if (id !== undefined) send({ type: "reasoning-end", id });
|
||||
break;
|
||||
}
|
||||
case "toolcall_start": {
|
||||
const block = (event.message as { content: any[] }).content[streamEvent.contentIndex];
|
||||
if (block?.type !== "toolCall") break;
|
||||
toolInputs.set(block.id, { toolName: block.name, input: undefined });
|
||||
const part: ToolPart = {
|
||||
type: `tool-${block.name}`,
|
||||
toolCallId: block.id,
|
||||
state: "input-streaming",
|
||||
input: undefined,
|
||||
};
|
||||
newToolParts.set(block.id, part);
|
||||
parts.push(part as unknown as UIMessage["parts"][number]);
|
||||
send({ type: "tool-input-start", toolCallId: block.id, toolName: block.name });
|
||||
break;
|
||||
}
|
||||
case "toolcall_delta": {
|
||||
const block = (event.message as { content: any[] }).content[streamEvent.contentIndex];
|
||||
if (block?.type !== "toolCall") break;
|
||||
send({
|
||||
type: "tool-input-delta",
|
||||
toolCallId: block.id,
|
||||
inputTextDelta: streamEvent.delta,
|
||||
});
|
||||
break;
|
||||
}
|
||||
case "toolcall_end": {
|
||||
const toolCall = streamEvent.toolCall;
|
||||
toolInputs.set(toolCall.id, { toolName: toolCall.name, input: toolCall.arguments });
|
||||
// Input stays input-streaming here: it only becomes available
|
||||
// once the call clears validation at the execution boundary
|
||||
// (tool_execution_end) — an invalid input must never reach
|
||||
// input-available, where the dock would mount on it.
|
||||
const part = newToolParts.get(toolCall.id);
|
||||
if (part) part.input = toolCall.arguments;
|
||||
break;
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "tool_execution_end": {
|
||||
const known = toolInputs.get(event.toolCallId);
|
||||
const toolName = event.toolName || known?.toolName || "";
|
||||
const input = known?.input;
|
||||
const part = newToolParts.get(event.toolCallId);
|
||||
if (event.isError) {
|
||||
const errorText = resultErrorText(event.result);
|
||||
if (part) {
|
||||
part.state = "output-error";
|
||||
part.rawInput = input;
|
||||
part.errorText = errorText;
|
||||
}
|
||||
send({
|
||||
type: "tool-input-error",
|
||||
toolCallId: event.toolCallId,
|
||||
toolName,
|
||||
input,
|
||||
errorText,
|
||||
});
|
||||
} else {
|
||||
// Validation cleared: only NOW does the input become available
|
||||
// to the client (the InvalidToolInputError gating the SDK owned).
|
||||
if (part) {
|
||||
part.state = "input-available";
|
||||
part.input = input;
|
||||
}
|
||||
send({ type: "tool-input-available", toolCallId: event.toolCallId, toolName, input });
|
||||
if ((QUESTION_TOOLS as readonly string[]).includes(toolName)) {
|
||||
// Client-owned: the answer arrives via addToolOutput;
|
||||
// the synthetic placeholder result never reaches wire
|
||||
// or record.
|
||||
break;
|
||||
}
|
||||
const output = (event.result as { details?: unknown } | undefined)?.details;
|
||||
if (part) {
|
||||
part.state = "output-available";
|
||||
part.output = output;
|
||||
}
|
||||
send({ type: "tool-output-available", toolCallId: event.toolCallId, output });
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "turn_end":
|
||||
send({ type: "finish-step" });
|
||||
break;
|
||||
case "message_end":
|
||||
if (event.message && (event.message as { role?: string }).role === "assistant") {
|
||||
const m = event.message as { stopReason?: string; errorMessage?: string };
|
||||
finishReason = m.stopReason;
|
||||
if (m.stopReason === "aborted") sawAbort = true;
|
||||
if (m.stopReason === "error") errorMessage = m.errorMessage;
|
||||
}
|
||||
break;
|
||||
case "agent_end": {
|
||||
if (errorMessage !== undefined) {
|
||||
send({ type: "error", errorText: spec.onError?.(errorMessage) ?? errorMessage });
|
||||
}
|
||||
send({ type: "finish", finishReason: finishReasonOf(finishReason) });
|
||||
closeAll();
|
||||
settleWith();
|
||||
break;
|
||||
}
|
||||
// VERBATIM: every pi AgentEvent forwards as-is — no remapping.
|
||||
send(event);
|
||||
if (event.type === "message_end") {
|
||||
const m = event.message as { role?: string; stopReason?: string } | undefined;
|
||||
if (m?.role === "assistant" && m.stopReason === "aborted") sawAbort = true;
|
||||
}
|
||||
if (event.type === "agent_end") {
|
||||
closeAll();
|
||||
settleWith();
|
||||
}
|
||||
};
|
||||
agent.subscribe(handle);
|
||||
@@ -363,9 +136,12 @@ export function runPiTurn(spec: PiTurnSpec): PiTurnHandle {
|
||||
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" });
|
||||
// stream as stopReason "error"): surface, close, persist. If the
|
||||
// agent already settled (agent_end reached before the throw),
|
||||
// the seam has run — never settle twice.
|
||||
if (settled) return;
|
||||
console.error("[turn] agent loop failed:", err);
|
||||
send({ type: "error", errorText: onError?.(err) ?? "An error occurred." });
|
||||
closeAll();
|
||||
settleWith();
|
||||
}
|
||||
@@ -379,10 +155,10 @@ export function runPiTurn(spec: PiTurnSpec): PiTurnHandle {
|
||||
const encoder = new TextEncoder();
|
||||
let closed = false;
|
||||
sub = {
|
||||
push(chunk) {
|
||||
push(event) {
|
||||
if (closed) return;
|
||||
try {
|
||||
controller.enqueue(encoder.encode(`data: ${JSON.stringify(chunk)}\n\n`));
|
||||
controller.enqueue(encoder.encode(`data: ${JSON.stringify(event)}\n\n`));
|
||||
} catch {
|
||||
/* reader cancelled — stop emitting, keep running */
|
||||
closed = true;
|
||||
@@ -399,11 +175,11 @@ export function runPiTurn(spec: PiTurnSpec): PiTurnHandle {
|
||||
}
|
||||
},
|
||||
};
|
||||
// Attach THEN replay — synchronous, so no chunk can fall
|
||||
// Attach THEN replay — synchronous, so no event can fall
|
||||
// between replay and live (single-threaded: the agent's
|
||||
// handle() only runs after this stack returns).
|
||||
subscribers.add(sub);
|
||||
for (const chunk of log) sub.push(chunk);
|
||||
for (const event of log) sub.push(event);
|
||||
// A subscriber that attached after settle (the unregister
|
||||
// race) still gets the full history + [DONE].
|
||||
if (settled) sub.done();
|
||||
@@ -426,8 +202,8 @@ export function runPiTurn(spec: PiTurnSpec): PiTurnHandle {
|
||||
}
|
||||
|
||||
/** 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. */
|
||||
* 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: {
|
||||
@@ -436,4 +212,4 @@ export function piTurnResponse(handle: PiTurnHandle): Response {
|
||||
"X-Accel-Buffering": "no",
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user