import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { Check } from 'typebox/value'; import { PGlite } from '@electric-sql/pglite'; import type { AgentTool } from '@earendil-works/pi-agent-core'; import type { PPTTextElement } from '@openmaic/dsl'; import { ensureDocumentSchema } from '@openmaic/storage/document/pg'; import { buildCourseAllowlist, buildDslCourseToolset, DSL_TOOLS_PROMPT, type CourseDocument, type CourseStore, } from '@/lib/server/agent-runtime/course-tools'; import { buildDslCourseTools, foldNfkcCaseWithOffsets, GREP_COURSE_SCHEMA, PATCH_COURSE_SCHEMA, READ_COURSE_SCHEMA, } from '@/lib/server/agent-runtime/dsl-tools'; import { buildCurriculumTools, probeStageAccess, type StageAccess, } from '@/lib/server/agent-runtime/curriculum-tools'; import { buildRunnerCoursePrompt } from '@/lib/server/agent-runtime/runner-contract'; import { withPlainJsonDocumentWrites } from '@/lib/document-store/plain-json-store'; import { createOwnerBoundDocumentStore } from '@/lib/persistence/owner-bound-document-store'; import { ensureStageMetaSchema } from '@/lib/persistence/stage-meta'; import { validateAppScene, validateAppStage } from '@/lib/document-store/validators'; import type { InteractiveContent, PBLContent, QuizContent, Scene, SlideContent, } from '@/lib/types/stage'; function slideScene(order = 1, id = 'scene_slide'): Scene { return { id, stageId: 'stage-test', order, title: 'AI 开场', type: 'slide', content: { type: 'slide', canvas: { id: 'canvas-1', viewportSize: 1000, viewportRatio: 0.5625, theme: { backgroundColor: '#fff', themeColors: ['#2563eb'], fontColor: '#111', fontName: 'Inter', }, elements: [ { id: 'el-title', type: 'text', left: 10, top: 10, width: 400, height: 80, rotate: 0, content: '
Hello AI
', defaultFontName: 'Inter', defaultColor: '#111', fill: '#fff', } as PPTTextElement, ], }, }, actions: [{ id: 'act-1', type: 'speech', text: 'Welcome', audioId: 'asset-old' }], } as Scene; } function quizScene(): Scene { return { id: 'scene_quiz', stageId: 'stage-test', order: 2, title: 'Quiz', type: 'quiz', content: { type: 'quiz', questions: [ { id: 'q1', type: 'single', question: 'Which answer?', options: [ { label: 'Alpha', value: 'A' }, { label: 'Beta', value: 'B' }, ], answer: ['A'], analysis: 'Because Alpha.', }, ], }, actions: [], } as Scene; } function interactiveScene(): Scene { return { id: 'scene_widget', stageId: 'stage-test', order: 3, title: 'Widget', type: 'interactive', content: { type: 'interactive', html: 'Visible widget words', widgetType: 'simulation', widgetConfig: { type: 'simulation', concept: 'gravity', description: 'Change the mass', variables: [{ name: 'mass', label: 'Mass', min: 1, max: 10, default: 2 }], }, }, actions: [], } as Scene; } function pblScene(): Scene { return { id: 'scene_pbl', stageId: 'stage-test', order: 4, title: 'Project', type: 'pbl', content: { type: 'pbl', projectV2: { uiPhase: 'hero', title: 'Build a bridge', description: 'Make a model', proficiency: '', language: 'en', tags: [], status: 'designing', roles: [{ id: 'role-1', type: 'instructor', name: 'Instructor' }], milestones: [ { id: 'ms-1', title: 'Plan', status: 'locked', order: 1, microtasks: [ { id: 'mt-1', title: 'Sketch', status: 'todo', assignee: 'user', hints: ['Use triangles'], order: 1, }, ], }, ], submissions: [], evaluations: [], threads: [{ agentId: 'role-1', messages: [] }], engagementEvents: [], createdAt: '2026-08-22T00:00:00.000Z', updatedAt: '2026-08-22T00:00:00.000Z', }, }, actions: [], } as Scene; } function course(scenes: Scene[] = [slideScene(), quizScene(), interactiveScene(), pblScene()]) { return { stage: { id: 'stage-test', name: 'Test course', description: 'Course description', createdAt: 1, updatedAt: 1, }, outline: { outlines: scenes.map((scene) => ({ id: `outline-${scene.id}`, order: scene.order, type: scene.type, title: scene.title, description: `Outline ${scene.title}`, keyPoints: [], })), }, scenes, } as unknown as CourseDocument; } function state(initial = course()) { let doc = structuredClone(initial); const putScene = vi.fn(async (_stageId: string, scene: Scene) => { doc = { ...doc, scenes: doc.scenes.map((item) => (item.id === scene.id ? scene : item)), }; }); const loadDocument = vi.fn(async (stageId: string) => (stageId === doc.stage.id ? doc : null)); return { store: { loadDocument, putScene } as unknown as CourseStore, get: () => doc, putScene, loadDocument, }; } const OWNED: StageAccess = { kind: 'owned', stage: { stageId: 'stage-test', name: 'Test course' } }; function dslTools(store: CourseStore) { return buildDslCourseTools({ store, stageAccess: async () => OWNED, onCheckpoint: () => undefined, }); } function tool(tools: AgentTool${'x'.repeat(25_000)}
`; const current = state(course([scene])); const read = tool(dslTools(current.store), 'read_stage'); const first = await read.execute('first', { path: '/scenes/1', detail: 'source', } as never); expect(textOf(first)).toContain('bytes omitted'); expect(textOf(first)).not.toContain(imageBytes); expect(textOf(first)).toContain('grep_stage'); expect(textOf(first)).toContain('Output truncated at 12000 chars'); expect(textOf(first)).toContain('offset=12000'); expect(first.details).toMatchObject({ totalChars: expect.any(Number), nextOffset: 12_000 }); const nextOffset = (first.details as { nextOffset: number }).nextOffset; const second = await read.execute('second', { path: '/scenes/1', detail: 'source', offset: nextOffset, } as never); expect((second.details as { totalChars: number }).totalChars).toBe( (first.details as { totalChars: number }).totalChars, ); const textPage = await read.execute('text-page', { path: '/scenes/1', detail: 'text', } as never); expect(textPage.details).toMatchObject({ nextOffset: 12_000 }); expect(textOf(textPage)).toContain('grep_stage'); expect((current.get().scenes[0]!.content as SlideContent).canvas.elements[0]).toMatchObject({ src: `data:image/png;base64,${imageBytes}`, }); }); it('leaves untruncated results free of the pagination hint', async () => { const current = state(); const read = tool(dslTools(current.store), 'read_stage'); const source = await read.execute('source-small', { path: '/scenes/1', detail: 'source', } as never); expect(textOf(source)).not.toContain('Output truncated'); const text = await read.execute('text-small', { path: '/scenes/1', detail: 'text', } as never); expect(textOf(text)).not.toContain('Output truncated'); const tree = await read.execute('tree-small', {} as never); expect(textOf(tree)).not.toContain('Output truncated'); }); }); describe('patch_stage', () => { it.each(['scene_widget', 'scene_pbl'] as const)( 'rejects set /actions to a non-array on %s and never persists (R5-P2-2)', async (target) => { const current = state(); const patch = tool(dslTools(current.store), 'patch_stage'); const result = await patch.execute('bad-actions', { target: `/scenes/${target}`, intent: 'Set actions to garbage', ops: [{ op: 'set', path: '/actions', value: 'not-an-action-array' }], } as never); expect(result).toMatchObject({ isError: true }); expect(textOf(result)).toContain('structure validation'); // The corrupted actions value never reaches the store. expect(current.putScene).not.toHaveBeenCalled(); const scene = current.get().scenes.find((item) => item.id === target); expect(scene?.actions).toEqual([]); }, ); it.each(['scene_widget', 'scene_pbl'] as const)( 'accepts a valid actions array on %s (R5-P2-2)', async (target) => { const current = state(); const patch = tool(dslTools(current.store), 'patch_stage'); const actions = [{ id: 'a1', type: 'speech', text: '说明' }]; const result = await patch.execute('good-actions', { target: `/scenes/${target}`, intent: 'Set narration', ops: [{ op: 'set', path: '/actions', value: actions }], } as never); expect(result).not.toHaveProperty('isError'); expect(current.putScene).toHaveBeenCalledTimes(1); expect(current.get().scenes.find((item) => item.id === target)?.actions).toEqual(actions); }, ); it('applies set/remove atomically and clears stale speech audio', async () => { const current = state(); const patch = tool(dslTools(current.store), 'patch_stage'); const result = await patch.execute('patch', { target: '/scenes/scene_slide', intent: 'Update title color and narration', ops: [ { op: 'set', path: '/content/canvas/elements/0/defaultColor', value: '#f00' }, { op: 'set', path: '/actions/0/text', value: 'New narration' }, { op: 'remove', path: '/content/canvas/elements/0/fill' }, ], } as never); expect(result).not.toHaveProperty('isError'); const scene = current.get().scenes[0]!; expect( ((scene.content as SlideContent).canvas.elements[0] as PPTTextElement).defaultColor, ).toBe('#f00'); expect((scene.content as SlideContent).canvas.elements[0]).not.toHaveProperty('fill'); expect(scene.actions?.[0]).toMatchObject({ text: 'New narration' }); expect(scene.actions?.[0]).not.toHaveProperty('audioId'); expect(result.details).toMatchObject({ intent: 'Update title color and narration', updated: { ops: 3 }, tree: { id: 'scene_slide' }, }); }); it('a later patch_stage call keeps an earlier long HTML element change (no sibling loss)', async () => { const current = state(); const patch = tool(dslTools(current.store), 'patch_stage'); // >2KiB HTML, larger than the read-side media-byte-omission cutoff — the // value the next call must not drop. const longHtml = `${'深'.repeat(2400)}
`; const first = await patch.execute('patch', { target: '/scenes/scene_slide', intent: 'Set a long opening line', ops: [{ op: 'set', path: '/content/canvas/elements/0/content', value: longHtml }], } as never); expect(first).not.toHaveProperty('isError'); // A second, independent call patches a DIFFERENT sibling (the narration). const second = await patch.execute('patch', { target: '/scenes/1', intent: 'Fix the narration', ops: [{ op: 'set', path: '/actions/0/text', value: 'New narration' }], } as never); expect(second).not.toHaveProperty('isError'); const scene = current.get().scenes[0]!; expect((scene.content as SlideContent).canvas.elements[0]).toMatchObject({ content: longHtml, }); expect(scene.actions?.[0]).toMatchObject({ text: 'New narration' }); }); it('emits the resolved course stageId on the patch_stage checkpoint', async () => { const current = state(); const checkpoints: { tool: string; stageId?: string; sceneId?: string }[] = []; const tools = buildDslCourseTools({ store: current.store, stageAccess: async () => OWNED, onCheckpoint: (info) => checkpoints.push(info), }); const patch = tool(tools, 'patch_stage'); const result = await patch.execute('patch', { target: '/scenes/scene_slide', intent: 'Update title color', ops: [{ op: 'set', path: '/content/canvas/elements/0/defaultColor', value: '#f00' }], } as never); expect(result).not.toHaveProperty('isError'); expect(checkpoints).toHaveLength(1); expect(checkpoints[0]).toMatchObject({ tool: 'patch_stage', stageId: 'stage-test', sceneId: 'scene_slide', }); }); it('rolls back the entire batch when a middle op fails', async () => { const current = state(); const before = JSON.stringify(current.get()); const patch = tool(dslTools(current.store), 'patch_stage'); const result = await patch.execute('patch', { target: '/scenes/1', intent: 'Try an atomic batch', ops: [ { op: 'set', path: '/content/canvas/elements/0/defaultColor', value: '#f00' }, { op: 'remove', path: '/content/canvas/nope' }, { op: 'set', path: '/content/canvas/elements/0/content', value: 'Never
' }, ], } as never); expect(result).toMatchObject({ isError: true, details: { failedOp: 2 } }); expect(current.putScene).not.toHaveBeenCalled(); expect(JSON.stringify(current.get())).toBe(before); }); it('loud-fails unknown fields and rejects media omission placeholders', async () => { const current = state(); const patch = tool(dslTools(current.store), 'patch_stage'); const unknown = await patch.execute('unknown', { target: '/scenes/1', intent: 'Misspell a field', ops: [{ op: 'set', path: '/content/canvas/elements/0/defaultColour', value: '#f00' }], } as never); expect(unknown).toMatchObject({ isError: true }); expect(textOf(unknown)).toContain('additional properties'); const placeholder = 'Added
', defaultFontName: 'Inter', defaultColor: '#111', }, }, ], } as never); expect(add).not.toHaveProperty('isError'); const added = (current.get().scenes[0]!.content as SlideContent).canvas.elements[1]!; expect(added).toMatchObject({ type: 'text', content: 'Added
' }); expect(added.id).toMatch(/^el-/); const remove = await patch.execute('remove', { target: '/scenes/1', intent: 'Remove the supporting label', ops: [{ op: 'delete_element', elementId: added.id }], } as never); expect(remove).not.toHaveProperty('isError'); expect((current.get().scenes[0]!.content as SlideContent).canvas.elements).toHaveLength(1); }); it('patches quiz, widget and PBL through the same scene-root pointers', async () => { const current = state(); const patch = tool(dslTools(current.store), 'patch_stage'); for (const call of [ { target: '/scenes/2', intent: 'Update quiz wording', ops: [{ op: 'set', path: '/content/questions/0/question', value: 'Updated quiz?' }], }, { target: '/scenes/scene_widget', intent: 'Update widget guidance', ops: [{ op: 'set', path: '/content/widgetConfig/description', value: 'Move the slider' }], }, { target: '/scenes/4', intent: 'Update project milestone', ops: [{ op: 'set', path: '/content/projectV2/milestones/0/title', value: 'Research' }], }, ]) { const result = await patch.execute('generic', call as never); expect(result).not.toHaveProperty('isError'); } expect((current.get().scenes[1]!.content as QuizContent).questions[0]!.question).toBe( 'Updated quiz?', ); expect((current.get().scenes[2]!.content as InteractiveContent).widgetConfig).toMatchObject({ description: 'Move the slider', }); expect((current.get().scenes[3]!.content as PBLContent).projectV2?.milestones[0]!.title).toBe( 'Research', ); }); it('adds a quiz question by setting the complete /content/questions array (caller-owned ids)', async () => { const current = state(); const patch = tool(dslTools(current.store), 'patch_stage'); const result = await patch.execute('add-question', { target: '/scenes/2', intent: 'Add a second question', ops: [ { op: 'set', path: '/content/questions', value: [ { id: 'q1', type: 'single', question: 'Which answer?', options: [ { label: 'Alpha', value: 'A' }, { label: 'Beta', value: 'B' }, ], answer: ['A'], analysis: 'Because Alpha.', }, { id: 'q2', type: 'single', question: 'Which is larger?', options: [ { label: '1', value: 'A' }, { label: '2', value: 'B' }, ], answer: ['B'], }, ], }, ], } as never); expect(result).not.toHaveProperty('isError'); expect(current.putScene).toHaveBeenCalledTimes(1); const questions = (current.get().scenes[1]!.content as QuizContent).questions; expect(questions).toHaveLength(2); expect(questions[0]).toMatchObject({ id: 'q1', answer: ['A'] }); expect(questions[1]).toMatchObject({ id: 'q2', type: 'single', question: 'Which is larger?', answer: ['B'], }); }); it('adds an option by setting /content/questions/0/options and re-points answer atomically', async () => { const current = state(); const patch = tool(dslTools(current.store), 'patch_stage'); const result = await patch.execute('add-option', { target: '/scenes/2', intent: 'Add a third option and make it the correct one', ops: [ { op: 'set', path: '/content/questions/0/options', value: [ { label: 'Alpha', value: 'A' }, { label: 'Beta', value: 'B' }, { label: 'Gamma', value: 'C' }, ], }, { op: 'set', path: '/content/questions/0/answer', value: ['C'] }, ], } as never); expect(result).not.toHaveProperty('isError'); expect(current.putScene).toHaveBeenCalledTimes(1); const question = (current.get().scenes[1]!.content as QuizContent).questions[0]!; expect(question.options).toEqual([ { label: 'Alpha', value: 'A' }, { label: 'Beta', value: 'B' }, { label: 'Gamma', value: 'C' }, ]); // value/answer stay coupled: the answer points at a value that exists. expect(question.answer).toEqual(['C']); expect(question.options?.some((option) => option.value === question.answer?.[0])).toBe(true); }); it('adds a milestone and microtask by setting the complete /content/projectV2/milestones array (caller-owned order)', async () => { const current = state(); const patch = tool(dslTools(current.store), 'patch_stage'); const result = await patch.execute('add-milestone', { target: '/scenes/4', intent: 'Add a test milestone and a second microtask', ops: [ { op: 'set', path: '/content/projectV2/milestones', value: [ { id: 'ms-1', title: 'Plan', status: 'locked', order: 1, microtasks: [ { id: 'mt-1', title: 'Sketch', status: 'todo', assignee: 'user', hints: ['Use triangles'], order: 1, }, { id: 'mt-2', title: 'Prototype', status: 'todo', assignee: 'user', hints: ['Build the model'], order: 2, }, ], }, { id: 'ms-2', title: 'Test', status: 'locked', order: 2, microtasks: [ { id: 'mt-3', title: 'Verify', status: 'todo', assignee: 'user', hints: [], order: 1, }, ], }, ], }, ], } as never); expect(result).not.toHaveProperty('isError'); expect(current.putScene).toHaveBeenCalledTimes(1); const milestones = (current.get().scenes[3]!.content as PBLContent).projectV2!.milestones; expect(milestones).toHaveLength(2); // Order is caller-managed: exactly what was written is persisted, with no // server-side renumbering. expect(milestones.map((milestone) => milestone.order)).toEqual([1, 2]); expect(milestones[0]!.microtasks.map((microtask) => microtask.order)).toEqual([1, 2]); expect(milestones[1]).toMatchObject({ id: 'ms-2', title: 'Test', status: 'locked' }); expect(milestones[0]!.microtasks[1]).toMatchObject({ id: 'mt-2', title: 'Prototype' }); }); it('str_replace swaps one exact anchor inside a long HTML field and leaves siblings byte-identical', async () => { const scene = interactiveScene(); const head = 'Visible widget words
'; (scene.content as InteractiveContent).html = head + 'const speed = 0.015 * dt' + tail; const current = state(course([scene])); const patch = tool(dslTools(current.store), 'patch_stage'); const result = await patch.execute('patch', { target: '/scenes/scene_widget', intent: 'Slow the gravity simulation', ops: [ { op: 'str_replace', path: '/content/html', oldText: 'const speed = 0.015 * dt', newText: 'const speed = 0.006 * dt', }, ], } as never); expect(result).not.toHaveProperty('isError'); const html = (current.get().scenes[0]!.content as InteractiveContent).html!; expect(html).toBe(head + 'const speed = 0.006 * dt' + tail); expect(html).not.toContain('0.015 * dt'); expect(result.details).toMatchObject({ ops: [{ op: 'str_replace', path: '/content/html', occurrences: 1 }], }); }); it('str_replace loud-fails with a 0-occurrence count when the anchor is absent', async () => { const current = state(); const patch = tool(dslTools(current.store), 'patch_stage'); const result = await patch.execute('patch', { target: '/scenes/scene_widget', intent: 'Edit a missing anchor', ops: [ { op: 'str_replace', path: '/content/html', oldText: 'const speed = 0.015 * dt', newText: 'const speed = 0.006 * dt', }, ], } as never); expect(result).toMatchObject({ isError: true, details: { failedOp: 1 } }); expect(textOf(result)).toContain('anchor text not found in /content/html (0 occurrences)'); expect(current.putScene).not.toHaveBeenCalled(); }); it('str_replace rejects a repeated anchor with its count, then replaceAll swaps every occurrence', async () => { const scene = interactiveScene(); (scene.content as InteractiveContent).html = 'Visible widget words
'; const current = state(course([scene])); const patch = tool(dslTools(current.store), 'patch_stage'); const ambiguous = await patch.execute('ambiguous', { target: '/scenes/scene_widget', intent: 'Edit a repeated anchor', ops: [{ op: 'str_replace', path: '/content/html', oldText: '0.015', newText: '0.006' }], } as never); expect(ambiguous).toMatchObject({ isError: true, details: { failedOp: 1 } }); expect(textOf(ambiguous)).toContain( 'anchor text occurs 3 times; extend the anchor or set replaceAll', ); expect(current.putScene).not.toHaveBeenCalled(); const all = await patch.execute('all', { target: '/scenes/scene_widget', intent: 'Replace every occurrence', ops: [ { op: 'str_replace', path: '/content/html', oldText: '0.015', newText: '0.006', replaceAll: true, }, ], } as never); expect(all).not.toHaveProperty('isError'); expect((current.get().scenes[0]!.content as InteractiveContent).html).toBe( 'Visible widget words
', ); expect(all.details).toMatchObject({ ops: [{ op: 'str_replace', path: '/content/html', occurrences: 3 }], }); }); it('str_replace rejects omitted-bytes placeholder fragments in oldText and newText', async () => { const current = state(); const patch = tool(dslTools(current.store), 'patch_stage'); const placeholder = 'cafe\u0301
'; const current = state(course([scene])); const grep = tool(dslTools(current.store), 'grep_stage'); const result = await grep.execute('grep', { query: 'é', scope: 'text' } as never); const details = result.details as { hits: Array<{ start: number; end: number; snippet: string }>; }; expect(details.hits.length).toBeGreaterThanOrEqual(1); // The scene's combined visible text: title, the slide element, the action. const source = 'AI 开场\ncafe\u0301\nWelcome'; const hit = details.hits.find((h) => h.snippet.includes('cafe'))!; // The hit's span covers the whole original cluster (e + combining accent). expect(source.slice(hit.start, hit.end)).toBe('e\u0301'); }); it('foldNfkcCaseWithOffsets composes combining sequences and keeps cluster-boundary spans', () => { // Direct probe of the exported fold: decomposed and precomposed forms fold // to the same value, and every folded code unit maps to the WHOLE original // cluster (so a match always slices valid original text). const decomposed = foldNfkcCaseWithOffsets('cafe\u0301'); expect(decomposed.value).toBe('café'); expect('cafe\u0301'.normalize('NFKC')).toBe('café'); expect(decomposed.value.indexOf('é')).toBe(3); expect('cafe\u0301'.slice(decomposed.originalStarts[3]!, decomposed.originalEnds[3]!)).toBe( 'e\u0301', ); // A cluster that EXPANDS under NFKC maps both folded units to the same // original cluster span (ligature fi → fi: 'o'+'fi'+'ce' folds to 'ofice'). const ligature = foldNfkcCaseWithOffsets('ofice'); expect(ligature.value).toBe('ofice'); expect(ligature.originalStarts[1]).toBe(1); expect(ligature.originalStarts[2]).toBe(1); expect(ligature.originalEnds[1]).toBe(2); expect(ligature.originalEnds[2]).toBe(2); }); it('foldNfkcCaseWithOffsets folds Greek Σ/ς/σ to the same σ (R2-P2-2)', () => { // Unicode case folding: an uppercase ΟΣ, a word-final ς and a medial σ // must all search as σ, regardless of the word context a per-grapheme // lowercase can never see. expect(foldNfkcCaseWithOffsets('ΟΣ').value).toBe('οσ'); expect(foldNfkcCaseWithOffsets('ος').value).toBe('οσ'); expect(foldNfkcCaseWithOffsets('λόγος').value).toBe('λόγοσ'); expect(foldNfkcCaseWithOffsets('σ').value).toBe('σ'); }); it('matches an uppercase Greek ΟΣ against a lowercase ος query with correct offsets (R2-P2-2)', async () => { const scene = slideScene(); scene.title = 'ΟΣ 开场'; const current = state(course([scene])); const grep = tool(dslTools(current.store), 'grep_stage'); const result = await grep.execute('grep', { query: 'ος', scope: 'text' } as never); const details = result.details as { hits: Array<{ start: number; end: number; snippet: string }>; }; expect(details.hits.length).toBeGreaterThanOrEqual(1); const source = 'ΟΣ 开场\nHello AI\nWelcome'; const hit = details.hits.find((h) => h.snippet.includes('ΟΣ'))!; expect(source.slice(hit.start, hit.end)).toBe('ΟΣ'); }); it('matches a word-final ς source against a medial σ query (R2-P2-2)', async () => { const scene = slideScene(); scene.title = 'λόγος 开场'; const current = state(course([scene])); const grep = tool(dslTools(current.store), 'grep_stage'); const result = await grep.execute('grep', { query: 'σ', scope: 'text' } as never); const details = result.details as { hits: Array<{ start: number; end: number; snippet: string }>; }; expect(details.hits.length).toBeGreaterThanOrEqual(1); const source = 'λόγος 开场\nHello AI\nWelcome'; const hit = details.hits.find((h) => h.snippet.includes('ς'))!; expect(source.slice(hit.start, hit.end)).toBe('ς'); }); it('returns a cursor on truncation and continuation concatenates to the full scan', async () => { const scenes = Array.from({ length: 35 }, (_, index) => { const scene = slideScene(index + 1, `scene_${index + 1}`); scene.title = `needle page ${index + 1}`; ((scene.content as SlideContent).canvas.elements[0] as PPTTextElement).content = 'plain
'; scene.actions = []; return scene; }); const current = state(course(scenes)); const grep = tool(dslTools(current.store), 'grep_stage'); const first = await grep.execute('first', { query: 'needle' } as never); expect(first.details).toMatchObject({ truncated: true, cursor: expect.any(String) }); const firstDetails = first.details as { hits: unknown[]; cursor: string }; expect(firstDetails.hits).toHaveLength(30); const second = await grep.execute('second', { query: 'needle', cursor: firstDetails.cursor, } as never); const secondDetails = second.details as { hits: unknown[]; truncated: boolean }; expect(secondDetails.hits).toHaveLength(5); expect(secondDetails.truncated).toBe(false); expect([...firstDetails.hits, ...secondDetails.hits]).toHaveLength(35); }); it('declares query bounds and rejects a cursor used with another query', async () => { expect(Check(GREP_COURSE_SCHEMA, { query: '' })).toBe(false); expect(Check(GREP_COURSE_SCHEMA, { query: 'x'.repeat(201) })).toBe(false); const current = state( course(Array.from({ length: 35 }, (_, i) => slideScene(i + 1, `scene_${i}`))), ); const grep = tool(dslTools(current.store), 'grep_stage'); const first = await grep.execute('first', { query: 'hello' } as never); const cursor = (first.details as { cursor: string }).cursor; const mismatch = await grep.execute('mismatch', { query: 'other', cursor } as never); expect(mismatch).toMatchObject({ isError: true }); }); }); describe('DSL course-tool wiring', () => { const deps = (store: CourseStore) => ({ store, stageAccess: async () => OWNED, onCheckpoint: () => undefined, }); it('registers generation and DSL tools while excluding legacy editors', () => { const current = state(); const tools = buildDslCourseToolset(deps(current.store)); const names = tools.map((item) => item.name); for (const name of ['read_stage', 'patch_stage', 'grep_stage']) { expect(names).toContain(name); expect(buildCourseAllowlist()).toContain(name); } // The stage-level CRUD is a separate curriculum toolset the runner // registers beside the DSL tools; both feed the one allowlist. for (const name of ['create_stage', 'read_stage_outline']) { expect(buildCourseAllowlist()).toContain(name); } for (const name of [ 'generate_scene', 'list_scenes', 'generate_actions', 'duplicate_scene', 'import_pptx', 'generate_image', 'generate_tts', 'edit_deck', ]) { expect(names).toContain(name); expect(buildCourseAllowlist()).toContain(name); } for (const name of [ 'read_scene', 'edit_slide', 'edit_quiz', 'edit_widget', 'edit_actions', 'edit_pbl', 'set_roster', ]) { expect(names).not.toContain(name); expect(buildCourseAllowlist()).not.toContain(name); } expect(tool(tools, 'patch_stage')).toMatchObject({ executionMode: 'sequential' }); expect(tool(tools, 'read_stage')).not.toHaveProperty('executionMode'); expect(tool(tools, 'grep_stage')).not.toHaveProperty('executionMode'); expect(new Set(names)).toEqual( new Set([ 'generate_scene', 'list_scenes', 'generate_actions', 'duplicate_scene', 'import_pptx', 'generate_image', 'generate_tts', 'edit_deck', 'read_stage', 'patch_stage', 'grep_stage', ]), ); }); it('injects generic DSL compatibility guidance into every runner prompt', () => { expect(buildRunnerCoursePrompt({}).includes(DSL_TOOLS_PROMPT)).toBe(true); expect(DSL_TOOLS_PROMPT).toContain('stage-dsl'); expect(DSL_TOOLS_PROMPT).toContain('/scenes/<1-based order|sceneId>'); expect(DSL_TOOLS_PROMPT).toContain('Start with detail:"tree" to see structure'); expect(DSL_TOOLS_PROMPT).toContain('prefer grep_stage over paging with offset'); }); }); describe('grep_stage source scope searches the bounded projection (R6-P2-6)', () => { it('large inline media bytes never enter the searched source', async () => { const bigBytes = `UNIQUE-MEDIA-NEEDLE-${'A'.repeat(120_000)}`; const scene = slideScene(); ( scene.content as unknown as { canvas: { elements: { type: string; id: string; src?: string }[] }; } ).canvas.elements.push({ type: 'image', id: 'img-big', src: `data:image/png;base64,${bigBytes}`, }); const s = state(course([scene])); const grep = tool(dslTools(s.store), 'grep_stage'); // A needle that only exists inside the omitted media bytes: unreachable, // because the projection replaced those bytes before serialization. const insideMedia = (await grep.execute('c1', { stageId: 'stage-test', query: 'UNIQUE-MEDIA-NEEDLE', scope: 'source', } as never)) as { details: { hits: unknown[] } }; expect(insideMedia.details.hits).toHaveLength(0); // The placeholder marker IS findable — proof the projection (the same // source read_stage serves) is what got searched. const placeholder = (await grep.execute('c2', { stageId: 'stage-test', query: 'bytes omitted', scope: 'source', } as never)) as { details: { hits: unknown[] } }; expect(placeholder.details.hits.length).toBeGreaterThan(0); // Non-media source content stays searchable. const normal = (await grep.execute('c3', { stageId: 'stage-test', query: 'canvas-1', scope: 'source', } as never)) as { details: { hits: unknown[] } }; expect(normal.details.hits.length).toBeGreaterThan(0); }); }); /** * Cross-owner isolation through the run's OWNER-BOUND store and the * three-state owner probe. * * These tests drive the reference's tool-layer contract over PGlite through * the actual tool entry points: every stageId-bearing course/DSL tool is * owner-gated by `withOwnerStageAuthorization` before it touches the store, so * a FOREIGN stage is refused with the not-yours message on read, patch, AND * grep — the reference treats the whole open-domain course toolset as * owner-scoped, and only the owner's own tools read it. */ describe('cross-owner isolation (owner-scoped store)', () => { let db: PGlite; class PGlitePool { constructor(readonly database: PGlite) {} query(text: string, params?: unknown[]) { return this.database.query(text, params); } async connect() { return { query: (text: string, params?: unknown[]) => this.database.query(text, params), release() {}, }; } } function ownerStore(ownerId: string): CourseStore { // The same seam the runner uses: the owner-bound document store claims // `stage_meta` inside its create transaction, so a stage minted through // `create_stage` is immediately visible to `probeStageAccess`. return withPlainJsonDocumentWrites( createOwnerBoundDocumentStore({ pool: new PGlitePool(db), ownerId, validateScene: validateAppScene, validateStage: validateAppStage, }), ) as unknown as CourseStore; } function toolsFor(ownerId: string, sessionId: string) { const store = ownerStore(ownerId); const stageAccess = (stageId: string) => probeStageAccess(ownerId, stageId, db); const dsl = buildDslCourseToolset({ store, stageAccess, onCheckpoint: () => undefined, sessionId, }); const curriculum = buildCurriculumTools({ store, ownerId, sessionId, stageAccess, }); const run = (toolList: AgentTool