644 lines
28 KiB
TypeScript
644 lines
28 KiB
TypeScript
import type { ChatMessage } from '../types/chat'
|
|
import type { ProgressEntry, Session } from '../types/kanban'
|
|
import { getContextUsageMetrics } from './contextUsage'
|
|
import { isSessionWorking } from './sessionRuntime'
|
|
import { stableMessageTimelineKey } from './messageTimelineIdentity'
|
|
|
|
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
|
|
|
|
export function isMessageVisibleAtDetailLevel(
|
|
message: ChatMessage,
|
|
detailLevel: 'summary' | 'full',
|
|
): boolean {
|
|
if (detailLevel === 'full') return true
|
|
return String(message.metadata?.detail_visibility ?? 'summary').trim() !== 'full'
|
|
}
|
|
|
|
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<string, unknown>
|
|
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
|
|
}
|
|
|
|
export 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<string>()
|
|
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<string>()
|
|
if (activeSession.sessionId) parentKeys.add(activeSession.sessionId)
|
|
parentKeys.add(activeSession.taskId)
|
|
const activeOriginTaskId = String(activeSession.originTaskId ?? activeSession.taskId ?? '').trim()
|
|
|
|
const executionTurnOrder = new Map<string, number>()
|
|
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<string>()
|
|
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<string>()
|
|
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<string>()
|
|
const resultKeyIndex = new Map<string, number>()
|
|
const checkpointIndex = new Map<string, number>()
|
|
const merged: ChatMessage[] = []
|
|
for (const group of messageGroups) {
|
|
for (const message of group) {
|
|
const metadata = (message.metadata ?? {}) as Record<string, unknown>
|
|
const checkpointId = String(metadata.checkpoint_id ?? '').trim()
|
|
if (checkpointId) {
|
|
const existingIndex = checkpointIndex.get(checkpointId)
|
|
if (existingIndex !== undefined) {
|
|
const existing = merged[existingIndex]
|
|
const latest = message.timestamp >= existing.timestamp ? message : existing
|
|
const earliest = message.timestamp < existing.timestamp ? message : existing
|
|
merged[existingIndex] = {
|
|
...existing,
|
|
...latest,
|
|
id: existing.id,
|
|
channelId: existing.channelId,
|
|
timestamp: earliest.timestamp,
|
|
metadata: { ...(existing.metadata ?? {}), ...(latest.metadata ?? {}) },
|
|
}
|
|
continue
|
|
}
|
|
}
|
|
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) {
|
|
const existing = merged[existingIndex]
|
|
const candidateWins = resultSurfacePriority(message) > resultSurfacePriority(existing)
|
|
const preferred = candidateWins ? message : existing
|
|
const secondary = candidateWins ? existing : message
|
|
merged[existingIndex] = {
|
|
...secondary,
|
|
...preferred,
|
|
// A result surface keeps the chronology of the first underlying
|
|
// delivery, independent of which related channel happened to be
|
|
// traversed first for this render.
|
|
timestamp: Math.min(existing.timestamp, message.timestamp),
|
|
metadata: {
|
|
...(secondary.metadata ?? {}),
|
|
...(preferred.metadata ?? {}),
|
|
ui_timeline_id: stableMessageTimelineKey(existing),
|
|
},
|
|
}
|
|
continue
|
|
}
|
|
resultKeyIndex.set(resultKey, merged.length)
|
|
}
|
|
const checkpointKey = checkpointId ? `checkpoint:${checkpointId}` : ''
|
|
const dedupeKey = resultKey || checkpointKey || uiMessageId || `${message.sender}:${message.replyToId ?? ''}:${message.timestamp}:${message.content.trim()}`
|
|
if (seen.has(dedupeKey)) continue
|
|
seen.add(dedupeKey)
|
|
if (checkpointId) checkpointIndex.set(checkpointId, merged.length)
|
|
merged.push(message)
|
|
}
|
|
}
|
|
return merged.sort((a, b) => (
|
|
a.timestamp === b.timestamp
|
|
? a.id.localeCompare(b.id)
|
|
: a.timestamp - b.timestamp
|
|
))
|
|
}
|
|
|
|
/**
|
|
* Build the durable transcript shown by a company/org parent session.
|
|
*
|
|
* A parent conversation may observe every related runtime channel so that
|
|
* canonical role deliveries can be surfaced in one place. Those channels
|
|
* also contain transient child turns, however, and rendering all of them in
|
|
* the parent transcript makes the visible timeline change whenever the
|
|
* runtime projection selects a different child. Keep the parent's committed
|
|
* messages and only admit the canonical cross-channel result surfaces.
|
|
*/
|
|
export function selectCompanySummaryMessages(
|
|
messages: ChatMessage[],
|
|
parentChannelId: string,
|
|
): ChatMessage[] {
|
|
const terminalAssistantTurn = (message: ChatMessage): string => {
|
|
const metadata = (message.metadata ?? {}) as Record<string, unknown>
|
|
const kind = String(metadata.transcript_kind ?? metadata.kind ?? '').trim()
|
|
if (kind !== 'runtime_v2_assistant' && kind !== 'runtime_v2_company_assistant') return ''
|
|
return String(metadata.canonical_turn_id ?? metadata.turn_id ?? '').trim()
|
|
}
|
|
const parentTerminalTurns = new Set(
|
|
messages
|
|
.filter(message => (
|
|
message.channelId === parentChannelId
|
|
&& isMessageVisibleAtDetailLevel(message, 'summary')
|
|
))
|
|
.map(terminalAssistantTurn)
|
|
.filter(Boolean),
|
|
)
|
|
const childTerminalByTurn = new Map<string, ChatMessage>()
|
|
const durableMessages: ChatMessage[] = []
|
|
for (const message of messages) {
|
|
if (message.channelId === parentChannelId) {
|
|
if (isMessageVisibleAtDetailLevel(message, 'summary')) {
|
|
durableMessages.push(message)
|
|
}
|
|
continue
|
|
}
|
|
const metadata = (message.metadata ?? {}) as Record<string, unknown>
|
|
const kind = String(metadata.transcript_kind ?? metadata.kind ?? '').trim()
|
|
const checkpointId = String(metadata.checkpoint_id ?? '').trim()
|
|
const checkpointType = String(metadata.checkpoint_type ?? '').trim()
|
|
const checkpointResponseId = String(metadata.response_to_checkpoint_id ?? '').trim()
|
|
const escalationResponseId = String(metadata.response_to_escalation_id ?? '').trim()
|
|
if ((checkpointId && checkpointType) || checkpointResponseId || escalationResponseId) {
|
|
durableMessages.push(message)
|
|
continue
|
|
}
|
|
if ([
|
|
'company_role_result',
|
|
'company_role_result_retry',
|
|
'child_task_result',
|
|
'child_task_result_retry',
|
|
'child_result',
|
|
'top_level_reply',
|
|
].includes(kind)) {
|
|
durableMessages.push(message)
|
|
continue
|
|
}
|
|
// Snapshot builder deliberately marks the final runtime surface as
|
|
// summary-visible. Preserve that durable contract instead of reusing the
|
|
// result-dedupe priority table as a visibility threshold.
|
|
const isSummaryTerminal = String(metadata.detail_visibility ?? '').trim() === 'summary'
|
|
&& (kind === 'runtime_v2_assistant' || kind === 'runtime_v2_company_assistant')
|
|
if (!isSummaryTerminal) continue
|
|
const turnId = terminalAssistantTurn(message)
|
|
if (!turnId) {
|
|
durableMessages.push(message)
|
|
continue
|
|
}
|
|
if (parentTerminalTurns.has(turnId)) continue
|
|
const existing = childTerminalByTurn.get(turnId)
|
|
if (!existing) {
|
|
childTerminalByTurn.set(turnId, message)
|
|
continue
|
|
}
|
|
const existingPriority = resultSurfacePriority(existing)
|
|
const candidatePriority = resultSurfacePriority(message)
|
|
if (candidatePriority > existingPriority) {
|
|
childTerminalByTurn.set(turnId, message)
|
|
} else if (
|
|
candidatePriority === existingPriority
|
|
&& (message.timestamp > existing.timestamp
|
|
|| (message.timestamp === existing.timestamp && message.id.localeCompare(existing.id) > 0))
|
|
) {
|
|
childTerminalByTurn.set(turnId, message)
|
|
}
|
|
}
|
|
durableMessages.push(...childTerminalByTurn.values())
|
|
return mergeConversationMessages([durableMessages])
|
|
}
|
|
|
|
export function mergeConversationProgressLog(timelineSessions: Session[]): ProgressEntry[] {
|
|
const seen = new Set<string>()
|
|
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,
|
|
)
|
|
}
|