/** * Copyright 2026 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ import { StepBlock, PhaseBlock, StepEvent, StreamSegment, StreamResetNotice, DEFAULT_STREAM_RESET_MESSAGE, PersistedCheckerStream } from '../core/models/stream.model'; import { isAndroidAction, isReportStatusAction, getReportStatusExplanation, getReportStatusValue, getActionObject } from './action-formatter.util'; import { getUniqueGenericTools, isInternalPlumbingTool } from './tool-formatter.util'; /** * Helper to safely extract milliseconds timestamp */ export function getItemTimestamp(ts: any, fallback: number = 0): number { if (ts !== undefined && ts !== null) { if (typeof ts === 'number') { return ts < 1e11 ? ts * 1000 : ts; } const parsed = new Date(ts).getTime(); if (!isNaN(parsed) && parsed > 0) return parsed; } return fallback; } /** * Index of the `checker` block that owns a trace id (the Checker's own agent * trace). The Checker runs concurrently with Operator steps, so its streamed * reasoning and tool traces are routed by `parent_trace_id`, never by the * step that happens to be current. */ function findCheckerBlockByTrace(blocks: StepBlock[], parentTraceId: any): number { if (!parentTraceId) return -1; const key = String(parentTraceId); return blocks.findIndex(b => b.type === 'checker' && b.data?.trace_id && String(b.data.trace_id) === key); } /** Latest still-running checker block (fallback when only the agent name is known). */ function findRunningCheckerBlock(blocks: StepBlock[]): number { for (let i = blocks.length - 1; i >= 0; i--) { if (blocks[i].type === 'checker' && blocks[i].data?.isCompleted === false) return i; } return -1; } /** * Rebuild the live `StreamSegment` list of a Checker attempt from its * persisted transcript, so a reopened session renders the same interleaved * Thought/Work segments as the live stream did. */ export function persistedStreamToSegments(record: PersistedCheckerStream | null | undefined): StreamSegment[] { if (!record || !Array.isArray(record.segments)) return []; const segments: StreamSegment[] = []; for (const seg of record.segments) { if (!seg || typeof seg.text !== 'string' || !seg.text) continue; const when = typeof seg.when === 'number' && seg.when > 0 ? seg.when : (record.ts || 0); segments.push({ execution_id: String(seg.execution_id || ''), stream_type: seg.role === 'thought' ? 'thinking' : 'text', text: seg.text, timestamp: new Date(when * 1000).toISOString(), isCompleted: true }); } return segments; } function segmentKey(segment: StreamSegment): string { return `${segment.execution_id}|${segment.stream_type}`; } /** * Merge persisted segments into the ones a block already holds. Live chunks * (complete by the time the transcript exists) win over the backfill copy of * the same turn; turns only the transcript knows are added, then everything * is kept in first-chunk order. */ function mergeStreamSegments(existing: any, incoming: any): StreamSegment[] | undefined { const base: StreamSegment[] = Array.isArray(existing) ? [...existing] : []; const extra: StreamSegment[] = Array.isArray(incoming) ? incoming : []; if (extra.length === 0) return base.length > 0 ? base : undefined; const seen = new Set(base.map(segmentKey)); let added = false; for (const segment of extra) { const key = segmentKey(segment); if (seen.has(key)) continue; seen.add(key); base.push(segment); added = true; } if (added) { base.sort((a, b) => getItemTimestamp(a.timestamp, 0) - getItemTimestamp(b.timestamp, 0)); } return base; } /** * Keep the flat `operator_*_thinking` fields (joined text + first-seen time) * in step with the segment list for consumers that only know steps. */ function applyFlatStreamFields(data: any, segments: StreamSegment[]): void { const of = (type: string) => segments.filter((s) => s.stream_type === type && s.text.trim()); const joined = (type: string) => of(type).map((s) => s.text).join('\n\n'); data.operator_native_thinking = joined('thinking'); data.operator_raw_thinking = joined('text'); const firstThinking = of('thinking')[0]; const firstText = of('text')[0]; if (firstThinking && !data.operator_native_thinking_timestamp) { data.operator_native_thinking_timestamp = firstThinking.timestamp; } if (firstText && !data.operator_raw_thinking_timestamp) { data.operator_raw_thinking_timestamp = firstText.timestamp; } } /** * Apply one `checker_event` (attempt_started / attempt_finished / run_outcome) * to the block list. Attempts are keyed by attempt id so live events and the * ledger backfill merge instead of duplicating. */ function applyCheckerLog(blocks: StepBlock[], log: any): void { const d = log.data || {}; if (!d.event) return; if (d.event === 'run_outcome') { const id = 'checker-outcome'; const idx = blocks.findIndex(b => b.id === id); const data = { ...(idx > -1 ? blocks[idx].data : {}), ...d, phase: 'outcome', status: 'done', isCompleted: true, generic_tools: [] }; const block: StepBlock = { id, type: 'checker', timestamp: log.timestamp, data }; if (idx > -1) blocks[idx] = block; else blocks.push(block); return; } if (!d.attempt_id) return; const id = `checker-${d.attempt_id}`; const idx = blocks.findIndex(b => b.id === id); const existing = idx > -1 ? blocks[idx] : null; const ts = typeof d.ts === 'number' ? d.ts : (log.timestamp ? new Date(log.timestamp).getTime() / 1000 : undefined); if (d.event === 'attempt_started') { const data = { ...(existing?.data || {}), ...d, // A late "started" must not resurrect a finished attempt. status: existing?.data?.isCompleted ? existing.data.status : 'running', isCompleted: existing?.data?.isCompleted ?? false, started_at: existing?.data?.started_at ?? ts, verdicts: existing?.data?.verdicts || [], findings: existing?.data?.findings || [], generic_tools: existing?.data?.generic_tools || [] }; const block: StepBlock = { id, type: 'checker', timestamp: existing?.timestamp || log.timestamp, data }; if (existing) blocks[idx] = block; else blocks.push(block); return; } if (d.event === 'attempt_finished') { const startedAt = existing?.data?.started_at ?? ts; const verdicts = Array.isArray(d.verdicts) ? d.verdicts : (existing?.data?.verdicts || []); const items = existing?.data?.items?.length ? existing.data.items : verdicts.map((v: any) => ({ kind: v.kind, text: v.item_text, when: v.when })); // The ledger backfill carries the attempt's persisted transcript as // ready-made segments; a live block keeps the ones it streamed. const streamSegments = mergeStreamSegments(existing?.data?.stream_segments, d.stream_segments); const data: any = { ...(existing?.data || {}), ...d, status: d.status || 'done', verdicts, items, findings: Array.isArray(d.findings) ? d.findings : (existing?.data?.findings || []), generic_tools: existing?.data?.generic_tools || [], stream_segments: streamSegments, started_at: startedAt, finished_at: ts, duration: startedAt !== undefined && ts !== undefined ? Math.max(0, ts - startedAt) : undefined, isCompleted: true }; if (streamSegments) applyFlatStreamFields(data, streamSegments); const block: StepBlock = { id, type: 'checker', timestamp: existing?.timestamp || log.timestamp, data }; if (existing) blocks[idx] = block; else blocks.push(block); } } /** * Persisted steps list the Checker's tool traces (and its agent trace) under * the Operator step that was current while it ran, because traces are stored * by step id. Once the checker blocks exist (ledger backfill carries the * attempt's trace id), move those traces to the attempt they belong to and * drop the agent trace itself. */ function relocateCheckerTools(blocks: StepBlock[]): void { const checkerByTrace = new Map(); blocks.forEach((b, i) => { if (b.type === 'checker' && b.data?.trace_id) { checkerByTrace.set(String(b.data.trace_id), i); } }); if (checkerByTrace.size === 0) return; blocks.forEach((block, i) => { if (block.type === 'checker') return; const tools: any[] = block.data?.generic_tools || []; if (tools.length === 0) return; const keep: any[] = []; let moved = false; for (const tool of tools) { const ownTrace = tool?.trace_id ? String(tool.trace_id) : ''; const parent = tool?.parent_trace_id ? String(tool.parent_trace_id) : ''; if (ownTrace && checkerByTrace.has(ownTrace) && tool?.type === 'agent') { moved = true; // the Checker's own agent trace: not a tool of the step continue; } const target = parent ? checkerByTrace.get(parent) : undefined; if (target === undefined) { keep.push(tool); continue; } const checker = blocks[target]; const existing: any[] = checker.data.generic_tools || []; if (!existing.some((t) => t.trace_id && t.trace_id === tool.trace_id)) { blocks[target] = { ...checker, data: { ...checker.data, generic_tools: [...existing, tool] } }; } moved = true; } if (moved) { blocks[i] = { ...block, data: { ...block.data, generic_tools: keep } }; } }); } /** * A `hierarchy_backend` log trace that records a *switch* (helper -> UIAutomator2 * or back). The initial "source: ..." line belongs to the startup block; only * the mid-run change is worth a row in the timeline. */ export function isBackendSwitchNote(tool: any): boolean { return tool?.type === 'log' && tool?.name === 'hierarchy_backend' && Boolean(tool?.payload?.previous_backend); } /** Consolidate raw SSE logs into deduplicated and ordered StepBlocks. */ export function consolidateLogsToBlocks(rawLogs: any[]): StepBlock[] { if (!rawLogs || rawLogs.length === 0) return []; const blocks: StepBlock[] = []; rawLogs.forEach(log => { if (!log) return; if (log.type === 'checker_event') { applyCheckerLog(blocks, log); return; } if (log.type === 'llm_stream') { const execId = log.data?.execution_id || log.id; const stepId = log.data?.step_id; const streamType = log.data?.stream_type || 'text'; const text = log.data?.text || ''; const isCompleted = log.data?.isCompleted ?? false; const isReset = log.data?.isReset ?? false; const resetMessage = log.data?.resetMessage; // The Checker's own reasoning streams under its agent trace: route it to // the attempt block, never to the Operator step that is current. const checkerIndex = findCheckerBlockByTrace(blocks, log.data?.parent_trace_id); if (checkerIndex > -1) { const checkerBlock = blocks[checkerIndex]; const checkerData: any = { ...checkerBlock.data }; // The Checker is a multi-turn tool loop: every LLM turn streams under // its own execution id. Keep one segment per turn (with its first-seen // time) so the timeline can interleave the text with the tool calls it // triggered, instead of collapsing all turns into one early block. const segments: StreamSegment[] = Array.isArray(checkerData.stream_segments) ? [...checkerData.stream_segments] : []; const segmentIndex = segments.findIndex( (s) => s.execution_id === execId && s.stream_type === streamType ); const segment: StreamSegment = { execution_id: execId, stream_type: streamType === 'thinking' ? 'thinking' : 'text', text, timestamp: segmentIndex > -1 ? segments[segmentIndex].timestamp : log.timestamp, isCompleted, isReset, resetMessage }; if (segmentIndex > -1) segments[segmentIndex] = segment; else segments.push(segment); checkerData.stream_segments = segments; // Flat fields stay as the joined text for consumers that only know steps. if (streamType === 'thinking') { checkerData.operator_native_thinking_timestamp = checkerData.operator_native_thinking_timestamp || log.timestamp; } else { checkerData.operator_raw_thinking_timestamp = checkerData.operator_raw_thinking_timestamp || log.timestamp; } applyFlatStreamFields(checkerData, segments); if (execId && !checkerData.execution_id) { checkerData.execution_id = execId; } blocks[checkerIndex] = { ...checkerBlock, data: checkerData }; return; } // Find existing block: match by stepId if available, or by execId, or active incomplete block let existingIndex = -1; if (stepId) { existingIndex = blocks.findIndex(b => b.id === `step-${stepId}` || b.data?.step_id === stepId); } if (existingIndex === -1 && execId) { existingIndex = blocks.findIndex(b => b.data?.execution_id === execId || b.id === `stream-${execId}`); } // If no stepId and not matched by execId, check if there is an unattached stream block that was reset. // The retry stream supersedes that reset block rather than spawning an orphaned duplicate card. if (existingIndex === -1 && !stepId) { existingIndex = blocks.findIndex(b => b.type === 'llm_stream' && b.data?.isReset); } if (existingIndex > -1) { const existing = blocks[existingIndex]; const updatedData = { ...existing.data }; const existingResets: StreamResetNotice[] = Array.isArray(updatedData.stream_resets) ? [...updatedData.stream_resets] : []; if (isReset) { updatedData.isReset = true; updatedData.resetMessage = resetMessage; const msg = resetMessage || DEFAULT_STREAM_RESET_MESSAGE; const resetId = execId || `reset-${log.timestamp || updatedData.step_id || 'stream'}`; const existingResetIdx = existingResets.findIndex(r => r.id === resetId); if (existingResetIdx > -1) { existingResets[existingResetIdx] = { id: resetId, message: msg, isWaiting: true, streamType }; } else { existingResets.push({ id: resetId, message: msg, isWaiting: true, streamType }); } updatedData.stream_resets = existingResets; } else if (execId && updatedData.execution_id !== execId) { // New execution replacing a reset stream: mark prior resets as finished waiting updatedData.stream_resets = existingResets.map(r => ({ ...r, isWaiting: false })); updatedData.isReset = false; updatedData.resetMessage = undefined; updatedData.execution_id = execId; } else if (!isReset && text && text.length > 0 && existingResets.some(r => r.isWaiting)) { updatedData.stream_resets = existingResets.map(r => ({ ...r, isWaiting: false })); updatedData.isReset = false; updatedData.resetMessage = undefined; } if (streamType === 'thinking') { updatedData.operator_native_thinking = text; updatedData.operator_native_thinking_timestamp = updatedData.operator_native_thinking_timestamp || log.timestamp; } else { updatedData.operator_raw_thinking = text; updatedData.operator_raw_thinking_timestamp = updatedData.operator_raw_thinking_timestamp || log.timestamp; } if (stepId && !updatedData.step_id) { updatedData.step_id = stepId; } if (execId && !updatedData.execution_id) { updatedData.execution_id = execId; } updatedData.isCompleted = isCompleted; if (isCompleted && updatedData.stream_resets) { updatedData.stream_resets = updatedData.stream_resets.map((r: any) => ({ ...r, isWaiting: false })); } blocks[existingIndex] = { ...existing, data: updatedData }; } else { const blockId = stepId ? `step-${stepId}` : `stream-${execId}`; const blockData: any = { execution_id: execId, step_id: stepId, isCompleted: isCompleted, isReset: isReset, resetMessage: resetMessage, stream_resets: isReset ? [{ id: execId || `reset-${log.timestamp || stepId || 'stream'}`, message: resetMessage || DEFAULT_STREAM_RESET_MESSAGE, isWaiting: true, streamType }] : [], generic_tools: [] }; if (streamType === 'thinking') { blockData.operator_native_thinking = text; blockData.operator_native_thinking_timestamp = log.timestamp; } else { blockData.operator_raw_thinking = text; blockData.operator_raw_thinking_timestamp = log.timestamp; } blocks.push({ id: blockId, type: stepId ? 'step' : 'llm_stream', timestamp: log.timestamp, data: blockData }); } } else if (log.type === 'step_recorded' || log.type === 'step_updated') { const stepId = log.data.step_id || log.data.step_number || 'unknown'; // Find if there is an existing block for this step or an unattached stream block from this turn let existingIndex = blocks.findIndex(b => b.id === `step-${stepId}` || b.data?.step_id === stepId); // No adoption of untagged stream blocks here: the Operator's streams // always carry the step id (Perception / the Flash turn pre-allocate it // before the LLM call), so a stream without one belongs to another agent // (Planner, Checker...). A step whose Operator emitted only tool calls // and no text used to steal the latest such block -- typically the // Planner's -- and render its action inside it at the top of the timeline. if (existingIndex > -1) { const existingBlock = blocks[existingIndex]; const existingTools = existingBlock.data.generic_tools || []; const newTools = log.data.generic_tools || []; const mergedTools = [...existingTools]; newTools.forEach((nt: any) => { const matchIdx = mergedTools.findIndex(et => et.trace_id && nt.trace_id && et.trace_id === nt.trace_id); if (matchIdx > -1) { mergedTools[matchIdx] = { ...mergedTools[matchIdx], ...nt }; } else { mergedTools.push(nt); } }); const mergedData = { ...existingBlock.data, ...log.data, operator_native_thinking: log.data.operator_native_thinking || existingBlock.data.operator_native_thinking || undefined, operator_raw_thinking: log.data.operator_raw_thinking || existingBlock.data.operator_raw_thinking || undefined, operator_native_thinking_timestamp: existingBlock.data.operator_native_thinking_timestamp || log.data.operator_native_thinking_timestamp || (log.data.operator_native_thinking ? log.timestamp : undefined), operator_raw_thinking_timestamp: existingBlock.data.operator_raw_thinking_timestamp || log.data.operator_raw_thinking_timestamp || (log.data.operator_raw_thinking ? log.timestamp : undefined), action_taken: log.data.action_taken || existingBlock.data.action_taken || undefined, last_execution_result: log.data.last_execution_result || existingBlock.data.last_execution_result || undefined, generic_tools: mergedTools, isCompleted: true }; if (mergedData.stream_resets) { mergedData.stream_resets = mergedData.stream_resets.map((r: any) => ({ ...r, isWaiting: false })); } blocks[existingIndex] = { ...existingBlock, id: `step-${stepId}`, type: 'step', timestamp: existingBlock.timestamp || log.timestamp, data: mergedData }; } else { blocks.push({ id: `step-${stepId}`, type: 'step', timestamp: log.timestamp, data: { ...log.data, isCompleted: true, operator_native_thinking_timestamp: log.data.operator_native_thinking_timestamp || (log.data.operator_native_thinking ? log.timestamp : undefined), operator_raw_thinking_timestamp: log.data.operator_raw_thinking_timestamp || (log.data.operator_raw_thinking ? log.timestamp : undefined), generic_tools: log.data.generic_tools || [] } }); } } else if (log.type === 'trace_recorded') { const isAction = log.data.type === 'action'; const isTool = log.data.type === 'tool' || isAction; const isVisibleLLMEvent = log.data.type === 'llm_call' && (log.data.status === 'failed' || log.data.status === 'retrying'); if (!isTool && !isVisibleLLMEvent && !isBackendSwitchNote(log.data)) return; let stepId = log.data.step_id; let existingIndex = -1; // 0. Tools invoked by the Checker belong to its attempt block (routed by // the parent trace, falling back to the running attempt by agent name). let checkerIndex = findCheckerBlockByTrace(blocks, log.data.parent_trace_id); if (checkerIndex === -1 && String(log.data.agent_name || '').toLowerCase() === 'checker') { checkerIndex = findRunningCheckerBlock(blocks); } if (checkerIndex > -1) { existingIndex = checkerIndex; } // 1. If this trace_id already exists in some block, update that block directly if (existingIndex === -1 && log.data.trace_id) { existingIndex = blocks.findIndex(b => b.data?.generic_tools?.some((t: any) => t.trace_id === log.data.trace_id) ); } // 2. If not found by trace_id and stepId is present, find by step_id if (existingIndex === -1 && stepId) { existingIndex = blocks.findIndex(b => b.id === `step-${stepId}` || b.data?.step_id === stepId); } // 3. If still not found and no stepId (e.g. pre-planning / initial planning phase) if (existingIndex === -1 && !stepId) { existingIndex = blocks.findIndex(b => b.id === 'step-pre-planning' || b.data?.step_id === 'pre-planning'); } // 4. Untagged traces only: attach to the latest block that precedes // them. A trace that names a step whose block does not exist yet // (tool call before any streamed text, or an action recorded before // the step in Flash) creates that step's block below instead of being // glued to an earlier step, where the later `step_recorded` (which // carries the same trace) would leave a duplicate card. if (existingIndex === -1 && !stepId) { const logTime = log.timestamp ? new Date(log.timestamp).getTime() : 0; let maxStepTime = -1; for (let i = 0; i < blocks.length; i++) { const b = blocks[i]; const bTime = b.timestamp ? new Date(b.timestamp).getTime() : 0; if (bTime <= logTime && bTime >= maxStepTime) { maxStepTime = bTime; existingIndex = i; } } } if (existingIndex > -1) { const stepData = blocks[existingIndex].data; const genericTools = stepData.generic_tools ? [...stepData.generic_tools] : []; const existingToolIdx = log.data.trace_id ? genericTools.findIndex((t: any) => t.trace_id === log.data.trace_id) : -1; if (existingToolIdx > -1) { genericTools[existingToolIdx] = { ...genericTools[existingToolIdx], ...log.data }; } else { genericTools.push(log.data); } blocks[existingIndex] = { ...blocks[existingIndex], data: { ...stepData, generic_tools: genericTools } }; } else { const fallbackStepId = stepId || 'pre-planning'; blocks.push({ id: `step-${fallbackStepId}`, type: 'step', timestamp: log.timestamp, data: { step_id: fallbackStepId, isCompleted: true, generic_tools: [log.data] } }); } } }); relocateCheckerTools(blocks); blocks.sort((a, b) => { const timeA = a.timestamp ? new Date(a.timestamp).getTime() : 0; const timeB = b.timestamp ? new Date(b.timestamp).getTime() : 0; if (Math.abs(timeA - timeB) > 50) { return timeA - timeB; } // Order by step_number if available (0 is planning phase) const getStepOrder = (block: StepBlock): number => { if (block.data?.step_number !== undefined && block.data.step_number !== null) { return Number(block.data.step_number); } if (block.data?.step_id === 'pre-planning' || block.data?.step_type === 'planning') { return 0; } return 99999; }; const stepNumA = getStepOrder(a); const stepNumB = getStepOrder(b); if (stepNumA !== stepNumB && stepNumA !== 99999 && stepNumB !== 99999) { return stepNumA - stepNumB; } return timeA - timeB; }); return blocks; } /** * Cache for the token-estimate fallback. Tool payloads can carry multi-megabyte * base64 screenshots; serializing them on every recompute would block the main * thread, so each payload object is measured exactly once. */ const payloadSizeCache = new WeakMap(); function estimatePayloadSize(payload: any): { isImage: boolean; length: number } { if (typeof payload === 'string') { return { isImage: payload.includes('data:image/') || payload.includes('base64,'), length: payload.length }; } if (payload === null || typeof payload !== 'object') { return { isImage: false, length: String(payload).length }; } const cached = payloadSizeCache.get(payload); if (cached) return cached; let payloadStr = ''; try { payloadStr = JSON.stringify(payload) || ''; } catch { payloadStr = String(payload); } const result = { isImage: payloadStr.includes('data:image/') || payloadStr.includes('base64,'), length: payloadStr.length }; payloadSizeCache.set(payload, result); return result; } /** * Safely extract and aggregate token usage for a step block */ export function extractBlockTokens(block: StepBlock): { total: number; prompt: number; completion: number } { if (!block || !block.data) return { total: 0, prompt: 0, completion: 0 }; const data = block.data; // 1. Direct total_tokens or token_usage object if (data.total_tokens !== undefined && data.total_tokens !== null && Number(data.total_tokens) > 0) { const pr = data.token_usage?.prompt_tokens || data.token_usage?.input_tokens || 0; const co = data.token_usage?.completion_tokens || data.token_usage?.output_tokens || 0; return { total: Number(data.total_tokens), prompt: Number(pr), completion: Number(co) }; } if (data.token_usage && typeof data.token_usage === 'object') { const pr = data.token_usage.prompt_tokens || data.token_usage.input_tokens || 0; const co = data.token_usage.completion_tokens || data.token_usage.output_tokens || 0; const to = data.token_usage.total_tokens || (pr + co); if (to > 0) { return { total: Number(to), prompt: Number(pr), completion: Number(co) }; } } if (data.extra_metadata?.token_usage && typeof data.extra_metadata.token_usage === 'object') { const u = data.extra_metadata.token_usage; const pr = u.prompt_tokens || u.prompt_token_count || u.input_tokens || 0; const co = u.completion_tokens || u.candidates_token_count || u.output_tokens || 0; const to = u.total_tokens || u.total_token_count || (pr + co); if (to > 0) { return { total: Number(to), prompt: Number(pr), completion: Number(co) }; } } // 2. Generic tools traces (e.g. llm_call traces or tool payloads) let sumPrompt = 0; let sumCompletion = 0; let sumTotal = 0; if (data.generic_tools && Array.isArray(data.generic_tools)) { data.generic_tools.forEach((t: any) => { if (t && t.payload) { const u = t.payload.token_usage || t.payload.usage_metadata; if (u) { const pr = u.prompt_tokens || u.prompt_token_count || u.input_tokens || 0; const co = u.completion_tokens || u.candidates_token_count || u.output_tokens || 0; const to = u.total_tokens || u.total_token_count || (pr + co); sumPrompt += Number(pr); sumCompletion += Number(co); sumTotal += Number(to); } } }); } if (sumTotal > 0) { return { total: sumTotal, prompt: sumPrompt, completion: sumCompletion }; } // 3. Fallback estimate from thoughts/messages/actions text if no tokens stored let textLen = 0; let numImages = 0; if (data.operator_native_thinking) textLen += data.operator_native_thinking.length; if (data.operator_raw_thinking) textLen += data.operator_raw_thinking.length; if (data.generic_tools && Array.isArray(data.generic_tools)) { data.generic_tools.forEach((t: any) => { if (t.payload) { const estimate = estimatePayloadSize(t.payload); if (estimate.isImage) { numImages += 1; } else { textLen += estimate.length; } } }); } if (textLen > 20 || numImages > 0) { const estimated = Math.round(textLen / 4) + (numImages * 258); return { total: estimated, prompt: 0, completion: estimated }; } return { total: 0, prompt: 0, completion: 0 }; } /** * Format token count with clean units (e.g. "842 tokens", "76.4k tokens", "1.2M tokens") */ export function formatTokenCount(tokens?: number): string { if (!tokens || tokens <= 0) return '0 tokens'; if (tokens < 1000) { return `${tokens} tokens`; } if (tokens < 1_000_000) { const inK = (tokens / 1000).toFixed(1).replace(/\.0$/, ''); return `${inK}k tokens`; } const inM = (tokens / 1_000_000).toFixed(2).replace(/\.?0+$/, ''); return `${inM}M tokens`; } /** * Group step blocks into chronological execution phases (segmented per work unit) */ export function groupBlocksToPhases(blocks: StepBlock[], sessionStartTime: number): PhaseBlock[] { if (!blocks || blocks.length === 0) return []; const phases: PhaseBlock[] = []; // Base start timestamp in ms let previousEndTime = sessionStartTime ? sessionStartTime * 1000 : 0; blocks.forEach((block, index) => { const blockTime = block.timestamp ? new Date(block.timestamp).getTime() : Date.now(); if (previousEndTime === 0 && blockTime > 0) { previousEndTime = blockTime; } let durationSeconds = 0; if (block.data?.duration && typeof block.data.duration === 'number' && block.data.duration > 0) { durationSeconds = Math.max(1, Math.round(block.data.duration)); } else if (blockTime >= previousEndTime && previousEndTime > 0) { durationSeconds = Math.max(1, Math.round((blockTime - previousEndTime) / 1000)); } else { durationSeconds = 1; } const tokensInfo = extractBlockTokens(block); phases.push({ id: `phase-${block.id || index}`, durationSeconds, tokens: tokensInfo.total > 0 ? tokensInfo.total : undefined, promptTokens: tokensInfo.prompt > 0 ? tokensInfo.prompt : undefined, completionTokens: tokensInfo.completion > 0 ? tokensInfo.completion : undefined, blocks: [block] }); previousEndTime = blockTime; }); return phases; } /** * Returns all actions and tools inside a step card, sorted in true chronological sequence by timestamp */ export function getSortedStepEvents( stepData: any, cache?: WeakMap ): StepEvent[] { if (!stepData || typeof stepData !== 'object') return []; const toolsLen = stepData.generic_tools ? stepData.generic_tools.length : 0; const toolsSignature = stepData.generic_tools && Array.isArray(stepData.generic_tools) ? stepData.generic_tools.map((t: any) => `${t?.trace_id || t?.name || ''}:${t?.status || ''}`).join(',') : ''; const actionTs = stepData.action_taken ? (stepData.action_taken.timestamp || stepData.action_taken.start_time || stepData.action_taken.created_at) : null; const nativeText = typeof stepData.operator_native_thinking === 'string' ? stepData.operator_native_thinking.trim() : ''; const rawText = typeof stepData.operator_raw_thinking === 'string' ? stepData.operator_raw_thinking.trim() : ''; const segments: StreamSegment[] | null = Array.isArray(stepData.stream_segments) && stepData.stream_segments.length > 0 ? stepData.stream_segments : null; const signature = [ toolsLen, toolsSignature, actionTs ?? '', stepData.operator_native_thinking_timestamp ?? '', stepData.operator_raw_thinking_timestamp ?? '', nativeText.length, rawText.length, stepData.isReset ? '1' : '0', stepData.resetMessage ?? '', stepData.stream_resets ? stepData.stream_resets.map((r: any) => `${r.id}:${r.isWaiting ? 1 : 0}`).join(',') : '', segments ? segments.map((s) => `${s.execution_id}:${s.stream_type}:${s.text.length}`).join(',') : '' ].join('|'); if (cache) { const cached = cache.get(stepData); if (cached?.signature === signature) { return cached.events; } } const events: Array<{ type: StepEvent['type']; data: any; timestamp: number; sequence: number }> = []; const fallbackTime = getItemTimestamp(stepData.timestamp, 0); let sequence = 0; // Text and tools share one timeline. Live streams carry their first-seen // timestamps; persisted steps use the step timestamp as a stable fallback. if (segments) { // Multi-turn agent (Checker): one event per streamed turn, each at the // time its first chunk arrived, so turns sort between the tool calls. segments.forEach((segment) => { if ((!segment.text || !segment.text.trim()) && !segment.isReset) return; events.push({ type: segment.stream_type === 'thinking' ? 'thinking' : 'text', data: { text: segment.text, execution_id: segment.execution_id, segment: true, isReset: segment.isReset, resetMessage: segment.resetMessage }, timestamp: getItemTimestamp(segment.timestamp, fallbackTime), sequence: sequence++ }); }); } else { const hasThinkingResets = Array.isArray(stepData.stream_resets) && stepData.stream_resets.some((r: any) => r.streamType === 'thinking'); if (nativeText || hasThinkingResets) { events.push({ type: 'thinking', data: { text: stepData.operator_native_thinking || '' }, timestamp: getItemTimestamp(stepData.operator_native_thinking_timestamp, fallbackTime), sequence: sequence++ }); } if (rawText || (stepData.isReset && stepData.resetMessage) || (Array.isArray(stepData.stream_resets) && stepData.stream_resets.length > 0)) { events.push({ type: 'text', data: { text: stepData.operator_raw_thinking || '', isReset: stepData.isReset, resetMessage: stepData.resetMessage }, timestamp: getItemTimestamp(stepData.operator_raw_thinking_timestamp, fallbackTime), sequence: sequence++ }); } } // 2. Single Source of Truth & Fallback Resolution: const uniqueTools = getUniqueGenericTools(stepData.generic_tools); const hasActionInTools = uniqueTools.some((tool: any) => tool && (tool.type === 'action' || isAndroidAction(tool) || isReportStatusAction(tool)) ); // Fallback: only add action_taken if generic_tools did NOT already contain an action trace. // Pushed before uniqueTools so that when timestamps are missing/equal, the primary action // preserves deterministic sequence ordering ahead of generic fallback tools. if (!hasActionInTools && stepData.action_taken && (isAndroidAction(stepData.action_taken) || isReportStatusAction(stepData.action_taken))) { const actObj = getActionObject(stepData.action_taken); const actTime = getItemTimestamp( actObj?.timestamp ?? actObj?.start_time ?? actObj?.created_at, fallbackTime ); events.push({ type: 'action', data: stepData.action_taken, timestamp: actTime, sequence: sequence++ }); } // Add all real tool calls and actions from the event stream. uniqueTools.forEach((tool: any) => { if (isInternalPlumbingTool(tool) && !isReportStatusAction(tool)) { return; } const isAction = tool.type === 'action' || isAndroidAction(tool) || isReportStatusAction(tool); const toolTime = getItemTimestamp( tool.timestamp ?? tool.start_time ?? tool.created_at, fallbackTime ); events.push({ type: isAction ? 'action' : 'tool', data: tool, timestamp: toolTime, sequence: sequence++ }); }); // Sort chronologically. The explicit sequence makes equal/missing timestamp // behavior deterministic across browsers and preserves the legacy fallback. events.sort((a, b) => (a.timestamp - b.timestamp) || (a.sequence - b.sequence)); const result: StepEvent[] = events.map(e => ({ type: e.type, data: e.data, timestamp: e.timestamp })); if (cache) { cache.set(stepData, { signature, events: result }); } return result; } /** * Compile human-readable session summary from logs */ export function compileSessionSummary(logs: any[]): string { if (!logs || logs.length === 0) return 'No logs available.'; // Check for goal completed / checker response / cancellation / report_task_status for (let i = logs.length - 1; i >= 0; i--) { const log = logs[i]; if (log.type === 'session_ended') { if (log.data?.status === 'cancelled' || log.data?.was_stopped_manually) { return 'Task stopped manually.'; } } if (log.type === 'llm_stream' && log.data?.text) { const text = log.data.text; if (text.includes('```json') && text.includes('"success"')) { return 'Execution completed and verified by checker.'; } } if (log.type === 'step_recorded' || log.type === 'step_updated') { const act = log.data?.action_taken; if (act && isReportStatusAction(act)) { const exp = getReportStatusExplanation(act); if (exp) return exp; } if (log.data?.status === 'completed') { return log.data.message || 'Task completed successfully.'; } } } return 'Execution session in progress or ended.'; } /** * Calculate total session duration in seconds */ export function computeSessionDuration(sessionStartTime: number, logs: any[]): number { if (!sessionStartTime || !logs || logs.length === 0) return 0; const startMs = sessionStartTime * 1000; const lastLog = logs[logs.length - 1]; const endMs = lastLog?.timestamp ? new Date(lastLog.timestamp).getTime() : Date.now(); return Math.max(0, Math.round((endMs - startMs) / 1000)); } /** * Check if the planning loader should be visible (while running, waiting for next step/action and not actively typing a stream) */ export function checkPlanningLoader( logs: any[], isRunning: boolean, isCurrentRunningSession: boolean = true ): boolean { if (!isRunning || !isCurrentRunningSession) return false; if (!logs || logs.length === 0) return true; // If the latest step or event indicates task completion, do not show loader for (let i = logs.length - 1; i >= Math.max(0, logs.length - 3); i--) { const log = logs[i]; if (log.type === 'session_ended') return false; if (log.type === 'step_recorded' || log.type === 'step_updated') { const act = log.data?.action_taken; if (act && (act.action === 'report_task_status' || act === 'report_task_status' || isReportStatusAction(act))) { return false; } if (log.data?.status === 'completed' || log.data?.status === 'failed') { return false; } } } // If there is an active incomplete stream currently streaming chunks, hide the planning loader const hasActiveStream = logs.some( l => l && l.type === 'llm_stream' && l.data && l.data.isCompleted === false ); if (hasActiveStream) { return false; } return true; }