import type { RunStreamEvent } from '@/lib/novel-promotion/run-stream/types' import { TASK_EVENT_TYPE, TASK_SSE_EVENT_TYPE, type SSEEvent } from '@/lib/task/types' import { resolveTaskErrorMessage as resolveUnifiedTaskErrorMessage } from '@/lib/task/error-message' import type { RunResult } from './types' export function parseSSEBlock(block: string): { event: string; data: string } | null { const lines = block.split('\n') let event = 'message' const dataLines: string[] = [] for (const line of lines) { if (!line) continue if (line.startsWith('event:')) { event = line.slice(6).trim() continue } if (line.startsWith('data:')) { dataLines.push(line.slice(5).trim()) } } if (dataLines.length === 0) return null return { event, data: dataLines.join('\n'), } } export function toObject(value: unknown): Record { if (!value || typeof value !== 'object' || Array.isArray(value)) return {} return value as Record } export function readTextField(payload: Record, key: string): string | undefined { const value = payload[key] return typeof value === 'string' ? value : undefined } export function readStepField(payload: Record, key: string): number | undefined { const value = payload[key] return typeof value === 'number' && Number.isFinite(value) ? Math.max(1, Math.floor(value)) : undefined } function readStringArrayField(payload: Record, key: string): string[] { const value = payload[key] if (!Array.isArray(value)) return [] const rows: string[] = [] for (const item of value) { if (typeof item !== 'string') continue const trimmed = item.trim() if (!trimmed) continue rows.push(trimmed) } return rows } function readBoolField(payload: Record, key: string): boolean | undefined { const value = payload[key] if (value === true) return true if (value === false) return false return undefined } function normalizeLifecycleType(value: unknown): string | null { if (typeof value !== 'string') return null if (value === TASK_EVENT_TYPE.PROGRESS) return TASK_EVENT_TYPE.PROCESSING if ( value === TASK_EVENT_TYPE.CREATED || value === TASK_EVENT_TYPE.PROCESSING || value === TASK_EVENT_TYPE.COMPLETED || value === TASK_EVENT_TYPE.FAILED ) { return value } return null } function stageLooksCompleted(stage: string | undefined) { if (!stage) return false return ( stage === 'llm_completed' || stage === 'worker_llm_completed' || stage === 'worker_llm_complete' || stage === 'llm_proxy_persist' || stage === 'completed' ) } function stageLooksFailed(stage: string | undefined) { if (!stage) return false return stage === 'llm_error' || stage === 'worker_llm_error' || stage === 'error' } function resolveTaskErrorMessage(payload: Record, fallback = 'task failed') { return resolveUnifiedTaskErrorMessage(payload, fallback) } function extractTerminalPayload(payload: Record) { const result = toObject(payload.result) if (Object.keys(result).length > 0) { return result } return payload } export function mapTaskSSEEventToRunEvents(event: SSEEvent): RunStreamEvent[] { const rawPayload = toObject(event.payload) const payloadMeta = toObject(rawPayload.meta) const runId = readTextField(rawPayload, 'runId') || readTextField(payloadMeta, 'runId') || (typeof event.taskId === 'string' ? event.taskId : '') if (!runId) return [] const payload = rawPayload const lifecycleType = event.type === TASK_SSE_EVENT_TYPE.LIFECYCLE ? normalizeLifecycleType(payload.lifecycleType) : null const ts = typeof event.ts === 'string' ? event.ts : new Date().toISOString() const flowStageTitle = readTextField(payload, 'flowStageTitle') const flowStageIndex = readStepField(payload, 'flowStageIndex') const flowStageTotal = readStepField(payload, 'flowStageTotal') const rawStepId = readTextField(payload, 'stepId') const rawStepTitle = readTextField(payload, 'stepTitle') const stepId = rawStepId || ( event.type === TASK_SSE_EVENT_TYPE.STREAM ? `step:${event.taskType || 'llm'}` : undefined) const stepAttempt = readStepField(payload, 'stepAttempt') const stepTitle = rawStepTitle || flowStageTitle || undefined const stepIndex = readStepField(payload, 'stepIndex') ?? flowStageIndex const stepTotal = readStepField(payload, 'stepTotal') ?? flowStageTotal const stage = readTextField(payload, 'stage') const message = readTextField(payload, 'message') const done = payload.done === true const text = readTextField(payload, 'output') || readTextField(payload, 'text') const reasoning = readTextField(payload, 'reasoning') || readTextField(payload, 'thinking') const dependsOn = readStringArrayField(payload, 'dependsOn') const blockedBy = readStringArrayField(payload, 'blockedBy') const groupId = readTextField(payload, 'groupId') const parallelKey = readTextField(payload, 'parallelKey') const retryable = readBoolField(payload, 'retryable') const stale = readBoolField(payload, 'stale') if (event.type === TASK_SSE_EVENT_TYPE.STREAM) { const stream = toObject(payload.stream) const kind = stream.kind === 'reasoning' ? 'reasoning' : 'text' const delta = typeof stream.delta === 'string' ? stream.delta : '' if (!delta || !stepId) return [] const lane = stream.lane === 'reasoning' || kind === 'reasoning' ? 'reasoning' : 'text' const seq = typeof stream.seq === 'number' && Number.isFinite(stream.seq) ? Math.max(1, Math.floor(stream.seq)) : undefined const streamStepAttempt = typeof stream.attempt === 'number' && Number.isFinite(stream.attempt) ? Math.max(1, Math.floor(stream.attempt)) : undefined return [{ runId, event: 'step.chunk', ts, status: 'running', stepId, stepAttempt: stepAttempt ?? streamStepAttempt, stepTitle, stepIndex, stepTotal, dependsOn: dependsOn.length > 0 ? dependsOn : undefined, blockedBy: blockedBy.length > 0 ? blockedBy : undefined, groupId, parallelKey, retryable, lane, seq, textDelta: lane === 'text' ? delta : undefined, reasoningDelta: lane === 'reasoning' ? delta : undefined, message, }] } if (event.type !== TASK_SSE_EVENT_TYPE.LIFECYCLE) return [] const runEvents: RunStreamEvent[] = [] if (lifecycleType === TASK_EVENT_TYPE.CREATED) { runEvents.push({ runId, event: 'run.start', ts, status: 'running', message, payload, }) return runEvents } if (lifecycleType === TASK_EVENT_TYPE.PROCESSING) { if (stepId) { runEvents.push({ runId, event: 'step.start', ts, status: 'running', stepId, stepAttempt, stepTitle, stepIndex, stepTotal, dependsOn: dependsOn.length > 0 ? dependsOn : undefined, blockedBy: blockedBy.length > 0 ? blockedBy : undefined, groupId, parallelKey, retryable, message, }) if (done || stageLooksCompleted(stage)) { runEvents.push({ runId, event: 'step.complete', ts, status: stale ? 'stale' : 'completed', stepId, stepAttempt, stepTitle, stepIndex, stepTotal, dependsOn: dependsOn.length > 0 ? dependsOn : undefined, blockedBy: blockedBy.length > 0 ? blockedBy : undefined, groupId, parallelKey, retryable, text, reasoning, message, }) } else if (stageLooksFailed(stage)) { runEvents.push({ runId, event: 'step.error', ts, status: 'failed', stepId, stepAttempt, stepTitle, stepIndex, stepTotal, dependsOn: dependsOn.length > 0 ? dependsOn : undefined, blockedBy: blockedBy.length > 0 ? blockedBy : undefined, groupId, parallelKey, retryable, message: resolveTaskErrorMessage(payload), }) } } return runEvents } if (lifecycleType === TASK_EVENT_TYPE.COMPLETED) { if (stepId) { runEvents.push({ runId, event: 'step.complete', ts, status: 'completed', stepId, stepAttempt, stepTitle, stepIndex, stepTotal, dependsOn: dependsOn.length > 0 ? dependsOn : undefined, blockedBy: blockedBy.length > 0 ? blockedBy : undefined, groupId, parallelKey, retryable, text, reasoning, message, }) } runEvents.push({ runId, event: 'run.complete', ts, status: 'completed', message, payload: extractTerminalPayload(payload), }) return runEvents } if (lifecycleType === TASK_EVENT_TYPE.FAILED) { const errorMessage = resolveTaskErrorMessage(payload) if (stepId) { runEvents.push({ runId, event: 'step.error', ts, status: 'failed', stepId, stepAttempt, stepTitle, stepIndex, stepTotal, dependsOn: dependsOn.length > 0 ? dependsOn : undefined, blockedBy: blockedBy.length > 0 ? blockedBy : undefined, groupId, parallelKey, retryable, message: errorMessage, }) } runEvents.push({ runId, event: 'run.error', ts, status: 'failed', message: errorMessage, payload, }) } return runEvents } export function toTerminalRunResult(event: RunStreamEvent): RunResult | null { if (event.event !== 'run.complete' && event.event !== 'run.error') return null const summaryFromPayload = event.payload && typeof event.payload.summary === 'object' && event.payload.summary ? (event.payload.summary as Record) : null return { runId: event.runId, status: event.event === 'run.complete' ? 'completed' : 'failed', summary: event.event === 'run.complete' ? summaryFromPayload || event.payload || null : summaryFromPayload, payload: event.payload || null, errorMessage: event.event === 'run.error' ? event.message || 'run failed' : '', } }