import type { ChatMessage } from '../types/chat' import type { ProgressEntry, Session } from '../types/kanban' import { getContextUsageMetrics } from './contextUsage' import { isSessionWorking } from './sessionRuntime' const CONTEXT_TOKENS_RE = /(\d[\d,]*)\s*\/\s*(\d[\d,]*)\s+tokens/i const USED_PCT_RE = /(\d{1,3})%\s*used/i const REMAINING_PCT_RE = /(\d{1,3})%\s*remaining/i function compactWhitespace(value: string): string { return value.replace(/\s+/g, ' ').trim() } function stripNarrativeTitlePrefix(content: string): string { const trimmed = String(content || '').trim() const markdownTitle = trimmed.match(/^\*\*(.{8,160}?)\*\*:\s+([\s\S]+)$/) if (markdownTitle) { const body = markdownTitle[2].trim() if (body.length >= 80) return body } const colonIndex = trimmed.indexOf(': ') if (colonIndex < 8 || colonIndex > 160) return trimmed const prefix = trimmed.slice(0, colonIndex).replace(/\*/g, '').trim() const body = trimmed.slice(colonIndex + 2).trim() if (body.length < 80) return trimmed if (!/[A-Za-z\u4e00-\u9fff]/.test(prefix)) return trimmed if (/^(https?|file)$/i.test(prefix)) return trimmed return body } function resultSurfacePriority(message: ChatMessage): number { const meta = (message.metadata ?? {}) as Record const transcriptKind = String(meta.transcript_kind ?? meta.kind ?? '').trim() switch (transcriptKind) { case 'child_task_result': return 80 case 'child_task_result_retry': return 79 case 'company_role_result': return 75 case 'company_role_result_retry': return 74 case 'child_result': return 70 case 'runtime_v2_assistant': return 60 case 'runtime_v2_company_assistant': return 20 case 'top_level_reply': return 40 default: break } if (String(meta.kind ?? '').trim() === 'worker_notification') { return 20 } return 0 } function resultSurfaceDedupeKey(message: ChatMessage): string { if (resultSurfacePriority(message) <= 0) return '' const content = compactWhitespace(stripNarrativeTitlePrefix(message.content)).slice(0, 2000) return content ? `result:${content}` : '' } function parseProgressNumber(value: string | undefined): number | undefined { if (!value) return undefined const normalized = value.replace(/,/g, '').trim() if (!normalized) return undefined const parsed = Number(normalized) return Number.isFinite(parsed) ? Math.max(0, Math.round(parsed)) : undefined } function clampPct(value: number | undefined): number | undefined { if (typeof value !== 'number' || !Number.isFinite(value)) return undefined return Math.max(0, Math.min(Math.round(value), 100)) } export function deriveCompanyRuntimeDisplayStatus(session: Session | null | undefined): string | undefined { if (!session) return undefined const execMode = String(session.execMode ?? '').trim() const isCompanyLike = ( execMode === 'company' || execMode === 'org' || execMode === 'custom' || !!session.isCompanyRuntime || !!session.roleWorkItems || !!session.executorRoleWorkItems ) if (!isCompanyLike) return undefined const summaries = Object.values(session.executorRoleWorkItems ?? session.roleWorkItems ?? {}) if (summaries.length === 0) return undefined const statuses = summaries.map(summary => summary.aggregatedStatus) if (statuses.some(status => status === 'active')) return 'running' if (statuses.some(status => status === 'waiting')) return 'pending' if (statuses.some(status => status === 'pending')) return 'pending' if (statuses.every(status => status === 'failed')) return 'failed' if (statuses.every(status => status === 'done')) return 'done' if (statuses.some(status => status === 'done')) return 'done' return undefined } function deriveContextFromProgressLog(progressLog: ProgressEntry[] | undefined): { contextTokens?: number contextWindow?: number contextRemainingPct?: number } { const derived: { contextTokens?: number contextWindow?: number contextRemainingPct?: number } = {} for (const entry of progressLog ?? []) { const text = `${entry.summary ?? ''} ${entry.detail ?? ''}`.trim() if (!text) continue const tokenMatch = CONTEXT_TOKENS_RE.exec(text) if (tokenMatch) { const usedTokens = parseProgressNumber(tokenMatch[1]) const windowTokens = parseProgressNumber(tokenMatch[2]) if (typeof usedTokens === 'number') derived.contextTokens = usedTokens if (typeof windowTokens === 'number' && windowTokens > 0) derived.contextWindow = windowTokens } const remainingMatch = REMAINING_PCT_RE.exec(text) if (remainingMatch) { derived.contextRemainingPct = clampPct(parseProgressNumber(remainingMatch[1])) continue } const usedMatch = USED_PCT_RE.exec(text) if (usedMatch) { const usedPct = clampPct(parseProgressNumber(usedMatch[1])) if (typeof usedPct === 'number') derived.contextRemainingPct = 100 - usedPct } } return derived } function withDerivedSessionRuntime(session: Session): Session { const derivedContext = deriveContextFromProgressLog(session.progressLog) const contextTokens = session.contextTokens ?? derivedContext.contextTokens const contextWindow = session.contextWindow ?? derivedContext.contextWindow const contextRemainingPct = session.contextRemainingPct ?? derivedContext.contextRemainingPct if ( contextTokens === session.contextTokens && contextWindow === session.contextWindow && contextRemainingPct === session.contextRemainingPct ) { return session } return { ...session, contextTokens, contextWindow, contextRemainingPct, } } function uniqueSessionsByTaskId(sessions: Session[]): Session[] { const seen = new Set() const unique: Session[] = [] for (const session of sessions) { if (seen.has(session.taskId)) continue seen.add(session.taskId) unique.push(session) } return unique } function hasWorkItemRoleIdentity(session: Session | null | undefined): boolean { return !!( String(session?.workItemRoleId ?? '').trim() || String(session?.workItemRoleName ?? '').trim() ) } export function getWorkItemChildSessions(activeSession: Session | null, sessions: Session[]): Session[] { if (!activeSession || activeSession.mode === 'child') return [] const parentKeys = new Set() if (activeSession.sessionId) parentKeys.add(activeSession.sessionId) parentKeys.add(activeSession.taskId) const activeOriginTaskId = String(activeSession.originTaskId ?? activeSession.taskId ?? '').trim() const executionTurnOrder = new Map() for (const entry of activeSession.workItemLog ?? []) { const taskId = entry.executionTurnId || entry.runtimeTaskId if (taskId && !executionTurnOrder.has(taskId)) { executionTurnOrder.set(taskId, executionTurnOrder.size) } } const seen = new Set() const matches = sessions.filter((session) => { if (session.taskId === activeSession.taskId) return false if (seen.has(session.taskId)) return false const linkedByParent = !!session.parentSessionId && parentKeys.has(session.parentSessionId) const linkedByWorkItem = executionTurnOrder.has(session.taskId) const linkedByOrigin = !!activeOriginTaskId && String(session.originTaskId ?? '').trim() === activeOriginTaskId if (!linkedByParent && !linkedByWorkItem && !linkedByOrigin) return false seen.add(session.taskId) return true }) return matches.sort((a, b) => { const aOrder = executionTurnOrder.get(a.taskId) const bOrder = executionTurnOrder.get(b.taskId) if (aOrder != null && bOrder != null && aOrder !== bOrder) return aOrder - bOrder if (aOrder != null && bOrder == null) return -1 if (aOrder == null && bOrder != null) return 1 return b.updatedAt - a.updatedAt }) } export function getWorkItemRoleSessions(activeSession: Session | null, sessions: Session[]): Session[] { const childSessions = getWorkItemChildSessions(activeSession, sessions) if (!activeSession || activeSession.mode === 'child') return childSessions if (!hasWorkItemRoleIdentity(activeSession)) return childSessions return uniqueSessionsByTaskId([activeSession, ...childSessions]) } export function getConversationPeerSessions(activeSession: Session | null, sessions: Session[]): Session[] { if (!activeSession || activeSession.mode === 'child') return [] const activeSessionId = String(activeSession.sessionId ?? '').trim() if (!activeSessionId) return [] const seen = new Set() return sessions.filter((session) => { if (session.taskId === activeSession.taskId) return false if (seen.has(session.taskId)) return false if (String(session.sessionId ?? '').trim() !== activeSessionId) return false seen.add(session.taskId) return true }).sort((a, b) => b.updatedAt - a.updatedAt) } function isSummaryConversationSession(session: Session | null | undefined): boolean { const execMode = String(session?.execMode ?? '').trim() return execMode === 'company' || execMode === 'org' || execMode === 'custom' } function projectedDisplayScore(session: Session): number { const workingBonus = isSessionWorking(session) ? 1_000_000_000 : 0 const draftBonus = String(session.draftAssistantText ?? '').trim() ? 10_000_000_000 : 0 const messageBonus = Math.max(0, session.messageCount ?? 0) * 10_000 const progressBonus = (session.progressLog?.length ?? 0) * 100 return draftBonus + workingBonus + messageBonus + progressBonus + session.updatedAt } function runtimeProjectionScore(session: Session): number { const contextUsage = getContextUsageMetrics(session) const workingBonus = isSessionWorking(session) ? 100_000_000_000 : 0 const draftBonus = String(session.draftAssistantText ?? '').trim() ? 10_000_000_000 : 0 const contextBonus = ( typeof contextUsage.usedPct === 'number' || typeof contextUsage.usedTokens === 'number' || typeof contextUsage.windowTokens === 'number' ) ? 10_000_000_000 : 0 const tokenBonus = ( typeof session.inputTokens === 'number' || typeof session.outputTokens === 'number' || typeof session.totalTokens === 'number' ) ? 1_000_000_000 : 0 const messageBonus = Math.max(0, session.messageCount ?? 0) * 10_000 const progressBonus = (session.progressLog?.length ?? 0) * 100 return workingBonus + draftBonus + contextBonus + tokenBonus + messageBonus + progressBonus } export interface SessionConversationProjection { timelineSessions: Session[] displaySession: Session | null runtimeSession: Session | null projectedFromChild: boolean } export function projectSessionConversation( activeSession: Session | null, relatedSessions: Session[], ): SessionConversationProjection { if (!activeSession) { return { timelineSessions: [], displaySession: null, runtimeSession: null, projectedFromChild: false, } } const normalizedActiveSession = withDerivedSessionRuntime(activeSession) if (activeSession.mode === 'child' || relatedSessions.length === 0) { return { timelineSessions: [normalizedActiveSession], displaySession: normalizedActiveSession, runtimeSession: normalizedActiveSession, projectedFromChild: false, } } const normalizedRelatedSessions = relatedSessions.map(withDerivedSessionRuntime) const visibleRelatedSessions = normalizedRelatedSessions.filter((session) => session.status !== 'cancelled') const projectedSessions = visibleRelatedSessions.length > 0 ? visibleRelatedSessions : normalizedRelatedSessions const timelineSessions = uniqueSessionsByTaskId([normalizedActiveSession, ...projectedSessions]) const summaryConversation = isSummaryConversationSession(activeSession) const projectedDisplaySession = [...timelineSessions].sort( (a, b) => projectedDisplayScore(b) - projectedDisplayScore(a), )[0] ?? normalizedActiveSession const displaySession = summaryConversation ? normalizedActiveSession : projectedDisplaySession const runtimeSession = [...timelineSessions].sort( (a, b) => runtimeProjectionScore(b) - runtimeProjectionScore(a), )[0] ?? displaySession return { timelineSessions, displaySession, runtimeSession, projectedFromChild: !summaryConversation && displaySession.taskId !== activeSession.taskId, } } export function getConversationSessionView( activeSession: Session | null, runtimeSession: Session | null, timelineSessions: Session[], ): Session | null { if (!activeSession) return null const normalizedActiveSession = withDerivedSessionRuntime(activeSession) const runtimeSource = runtimeSession ?? normalizedActiveSession const companyDisplayStatus = deriveCompanyRuntimeDisplayStatus(runtimeSource) ?? deriveCompanyRuntimeDisplayStatus(normalizedActiveSession) const mergedMessageCount = getConversationMessageCount( timelineSessions.length > 0 ? timelineSessions : [normalizedActiveSession], ) return { ...normalizedActiveSession, status: companyDisplayStatus ?? (runtimeSource.status || normalizedActiveSession.status), assigneeIds: runtimeSource.assigneeIds.length > 0 ? runtimeSource.assigneeIds : normalizedActiveSession.assigneeIds, agentStatus: runtimeSource.agentStatus ?? normalizedActiveSession.agentStatus, currentTool: runtimeSource.currentTool ?? normalizedActiveSession.currentTool, displayTool: runtimeSource.displayTool ?? runtimeSource.currentTool ?? normalizedActiveSession.displayTool ?? normalizedActiveSession.currentTool, toolElapsedMs: runtimeSource.toolElapsedMs ?? normalizedActiveSession.toolElapsedMs, lastToolSummary: runtimeSource.lastToolSummary ?? normalizedActiveSession.lastToolSummary, contextTokens: runtimeSource.contextTokens ?? normalizedActiveSession.contextTokens, contextWindow: runtimeSource.contextWindow ?? normalizedActiveSession.contextWindow, contextRemainingPct: runtimeSource.contextRemainingPct ?? normalizedActiveSession.contextRemainingPct, inputTokens: runtimeSource.inputTokens ?? normalizedActiveSession.inputTokens, outputTokens: runtimeSource.outputTokens ?? normalizedActiveSession.outputTokens, totalTokens: runtimeSource.totalTokens ?? normalizedActiveSession.totalTokens, turnCostUsd: runtimeSource.turnCostUsd ?? normalizedActiveSession.turnCostUsd, sessionCostUsd: runtimeSource.sessionCostUsd ?? normalizedActiveSession.sessionCostUsd, pendingPermissionCount: runtimeSource.pendingPermissionCount ?? normalizedActiveSession.pendingPermissionCount, drainMode: runtimeSource.drainMode ?? normalizedActiveSession.drainMode, workItemProjectionId: runtimeSource.workItemProjectionId ?? normalizedActiveSession.workItemProjectionId, workItemTurnType: runtimeSource.workItemTurnType ?? normalizedActiveSession.workItemTurnType, companyProfile: normalizedActiveSession.companyProfile ?? runtimeSource.companyProfile, workItemRoleId: normalizedActiveSession.workItemRoleId ?? runtimeSource.workItemRoleId, workItemRoleName: normalizedActiveSession.workItemRoleName ?? runtimeSource.workItemRoleName, workItemGate: normalizedActiveSession.workItemGate ?? runtimeSource.workItemGate, employeeAssignment: normalizedActiveSession.employeeAssignment ?? runtimeSource.employeeAssignment, selectedExecutionAgent: runtimeSource.selectedExecutionAgent ?? normalizedActiveSession.selectedExecutionAgent, draftAssistantText: runtimeSource.draftAssistantText ?? normalizedActiveSession.draftAssistantText, draftUpdatedAt: runtimeSource.draftUpdatedAt ?? normalizedActiveSession.draftUpdatedAt, draftIteration: runtimeSource.draftIteration ?? normalizedActiveSession.draftIteration, draftTurnId: runtimeSource.draftTurnId ?? normalizedActiveSession.draftTurnId, runtimeControlState: runtimeSource.runtimeControlState ?? normalizedActiveSession.runtimeControlState, canStop: runtimeSource.canStop ?? normalizedActiveSession.canStop, canResume: runtimeSource.canResume ?? normalizedActiveSession.canResume, resumeParentTaskId: runtimeSource.resumeParentTaskId ?? normalizedActiveSession.resumeParentTaskId, resumeParentSessionId: runtimeSource.resumeParentSessionId ?? normalizedActiveSession.resumeParentSessionId, pendingRuntimeCheckpointId: runtimeSource.pendingRuntimeCheckpointId ?? normalizedActiveSession.pendingRuntimeCheckpointId, stopIntentId: runtimeSource.stopIntentId ?? normalizedActiveSession.stopIntentId, updatedAt: Math.max( normalizedActiveSession.updatedAt, runtimeSource.updatedAt, ), messageCount: Math.max( normalizedActiveSession.messageCount ?? 0, runtimeSource.messageCount ?? 0, mergedMessageCount, ), isCompanyRuntime: !!(normalizedActiveSession.isCompanyRuntime || runtimeSource.isCompanyRuntime), workItemLog: (normalizedActiveSession.workItemLog?.length ?? 0) > 0 ? normalizedActiveSession.workItemLog : runtimeSource.workItemLog, roleWorkItems: normalizedActiveSession.roleWorkItems ?? runtimeSource.roleWorkItems, executorRoleWorkItems: normalizedActiveSession.executorRoleWorkItems ?? runtimeSource.executorRoleWorkItems, activeSubagents: normalizedActiveSession.activeSubagents ?? runtimeSource.activeSubagents, permissionRequests: normalizedActiveSession.permissionRequests ?? runtimeSource.permissionRequests, } } export function getConversationHeaderSession( activeSession: Session | null, runtimeSession: Session | null, timelineSessions: Session[], ): Session | null { if (!activeSession) return null const normalizedActiveSession = withDerivedSessionRuntime(activeSession) const runtimeSource = runtimeSession ?? normalizedActiveSession const companyDisplayStatus = deriveCompanyRuntimeDisplayStatus(runtimeSource) ?? deriveCompanyRuntimeDisplayStatus(normalizedActiveSession) const mergedMessageCount = getConversationMessageCount( timelineSessions.length > 0 ? timelineSessions : [normalizedActiveSession], ) return { ...normalizedActiveSession, status: companyDisplayStatus ?? (runtimeSource.status || normalizedActiveSession.status), agentStatus: runtimeSource.agentStatus ?? normalizedActiveSession.agentStatus, currentTool: runtimeSource.currentTool ?? normalizedActiveSession.currentTool, displayTool: runtimeSource.displayTool ?? runtimeSource.currentTool ?? normalizedActiveSession.displayTool ?? normalizedActiveSession.currentTool, toolElapsedMs: runtimeSource.toolElapsedMs ?? normalizedActiveSession.toolElapsedMs, lastToolSummary: runtimeSource.lastToolSummary ?? normalizedActiveSession.lastToolSummary, contextTokens: runtimeSource.contextTokens ?? normalizedActiveSession.contextTokens, contextWindow: runtimeSource.contextWindow ?? normalizedActiveSession.contextWindow, contextRemainingPct: runtimeSource.contextRemainingPct ?? normalizedActiveSession.contextRemainingPct, inputTokens: runtimeSource.inputTokens ?? normalizedActiveSession.inputTokens, outputTokens: runtimeSource.outputTokens ?? normalizedActiveSession.outputTokens, totalTokens: runtimeSource.totalTokens ?? normalizedActiveSession.totalTokens, turnCostUsd: runtimeSource.turnCostUsd ?? normalizedActiveSession.turnCostUsd, sessionCostUsd: runtimeSource.sessionCostUsd ?? normalizedActiveSession.sessionCostUsd, pendingPermissionCount: runtimeSource.pendingPermissionCount ?? normalizedActiveSession.pendingPermissionCount, drainMode: runtimeSource.drainMode ?? normalizedActiveSession.drainMode, selectedExecutionAgent: runtimeSource.selectedExecutionAgent ?? normalizedActiveSession.selectedExecutionAgent, runtimeControlState: runtimeSource.runtimeControlState ?? normalizedActiveSession.runtimeControlState, canStop: runtimeSource.canStop ?? normalizedActiveSession.canStop, canResume: runtimeSource.canResume ?? normalizedActiveSession.canResume, resumeParentTaskId: runtimeSource.resumeParentTaskId ?? normalizedActiveSession.resumeParentTaskId, resumeParentSessionId: runtimeSource.resumeParentSessionId ?? normalizedActiveSession.resumeParentSessionId, pendingRuntimeCheckpointId: runtimeSource.pendingRuntimeCheckpointId ?? normalizedActiveSession.pendingRuntimeCheckpointId, stopIntentId: runtimeSource.stopIntentId ?? normalizedActiveSession.stopIntentId, updatedAt: Math.max(normalizedActiveSession.updatedAt, runtimeSource.updatedAt), messageCount: Math.max( normalizedActiveSession.messageCount ?? 0, runtimeSource.messageCount ?? 0, mergedMessageCount, ), } } export function mergeConversationMessages(messageGroups: ChatMessage[][]): ChatMessage[] { const seen = new Set() const resultKeyIndex = new Map() const merged: ChatMessage[] = [] for (const group of messageGroups) { for (const message of group) { const metadata = (message.metadata ?? {}) as Record const uiMessageId = typeof metadata.ui_message_id === 'string' ? metadata.ui_message_id.trim() : '' const resultKey = resultSurfaceDedupeKey(message) if (resultKey) { const existingIndex = resultKeyIndex.get(resultKey) if (existingIndex !== undefined) { if (resultSurfacePriority(message) > resultSurfacePriority(merged[existingIndex])) { merged[existingIndex] = message } continue } resultKeyIndex.set(resultKey, merged.length) } const dedupeKey = resultKey || uiMessageId || `${message.sender}:${message.replyToId ?? ''}:${message.timestamp}:${message.content.trim()}` if (seen.has(dedupeKey)) continue seen.add(dedupeKey) merged.push(message) } } return merged.sort((a, b) => ( a.timestamp === b.timestamp ? a.id.localeCompare(b.id) : a.timestamp - b.timestamp )) } export function mergeConversationProgressLog(timelineSessions: Session[]): ProgressEntry[] { const seen = new Set() const merged: ProgressEntry[] = [] for (const session of timelineSessions) { for (const entry of session.progressLog ?? []) { const dedupeKey = `${entry.timestamp}:${entry.type}:${entry.summary}:${entry.detail ?? ''}` if (seen.has(dedupeKey)) continue seen.add(dedupeKey) merged.push(entry) } } return merged.sort((a, b) => a.timestamp - b.timestamp) } export function getConversationMessageCount(timelineSessions: Session[]): number { return timelineSessions.reduce( (total, session) => total + Math.max(0, session.messageCount ?? 0), 0, ) }