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:
Amos
2026-08-22 00:57:16 +03:00
parent 25063b2a3e
commit 296cc1b7b6
14 changed files with 1485 additions and 441 deletions
+234 -155
View File
@@ -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);
});
}
+3 -2
View File
@@ -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 };
+13 -1
View File
@@ -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 = {