// Pure chat-activity helpers shared by chat.js and dependency-free node tests: // live-card presentation projections (moved verbatim from chat.js) plus the // in-flight direct/ephemeral turn status reducer and snapshot hydration. import { executorIdentityMarkup, joinMetaParts } from './harness_presentation.js'; import { resultFilesItemHtml } from './result_files.js'; import { compactModel, formatLogDuration, modelExecutionLabel } from './log_events.js'; import { createSystemMessageActions } from './ui_helpers.js'; import { projectReference } from './project_reference.js'; import { joinMarkdownHeadings } from './utils.js'; import { REUSABLE_TASK_IDS } from './task_control_menu.js'; import { accountedUpperBound, accountedUpperBoundWithChildren, escapeHtmlAttr, escapeHtmlText as escapeHtml, formatUsdWhole, renderMarkdown, } from './utils.js'; export function withTaskCostMeta(summary, payload, { replace = false, rawTs = '' } = {}) { const projection = taskCostProjection(payload, rawTs); // `replace` frames (task_done/task_cost_finalized) never keep the // summarizer's own meta strings. Cost renders ONLY from the card's sticky // record.costMeta (applyLiveCardState); summarizer-built `cost=` strings // are dropped UNCONDITIONALLY — a frame without task-scope accounting // evidence must show no money at all, not a bare per-call number. const base = replace ? { ...summary, meta: [] } : summary; const out = projection ? { ...base, costProjection: projection } : { ...base }; if (payload?.model_execution && typeof payload.model_execution === 'object') { out.modelExecution = payload.model_execution; } if (Array.isArray(out.meta) && out.meta.length) { out.meta = out.meta.filter((entry) => !String(entry || '').startsWith('cost=')); } return out; } export function applyHistoricalModelExecution(record, historical) { if (!record || record.modelExecution || !historical?.model_execution) return false; record.modelExecution = historical.model_execution; return true; } export function senderLabel(role, isProgress = false, systemType = '', opts = {}, chatSessionId = '') { if (role === 'user') { if (opts.source === 'telegram') return opts.senderLabel || 'Telegram'; if (opts.senderSessionId && opts.senderSessionId !== chatSessionId) { return `WebUI (${opts.senderSessionId.slice(0, 8)})`; } return opts.senderLabel || 'You'; } if (role === 'system') { if (systemType === 'task_summary') return '📋 Task Summary'; if (systemType === 'skill_review') return '📋 Skill Review'; return '📋 System'; } if (isProgress) return '💬 Thought'; // A self-initiated turn (a consciousness wake-up) signs its final bubble // through the same sender line the user bubble uses for its source. if (opts.initiator === 'consciousness') return 'Ouroboros · Consciousness'; return 'Ouroboros'; } export function isLiveLineExpandable(item) { return Boolean( (item.fullHeadline && item.fullHeadline !== item.headline) || (item.fullBody && item.fullBody !== item.body) // P3: even when the preview equals the capped body, a server-truncated line // with a fetch ref has MORE to show (the genuinely-full output on demand). || (item.truncated && item.fullRef) ); } export function buildTimelineItemHtml(item, record) { if (item.resultArtifacts) return resultFilesItemHtml(item); const expandable = isLiveLineExpandable(item); const expanded = expandable && record.expandedLineKeys.has(item.lineKey); const displayHeadline = expanded && item.fullHeadline ? item.fullHeadline : item.headline; // P3: when expanded, prefer the genuinely-full fetched output, then the capped // fullBody, then the preview body. A server-truncated line shows the fetched full // text in a bounded-scroll box so a huge research output never grows the chat. const displayBody = expanded ? (item.fetchedFull || item.fullBody || item.body) : item.body; const showingFetched = expanded && Boolean(item.fetchedFull); const loadingFull = expanded && Boolean(item.truncated && item.fullRef && !item.fetchedFull); const isProgressLine = item.phase === 'working' || item.phase === 'thinking'; const bodyId = `chat-live-line-body-${String(record.groupId || 'task').replace(/[^A-Za-z0-9_-]/g, '-')}-${String(item.lineKey || '').replace(/[^A-Za-z0-9_-]/g, '-')}`; const headContent = ` ${isProgressLine ? renderMarkdown(displayHeadline, { inlineHeadingBreaks: true }) : escapeHtml(displayHeadline)} 1 ? '' : 'hidden'}>${item.count > 1 ? `${item.count}x` : ''} ${item.ts ? `${escapeHtml(item.ts)}` : ''} `; const headHtml = expandable ? `