import type { StatelessChatRequest } from '@/lib/types/chat'; import type { WhiteboardActionRecord } from '../types'; import { createCodeRenderBudget, renderCodeLines } from './code-line-budget'; interface VirtualWhiteboardElement { agentName: string; summary: string; elementId?: string; codeLines?: Array<{ id: string; content: string }>; codeLanguage?: string; codeFileName?: string; // The mutation sequence number of the last wb_draw_code / wb_edit_code that // touched this element during the current round; null for elements seeded // from the request-start snapshot and for non-code draws (which do not // compete for the code-line budget). Budget is allocated most-recently- // touched first, so when several code blocks are drawn this round the one a // later child agent is most likely to edit keeps its line ids even if an // earlier, larger block would otherwise exhaust the shared budget. lastTouchedSequence: number | null; } function getRecordElementId(record: WhiteboardActionRecord): string | undefined { const elementId = record.params.elementId; return typeof elementId === 'string' && elementId ? elementId : undefined; } function summarizeCodeElement(element: VirtualWhiteboardElement): string { const lines = element.codeLines ?? []; const fileName = element.codeFileName ? ` "${element.codeFileName}"` : ''; return `code block${fileName} (${element.codeLanguage || 'text'}, ${lines.length} lines)`; } function getInitialWhiteboardElements( storeState: StatelessChatRequest['storeState'], ): VirtualWhiteboardElement[] { const whiteboards = storeState.stage?.whiteboard; const latestWhiteboard = Array.isArray(whiteboards) ? whiteboards.at(-1) : null; const source = latestWhiteboard?.elements; if (!Array.isArray(source)) return []; return source.flatMap((element) => { if (!element || typeof element !== 'object') return []; const candidate = element as { id?: unknown; type?: unknown; language?: unknown; fileName?: unknown; lines?: unknown; }; if (typeof candidate.id !== 'string' || !candidate.id) return []; if (candidate.type !== 'code') { return [ { agentName: 'Before this round', elementId: candidate.id, summary: `existing ${String(candidate.type || 'element')} [id:${candidate.id}]`, lastTouchedSequence: null, }, ]; } const virtual: VirtualWhiteboardElement = { agentName: 'Before this round', elementId: candidate.id, summary: '', lastTouchedSequence: null, codeLines: Array.isArray(candidate.lines) ? candidate.lines.flatMap((line) => { if (!line || typeof line !== 'object') return []; const value = line as { id?: unknown; content?: unknown }; return typeof value.id === 'string' ? [{ id: value.id, content: String(value.content ?? '') }] : []; }) : [], codeLanguage: typeof candidate.language === 'string' ? candidate.language : 'text', codeFileName: typeof candidate.fileName === 'string' ? candidate.fileName : undefined, }; virtual.summary = summarizeCodeElement(virtual); return [virtual]; }); } function applyCodeEdit( target: VirtualWhiteboardElement, record: WhiteboardActionRecord, sequence: number, ): void { if (!target.codeLines) return; const operation = record.params.operation; const contentLines = String(record.params.content ?? '').split('\n'); const targetIds = Array.isArray(record.params.lineIds) ? record.params.lineIds.map(String) : []; const newLineIds = Array.isArray(record.params.newLineIds) ? record.params.newLineIds.map(String) : []; if (operation === 'insert_after' || operation === 'insert_before') { const lineId = String(record.params.lineId || ''); const index = target.codeLines.findIndex((line) => line.id === lineId); if (index >= 0) { target.codeLines.splice( operation === 'insert_after' ? index + 1 : index, 0, ...contentLines.map((content, offset) => ({ id: newLineIds[offset] ?? `${lineId}-${operation}-${offset + 1}`, content, })), ); } } else if (operation === 'delete_lines') { const deleteIds = new Set(targetIds); target.codeLines = target.codeLines.filter((line) => !deleteIds.has(line.id)); } else if (operation === 'replace_lines') { const firstIndex = target.codeLines.findIndex((line) => line.id === targetIds[0]); if (firstIndex >= 0) { const replaceIds = new Set(targetIds); target.codeLines = target.codeLines.filter((line) => !replaceIds.has(line.id)); target.codeLines.splice( firstIndex, 0, ...contentLines.map((content, index) => ({ id: newLineIds[index] ?? targetIds[index] ?? `${targetIds[0]}-replacement-${index + 1}`, content, })), ); } } target.agentName = record.agentName; target.lastTouchedSequence = sequence; target.summary = `${summarizeCodeElement(target)}; edited (${String(operation || 'edit')})`; } /** Replay this round's ledger on top of the request-start whiteboard snapshot. */ export function buildVirtualWhiteboardContext( storeState: StatelessChatRequest['storeState'], ledger?: WhiteboardActionRecord[], ): string { if (!ledger || ledger.length === 0) return ''; const elements = getInitialWhiteboardElements(storeState); let hasContentMutation = false; // Monotonic counter incremented on each wb_draw_code / successful // wb_edit_code, recorded on the target element so budget allocation can // prefer the most recently touched code block this round. let touchSequence = 0; for (const record of ledger) { const elementId = getRecordElementId(record); switch (record.actionName) { case 'wb_clear': hasContentMutation = true; elements.length = 0; break; case 'wb_delete': hasContentMutation = true; for (let index = elements.length - 1; index >= 0; index -= 1) { if (elements[index].elementId === elementId) elements.splice(index, 1); } break; case 'wb_draw_text': { hasContentMutation = true; const content = String(record.params.content || '').slice(0, 40); elements.push({ agentName: record.agentName, elementId, lastTouchedSequence: null, summary: `text: "${content}${content.length >= 40 ? '...' : ''}" at (${record.params.x ?? '?'},${record.params.y ?? '?'}), size ~${record.params.width ?? 400}x${record.params.height ?? 100}`, }); break; } case 'wb_draw_shape': hasContentMutation = true; elements.push({ agentName: record.agentName, elementId, lastTouchedSequence: null, summary: `shape(${record.params.type || record.params.shape || 'rectangle'}) at (${record.params.x ?? '?'},${record.params.y ?? '?'}), size ${record.params.width ?? 100}x${record.params.height ?? 100}`, }); break; case 'wb_draw_chart': { hasContentMutation = true; const labels = Array.isArray(record.params.labels) ? record.params.labels : (record.params.data as Record)?.labels; elements.push({ agentName: record.agentName, elementId, lastTouchedSequence: null, summary: `chart(${record.params.chartType || record.params.type || 'bar'})${labels ? `: labels=[${(labels as string[]).slice(0, 4).join(',')}]` : ''} at (${record.params.x ?? '?'},${record.params.y ?? '?'}), size ${record.params.width ?? 350}x${record.params.height ?? 250}`, }); break; } case 'wb_draw_latex': { hasContentMutation = true; const latex = String(record.params.latex || '').slice(0, 40); elements.push({ agentName: record.agentName, elementId, lastTouchedSequence: null, summary: `latex: "${latex}${latex.length >= 40 ? '...' : ''}" at (${record.params.x ?? '?'},${record.params.y ?? '?'}), size ~${record.params.width ?? 400}x${record.params.height ?? 80}`, }); break; } case 'wb_draw_table': { hasContentMutation = true; const data = record.params.data as unknown[][] | undefined; const rows = data?.length || 0; const cols = data?.[0]?.length || 0; elements.push({ agentName: record.agentName, elementId, lastTouchedSequence: null, summary: `table(${rows}×${cols}) at (${record.params.x ?? '?'},${record.params.y ?? '?'}), size ${record.params.width ?? 400}x${record.params.height ?? rows * 40 + 20}`, }); break; } case 'wb_draw_line': hasContentMutation = true; elements.push({ agentName: record.agentName, elementId, lastTouchedSequence: null, summary: `line${(record.params.points as string[] | undefined)?.includes('arrow') ? ' (arrow)' : ''}: (${record.params.startX ?? '?'},${record.params.startY ?? '?'}) → (${record.params.endX ?? '?'},${record.params.endY ?? '?'})`, }); break; case 'wb_draw_code': { hasContentMutation = true; const code = String(record.params.code || ''); const suppliedIds = Array.isArray(record.params.lineIds) ? record.params.lineIds.map(String) : []; const virtual: VirtualWhiteboardElement = { agentName: record.agentName, elementId, summary: '', lastTouchedSequence: (touchSequence += 1), codeLines: code .split('\n') .map((content, index) => ({ id: suppliedIds[index] ?? `L${index + 1}`, content })), codeLanguage: String(record.params.language || 'text'), codeFileName: record.params.fileName ? String(record.params.fileName) : undefined, }; virtual.summary = `${summarizeCodeElement(virtual)} at (${record.params.x ?? '?'},${record.params.y ?? '?'}), size ${record.params.width ?? 500}x${record.params.height ?? 300}`; elements.push(virtual); break; } case 'wb_edit_code': { hasContentMutation = true; const target = elements.find((element) => element.elementId === elementId); if (target) applyCodeEdit(target, record, (touchSequence += 1)); break; } default: break; } } if (!hasContentMutation) return ''; if (elements.length === 0) { return ` ## Whiteboard Changes This Round (IMPORTANT) The whiteboard is now empty after changes made during this discussion round. `; } // Expose each element's id (and, for code, its line ids) so a later child // agent can target them with wb_delete / wb_edit_code — the runtime validators // require exact elementId/lineId, which were previously invisible in-prompt. // // The code-line budget is shared across all elements so the whole section is // bounded. It is spent in PRIORITY order — this round's code blocks first // (most recently drawn/edited first), pre-existing snapshot blocks last — so // neither a large stale block nor a large earlier block drawn this round can // exhaust the budget before the block a later child is most likely to edit // gets its line ids rendered (a later child needs those ids to edit it this // loop). The displayed list stays in board order; only budget allocation is // reordered. let codeBudget = createCodeRenderBudget(); const renderedCodeByIndex = new Map(); const budgetOrder = elements .map((_, index) => index) .sort((a, b) => { const sa = elements[a].lastTouchedSequence; const sb = elements[b].lastTouchedSequence; // Snapshot elements (null) sort last; among this-round code, higher // sequence (more recent) sorts first. if (sa === null && sb === null) return 0; if (sa === null) return 1; if (sb === null) return -1; return sb - sa; }); for (const index of budgetOrder) { const codeLines = elements[index].codeLines ?? []; if (codeLines.length === 0) continue; const { text, budget } = renderCodeLines(codeLines, codeBudget); codeBudget = budget; renderedCodeByIndex.set(index, text); } const elementLines = elements .map((element, index) => { const idTag = element.elementId ? ` (id: ${element.elementId})` : ''; const header = ` ${index + 1}. [by ${element.agentName}]${idTag} ${element.summary}`; const codeText = renderedCodeByIndex.get(index); return codeText ? `${header}\n${codeText}` : header; }) .join('\n'); return ` ## Whiteboard Changes This Round (IMPORTANT) Other agents have modified the whiteboard during this discussion round. Current whiteboard elements (${elements.length}): ${elementLines} DO NOT redraw content that already exists. Check positions above before adding new elements. `; }