From a22b74abea9bcb373016dfc182966973b31d3e65 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 20 Mar 2026 11:22:13 +0000 Subject: [PATCH 1/8] fix: fix 3 bugs found during code review 1. formatQuestionsForPrompt called .join() on QuizOption objects, producing "[object Object]" in AI prompts instead of readable option text (e.g. "A. Option1, B. Option2"). 2. json-repair Fix 1 regex double-escaped valid JSON escapes (\t, \n, \r, \b, \f) and couldn't handle strings with escaped quotes. Now uses a proper string-aware regex and preserves valid escapes. 3. PlaybackEngine spotlight/laser actions used synchronous recursion via processNext(), risking stack overflow with many consecutive non-speech actions. Now uses queueMicrotask() to break recursion. https://claude.ai/code/session_01JdigqQtiAvomeifvWSQbjm --- lib/generation/json-repair.ts | 13 +++++++++---- lib/generation/scene-generator.ts | 4 +++- lib/playback/engine.ts | 6 ++++-- 3 files changed, 16 insertions(+), 7 deletions(-) diff --git a/lib/generation/json-repair.ts b/lib/generation/json-repair.ts index 0487714b..89f7fa0b 100644 --- a/lib/generation/json-repair.ts +++ b/lib/generation/json-repair.ts @@ -111,10 +111,15 @@ export function tryParseJson(jsonStr: string): T | null { // Fix 1: Handle LaTeX-style escapes that break JSON (e.g., \frac, \left, \right, \times, etc.) // These are common in math content and need to be double-escaped - // Match backslash followed by letters (LaTeX commands) inside strings - fixed = fixed.replace(/"([^"]*?)"/g, (_match, content) => { - // Double-escape any backslash followed by a letter (except valid JSON escapes) - const fixedContent = content.replace(/\\([a-zA-Z])/g, '\\\\$1'); + // Match backslash followed by letters (LaTeX commands) inside strings, + // but skip valid JSON escape sequences (\b, \f, \n, \r, \t, \u) + fixed = fixed.replace(/"([^"\\]*(?:\\.[^"\\]*)*)"/g, (_match, content) => { + // Double-escape backslash+letter ONLY for non-JSON-escape letters + const fixedContent = content.replace(/\\([a-zA-Z])/g, (_m: string, ch: string) => { + // Preserve valid JSON escape sequences + if ('bfnrtu'.includes(ch)) return `\\${ch}`; + return `\\\\${ch}`; + }); return `"${fixedContent}"`; }); diff --git a/lib/generation/scene-generator.ts b/lib/generation/scene-generator.ts index 1dc22937..ff81a840 100644 --- a/lib/generation/scene-generator.ts +++ b/lib/generation/scene-generator.ts @@ -1079,7 +1079,9 @@ function formatElementsForPrompt(elements: PPTElement[]): string { function formatQuestionsForPrompt(questions: QuizQuestion[]): string { return questions .map((q, i) => { - const optionsText = q.options ? `Options: ${q.options.join(', ')}` : ''; + const optionsText = q.options + ? `Options: ${q.options.map((o) => `${o.value}. ${o.label}`).join(', ')}` + : ''; return `Q${i + 1} (${q.type}): ${q.question}\n${optionsText}`; }) .join('\n\n'); diff --git a/lib/playback/engine.ts b/lib/playback/engine.ts index c9c5c8bf..66d8d8ee 100644 --- a/lib/playback/engine.ts +++ b/lib/playback/engine.ts @@ -500,8 +500,10 @@ export class PlaybackEngine { ? { dimOpacity: action.dimOpacity } : { color: action.color }), } as Effect); - // Don't block — continue immediately - this.processNext(); + // Don't block — continue immediately (use queueMicrotask to avoid + // stack overflow from deep synchronous recursion when many consecutive + // spotlight/laser actions appear in sequence) + queueMicrotask(() => this.processNext()); break; } From 0ec2f431c6d523352af4ad85eb1fa7e2e8d13cfb Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 20 Mar 2026 11:23:19 +0000 Subject: [PATCH 2/8] fix: ensure allowedActions whitelist applies to non-slide scenes The early return in Step 6 (slide-only action filter) caused Step 7 (allowedActions whitelist) to be skipped for non-slide scenes. This meant hallucinated actions from agents in quiz/interactive/pbl scenes were not filtered by the role-based allowedActions whitelist. Refactored to use a mutable result variable instead of early returns so both filters always apply. https://claude.ai/code/session_01JdigqQtiAvomeifvWSQbjm --- lib/generation/action-parser.ts | 21 ++++++++++----------- 1 file changed, 10 insertions(+), 11 deletions(-) diff --git a/lib/generation/action-parser.ts b/lib/generation/action-parser.ts index 7f934ba2..a471b688 100644 --- a/lib/generation/action-parser.ts +++ b/lib/generation/action-parser.ts @@ -127,28 +127,27 @@ export function parseActionsFromStructuredOutput( } // Step 6: Filter out slide-only actions for non-slide scenes (defense in depth) + let result = actions; if (sceneType && sceneType !== 'slide') { - const before = actions.length; - const filtered = actions.filter((a) => !SLIDE_ONLY_ACTIONS.includes(a.type as ActionType)); - if (filtered.length < before) { - log.info(`Stripped ${before - filtered.length} slide-only action(s) from ${sceneType} scene`); + const before = result.length; + result = result.filter((a) => !SLIDE_ONLY_ACTIONS.includes(a.type as ActionType)); + if (result.length < before) { + log.info(`Stripped ${before - result.length} slide-only action(s) from ${sceneType} scene`); } - return filtered; } // Step 7: Filter by allowedActions whitelist (defense in depth for role-based isolation) // Catches hallucinated actions not in the agent's permitted set, e.g. a student agent // mimicking spotlight/laser after seeing teacher actions in chat history. if (allowedActions && allowedActions.length > 0) { - const before = actions.length; - const filtered = actions.filter((a) => a.type === 'speech' || allowedActions.includes(a.type)); - if (filtered.length < before) { + const before = result.length; + result = result.filter((a) => a.type === 'speech' || allowedActions.includes(a.type)); + if (result.length < before) { log.info( - `Stripped ${before - filtered.length} disallowed action(s) by allowedActions whitelist`, + `Stripped ${before - result.length} disallowed action(s) by allowedActions whitelist`, ); } - return filtered; } - return actions; + return result; } From f2ef563778ed7993722e50280236da2bf45ff183 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 20 Mar 2026 11:24:23 +0000 Subject: [PATCH 3/8] fix: prevent video playback hang and division by zero in CJK detection 1. executePlayVideo could hang forever if the video element was invalid or the state change was missed between playVideo() and subscribe(). Added a 5-minute safety timeout to prevent indefinite blocking. 2. CJK language detection in browser TTS divided by chunkText.length without checking for empty strings, producing NaN. Now guards against zero-length text. https://claude.ai/code/session_01JdigqQtiAvomeifvWSQbjm --- lib/action/engine.ts | 12 +++++++++++- lib/playback/engine.ts | 5 +++-- 2 files changed, 14 insertions(+), 3 deletions(-) diff --git a/lib/action/engine.ts b/lib/action/engine.ts index d2d38316..2b70a4ce 100644 --- a/lib/action/engine.ts +++ b/lib/action/engine.ts @@ -214,15 +214,25 @@ export class ActionEngine { useCanvasStore.getState().playVideo(action.elementId); - // Wait until the video finishes playing + // Wait until the video finishes playing, with a safety timeout to prevent + // the playback engine from hanging indefinitely if the video element is + // invalid or the state change is missed. return new Promise((resolve) => { + const MAX_VIDEO_WAIT_MS = 5 * 60 * 1000; // 5 minutes + const timeout = setTimeout(() => { + unsubscribe(); + log.warn(`[playVideo] Timeout waiting for video ${action.elementId} to finish`); + resolve(); + }, MAX_VIDEO_WAIT_MS); const unsubscribe = useCanvasStore.subscribe((state) => { if (state.playingVideoElementId !== action.elementId) { + clearTimeout(timeout); unsubscribe(); resolve(); } }); if (useCanvasStore.getState().playingVideoElementId !== action.elementId) { + clearTimeout(timeout); unsubscribe(); resolve(); } diff --git a/lib/playback/engine.ts b/lib/playback/engine.ts index 66d8d8ee..10f195d2 100644 --- a/lib/playback/engine.ts +++ b/lib/playback/engine.ts @@ -634,8 +634,9 @@ export class PlaybackEngine { if (!voiceFound) { // No usable voice configured — detect text language so the browser // auto-selects an appropriate voice. - const cjkRatio = - (chunkText.match(/[\u4e00-\u9fff\u3400-\u4dbf]/g) || []).length / chunkText.length; + const cjkRatio = chunkText.length > 0 + ? (chunkText.match(/[\u4e00-\u9fff\u3400-\u4dbf]/g) || []).length / chunkText.length + : 0; utterance.lang = cjkRatio > CJK_LANG_THRESHOLD ? 'zh-CN' : 'en-US'; } From 47bcccb92bb7eb88b36b1f4c21e9592806b26d9a Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 20 Mar 2026 11:25:22 +0000 Subject: [PATCH 4/8] fix: prevent ordered entry with invalid index when partial text has no remaining content When a previously-partial text item completed but all content had already been streamed as deltas (remaining is empty), the code still pushed an ordered entry pointing to textChunks.length - 1, which could be -1 or reference an unrelated chunk. This caused spurious warnings in director-graph and potential text loss. Now only pushes the ordered entry when there is actual remaining content to emit. https://claude.ai/code/session_01JdigqQtiAvomeifvWSQbjm --- lib/orchestration/stateless-generate.ts | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/lib/orchestration/stateless-generate.ts b/lib/orchestration/stateless-generate.ts index c307f091..56c69e04 100644 --- a/lib/orchestration/stateless-generate.ts +++ b/lib/orchestration/stateless-generate.ts @@ -213,12 +213,12 @@ export function parseStructuredChunk(chunk: string, state: ParserState): ParseRe const remaining = content.slice(state.lastPartialTextLength); if (remaining) { result.textChunks.push(remaining); + // Only push ordered entry when there is actual content to emit + result.ordered.push({ + type: 'text', + index: result.textChunks.length - 1, + }); } - // Use per-call array index for consistency with emitItem fix - result.ordered.push({ - type: 'text', - index: result.textChunks.length - 1, - }); textSegmentIndex++; state.lastPartialTextLength = 0; continue; From a7d72c2474e7545ae85451ae4440e5ac0cb6825a Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 20 Mar 2026 11:26:32 +0000 Subject: [PATCH 5/8] fix: validate quiz points parameter and fix parse-pdf metadata spread order 1. quiz-grade: `points` from request body was used without validation, causing NaN in score calculation and invalid JSON responses when points was undefined, negative, or non-numeric. Now validates it is a positive finite number. 2. parse-pdf: metadata spread order put the default `pageCount` before `...result.metadata`, so the spread would overwrite the fallback. Fixed by spreading metadata first, then applying defaults after. Also changed `||` to `??` so pageCount=0 is preserved correctly. https://claude.ai/code/session_01JdigqQtiAvomeifvWSQbjm --- app/api/parse-pdf/route.ts | 2 +- app/api/quiz-grade/route.ts | 5 +++++ 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/app/api/parse-pdf/route.ts b/app/api/parse-pdf/route.ts index 94feff54..422a64cc 100644 --- a/app/api/parse-pdf/route.ts +++ b/app/api/parse-pdf/route.ts @@ -62,8 +62,8 @@ export async function POST(req: NextRequest) { const resultWithMetadata: ParsedPdfContent = { ...result, metadata: { - pageCount: result.metadata?.pageCount || 0, // Ensure pageCount is always a number ...result.metadata, + pageCount: result.metadata?.pageCount ?? 0, // Ensure pageCount is always a number fileName: pdfFile.name, fileSize: pdfFile.size, }, diff --git a/app/api/quiz-grade/route.ts b/app/api/quiz-grade/route.ts index d0aab62e..5436ec66 100644 --- a/app/api/quiz-grade/route.ts +++ b/app/api/quiz-grade/route.ts @@ -34,6 +34,11 @@ export async function POST(req: NextRequest) { return apiError('MISSING_REQUIRED_FIELD', 400, 'question and userAnswer are required'); } + // Validate points is a positive finite number + if (!points || !Number.isFinite(points) || points <= 0) { + return apiError('INVALID_PARAMETER', 400, 'points must be a positive number'); + } + // Resolve model from request headers const { model: languageModel } = resolveModelFromHeaders(req); From d090b92ad3c5b8d88ca36506fe7b45bffaebf791 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 20 Mar 2026 11:27:47 +0000 Subject: [PATCH 6/8] fix: crash in moveUpElement, dead-code in video options, wrong parens in canCombine 1. use-order-element.ts: moveUpElement crashed with TypeError when a grouped element was already at the top of the z-order. The code accessed copyOfElementList[maxLevel + 1] without bounds checking, then dereferenced .groupId on undefined. Added early return guard matching the pattern used in the non-grouped branch. 2. video-providers.ts: normalizeVideoOptions had a dead-code ternary inside an if-block that already proved the condition was false. Simplified to directly use the fallback value. 3. use-canvas-operations.ts: canCombine check had incorrect parenthesization: (el.groupId && el.groupId) === firstGroupId evaluated the && first, which is equivalent to just el.groupId. Fixed to: el.groupId && el.groupId === firstGroupId. https://claude.ai/code/session_01JdigqQtiAvomeifvWSQbjm --- lib/hooks/use-canvas-operations.ts | 2 +- lib/hooks/use-order-element.ts | 2 ++ lib/media/video-providers.ts | 4 +--- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/lib/hooks/use-canvas-operations.ts b/lib/hooks/use-canvas-operations.ts index 92c6d0ca..3bc61738 100644 --- a/lib/hooks/use-canvas-operations.ts +++ b/lib/hooks/use-canvas-operations.ts @@ -371,7 +371,7 @@ export function useCanvasOperations() { if (!firstGroupId) return true; const inSameGroup = activeElementList.every( - (el) => (el.groupId && el.groupId) === firstGroupId, + (el) => el.groupId && el.groupId === firstGroupId, ); return !inSameGroup; }, [activeElementList]); diff --git a/lib/hooks/use-order-element.ts b/lib/hooks/use-order-element.ts index 5fef1e5f..5defc12a 100644 --- a/lib/hooks/use-order-element.ts +++ b/lib/hooks/use-order-element.ts @@ -35,6 +35,8 @@ export function useOrderElement() { const { minLevel, maxLevel } = getCombineElementLevelRange(elementList, combineElementList); // Already at the top level, cannot move further + if (maxLevel >= elementList.length - 1) return; + const nextElement = copyOfElementList[maxLevel + 1]; const movedElementList = copyOfElementList.splice(minLevel, combineElementList.length); diff --git a/lib/media/video-providers.ts b/lib/media/video-providers.ts index cba1ee92..6432c2c9 100644 --- a/lib/media/video-providers.ts +++ b/lib/media/video-providers.ts @@ -122,9 +122,7 @@ export function normalizeVideoOptions( !provider.supportedAspectRatios.includes(normalized.aspectRatio) ) { normalized.aspectRatio = - normalized.aspectRatio && provider.supportedAspectRatios.includes(normalized.aspectRatio) - ? normalized.aspectRatio - : (provider.supportedAspectRatios[0] as VideoGenerationOptions['aspectRatio']); + provider.supportedAspectRatios[0] as VideoGenerationOptions['aspectRatio']; } } From a356e2fcaac9f1d03b9bfb4a90529e3b1bea8182 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 21 Mar 2026 14:01:19 +0000 Subject: [PATCH 7/8] style: fix prettier formatting in 3 files https://claude.ai/code/session_01JdigqQtiAvomeifvWSQbjm --- lib/hooks/use-canvas-operations.ts | 4 +--- lib/media/video-providers.ts | 4 ++-- lib/playback/engine.ts | 7 ++++--- 3 files changed, 7 insertions(+), 8 deletions(-) diff --git a/lib/hooks/use-canvas-operations.ts b/lib/hooks/use-canvas-operations.ts index 3bc61738..bfcbfd3e 100644 --- a/lib/hooks/use-canvas-operations.ts +++ b/lib/hooks/use-canvas-operations.ts @@ -370,9 +370,7 @@ export function useCanvasOperations() { const firstGroupId = activeElementList[0].groupId; if (!firstGroupId) return true; - const inSameGroup = activeElementList.every( - (el) => el.groupId && el.groupId === firstGroupId, - ); + const inSameGroup = activeElementList.every((el) => el.groupId && el.groupId === firstGroupId); return !inSameGroup; }, [activeElementList]); diff --git a/lib/media/video-providers.ts b/lib/media/video-providers.ts index 6432c2c9..0169663b 100644 --- a/lib/media/video-providers.ts +++ b/lib/media/video-providers.ts @@ -121,8 +121,8 @@ export function normalizeVideoOptions( !normalized.aspectRatio || !provider.supportedAspectRatios.includes(normalized.aspectRatio) ) { - normalized.aspectRatio = - provider.supportedAspectRatios[0] as VideoGenerationOptions['aspectRatio']; + normalized.aspectRatio = provider + .supportedAspectRatios[0] as VideoGenerationOptions['aspectRatio']; } } diff --git a/lib/playback/engine.ts b/lib/playback/engine.ts index 10f195d2..cd55d36f 100644 --- a/lib/playback/engine.ts +++ b/lib/playback/engine.ts @@ -634,9 +634,10 @@ export class PlaybackEngine { if (!voiceFound) { // No usable voice configured — detect text language so the browser // auto-selects an appropriate voice. - const cjkRatio = chunkText.length > 0 - ? (chunkText.match(/[\u4e00-\u9fff\u3400-\u4dbf]/g) || []).length / chunkText.length - : 0; + const cjkRatio = + chunkText.length > 0 + ? (chunkText.match(/[\u4e00-\u9fff\u3400-\u4dbf]/g) || []).length / chunkText.length + : 0; utterance.lang = cjkRatio > CJK_LANG_THRESHOLD ? 'zh-CN' : 'en-US'; } From 558a5a826b46a8811ebadfcbfb02b3f06e9cb078 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 21 Mar 2026 14:04:22 +0000 Subject: [PATCH 8/8] fix: use valid ApiErrorCode in quiz-grade route INVALID_PARAMETER is not a member of API_ERROR_CODES. Use INVALID_REQUEST instead, which is the appropriate code for invalid request parameters. https://claude.ai/code/session_01JdigqQtiAvomeifvWSQbjm --- app/api/quiz-grade/route.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/api/quiz-grade/route.ts b/app/api/quiz-grade/route.ts index 5436ec66..f49a9366 100644 --- a/app/api/quiz-grade/route.ts +++ b/app/api/quiz-grade/route.ts @@ -36,7 +36,7 @@ export async function POST(req: NextRequest) { // Validate points is a positive finite number if (!points || !Number.isFinite(points) || points <= 0) { - return apiError('INVALID_PARAMETER', 400, 'points must be a positive number'); + return apiError('INVALID_REQUEST', 400, 'points must be a positive number'); } // Resolve model from request headers