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
This commit is contained in:
@@ -0,0 +1,118 @@
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
Reference in New Issue
Block a user