v2 web interface: fix delivery races, real URLs, optimistic echo, thinking stream, button-based quiz UI
Root causes of the v1 browser bugs (all fixed + covered by deterministic tests): - duplicated messages: per-socket snapshot + global broadcast marking raced; replaced with a single append-only entry log (exactly-once per socket, structurally) — covered by tests/server.test.ts - delayed/missing echo of own messages: optimistic append with sending/failed status, reconciled by text when the real entry arrives - no thinking shown: thinking deltas streamed live (kind: thinking) and persisted in assistant entries so they survive reloads - ask answers graded as wrong: AnsweredCard no longer applies quiz grading to ask answers (no ✓/✗, no verdict) - broken option selection: native radio/checkbox inputs fighting React state replaced with button-based options (single source of truth, one-click select) - no URLs: History API routing — / home, /s/<file> per session (deep links, refresh, back button); session view resumes its file on mount - lost connection = dead UI: exponential-backoff reconnect with full resync - stick-to-bottom transcript with jump-to-latest pill Server: startServer() with injectable runtime factory (scripted model in tests); createSession returns file for routing; scripted model gained thinking turns. 51 deterministic tests green.
This commit is contained in:
+234
-155
@@ -3,13 +3,22 @@
|
||||
* pi SDK (web dispatch mode: quiz/ask return immediately, answers arrive as
|
||||
* user messages — nothing blocks, so sessions can be exited/resumed mid-question).
|
||||
*
|
||||
* v1 scope (deliberately minimal):
|
||||
* - one active session at a time
|
||||
* - REST: list/create/resume sessions
|
||||
* - WS: entry stream (backfill + live), streaming deltas, pending question,
|
||||
* prompt / answer / dismiss / abort from the client
|
||||
* Delivery model (the part that must never double-send or drop):
|
||||
* - ONE append-only entry log per active session (`entryLog`), plus a cursor
|
||||
* (`syncedCount`) into the session manager's entries.
|
||||
* - Every entry enters the log exactly once — either at activation (seeded
|
||||
* from history, no broadcast) or live via syncEntries (broadcast to all
|
||||
* connected sockets).
|
||||
* - A socket connecting at ANY time receives the whole log as backfill.
|
||||
* There is no per-socket marking and no window in which an entry can be
|
||||
* both backfilled and broadcast — the duplication race from v1 is
|
||||
* structurally impossible now.
|
||||
*
|
||||
* NOT wired yet: extraction pipeline (post-session), graph queries.
|
||||
* - Streaming: thinking and text deltas are forwarded as they happen;
|
||||
* ephemeral (a socket that connects mid-turn gets `streaming: true` in its
|
||||
* snapshot and the entries when they land).
|
||||
*
|
||||
* v1 scope: one active session at a time; extraction pipeline NOT wired yet.
|
||||
*/
|
||||
|
||||
import fs from "node:fs";
|
||||
@@ -18,10 +27,10 @@ import Fastify from "fastify";
|
||||
import fastifyWebsocket from "@fastify/websocket";
|
||||
import fastifyStatic from "@fastify/static";
|
||||
import {
|
||||
AgentSessionRuntime,
|
||||
type AgentSessionEvent,
|
||||
type AgentSessionRuntimeDiagnostic,
|
||||
createAgentSessionRuntime,
|
||||
SessionManager,
|
||||
type AgentSessionEvent,
|
||||
} from "@earendil-works/pi-coding-agent";
|
||||
import { PATHS } from "../../config.ts";
|
||||
import { buildTeacherRuntimeFactory } from "../teacher/create-session.ts";
|
||||
@@ -36,25 +45,36 @@ import {
|
||||
import { getBridge } from "../../extensions/core/bridge.ts";
|
||||
import type { ClientEntry, ServerMessage, SessionSummary } from "../shared/protocol.ts";
|
||||
|
||||
const PORT = Number(process.env.LH_PORT ?? 8787);
|
||||
|
||||
// ─── active session state ───────────────────────────────────────────────────
|
||||
|
||||
interface ActiveSession {
|
||||
runtime: AgentSessionRuntime;
|
||||
sessionId: string;
|
||||
/** What the server needs from a teacher runtime (real or scripted). */
|
||||
export interface RuntimeLike {
|
||||
session: {
|
||||
subscribe(listener: (evt: AgentSessionEvent) => void): () => void;
|
||||
dispose(): void;
|
||||
isStreaming: boolean;
|
||||
prompt(text: string): Promise<void>;
|
||||
abort(): unknown;
|
||||
setSessionName(name: string): unknown;
|
||||
sessionManager: SessionManager;
|
||||
};
|
||||
diagnostics: readonly AgentSessionRuntimeDiagnostic[];
|
||||
}
|
||||
|
||||
let active: ActiveSession | null = null;
|
||||
const sockets = new Set<WebSocket>();
|
||||
export type RuntimeFactory = (options: {
|
||||
cwd: string;
|
||||
agentDir: string;
|
||||
sessionManager: SessionManager;
|
||||
}) => Promise<RuntimeLike>;
|
||||
|
||||
function broadcast(msg: ServerMessage): void {
|
||||
const raw = JSON.stringify(msg);
|
||||
for (const ws of sockets) {
|
||||
if (ws.readyState === ws.OPEN) ws.send(raw);
|
||||
}
|
||||
export interface ServerOptions {
|
||||
/** TCP port; 0 = pick a free port (tests). Default: LH_PORT or 8787. */
|
||||
port?: number;
|
||||
/** Teacher runtime factory — inject a scripted one in tests. */
|
||||
runtimeFactory?: RuntimeFactory;
|
||||
}
|
||||
|
||||
const defaultRuntimeFactory: RuntimeFactory = (options) =>
|
||||
createAgentSessionRuntime(buildTeacherRuntimeFactory("web"), options) as unknown as Promise<RuntimeLike>;
|
||||
|
||||
// ─── entry serialization ────────────────────────────────────────────────────
|
||||
|
||||
function extractText(content: unknown[]): string {
|
||||
@@ -64,6 +84,15 @@ function extractText(content: unknown[]): string {
|
||||
.join("");
|
||||
}
|
||||
|
||||
function extractThinking(content: unknown[]): string {
|
||||
return content
|
||||
.filter(
|
||||
(c): c is { type: "thinking"; thinking: string } => (c as { type?: string }).type === "thinking",
|
||||
)
|
||||
.map((c) => c.thinking)
|
||||
.join("\n");
|
||||
}
|
||||
|
||||
function serializeEntry(entry: {
|
||||
type: string;
|
||||
id: string;
|
||||
@@ -84,7 +113,14 @@ function serializeEntry(entry: {
|
||||
(c as { type?: string }).type === "toolCall",
|
||||
)
|
||||
.map((c) => ({ name: c.name, args: (c.arguments ?? {}) as Record<string, unknown> }));
|
||||
return { type: "assistant", id: entry.id, text: extractText(m.content ?? []), toolCalls };
|
||||
const thinking = extractThinking(m.content ?? []);
|
||||
return {
|
||||
type: "assistant",
|
||||
id: entry.id,
|
||||
text: extractText(m.content ?? []),
|
||||
thinking: thinking || undefined,
|
||||
toolCalls,
|
||||
};
|
||||
}
|
||||
if (m.role === "toolResult") {
|
||||
return { type: "toolResult", id: entry.id, toolName: m.toolName ?? "", text: extractText(m.content ?? []) };
|
||||
@@ -104,115 +140,6 @@ function serializeEntry(entry: {
|
||||
return { type: "meta", id: entry.id, kind: entry.type };
|
||||
}
|
||||
|
||||
function currentEntries(): Array<{ type: string; customType?: string; data?: unknown }> {
|
||||
if (!active) return [];
|
||||
return active.runtime.session.sessionManager.getEntries();
|
||||
}
|
||||
|
||||
function broadcastPending(): void {
|
||||
broadcast({ type: "pending", question: findPendingQuestion(currentEntries()) });
|
||||
}
|
||||
|
||||
// ─── session lifecycle ──────────────────────────────────────────────────────
|
||||
|
||||
// ─── entry synchronization ────────────────────────────────────────────────────────────
|
||||
// `entry_appended` in the session event stream only fires for EXTENSION-appended
|
||||
// custom entries — NOT for messages pi appends itself (user prompts, assistant
|
||||
// messages, tool results). So instead of relying on it, we diff the session
|
||||
// manager's entries against what we have already broadcast, on every session
|
||||
// event. Sessions are small; this is cheap and can not miss anything.
|
||||
|
||||
const broadcastEntryIds = new Set<string>();
|
||||
|
||||
function syncEntries(): void {
|
||||
if (!active) return;
|
||||
const entries = active.runtime.session.sessionManager.getEntries();
|
||||
for (const entry of entries) {
|
||||
if (broadcastEntryIds.has(entry.id)) continue;
|
||||
broadcastEntryIds.add(entry.id);
|
||||
const client = serializeEntry(entry as never);
|
||||
if (client) broadcast({ type: "entry", entry: client });
|
||||
if (
|
||||
entry.type === "custom" &&
|
||||
(entry.customType === QUESTION_ENTRY || entry.customType === QUESTION_ANSWERED_ENTRY)
|
||||
) {
|
||||
broadcastPending();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function handleSessionEvent(evt: AgentSessionEvent): void {
|
||||
if (!active) return;
|
||||
switch (evt.type) {
|
||||
case "message_update": {
|
||||
if (evt.assistantMessageEvent?.type === "text_delta") {
|
||||
broadcast({ type: "delta", delta: evt.assistantMessageEvent.delta });
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "agent_start":
|
||||
broadcast({ type: "streaming", value: true });
|
||||
break;
|
||||
case "agent_settled":
|
||||
syncEntries();
|
||||
broadcast({ type: "streaming", value: false });
|
||||
break;
|
||||
}
|
||||
// Catch every appended entry (messages + custom) regardless of event type.
|
||||
syncEntries();
|
||||
}
|
||||
|
||||
async function activate(sessionManager: SessionManager): Promise<ActiveSession> {
|
||||
if (active) {
|
||||
active.runtime.session.dispose();
|
||||
active = null;
|
||||
}
|
||||
const createRuntime = buildTeacherRuntimeFactory("web");
|
||||
const runtime = await createAgentSessionRuntime(createRuntime, {
|
||||
cwd: PATHS.root,
|
||||
agentDir: PATHS.agentDir,
|
||||
sessionManager,
|
||||
});
|
||||
for (const d of runtime.diagnostics) {
|
||||
if (d.type === "error") console.error(`[teacher] ${d.message}`);
|
||||
}
|
||||
runtime.session.subscribe(handleSessionEvent);
|
||||
active = { runtime, sessionId: sessionManager.getSessionId() };
|
||||
broadcastEntryIds.clear();
|
||||
// Notify every client of the switch + backfill.
|
||||
const rawEntries = currentEntries();
|
||||
const entries = rawEntries
|
||||
.map((e) => serializeEntry(e as never))
|
||||
.filter((e): e is ClientEntry => e !== null);
|
||||
// Everything backfilled here is marked as already-broadcast so a later
|
||||
// syncEntries() does not re-send the session history as live entries.
|
||||
for (const e of rawEntries) broadcastEntryIds.add((e as unknown as { id: string }).id);
|
||||
broadcast({ type: "hello", sessionId: active.sessionId });
|
||||
broadcast({ type: "backfill", entries });
|
||||
broadcastPending();
|
||||
broadcast({ type: "streaming", value: runtime.session.isStreaming });
|
||||
return active;
|
||||
}
|
||||
|
||||
async function createSession(topic: string): Promise<ActiveSession> {
|
||||
const sessionManager = SessionManager.create(PATHS.root, PATHS.sessions);
|
||||
const result = await activate(sessionManager);
|
||||
// Name the session after the topic (shown in the session list).
|
||||
const name = topic.trim().slice(0, 60) || "New session";
|
||||
result.runtime.session.setSessionName(name);
|
||||
// Fire the first prompt (don't await — the client follows along via WS).
|
||||
void result.runtime.session.prompt(topic).catch((err) => {
|
||||
console.error("[teacher] prompt failed:", err);
|
||||
broadcast({ type: "error", message: String(err) });
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
async function resumeSession(file: string): Promise<ActiveSession> {
|
||||
const sessionManager = SessionManager.open(path.join(PATHS.sessions, file), PATHS.sessions, PATHS.root);
|
||||
return activate(sessionManager);
|
||||
}
|
||||
|
||||
// ─── session listing (reads data/sessions/*.jsonl) ──────────────────────────
|
||||
|
||||
function listSessions(): SessionSummary[] {
|
||||
@@ -263,7 +190,141 @@ function listSessions(): SessionSummary[] {
|
||||
|
||||
// ─── server ─────────────────────────────────────────────────────────────────
|
||||
|
||||
async function main(): Promise<void> {
|
||||
export async function startServer(options: ServerOptions = {}): Promise<{
|
||||
port: number;
|
||||
close: () => Promise<void>;
|
||||
}> {
|
||||
const runtimeFactory = options.runtimeFactory ?? defaultRuntimeFactory;
|
||||
|
||||
// Active session state. `entryLog` is the single source of truth for what
|
||||
// clients have been able to see; `syncedCount` is how many session entries
|
||||
// it reflects. Both reset together on activation.
|
||||
interface ActiveSession {
|
||||
runtime: RuntimeLike;
|
||||
sessionId: string;
|
||||
file: string;
|
||||
}
|
||||
let active: ActiveSession | null = null;
|
||||
let unsubscribeEvents: (() => void) | null = null;
|
||||
let entryLog: ClientEntry[] = [];
|
||||
let syncedCount = 0;
|
||||
|
||||
const sockets = new Set<WebSocket>();
|
||||
|
||||
function broadcast(msg: ServerMessage): void {
|
||||
const raw = JSON.stringify(msg);
|
||||
for (const ws of sockets) {
|
||||
if (ws.readyState === ws.OPEN) ws.send(raw);
|
||||
}
|
||||
}
|
||||
|
||||
function broadcastPending(): void {
|
||||
if (!active) return;
|
||||
const pending = findPendingQuestion(active.runtime.session.sessionManager.getEntries());
|
||||
broadcast({ type: "pending", question: pending });
|
||||
}
|
||||
|
||||
/** Append every not-yet-logged session entry to the log (broadcasting each). */
|
||||
function syncEntries(): void {
|
||||
if (!active) return;
|
||||
const entries = active.runtime.session.sessionManager.getEntries();
|
||||
for (; syncedCount < entries.length; syncedCount++) {
|
||||
const entry = entries[syncedCount]!;
|
||||
const client = serializeEntry(entry as never);
|
||||
if (client) {
|
||||
entryLog.push(client);
|
||||
broadcast({ type: "entry", entry: client });
|
||||
}
|
||||
if (
|
||||
entry.type === "custom" &&
|
||||
((entry as { customType?: string }).customType === QUESTION_ENTRY ||
|
||||
(entry as { customType?: string }).customType === QUESTION_ANSWERED_ENTRY)
|
||||
) {
|
||||
broadcastPending();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function handleSessionEvent(evt: AgentSessionEvent): void {
|
||||
if (!active) return;
|
||||
switch (evt.type) {
|
||||
case "message_update": {
|
||||
const e = evt.assistantMessageEvent;
|
||||
if (e?.type === "text_delta") {
|
||||
broadcast({ type: "delta", kind: "text", delta: e.delta });
|
||||
} else if (e?.type === "thinking_delta") {
|
||||
broadcast({ type: "delta", kind: "thinking", delta: e.delta });
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "agent_start":
|
||||
broadcast({ type: "streaming", value: true });
|
||||
break;
|
||||
case "agent_settled":
|
||||
broadcast({ type: "streaming", value: false });
|
||||
break;
|
||||
}
|
||||
syncEntries();
|
||||
}
|
||||
|
||||
async function activate(sessionManager: SessionManager): Promise<ActiveSession> {
|
||||
if (unsubscribeEvents) {
|
||||
unsubscribeEvents();
|
||||
unsubscribeEvents = null;
|
||||
}
|
||||
if (active) {
|
||||
active.runtime.session.dispose();
|
||||
active = null;
|
||||
}
|
||||
const runtime = await runtimeFactory({
|
||||
cwd: PATHS.root,
|
||||
agentDir: PATHS.agentDir,
|
||||
sessionManager,
|
||||
});
|
||||
for (const d of runtime.diagnostics) {
|
||||
if (d.type === "error") console.error(`[teacher] ${d.message}`);
|
||||
}
|
||||
unsubscribeEvents = runtime.session.subscribe(handleSessionEvent);
|
||||
const file = path.basename(sessionManager.getSessionFile() ?? "");
|
||||
active = { runtime, sessionId: sessionManager.getSessionId(), file };
|
||||
|
||||
// Seed the log from history WITHOUT broadcasting: sockets that connect
|
||||
// later get it via their connect-backfill; sockets already connected get
|
||||
// it via the hello+backfill below. Either way, exactly once.
|
||||
entryLog = sessionManager
|
||||
.getEntries()
|
||||
.map((e) => serializeEntry(e as never))
|
||||
.filter((e): e is ClientEntry => e !== null);
|
||||
syncedCount = sessionManager.getEntries().length;
|
||||
|
||||
broadcast({ type: "hello", sessionId: active.sessionId });
|
||||
broadcast({ type: "backfill", entries: entryLog });
|
||||
broadcastPending();
|
||||
broadcast({ type: "streaming", value: runtime.session.isStreaming });
|
||||
return active;
|
||||
}
|
||||
|
||||
async function createSession(topic: string): Promise<ActiveSession> {
|
||||
const sessionManager = SessionManager.create(PATHS.root, PATHS.sessions);
|
||||
const result = await activate(sessionManager);
|
||||
// Name the session after the topic (shown in the session list).
|
||||
const name = topic.trim().slice(0, 60) || "New session";
|
||||
result.runtime.session.setSessionName(name);
|
||||
// Fire the first prompt (don't await — clients follow along via WS).
|
||||
void result.runtime.session.prompt(topic).catch((err) => {
|
||||
console.error("[teacher] prompt failed:", err);
|
||||
broadcast({ type: "error", message: String(err) });
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
async function resumeSession(file: string): Promise<ActiveSession> {
|
||||
const sessionManager = SessionManager.open(path.join(PATHS.sessions, file), PATHS.sessions, PATHS.root);
|
||||
return activate(sessionManager);
|
||||
}
|
||||
|
||||
// ─── HTTP ────────────────────────────────────────────────────────────────
|
||||
|
||||
const app = Fastify({ logger: false });
|
||||
await app.register(fastifyWebsocket);
|
||||
|
||||
@@ -289,7 +350,7 @@ async function main(): Promise<void> {
|
||||
const topic = (req.body?.topic ?? "").trim();
|
||||
if (!topic) return reply.code(400).send({ error: "topic is required" });
|
||||
const result = await createSession(topic);
|
||||
return { id: result.sessionId };
|
||||
return { id: result.sessionId, file: result.file };
|
||||
});
|
||||
|
||||
app.post<{ Params: { file: string } }>("/api/sessions/:file/resume", async (req, reply) => {
|
||||
@@ -300,34 +361,31 @@ async function main(): Promise<void> {
|
||||
}
|
||||
try {
|
||||
const result = await resumeSession(file);
|
||||
return { id: result.sessionId };
|
||||
return { id: result.sessionId, file: result.file };
|
||||
} catch (err) {
|
||||
return reply.code(500).send({ error: String(err) });
|
||||
}
|
||||
});
|
||||
|
||||
// ─── WebSocket ───────────────────────────────────────────────────────────
|
||||
|
||||
app.get("/ws", { websocket: true }, (socket) => {
|
||||
sockets.add(socket);
|
||||
socket.on("close", () => sockets.delete(socket));
|
||||
|
||||
// Initial state snapshot.
|
||||
socket.send(
|
||||
JSON.stringify({ type: "hello", sessionId: active?.sessionId ?? null } satisfies ServerMessage),
|
||||
);
|
||||
// Initial snapshot: everything the session has produced so far.
|
||||
socket.send(JSON.stringify({ type: "hello", sessionId: active?.sessionId ?? null } satisfies ServerMessage));
|
||||
socket.send(JSON.stringify({ type: "backfill", entries: entryLog } satisfies ServerMessage));
|
||||
if (active) {
|
||||
const entries = currentEntries()
|
||||
.map((e) => serializeEntry(e as never))
|
||||
.filter((e): e is ClientEntry => e !== null);
|
||||
socket.send(JSON.stringify({ type: "backfill", entries } satisfies ServerMessage));
|
||||
socket.send(
|
||||
JSON.stringify({ type: "pending", question: findPendingQuestion(currentEntries()) } satisfies ServerMessage),
|
||||
);
|
||||
socket.send(
|
||||
JSON.stringify({
|
||||
type: "streaming",
|
||||
value: active.runtime.session.isStreaming,
|
||||
type: "pending",
|
||||
question: findPendingQuestion(active.runtime.session.sessionManager.getEntries()),
|
||||
} satisfies ServerMessage),
|
||||
);
|
||||
socket.send(
|
||||
JSON.stringify({ type: "streaming", value: active.runtime.session.isStreaming } satisfies ServerMessage),
|
||||
);
|
||||
}
|
||||
|
||||
socket.on("message", (raw: Buffer) => {
|
||||
@@ -348,7 +406,7 @@ async function main(): Promise<void> {
|
||||
if (!text) return;
|
||||
// One question at a time: don't accept prompts while a question
|
||||
// is waiting to be answered.
|
||||
if (findPendingQuestion(currentEntries())) {
|
||||
if (findPendingQuestion(session.sessionManager.getEntries())) {
|
||||
socket.send(
|
||||
JSON.stringify({
|
||||
type: "error",
|
||||
@@ -395,11 +453,32 @@ async function main(): Promise<void> {
|
||||
});
|
||||
});
|
||||
|
||||
await app.listen({ port: PORT, host: "127.0.0.1" });
|
||||
console.log(`learning-harness web server: http://127.0.0.1:${PORT}`);
|
||||
const port = options.port ?? Number(process.env.LH_PORT ?? 8787);
|
||||
await app.listen({ port, host: "127.0.0.1" });
|
||||
const actualPort = (app.server.address() as { port: number }).port;
|
||||
|
||||
return {
|
||||
port: actualPort,
|
||||
close: async () => {
|
||||
if (unsubscribeEvents) unsubscribeEvents();
|
||||
if (active) active.runtime.session.dispose();
|
||||
for (const ws of sockets) ws.close();
|
||||
sockets.clear();
|
||||
await app.close();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error(err);
|
||||
process.exit(1);
|
||||
});
|
||||
// ─── entrypoint ─────────────────────────────────────────────────────────────
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const server = await startServer();
|
||||
console.log(`learning-harness web server: http://127.0.0.1:${server.port}`);
|
||||
}
|
||||
|
||||
if (process.argv[1] && process.argv[1].endsWith("server/index.ts")) {
|
||||
main().catch((err) => {
|
||||
console.error(err);
|
||||
process.exit(1);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -16,7 +16,7 @@ export interface ClientToolCall {
|
||||
|
||||
export type ClientEntry =
|
||||
| { type: "user"; id: string; text: string }
|
||||
| { type: "assistant"; id: string; text: string; toolCalls: ClientToolCall[] }
|
||||
| { type: "assistant"; id: string; text: string; thinking?: string; toolCalls: ClientToolCall[] }
|
||||
| { type: "toolResult"; id: string; toolName: string; text: string }
|
||||
| { type: "question"; id: string; data: ClientQuestion }
|
||||
| { type: "questionAnswered"; id: string; data: QuestionAnswerData }
|
||||
@@ -36,6 +36,7 @@ export interface SessionSummary {
|
||||
|
||||
export interface NewSessionResponse {
|
||||
id: string;
|
||||
file: string;
|
||||
}
|
||||
|
||||
// ─── WS protocol ────────────────────────────────────────────────────────────
|
||||
@@ -45,7 +46,7 @@ export type ServerMessage =
|
||||
| { type: "backfill"; entries: ClientEntry[] }
|
||||
| { type: "entry"; entry: ClientEntry }
|
||||
| { type: "pending"; question: ClientQuestion | null }
|
||||
| { type: "delta"; delta: string }
|
||||
| { type: "delta"; kind: "thinking" | "text"; delta: string }
|
||||
| { type: "streaming"; value: boolean }
|
||||
| { type: "error"; message: string };
|
||||
|
||||
|
||||
@@ -21,9 +21,10 @@ export interface ScriptedToolCall {
|
||||
arguments: Record<string, unknown>;
|
||||
}
|
||||
|
||||
/** One scripted assistant turn: tool calls and/or final text. */
|
||||
/** One scripted assistant turn: reasoning, tool calls, and/or final text. */
|
||||
export interface ScriptedTurn {
|
||||
text?: string;
|
||||
thinking?: string;
|
||||
toolCalls?: ScriptedToolCall[];
|
||||
}
|
||||
|
||||
@@ -140,6 +141,17 @@ export function createScriptedRuntime(script: ScriptedTurn[]): ScriptedRuntime {
|
||||
// "start"
|
||||
stream.push({ type: "start", partial: { ...msg, content: [] } });
|
||||
|
||||
// thinking (reasoning) — split into two deltas so accumulation is tested
|
||||
if (turn.thinking !== undefined && turn.thinking.length > 0) {
|
||||
content.push({ type: "thinking", thinking: turn.thinking });
|
||||
const idx = content.length - 1;
|
||||
stream.push({ type: "thinking_start", contentIndex: idx, partial: { ...msg, content: [...content] } });
|
||||
const mid = Math.ceil(turn.thinking.length / 2);
|
||||
stream.push({ type: "thinking_delta", contentIndex: idx, delta: turn.thinking.slice(0, mid), partial: { ...msg, content: [...content] } });
|
||||
stream.push({ type: "thinking_delta", contentIndex: idx, delta: turn.thinking.slice(mid), partial: { ...msg, content: [...content] } });
|
||||
stream.push({ type: "thinking_end", contentIndex: idx, content: turn.thinking, partial: { ...msg, content: [...content] } });
|
||||
}
|
||||
|
||||
// tool calls
|
||||
for (const tc of turn.toolCalls ?? []) {
|
||||
const toolCall = {
|
||||
|
||||
Reference in New Issue
Block a user