Files
learnharness/extensions/core/bridge.ts
T
Amos 25063b2a3e v1 web interface: dispatch-mode quiz/ask, Fastify+WS server, React frontend, deterministic test suite
- extensions/core/question-core.ts: pure quiz/ask logic (normalize/shuffle/resolve/grade/format) shared by both interfaces
- extensions/core/bridge.ts: in-process server<->extension bridge (answer/dismiss/re-arm)
- extensions/quiz-web.ts + ask-web.ts: web dispatch mode — tool returns immediately, graded answer arrives as next user message; sessions resumable mid-question via lh_question/lh_question_answered custom entries (never in LLM context)
- extensions/quiz.ts: refactored onto shared core (terminal blocking mode unchanged)
- src/server/index.ts: Fastify + WebSocket; REST (list/create/resume), entry sync (diff-based, all message kinds), streaming deltas, pending-question push
- web/: Vite + React — home view (graph placeholder, session list, new-session input), session view (markdown+KaTeX+mermaid transcript, quiz cards, streaming)
- src/testing/scripted-model.ts: deterministic fake LLM (scripted model runtime injected via factory overrides)
- tests/: 48 deterministic tests (unit: question-core + bridge; integration: full pi loop with scripted model incl. resume re-arm)
- scripts/e2e.ts: live smoke test (manual, real LLM, never a gate)
- teacher/system-prompt.md: end-your-turn rule for question tools
2026-08-22 00:19:49 +03:00

119 lines
4.1 KiB
TypeScript

/**
* bridge — in-process communication between the web server and the question
* extensions. Both run in the same Node process (the server embeds pi via the
* SDK), so a globalThis registry is the simplest robust channel.
*
* The server only ever needs ONE operation: `answer()` / `dismiss()` — which
* must run inside an extension (it needs the ExtensionAPI: appendEntry +
* sendUserMessage). Everything else (new questions, answered questions) the
* server observes directly from the session event stream (`entry_appended`),
* which is the single source of truth.
*
* The registry is rebuilt on every `session_start` (resume re-arm) by scanning
* the session entries for lh_question / lh_question_answered.
*/
import {
type AnswerRequest,
type QuestionData,
QUESTION_ANSWERED_ENTRY,
QUESTION_ENTRY,
type QuestionKind,
buildAnswer,
formatAnswerMessage,
} from "./question-core.ts";
interface Answerer {
(data: QuestionData, req: AnswerRequest): void;
}
interface Backend {
kind: QuestionKind;
/** Append a custom entry to the session (not sent to the LLM). */
appendAnswered(data: unknown): void;
/** Send a user message into the session (triggers an agent turn). */
sendUserMessage(text: string): void;
/** All entries of the current session branch. */
getEntries(): Array<{ type: string; customType?: string; data?: unknown }>;
}
export interface AnswerOutcome {
ok: boolean;
error?: string;
}
class QuestionBridge {
/** kind → backend (registered by each web extension at load) */
private backends = new Map<QuestionKind, Backend>();
/** questionId → data, insertion-ordered (oldest first) */
private pending = new Map<string, QuestionData>();
registerBackend(backend: Backend): void {
this.backends.set(backend.kind, backend);
}
/** Called by a web extension's tool execute(): record + persist the question. */
publish(data: QuestionData): void {
this.pending.set(data.id, data);
}
/** Clear this kind's pending questions (called on session_start before re-arm). */
clear(kind: QuestionKind): void {
for (const [id, qd] of this.pending) {
if (qd.kind === kind) this.pending.delete(id);
}
}
/** Re-arm from session entries (called on session_start after clear). */
rearm(kind: QuestionKind): void {
const backend = this.backends.get(kind);
if (!backend) return;
const answered = new Set<string>();
for (const e of backend.getEntries()) {
if (e.type === "custom" && e.customType === QUESTION_ANSWERED_ENTRY) {
const d = e.data as { questionId?: string } | undefined;
if (d?.questionId) answered.add(d.questionId);
}
}
for (const e of backend.getEntries()) {
if (e.type === "custom" && e.customType === QUESTION_ENTRY) {
const d = e.data as QuestionData | undefined;
if (d?.kind === kind && !answered.has(d.id)) this.pending.set(d.id, d);
}
}
}
answer(questionId: string, req: AnswerRequest): AnswerOutcome {
const qd = this.pending.get(questionId);
if (!qd) return { ok: false, error: "unknown or already-answered question" };
const backend = this.backends.get(qd.kind);
if (!backend) return { ok: false, error: "no backend registered" };
this.pending.delete(questionId);
const answerData = buildAnswer(qd, req);
backend.appendAnswered(answerData);
backend.sendUserMessage(formatAnswerMessage(answerData));
return { ok: true };
}
dismiss(questionId: string): AnswerOutcome {
const qd = this.pending.get(questionId);
if (!qd) return { ok: false, error: "unknown or already-answered question" };
const backend = this.backends.get(qd.kind);
if (!backend) return { ok: false, error: "no backend registered" };
this.pending.delete(questionId);
const answerData = buildAnswer(qd, { dontKnow: false });
answerData.dismissed = true;
backend.appendAnswered(answerData);
backend.sendUserMessage(formatAnswerMessage(answerData));
return { ok: true };
}
}
const BRIDGE_KEY = "__learningHarnessQuestionBridge";
export function getBridge(): QuestionBridge {
const g = globalThis as Record<string, unknown>;
if (!g[BRIDGE_KEY]) g[BRIDGE_KEY] = new QuestionBridge();
return g[BRIDGE_KEY] as QuestionBridge;
}