diff --git a/mcp/host-bridge.ts b/mcp/host-bridge.ts index b3a9693f..3fdc2c21 100644 --- a/mcp/host-bridge.ts +++ b/mcp/host-bridge.ts @@ -1,3 +1,5 @@ +import { fencedDetailBlock } from '../src/lib/mcp/fence'; + /** * JSON-RPC over postMessage, the way an MCP App talks to its host. * @@ -133,7 +135,15 @@ export class HostBridge { } /** Say something back into the conversation the widget is sitting in. */ - updateModelContext(text: string) { - return this.request('ui/update-model-context', { content: [{ type: 'text', text }] }); + updateModelContext(text: string, detail?: Record) { + // Prose first — hosts feed this to a model, and the sentence is the + // message. The structured block rides along so the model (or the host's + // tooling) can read exact fields instead of parsing English. The block + // is capped (payloads carry model-generated text of unbounded size) and + // fenced with more backticks than the content contains, so a payload + // string with ``` in it cannot break out of the fence into prose. + const content: { type: 'text'; text: string }[] = [{ type: 'text', text }]; + if (detail) content.push({ type: 'text', text: fencedDetailBlock(detail) }); + return this.request('ui/update-model-context', { content }); } } diff --git a/mcp/report-to-host.ts b/mcp/report-to-host.ts index 6f9f238f..9d27620f 100644 --- a/mcp/report-to-host.ts +++ b/mcp/report-to-host.ts @@ -12,10 +12,9 @@ import type { HostBridge } from './host-bridge'; * go in here, since the shell provides no telemetry sink. So this is not new * instrumentation, only a destination for what was always being reported. * - * Deliberately quiet: one message when the activity is finished, and nothing - * else. Narrating every keystroke into the transcript would bury the - * conversation in a student's typing, which is the same mistake the meter - * itself was tuned away from. + * Deliberately quiet: one message when the activity is finished, at most one + * struggle signal before it, and nothing else. Narrating every keystroke + * into the transcript would bury the conversation in a student's typing. */ type Event = { @@ -46,21 +45,110 @@ function describe(event: Event, attempts: number): string { return `The student worked through the ${kind}${standard} ${outcome}.${struggle}${score}`; } +/** How many consecutive wrong checks before the conversation hears about it early. */ +const STRUGGLE_AFTER_WRONG = 3; + +/** + * Payload fields that mean "the student is advancing through a multi-part + * widget". Crossword and friends emit `answer_checked` with `correct: false` + * meaning "not finished yet" while these counters climb — that is progress, + * not struggle, and must not trigger the early signal. Heuristic on purpose; + * the durable fix is completion semantics on the registry entry, tracked in + * the registry-owned-semantics refactor. + */ +const PROGRESS_FIELDS = ['solved', 'placed', 'matched', 'correctCount', 'revealed'] as const; + +function progressReading(payload: Record | undefined): number { + if (!payload) return 0; + let total = 0; + for (const field of PROGRESS_FIELDS) { + const value = payload[field]; + if (typeof value === 'number') total += value; + } + return total; +} + export function reportCompletionToHost(bridge: HostBridge) { let attempts = 0; + let hints = 0; + let wrongStreak = 0; + let lastProgress = 0; let reported = false; + let struggleReported = false; + + /** Shared base for both report shapes, so they cannot drift apart. */ + const resultBase = (event: Event) => ({ + kind: event.widgetKind ?? null, + standardCode: event.standardCode ?? null, + attempts, + hintsUsed: hints, + }); return { track(event: Event) { // Every attempt at an answer counts, whatever the widget calls it. - if (event.eventType === 'answer_checked' || event.eventType === 'attempt') attempts += 1; + if (event.eventType === 'answer_checked' || event.eventType === 'attempt') { + attempts += 1; + + const progress = progressReading(event.payload); + if (event.correct === true || progress > lastProgress) { + wrongStreak = 0; + } else if (event.correct === false) { + wrongStreak += 1; + } + lastProgress = Math.max(lastProgress, progress); + } + if (event.eventType === 'hint_requested') hints += 1; + + // One early signal, before the finish line: an agent that only hears + // about completed work can never help with stuck work. Sent once, and + // only for consecutive wrong checks with no visible progress — a slow + // careful student, or one steadily solving a multi-part widget, is not + // stuck. + if ( + !reported && + !struggleReported && + event.eventType === 'answer_checked' && + event.correct === false && + wrongStreak >= STRUGGLE_AFTER_WRONG + ) { + struggleReported = true; + void bridge.updateModelContext( + `The student is still working through the ${event.widgetKind ?? 'activity'} and has checked ${attempts} answers without getting it yet. They have not asked for help.`, + { type: 'widget_progress', ...resultBase(event), completed: false }, + ); + } if (event.eventType !== 'widget_completed' || reported) return; reported = true; - void bridge.updateModelContext(describe(event, attempts)); + // Prose for the model to respond to, plus the structured result — the + // same shape the SDK's universal WidgetResult is converging on — so + // exact fields survive without parsing English. + void bridge.updateModelContext(describe(event, attempts), { + type: 'widget_result', + ...resultBase(event), + completed: true, + correct: event.correct ?? null, + score: typeof event.payload?.score === 'number' ? event.payload.score : undefined, + detail: event.payload ?? undefined, + }); }, trackHesitation() {}, flush() {}, + /** + * A host can hand the same frame a new widget (a second tool result). + * Counters describe one activity, so the shell calls this when the spec + * changes — widget B must not inherit widget A's attempts, or its + * already-reported silence. + */ + reset() { + attempts = 0; + hints = 0; + wrongStreak = 0; + lastProgress = 0; + reported = false; + struggleReported = false; + }, }; } diff --git a/mcp/shell.tsx b/mcp/shell.tsx index dd8aee57..6c4385c5 100644 --- a/mcp/shell.tsx +++ b/mcp/shell.tsx @@ -174,6 +174,15 @@ function Shell() { const spec = hostSpec ?? window.__WIDGET_SPEC__; + // A host can re-use this frame for a second tool result. The completion + // tracker's counters describe one activity — reset them whenever the spec + // object changes so widget B doesn't inherit widget A's attempt count or + // its already-reported silence. (A re-delivered identical spec also resets: + // over-counting a restart beats permanently silencing the frame.) + useEffect(() => { + telemetry.reset(); + }, [spec]); + if (!spec) { return (

diff --git a/src/app/api/mcp/route.test.ts b/src/app/api/mcp/route.test.ts new file mode 100644 index 00000000..1b54543b --- /dev/null +++ b/src/app/api/mcp/route.test.ts @@ -0,0 +1,100 @@ +import { describe, expect, it } from 'vitest'; + +import { POST } from './route'; + +/** + * Protocol-level integration: drive the deployed endpoint's handler with real + * Requests and assert the wire contract — the part a host depends on before + * any model call happens. Tool *execution* is covered by unit tests + * (find.test.ts, run.test.ts) and the eval-harness roadmap; this file pins + * the envelope. + */ +function rpc(body: unknown) { + return POST( + new Request('https://example.test/api/mcp', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }), + ); +} + +describe('/api/mcp protocol surface', () => { + it('initializes with instructions and capabilities', async () => { + const res = await rpc({ jsonrpc: '2.0', id: 1, method: 'initialize', params: {} }); + const json = await res.json(); + expect(json.result.serverInfo.name).toBe('interactive-learning-widgets'); + expect(json.result.capabilities).toHaveProperty('tools'); + expect(json.result.instructions).toContain('show_widget'); + }); + + it('lists the full tool surface with MCP Apps metadata on the renderer', async () => { + const res = await rpc({ jsonrpc: '2.0', id: 2, method: 'tools/list' }); + const { result } = await res.json(); + const names = result.tools.map((tool: { name: string }) => tool.name); + expect(names).toEqual( + expect.arrayContaining(['show_widget', 'find_activity', 'build_pathway', 'score_draft']), + ); + const show = result.tools.find((tool: { name: string }) => tool.name === 'show_widget'); + expect(show._meta.ui.resourceUri).toMatch(/^ui:\/\//); + }); + + it('takes audienceHint, not a grade, and keeps the alias show_widget shipped with', async () => { + const res = await rpc({ jsonrpc: '2.0', id: 21, method: 'tools/list' }); + const { result } = await res.json(); + const props = (name: string) => + result.tools.find((tool: { name: string }) => tool.name === name).inputSchema.properties; + + // Segment-neutral by name: nothing in the surface presumes the learner + // is in a grade, so a higher-ed or workplace deployment has an honest + // argument to pass instead of one that lies. + for (const name of ['show_widget', 'find_activity', 'build_pathway']) { + expect(props(name).audienceHint?.type).toBe('string'); + // `audience` on a manifest is scheme-scoped and graph-derived. The tool + // input is unverified caller text, so it must not borrow the bare name. + expect(props(name)).not.toHaveProperty('audience'); + } + + // These two are new here, so they never carried the old name. + expect(props('find_activity')).not.toHaveProperty('gradeHint'); + expect(props('build_pathway')).not.toHaveProperty('gradeHint'); + + // show_widget shipped with `gradeHint`; removing it would break callers + // written against the deployed tool, so it stays accepted. + expect(props('show_widget')).toHaveProperty('gradeHint'); + }); + + it('serves the shell resource listing', async () => { + const res = await rpc({ jsonrpc: '2.0', id: 3, method: 'resources/list' }); + const { result } = await res.json(); + expect(result.resources[0].mimeType).toBe('text/html;profile=mcp-app'); + }); + + it('rejects unknown methods and unknown tools without throwing', async () => { + const method = await rpc({ jsonrpc: '2.0', id: 4, method: 'no/such-method' }); + expect((await method.json()).error.code).toBe(-32601); + + const tool = await rpc({ jsonrpc: '2.0', id: 5, method: 'tools/call', params: { name: 'no_such_tool' } }); + expect((await tool.json()).error.code).toBe(-32602); + }); + + it('answers malformed bodies with a parse error, not a crash', async () => { + const res = await POST( + new Request('https://example.test/api/mcp', { method: 'POST', body: 'not json' }), + ); + expect(res.status).toBe(400); + expect((await res.json()).error.code).toBe(-32700); + }); + + it('requires a topic before spending a build_pathway run', async () => { + const res = await rpc({ + jsonrpc: '2.0', + id: 6, + method: 'tools/call', + params: { name: 'build_pathway', arguments: {} }, + }); + const { result } = await res.json(); + expect(result.isError).toBe(true); + expect(result.content[0].text).toMatch(/topic/i); + }); +}); diff --git a/src/app/api/mcp/route.ts b/src/app/api/mcp/route.ts index ca630b22..9ed4b10c 100644 --- a/src/app/api/mcp/route.ts +++ b/src/app/api/mcp/route.ts @@ -1,9 +1,13 @@ import { findActivities } from '@/lib/activities/find'; import { scoreDraft } from '@/lib/draft-meter/score'; import { scoreRequest } from '@/lib/draft-meter/schema'; +import { runPathway } from '@/lib/pathway/run'; +import { storageAdapter } from '@/lib/storage'; import { buildWidget, WidgetBuildError, WIDGET_KINDS } from '@/lib/widgets/build'; -export const maxDuration = 60; +// Long enough for `build_pathway` (~30s across five model calls), not just +// the single-call `show_widget`. +export const maxDuration = 120; /** * The MCP server, deployed with the app. @@ -112,6 +116,16 @@ const INSTRUCTIONS = [ 'The widget renders itself — do not describe it in detail or restate its contents. Say what it', 'is in a line, and let the student use it. When they finish, the widget tells you what they did,', 'and that is the moment to respond to their work or offer the next thing.', + '', + 'Two ways to teach a sequence — one pedagogy. The planned arc (activate prior knowledge →', + 'model the idea → practice → check) is what makes a sequence teach, so keep it either way.', + 'In a live conversation, YOU deliver that arc: sequence `show_widget` calls through those', + 'stages, read the evidence each widget reports back, and let it set the pacing — linger,', + 'remediate, or advance, but inside the arc rather than wandering. Calling `build_pathway`', + 'first and using its returned plan as your spine is a fine way to start. When someone wants', + 'a complete lesson to hand off (a teacher planning, a parent printing a link), call', + '`build_pathway` for the link itself: 4-6 sequenced activities against the verified standard.', + 'It takes about half a minute; say so, then call it.', ].join('\n'); const TOOL_DESCRIPTION = [ @@ -177,7 +191,10 @@ function tools(origin: string) { type: 'string', description: 'Optional. A Common Core or NGSS code if known; verified against the standards graph.', }, - gradeHint: { type: 'string', description: 'Optional, e.g. "4th grade".' }, + audienceHint: { + type: 'string', + description: 'Optional. Who this is for, in plain words — "4th grade", "undergraduate intro stats", "new hires". An unverified steer for standard selection, never a claim about the activity.', + }, need: { type: 'string', description: 'Optional preference in plain words — "a game", "something they write", "a quick check".', @@ -197,7 +214,14 @@ function tools(origin: string) { type: 'string', description: 'What the activity should be about, in plain words — "the Industrial Revolution", "comparing fractions". Enough on its own.', }, - gradeHint: { type: 'string', description: 'Optional, e.g. "8th grade", "high school".' }, + audienceHint: { + type: 'string', + description: 'Optional. Who this is for, in plain words — "4th grade", "undergraduate intro stats", "new hires". An unverified steer for standard selection, never a claim about the activity.', + }, + gradeHint: { + type: 'string', + description: 'Deprecated alias for `audienceHint`, still accepted so callers written against the shipped tool keep working.', + }, standardCode: { type: 'string', description: 'Optional. A Common Core or NGSS code, if you already know which one you want.', @@ -212,6 +236,35 @@ function tools(origin: string) { }, _meta: uiMeta(origin), }, + { + name: 'build_pathway', + title: 'Build a complete multi-step learning pathway', + description: [ + 'Plan and build a complete multi-step lesson — 4-6 sequenced activities against one', + 'verified standard, with a share link a student opens to work through it (progress,', + 'per-step evidence back to the teacher, automatic re-teach steps on struggle).', + '', + 'Use this when someone wants a whole lesson or something to assign; use `show_widget`', + 'when the student is here in the conversation and you will sequence activities yourself.', + '', + 'This is the slow tool: about half a minute across several model calls. Tell the user', + 'it is being built, then call it. The result includes the link and the plan summary.', + ].join('\n'), + inputSchema: { + type: 'object', + properties: { + topic: { + type: 'string', + description: 'What the pathway teaches, in plain words — "comparing fractions", "the water cycle".', + }, + audienceHint: { + type: 'string', + description: 'Optional. Who this is for, in plain words — "4th grade", "undergraduate intro stats", "new hires". An unverified steer for standard selection, never a claim about the activity.', + }, + }, + required: ['topic'], + }, + }, { name: 'score_draft', title: 'Score a draft', @@ -351,12 +404,21 @@ async function handle(message: RpcRequest, request: Request) { const args = (message.params?.arguments ?? {}) as { topic?: string; standardCode?: string; - gradeHint?: string; + audienceHint?: string; need?: string; }; try { - const found = await findActivities(args); + // Named `audienceHint`, not `audience`, and the suffix is load-bearing: + // the manifest's `audience` is scheme-scoped and graph-derived, so + // reusing the bare name for unverified caller text would make one + // word mean two things across the same surface. + const found = await findActivities({ + topic: args.topic, + standardCode: args.standardCode, + need: args.need, + gradeHint: args.audienceHint, + }); const header = found.standard ? `${found.activities.length} activities for ${found.standard.code} — ${found.standard.description}` + @@ -380,21 +442,121 @@ async function handle(message: RpcRequest, request: Request) { } } + if (message.params?.name === 'build_pathway') { + const args = (message.params?.arguments ?? {}) as { topic?: string; audienceHint?: string }; + const topic = typeof args.topic === 'string' ? args.topic.trim() : ''; + if (!topic) { + return ok(message.id, { content: [{ type: 'text', text: 'A topic is required.' }], isError: true }); + } + + try { + const run = await runPathway(topic, args.audienceHint?.trim() || undefined); + + // Persistence is best-effort and separately guarded: a storage + // failure must not discard a pathway that took five model calls to + // build — the plan summary is still the answer, minus the link, + // with the real reason stated instead of a guess. + const adapter = storageAdapter(); + let ownerId: string | null = null; + let sessionId: string | null = null; + let saveReason: string | null = null; + try { + // The session needs an owner the storage layer knows. MCP callers + // have no browser learner id, so one is minted per pathway; it is + // returned as ownerId because the edit endpoint gates on it — a + // caller that discards it can share the pathway but never edit it. + ownerId = await adapter.createStudent(); + if (!ownerId) { + saveReason = 'storage declined to create an owner id'; + } else { + sessionId = await adapter.persistSession({ + studentId: ownerId, + topic, + gradeHint: args.audienceHint?.trim() || null, + anchor: run.anchor, + plan: run.plan, + stepWidgets: run.stepWidgets, + rejectedCodes: run.rejected, + }); + if (!sessionId) saveReason = 'storage is not accepting writes'; + } + } catch (saveError) { + saveReason = saveError instanceof Error ? saveError.message : 'unknown storage error'; + } + + const kindOf = (widget: unknown): string | null => + widget && typeof widget === 'object' && 'kind' in widget && typeof widget.kind === 'string' + ? widget.kind + : null; + + const steps = run.plan.steps.map((step, i) => { + const note = run.stepWidgetNotes[i]; + return `${i + 1}. [${step.purpose}] ${step.title}${note ? ` — note: ${note}` : ''}`; + }); + const verified = run.anchor.standard.verified + ? `verified against ${run.anchor.standard.sourceLabel}` + : 'no standard verified — this is an exploration pathway'; + const summary = [ + `Built "${run.plan.bigIdea}" — ${run.plan.steps.length} steps for ${run.anchor.standard.code} (${verified}).`, + ...steps, + sessionId + ? `Student link: ${origin}/learn/${sessionId}` + : `Not saved — ${saveReason ?? 'no reason recorded'}. The pathway above is complete; rebuilding is one call.`, + run.rejected.length > 0 ? `Rejected codes kept on record: ${run.rejected.join(', ')}` : null, + ] + .filter(Boolean) + .join('\n'); + + return ok(message.id, { + content: [{ type: 'text', text: summary }], + structuredContent: { + sessionId, + url: sessionId ? `${origin}/learn/${sessionId}` : null, + /** Keep this to edit the pathway later — the edit API gates on it. */ + ownerId, + standard: { code: run.anchor.standard.code, verified: run.anchor.standard.verified }, + // The kind actually built per step — generation can substitute a + // fallback kind, and the plan's ask is reported only when it + // differs, so the caller never describes widgets that aren't there. + steps: run.plan.steps.map((step, i) => { + const builtKind = kindOf(run.stepWidgets[i]); + return { + title: step.title, + purpose: step.purpose, + widgetKind: builtKind ?? step.widgetKind ?? null, + ...(builtKind && step.widgetKind && builtKind !== step.widgetKind + ? { plannedWidgetKind: step.widgetKind } + : {}), + ...(run.stepWidgetNotes[i] ? { note: run.stepWidgetNotes[i] } : {}), + }; + }), + rejectedCodes: run.rejected, + }, + }); + } catch (error) { + const text = error instanceof Error ? error.message : 'Pathway generation failed.'; + return ok(message.id, { content: [{ type: 'text', text }], isError: true }); + } + } + if (message.params?.name !== 'show_widget') { return fail(message.id, -32602, `Unknown tool: ${String(message.params?.name)}`); } const args = (message.params?.arguments ?? {}) as { topic?: string; + audienceHint?: string; + /** Deprecated alias for `audienceHint`; this tool shipped with it. */ gradeHint?: string; standardCode?: string; kind?: string; }; + const audienceHint = args.audienceHint ?? args.gradeHint; try { const built = await buildWidget({ topic: args.topic, - gradeHint: args.gradeHint, + gradeHint: audienceHint, standardCode: args.standardCode, kind: args.kind, }); @@ -425,7 +587,7 @@ async function handle(message: RpcRequest, request: Request) { if (topic && (args.standardCode || args.kind)) { try { - const retry = await buildWidget({ topic, gradeHint: args.gradeHint }); + const retry = await buildWidget({ topic, gradeHint: audienceHint }); return ok(message.id, { content: [ { diff --git a/src/lib/mcp/fence.ts b/src/lib/mcp/fence.ts new file mode 100644 index 00000000..86389074 --- /dev/null +++ b/src/lib/mcp/fence.ts @@ -0,0 +1,21 @@ +/** + * The one safe way to put a structured detail block into model context. + * + * Payloads carry model-generated text of unbounded size that can legitimately + * contain ``` — a fence the content can close is a fence the content can + * escape, spilling data into the transcript as prose. So: cap the size, then + * fence with more backticks than the longest run inside. + * + * Shared by the widget-side reporter (src/lib/mcp/report.ts) and the shell's + * host bridge (mcp/host-bridge.ts) — the two deliberately share this pure + * helper and nothing else, so neither drags the other's runtime along. + */ +const MAX_DETAIL_CHARS = 4000; + +export function fencedDetailBlock(detail: Record): string { + let json = JSON.stringify(detail); + if (json.length > MAX_DETAIL_CHARS) json = `${json.slice(0, MAX_DETAIL_CHARS)}… (truncated)`; + const longestRun = json.match(/`+/g)?.reduce((max, run) => Math.max(max, run.length), 0) ?? 0; + const fence = '`'.repeat(Math.max(3, longestRun + 1)); + return `${fence}json\n${json}\n${fence}`; +} diff --git a/src/lib/mcp/report-to-host.test.ts b/src/lib/mcp/report-to-host.test.ts new file mode 100644 index 00000000..82784843 --- /dev/null +++ b/src/lib/mcp/report-to-host.test.ts @@ -0,0 +1,96 @@ +import { describe, expect, it } from 'vitest'; + +import { fencedDetailBlock } from '@/lib/mcp/fence'; +import { reportCompletionToHost } from '../../../mcp/report-to-host'; + +type Sent = { text: string; detail?: Record }; + +function harness() { + const sent: Sent[] = []; + const bridge = { + updateModelContext(text: string, detail?: Record) { + sent.push({ text, detail }); + return Promise.resolve(undefined); + }, + }; + // The tracker only calls updateModelContext; the rest of HostBridge is irrelevant here. + return { sent, tracker: reportCompletionToHost(bridge as never) }; +} + +const wrong = (kind = 'drag-sort', payload?: Record) => ({ + eventType: 'answer_checked', + widgetKind: kind, + correct: false, + payload, +}); + +describe('reportCompletionToHost', () => { + it('reports completion once, with the structured result riding along', () => { + const { sent, tracker } = harness(); + tracker.track(wrong()); + tracker.track({ eventType: 'widget_completed', widgetKind: 'drag-sort', correct: true, payload: { score: 80 } }); + tracker.track({ eventType: 'widget_completed', widgetKind: 'drag-sort', correct: true }); + + expect(sent).toHaveLength(1); + expect(sent[0].detail).toMatchObject({ type: 'widget_result', completed: true, correct: true, attempts: 1, score: 80 }); + }); + + it('fires the struggle signal once after three consecutive wrong checks', () => { + const { sent, tracker } = harness(); + tracker.track(wrong()); + tracker.track(wrong()); + expect(sent).toHaveLength(0); + tracker.track(wrong()); + expect(sent).toHaveLength(1); + expect(sent[0].detail).toMatchObject({ type: 'widget_progress', completed: false, attempts: 3 }); + tracker.track(wrong()); + expect(sent).toHaveLength(1); + }); + + it('does not read steady multi-part progress as struggle (the crossword case)', () => { + const { sent, tracker } = harness(); + // correct:false means "not finished yet" while solved climbs — progress, not stuckness. + tracker.track(wrong('crossword', { solved: 1 })); + tracker.track(wrong('crossword', { solved: 2 })); + tracker.track(wrong('crossword', { solved: 3 })); + tracker.track(wrong('crossword', { solved: 4 })); + expect(sent).toHaveLength(0); + }); + + it('a right answer resets the wrong streak', () => { + const { sent, tracker } = harness(); + tracker.track(wrong()); + tracker.track(wrong()); + tracker.track({ eventType: 'answer_checked', widgetKind: 'drag-sort', correct: true }); + tracker.track(wrong()); + tracker.track(wrong()); + expect(sent).toHaveLength(0); + }); + + it('reset() gives a new widget fresh counters and a fresh voice', () => { + const { sent, tracker } = harness(); + tracker.track({ eventType: 'widget_completed', widgetKind: 'flashcard', correct: null }); + expect(sent).toHaveLength(1); + tracker.reset(); + tracker.track({ eventType: 'widget_completed', widgetKind: 'crossword', correct: true }); + expect(sent).toHaveLength(2); + expect(sent[1].detail).toMatchObject({ kind: 'crossword', attempts: 0 }); + }); +}); + +describe('fencedDetailBlock', () => { + it('fences with more backticks than the content contains', () => { + const block = fencedDetailBlock({ note: 'code: ```js\nalert(1)\n``` end' }); + const opening = block.split('json')[0]; + expect(opening.length).toBeGreaterThanOrEqual(4); + // The fence must not appear inside the body it wraps. + const body = block.slice(block.indexOf('\n') + 1, block.lastIndexOf('\n')); + expect(body.includes(opening)).toBe(false); + }); + + it('caps unbounded payloads', () => { + const block = fencedDetailBlock({ text: 'x'.repeat(10_000) }); + expect(block.length).toBeLessThan(4200); + expect(block).toContain('(truncated)'); + }); +}); diff --git a/src/lib/mcp/report.ts b/src/lib/mcp/report.ts index 84d4ed0a..258b2299 100644 --- a/src/lib/mcp/report.ts +++ b/src/lib/mcp/report.ts @@ -1,3 +1,5 @@ +import { fencedDetailBlock } from '@/lib/mcp/fence'; + /** * What the widget says back into the conversation it is sitting in. * @@ -47,18 +49,30 @@ function isEmbedded(): boolean { * Repeats are dropped: a widget that re-renders, or a student who checks the * same answer twice, should not narrate itself twice into the transcript. */ -export function reportToConversation(text: string): void { +export function reportToConversation(text: string, detail?: Record): void { const message = text.trim(); - if (!message || message === lastSent || !isEmbedded()) return; + if (!message || !isEmbedded()) return; + + // Prose leads — it is what the model responds to. The optional structured + // block rides along for exact fields (the same convention the shell's + // completion reporter uses; the cap and over-long fence are mirrored there + // too, since the two files deliberately share a wire format, not code). + const content: { type: 'text'; text: string }[] = [{ type: 'text', text: message }]; + const detailBlock = detail ? fencedDetailBlock(detail) : null; + if (detailBlock) content.push({ type: 'text', text: detailBlock }); - lastSent = message; + // Dedupe on everything sent, not just the prose — identical sentences with + // different structured detail are different reports. + const dedupeKey = detailBlock ? `${message}\n${detailBlock}` : message; + if (dedupeKey === lastSent) return; + lastSent = dedupeKey; window.parent?.postMessage( { jsonrpc: '2.0', id: nextId++, method: 'ui/update-model-context', - params: { content: [{ type: 'text', text: message }] }, + params: { content }, }, '*', ); diff --git a/src/lib/pathway/run.test.ts b/src/lib/pathway/run.test.ts new file mode 100644 index 00000000..e9b27d3d --- /dev/null +++ b/src/lib/pathway/run.test.ts @@ -0,0 +1,61 @@ +import { describe, expect, it, vi } from 'vitest'; + +import type { PathwayEvent } from '@/lib/pathway/events'; + +vi.mock('@/lib/pathway/generate', () => ({ + streamPathway: vi.fn(), +})); + +import { streamPathway } from '@/lib/pathway/generate'; +import { runPathway } from '@/lib/pathway/run'; + +const anchor = { standard: { code: 'MATH.4.NF.EQUIV' } } as never; +const plan = { bigIdea: 'x', steps: [{ title: 'a' }, { title: 'b' }] } as never; + +function feed(events: Partial[]) { + vi.mocked(streamPathway).mockImplementation(async function* () { + for (const event of events) yield event as PathwayEvent; + }); +} + +describe('runPathway', () => { + it('collects the stream into the same shape the NDJSON route accumulates', async () => { + feed([ + { type: 'verdict', code: 'FAKE.1', resolved: false }, + { type: 'verdict', code: 'MATH.4.NF.EQUIV', resolved: true }, + { type: 'anchor', anchor } as never, + { type: 'plan', plan } as never, + { type: 'step-widget', stepIndex: 0, widget: { kind: 'flashcard' } } as never, + { type: 'step-widget', stepIndex: 1, widget: { kind: 'drag-sort' }, note: 'fell back' } as never, + { type: 'done' } as never, + ]); + + const run = await runPathway('topic'); + expect(run.anchor).toBe(anchor); + expect(run.plan).toBe(plan); + expect(run.rejected).toEqual(['FAKE.1']); + expect(run.stepWidgets[1]).toMatchObject({ kind: 'drag-sort' }); + // Substitution notes survive collection — the caller reports them, never hides them. + expect(run.stepWidgetNotes).toEqual({ 1: 'fell back' }); + }); + + it('throws when the run produces no pathway instead of returning a hollow result', async () => { + feed([{ type: 'done' } as never]); + await expect(runPathway('topic')).rejects.toThrow(/did not produce a pathway/); + }); + + it('passes teacher context through to the pipeline', async () => { + feed([ + { type: 'anchor', anchor } as never, + { type: 'plan', plan } as never, + ]); + await runPathway('topic', '4', { teacherNote: 'they confuse thirds and fourths' }); + expect(vi.mocked(streamPathway)).toHaveBeenCalledWith( + 'topic', + '4', + null, + 'they confuse thirds and fourths', + undefined, + ); + }); +}); diff --git a/src/lib/pathway/run.ts b/src/lib/pathway/run.ts new file mode 100644 index 00000000..11e96d5e --- /dev/null +++ b/src/lib/pathway/run.ts @@ -0,0 +1,60 @@ +import type { Anchor } from '@/lib/pathway/events'; +import { streamPathway } from '@/lib/pathway/generate'; +import type { PathwayPlan } from '@/lib/pathway/schema'; + +export type PathwayRun = { + anchor: Anchor; + plan: PathwayPlan; + stepWidgets: Record; + /** Per-step degradation notes — e.g. "draft-meter didn't fit, built a fallback". */ + stepWidgetNotes: Record; + rejected: string[]; +}; + +/** + * The pipeline as a single promise instead of a stream. + * + * `streamPathway` is the real thing; the app consumes it event by event so + * the in-progress state can be the design. A protocol caller (the MCP + * `build_pathway` tool) has nowhere to put intermediate events — a tool call + * returns once — so this collects the same stream into the same shape the + * NDJSON route accumulates. Generator failures throw and propagate out of + * the `for await` — there is no in-band error event to handle. + * + * The pass-through options mirror `streamPathway`'s remaining parameters so + * other collect-the-stream callers (the assign route carries a third copy of + * this loop) can converge on this helper instead of forking it. + */ +export async function runPathway( + topic: string, + gradeHint?: string, + extras?: { teacherNote?: string; lessonPlanExcerpt?: string }, +): Promise { + let anchor: Anchor | null = null; + let plan: PathwayPlan | null = null; + const stepWidgets: Record = {}; + const stepWidgetNotes: Record = {}; + const rejected: string[] = []; + + for await (const event of streamPathway( + topic, + gradeHint, + null, + extras?.teacherNote, + extras?.lessonPlanExcerpt, + )) { + if (event.type === 'anchor') anchor = event.anchor; + if (event.type === 'plan') plan = event.plan; + if (event.type === 'step-widget') { + stepWidgets[event.stepIndex] = event.widget; + if (event.note) stepWidgetNotes[event.stepIndex] = event.note; + } + if (event.type === 'verdict' && !event.resolved) rejected.push(event.code); + } + + if (!anchor || !plan) { + throw new Error('The run did not produce a pathway — no anchor or plan came back.'); + } + + return { anchor, plan, stepWidgets, stepWidgetNotes, rejected }; +} diff --git a/test/stubs/server-only.ts b/test/stubs/server-only.ts new file mode 100644 index 00000000..cb0ff5c3 --- /dev/null +++ b/test/stubs/server-only.ts @@ -0,0 +1 @@ +export {}; diff --git a/vitest.config.mts b/vitest.config.mts index 769ffd1f..50cc46cf 100644 --- a/vitest.config.mts +++ b/vitest.config.mts @@ -6,6 +6,9 @@ export default defineConfig({ resolve: { alias: { '@': fileURLToPath(new URL('./src', import.meta.url)), + // Next's build-time marker package has no runtime — outside Next it + // must resolve to nothing rather than fail module resolution. + 'server-only': fileURLToPath(new URL('./test/stubs/server-only.ts', import.meta.url)), }, }, test: {