import type { WorkspaceAgentActivityTimelineItem } from "./workspaceAgentTimelineTypes"; import { appendWorkspaceAgentGoalControl } from "./workspaceAgentGoalControlProjection"; import { isWorkspaceAgentToolCallItem } from "./workspaceAgentToolCallDisplay"; import type { BuildWorkspaceAgentSessionDetailInput, WorkspaceAgentSessionDetailAgentItem, WorkspaceAgentSessionDetailGoalControl, WorkspaceAgentSessionDetailToolCall, WorkspaceAgentSessionDetailToolGroupEntry, WorkspaceAgentSessionDetailTurn, WorkspaceAgentSessionDetailViewModel } from "./workspaceAgentSessionDetailViewModel"; import { isPlaceholderThinkingBody, isRecentDuplicateUserMessage, messageBody, messageRole, messageStatusKind, normalizedMessageBody, stripReviewProcessSummaryTitle, thinkingStatusKind, userMessageProjectionKey } from "./workspaceAgentTimelineMessageHelpers"; import { isCollaborationTimelineItem } from "./agentConversation/projection/agentCollaborationProjection"; import { compareToolCallsAscending, delegatedToolStepFromCall, firstPresentString, isTaskLikeToolCall, mergeSourceTimelineItems, normalizedPayload, parentToolUseIdFromCall, shouldShowProcessingIndicator, stringRecordValue, summarizeToolCallGroup, systemNoticeFromPayload, toolCallView, visibleErrorFromPayload, withSourceTimelineItems } from "./workspaceAgentTimelineProjectionHelpers"; import { enrichProjectedTurnsWithCanonicalErrors } from "./workspaceAgentTurnErrorProjection"; import { normalizeToolName, shouldSuppressToolCall, suppressedUnavailableAskUserQuestionCallIds } from "./workspaceAgentTimelineSuppression"; export function buildCanonicalWorkspaceAgentDetailView({ activity, session, sessionTurns = [], timelineItems, workspaceRoot = null }: BuildWorkspaceAgentSessionDetailInput): WorkspaceAgentSessionDetailViewModel { const turns = new Map(); const goalControls: WorkspaceAgentSessionDetailGoalControl[] = []; const recentUserMessages = new Map< string, WorkspaceAgentActivityTimelineItem >(); const seenThinkingMessages = new Set(); let activeSequenceTurnId: string | null = null; const sortedTimelineItems = [...timelineItems].sort( compareTimelineItemsAscending ); const suppressedToolCallIds = suppressedUnavailableAskUserQuestionCallIds(sortedTimelineItems); for (const item of sortedTimelineItems) { const role = messageRole(item); const body = messageBody(item); const explicitTurnId = item.turnId?.trim(); if ( appendWorkspaceAgentGoalControl(goalControls, item, itemId(item), body) ) { continue; } if (role === "user") { const turnId = explicitTurnId || `seq:${item.seq || item.id}`; const projectionKey = userMessageProjectionKey(item, body); if (!projectionKey) { continue; } if ( isRecentDuplicateUserMessage( recentUserMessages.get(projectionKey), item ) ) { continue; } activeSequenceTurnId = turnId; const turn = getTurn(turns, turnId); const message = withSourceTimelineItems( { id: itemId(item), body, turnId, occurredAtUnixMs: item.occurredAtUnixMs ?? item.createdAtUnixMs ?? null }, [item] ); turn.userMessages.push(message); turn.userMessage ??= message; recentUserMessages.set(projectionKey, item); continue; } const turnId = explicitTurnId || activeSequenceTurnId || `seq:${item.seq || item.id}`; const turn = getTurn(turns, turnId); // Collaboration runs render as a dedicated card. Unlike ordinary agent // messages they must stay visible while their body (resultText) is still // empty — a running consult has no output yet — so this branch does not // require a non-empty body. if (isCollaborationTimelineItem(item)) { const status = firstPresentString( item.status, stringRecordValue(item.payload, "status") ); const statusKind = messageStatusKind(status); const message = withSourceTimelineItems( { id: itemId(item), body, ...(status ? { status } : {}), ...(statusKind ? { statusKind } : {}), turnId, occurredAtUnixMs: item.occurredAtUnixMs ?? item.createdAtUnixMs ?? null }, [item] ); turn.agentMessages.push(message); turn.agentItems.push({ kind: "message", message }); continue; } if (isWorkspaceAgentToolCallItem(item)) { if (shouldSuppressToolCall(item, suppressedToolCallIds)) { continue; } upsertToolCall(turn, item); continue; } if (role === "thinking" && body) { const payload = normalizedPayload(item.payload); if (payload?.messageKind === "review-process") { const status = firstPresentString( item.status, stringRecordValue(payload, "status") ); const statusKind = messageStatusKind(status); const message = withSourceTimelineItems( { id: itemId(item), body: stripReviewProcessSummaryTitle(body), ...(status ? { status } : {}), ...(statusKind ? { statusKind } : {}), turnId, occurredAtUnixMs: item.occurredAtUnixMs ?? item.createdAtUnixMs ?? null }, [item] ); turn.agentMessages.push(message); turn.agentItems.push({ kind: "message", message }); continue; } if (isPlaceholderThinkingBody(body)) { continue; } const duplicateKey = `${turnId}\u0000${normalizedMessageBody(body)}`; if (seenThinkingMessages.has(duplicateKey)) { continue; } seenThinkingMessages.add(duplicateKey); const statusKind = thinkingStatusKind(item); const thinking = withSourceTimelineItems( { id: itemId(item), body, ...(statusKind ? { statusKind } : {}), turnId, occurredAtUnixMs: item.occurredAtUnixMs ?? item.createdAtUnixMs ?? null }, [item] ); turn.agentItems.push({ kind: "thinking", thinking }); continue; } if (role === "agent" && body) { const payload = normalizedPayload(item.payload); const visibleError = visibleErrorFromPayload(payload); const systemNotice = systemNoticeFromPayload(payload, item); const status = firstPresentString( item.status, stringRecordValue(payload, "status") ); const statusKind = messageStatusKind(status); const message = withSourceTimelineItems( { id: itemId(item), body, ...(status ? { status } : {}), ...(statusKind ? { statusKind } : {}), turnId, occurredAtUnixMs: item.occurredAtUnixMs ?? item.createdAtUnixMs ?? null, ...(visibleError ? { visibleError } : {}), ...(systemNotice ? { systemNotice } : {}) }, [item] ); turn.agentMessages.push(message); turn.agentItems.push({ kind: "message", message }); } } enrichProjectedTurnsWithCanonicalErrors({ turns, latestTurnId: session.latestTurn?.turnId ?? null, sessionTurns: sessionTurns.length > 0 ? sessionTurns : session.latestTurn ? [session.latestTurn] : [], provider: session.provider, agentSessionId: session.agentSessionId }); const visibleTurns = [...turns.values()].filter( (turn) => turn.userMessages.length > 0 || turn.agentItems.length > 0 ); nestDelegatedToolCallsAcrossTurns(visibleTurns); visibleTurns.forEach(mergeBackgroundTerminalContinuations); visibleTurns.forEach((turn) => { turn.rawAgentItems = [...turn.agentItems]; turn.agentItems = regroupAgentItems(turn.agentItems); }); return { activity, session, sessionTurns, cwd: session.cwd.trim(), workspaceRoot: workspaceRoot?.trim() || null, goalControls, turns: visibleTurns, showProcessingIndicator: shouldShowProcessingIndicator( session, visibleTurns ) }; } function getTurn( turns: Map, id: string ): WorkspaceAgentSessionDetailTurn { const existing = turns.get(id); if (existing) { return existing; } const turn: WorkspaceAgentSessionDetailTurn = { id, userMessage: null, userMessages: [], agentMessages: [], toolCalls: [], toolCallCount: 0, hasFailedToolCall: false, agentItems: [] }; turns.set(id, turn); return turn; } function compareTimelineItemsAscending( left: WorkspaceAgentActivityTimelineItem, right: WorkspaceAgentActivityTimelineItem ): number { const leftSeq = left.seq ?? 0; const rightSeq = right.seq ?? 0; if (leftSeq > 0 && rightSeq > 0 && leftSeq !== rightSeq) { return leftSeq - rightSeq; } return ( (left.occurredAtUnixMs ?? left.createdAtUnixMs ?? 0) - (right.occurredAtUnixMs ?? right.createdAtUnixMs ?? 0) || leftSeq - rightSeq || left.id - right.id ); } function itemId(item: WorkspaceAgentActivityTimelineItem): string { const eventId = item.eventId?.trim(); if (eventId) { return eventId; } const seq = item.seq ?? 0; return seq > 0 ? `seq:${seq}` : `id:${item.id}`; } function upsertToolCall( turn: WorkspaceAgentSessionDetailTurn, item: WorkspaceAgentActivityTimelineItem ): void { const call = toolCallView(item); const existingIndex = turn.toolCalls.findIndex( (existing) => existing.id === call.id ); if (existingIndex >= 0) { const existing = turn.toolCalls[existingIndex]; if (!existing) { return; } turn.toolCalls[existingIndex] = mergeToolCallDetail(existing, call); } else { turn.toolCalls.push(call); } turn.toolCallCount = turn.toolCalls.length; turn.hasFailedToolCall = turn.toolCalls.some( (existing) => existing.statusKind === "failed" ); upsertToolCallAgentItem(turn, call, itemId(item)); } function upsertToolCallAgentItem( turn: WorkspaceAgentSessionDetailTurn, call: WorkspaceAgentSessionDetailToolCall, sourceId: string ): void { for (const entry of turn.agentItems) { if (entry.kind !== "tool-calls") { continue; } const existingIndex = entry.toolCalls.findIndex( (existing) => existing.id === call.id ); if (existingIndex >= 0) { const existing = entry.toolCalls[existingIndex]; if (!existing) { continue; } entry.toolCalls[existingIndex] = mergeToolCallDetail(existing, call); refreshToolCallAgentItem(entry); return; } } const entry: WorkspaceAgentSessionDetailAgentItem = { kind: "tool-calls", id: `tools:${sourceId}`, toolCalls: [call], toolCallCount: 1, hasFailedToolCall: call.statusKind === "failed" }; turn.agentItems.push(entry); } function refreshToolCallAgentItem( entry: Extract ): void { entry.toolCallCount = entry.toolCalls.length; entry.hasFailedToolCall = entry.toolCalls.some( (existing) => existing.statusKind === "failed" ); const summary = summarizeToolCallGroup(entry.toolCalls); if (summary) { entry.summary = summary; } else { delete entry.summary; } } function regroupAgentItems( items: readonly WorkspaceAgentSessionDetailAgentItem[] ): WorkspaceAgentSessionDetailAgentItem[] { const regrouped: WorkspaceAgentSessionDetailAgentItem[] = []; let pending: Array< Extract > = []; const flushPending = () => { if (pending.length === 0) { return; } const groupedCalls = pending.flatMap((item) => item.toolCalls); if (groupedCalls.length >= 2) { regrouped.push({ kind: "tool-calls", id: pending.map((item) => item.id).join("+"), toolCalls: groupedCalls, toolCallCount: groupedCalls.length, hasFailedToolCall: groupedCalls.some( (call) => call.statusKind === "failed" ), summary: summarizeToolCallGroup(groupedCalls), groupEntries: pending.flatMap((item) => item.toolCalls.map((call) => ({ kind: "tool-call", call })) ) }); } else { regrouped.push(...pending); } pending = []; }; for (const item of items) { if (item.kind === "tool-calls" && isGroupableToolCallItem(item)) { pending.push(item); continue; } flushPending(); regrouped.push(item); } flushPending(); return regrouped; } function isGroupableToolCallItem( item: Extract ): boolean { return ( item.toolCalls.length === 1 && item.toolCalls.every((call) => isGroupableToolCall(call)) ); } function mergeToolCallDetail( previous: WorkspaceAgentSessionDetailToolCall, next: WorkspaceAgentSessionDetailToolCall ): WorkspaceAgentSessionDetailToolCall { return withSourceTimelineItems( { ...next, name: next.name || previous.name, toolName: next.toolName || previous.toolName, callType: next.callType || previous.callType, summary: next.summary || previous.summary, payload: next.payload ?? previous.payload }, mergeSourceTimelineItems( previous.sourceTimelineItems, next.sourceTimelineItems ) ); } function mergeBackgroundTerminalContinuations( turn: WorkspaceAgentSessionDetailTurn ): void { if (turn.agentItems.length === 0 || turn.toolCalls.length === 0) { return; } const removedCallIDs = new Set(); const singleCallItems = turn.agentItems.filter( ( item ): item is Extract< WorkspaceAgentSessionDetailAgentItem, { kind: "tool-calls" } > => item.kind === "tool-calls" && item.toolCalls.length === 1 ); const turnToolIndexByID = new Map( turn.toolCalls.map((call, index) => [call.id, index]) ); for (let index = 0; index < singleCallItems.length - 1; index += 1) { const item = singleCallItems[index]; const primaryCall = item?.toolCalls[0]; if (!item || !primaryCall) { continue; } const terminalSessionID = backgroundTerminalSessionID(primaryCall); if (!terminalSessionID) { continue; } const continuationCall = singleCallItems[index + 1]?.toolCalls[0]; if ( !continuationCall || !isBackgroundTerminalContinuation(continuationCall, terminalSessionID) ) { continue; } const mergedCall = mergeBackgroundTerminalCall( primaryCall, continuationCall ); item.toolCalls[0] = mergedCall; const turnToolIndex = turnToolIndexByID.get(primaryCall.id); if (turnToolIndex !== undefined) { turn.toolCalls[turnToolIndex] = mergedCall; } refreshToolCallAgentItem(item); removedCallIDs.add(continuationCall.id); index += 1; } if (removedCallIDs.size === 0) { return; } turn.agentItems = turn.agentItems.filter( (item) => item.kind !== "tool-calls" || item.toolCalls.every((call) => !removedCallIDs.has(call.id)) ); turn.toolCalls = turn.toolCalls.filter( (call) => !removedCallIDs.has(call.id) ); turn.toolCallCount = turn.toolCalls.length; turn.hasFailedToolCall = turn.toolCalls.some( (existing) => existing.statusKind === "failed" ); } function mergeBackgroundTerminalCall( primary: WorkspaceAgentSessionDetailToolCall, continuation: WorkspaceAgentSessionDetailToolCall ): WorkspaceAgentSessionDetailToolCall { const continuationOutput = normalizedPayload( continuation.payload ?.output as WorkspaceAgentActivityTimelineItem["payload"] ); const mergedPayload = primary.payload ? { ...primary.payload } : {}; if (continuationOutput) { mergedPayload.output = continuationOutput; } return { ...primary, status: continuation.status || primary.status, statusKind: continuation.statusKind || primary.statusKind, payload: Object.keys(mergedPayload).length > 0 ? mergedPayload : primary.payload, occurredAtUnixMs: continuation.occurredAtUnixMs ?? primary.occurredAtUnixMs }; } function nestDelegatedToolCallsAcrossTurns( turns: readonly WorkspaceAgentSessionDetailTurn[] ): void { if (turns.length === 0) { return; } const parentCalls = new Map(); const callTurnByID = new Map(); for (const turn of turns) { for (const call of turn.toolCalls) { parentCalls.set(call.id, call); parentCalls.set(call.id.replace(/^call:/, ""), call); callTurnByID.set(call.id, turn); callTurnByID.set(call.id.replace(/^call:/, ""), turn); } } const childCallsByParent = new Map< string, WorkspaceAgentSessionDetailToolCall[] >(); for (const turn of turns) { for (const call of turn.toolCalls) { const parentToolUseId = parentToolUseIdFromCall(call); if (!parentToolUseId) { continue; } const parentCall = parentCalls.get(parentToolUseId); if (!parentCall || !isTaskLikeToolCall(parentCall)) { continue; } const children = childCallsByParent.get(parentToolUseId) ?? []; children.push(call); childCallsByParent.set(parentToolUseId, children); } } if (childCallsByParent.size === 0) { return; } const removeIDsByTurn = new Map< WorkspaceAgentSessionDetailTurn, Set >(); for (const [parentID, childCalls] of childCallsByParent.entries()) { const parentCall = parentCalls.get(parentID); if (!parentCall) { continue; } appendDelegatedToolSteps(parentCall, childCalls); for (const childCall of childCalls) { const childTurn = callTurnByID.get(childCall.id); if (!childTurn) { continue; } const removeIDs = removeIDsByTurn.get(childTurn) ?? new Set(); removeIDs.add(childCall.id); removeIDsByTurn.set(childTurn, removeIDs); } } for (const [turn, removeIDs] of removeIDsByTurn.entries()) { pruneTurnToolCalls(turn, removeIDs); } } function backgroundTerminalSessionID( call: WorkspaceAgentSessionDetailToolCall ): string | null { const outputText = stringRecordValue(call.payload?.output, "output"); if (!outputText) { return null; } const match = outputText.match(/Process running with session ID (\d+)/i); return match?.[1] ?? null; } function isBackgroundTerminalContinuation( call: WorkspaceAgentSessionDetailToolCall, sessionID: string ): boolean { const input = normalizedPayload( call.payload?.input as WorkspaceAgentActivityTimelineItem["payload"] ); const inputSessionID = firstPresentString( stringRecordValue(input, "session_id"), stringRecordValue(input, "sessionId") ); if (inputSessionID !== sessionID) { return false; } const chars = firstPresentString(stringRecordValue(input, "chars")) ?? ""; if (chars !== "") { return false; } return stringRecordValue(call.payload?.output, "output") !== null; } function isGroupableToolCall( call: WorkspaceAgentSessionDetailToolCall ): boolean { if ( call.callType === "approval" || call.callType === "interactive" || call.callType === "subagent" ) { return false; } switch (normalizeToolName(call.toolName)) { case "askuserquestion": case "enterplanmode": case "exitplanmode": case "task": case "subagent": case "delegatetask": return false; default: return true; } } function pruneTurnToolCalls( turn: WorkspaceAgentSessionDetailTurn, removeIDs: ReadonlySet ): void { if (removeIDs.size === 0) { return; } turn.toolCalls = turn.toolCalls.filter((call) => !removeIDs.has(call.id)); turn.toolCallCount = turn.toolCalls.length; turn.hasFailedToolCall = turn.toolCalls.some( (existing) => existing.statusKind === "failed" ); turn.agentItems = turn.agentItems.flatMap((item) => { if (item.kind !== "tool-calls") { return [item]; } const toolCalls = item.toolCalls.filter( (call) => !removeIDs.has(call.id) ); if (toolCalls.length === 0) { return []; } const nextItem = { ...item, toolCalls }; refreshToolCallAgentItem(nextItem); return [nextItem]; }); } function appendDelegatedToolSteps( parentCall: WorkspaceAgentSessionDetailToolCall, childCalls: readonly WorkspaceAgentSessionDetailToolCall[] ): void { const nextPayload = parentCall.payload ? { ...parentCall.payload } : {}; const nextMetadata = normalizedPayload( nextPayload.metadata as WorkspaceAgentActivityTimelineItem["payload"] ) ?? {}; const existingSteps = Array.isArray(nextMetadata.steps) ? [...nextMetadata.steps] : []; const existingStepIDs = new Set( existingSteps .map((step) => normalizedPayload(step as WorkspaceAgentActivityTimelineItem["payload"]) ) .map( (step) => stringRecordValue(step, "toolUseId") ?? stringRecordValue(step, "id") ) .filter((value): value is string => Boolean(value)) ); for (const childCall of [...childCalls].sort(compareToolCallsAscending)) { const step = delegatedToolStepFromCall(childCall); const stepID = stringRecordValue(step, "toolUseId") ?? stringRecordValue(step, "id"); if (stepID && existingStepIDs.has(stepID)) { continue; } if (stepID) { existingStepIDs.add(stepID); } existingSteps.push(step); } nextMetadata.steps = existingSteps; nextPayload.metadata = nextMetadata; parentCall.payload = nextPayload; }