import type { ToolCallStatusKind } from "./workspaceAgentToolCallDisplay"; import { isWorkspaceAgentSyntheticControlMessage } from "./workspaceAgentSyntheticMessages"; import type { WorkspaceAgentActivityTimelineItem } from "./workspaceAgentTimelineTypes"; export function messageRole( item: WorkspaceAgentActivityTimelineItem ): "user" | "agent" | "thinking" | null { const explicitRole = item.role?.trim().toLowerCase(); if (explicitRole === "user") { return "user"; } if (explicitRole === "assistant_thinking") { return "thinking"; } if (explicitRole === "assistant" || explicitRole === "agent") { return "agent"; } const itemType = item.itemType.trim().toLowerCase(); if (itemType === "message.user") { return "user"; } if (itemType === "message.agent" || itemType === "message.assistant") { return "agent"; } if (itemType === "message.assistant_thinking") { return "thinking"; } return null; } export function messageBody(item: WorkspaceAgentActivityTimelineItem): string { const payloadContent = item.payload?.content; if (typeof payloadContent === "string" && payloadContent.trim()) { return isWorkspaceAgentSyntheticControlMessage(payloadContent) ? "" : payloadContent; } const content = item.content; if (content?.trim()) { return isWorkspaceAgentSyntheticControlMessage(content) ? "" : content; } const payloadText = item.payload?.text; if (typeof payloadText !== "string") { return ""; } return payloadText.trim() && !isWorkspaceAgentSyntheticControlMessage(payloadText) ? payloadText : ""; } export function thinkingStatusKind( item: WorkspaceAgentActivityTimelineItem ): ToolCallStatusKind | null { const status = firstPresentString( item.status, stringRecordValue(item.payload, "status"), stringRecordValue(item.payload, "streamState"), stringRecordValue(item.payload, "messageStreamState") ); return messageStatusKind(status); } export function messageStatusKind( status: string | null ): ToolCallStatusKind | null { switch (normalizeStatusToken(status)) { case "active": case "running": case "streaming": case "working": case "inprogress": case "in_progress": return "working"; case "completed": case "complete": case "done": case "success": case "succeeded": return "completed"; case "failed": case "error": return "failed"; case "canceled": return "canceled"; case "pending": case "waiting": return "waiting"; default: return null; } } export function isPlaceholderThinkingBody(body: string): boolean { const normalized = body.trim(); return normalized === "..." || normalized === "…"; } // Codex review reasoning summaries lead with a bold section title, e.g. // "**Considering workspace registration order**\n\nI'm looking at...". Hide // the title in /review process prose; keep the body paragraph. const reviewProcessSummaryTitlePattern = /^\*\*(.+?)\*\*(?:\r?\n\s*)?/; export function stripReviewProcessSummaryTitle(body: string): string { const match = body.match(reviewProcessSummaryTitlePattern); if (!match) { return body; } return body.slice(match[0].length).trimStart(); } export function normalizedMessageBody(body: string): string { return body.trim().replace(/\s+/g, " "); } export function userMessageProjectionKey( item: WorkspaceAgentActivityTimelineItem, body: string ): string | null { const normalizedBody = normalizedMessageBody(body); if (normalizedBody) { return `text:${normalizedBody}`; } if (!hasUserPromptContent(item.payload?.content)) { return null; } const clientSubmitId = stringRecordValue(item.payload, "clientSubmitId"); return clientSubmitId ? `client-submit:${clientSubmitId}` : `event:${item.eventId}`; } export function isRecentDuplicateUserMessage( previous: WorkspaceAgentActivityTimelineItem | undefined, current: WorkspaceAgentActivityTimelineItem ): boolean { if (!previous) { return false; } const previousTurnId = previous.turnId?.trim(); const currentTurnId = current.turnId?.trim(); if (previousTurnId && currentTurnId && previousTurnId === currentTurnId) { return true; } if (previousTurnId && currentTurnId) { return false; } const previousOccurredAt = previous.occurredAtUnixMs ?? 0; const currentOccurredAt = current.occurredAtUnixMs ?? 0; if (previousOccurredAt > 0 && currentOccurredAt > 0) { return Math.abs(currentOccurredAt - previousOccurredAt) <= 60_000; } const previousSeq = previous.seq ?? 0; const currentSeq = current.seq ?? 0; if (previousSeq > 0 && currentSeq > 0) { return Math.abs(currentSeq - previousSeq) <= 5; } return false; } function hasUserPromptContent(content: unknown): boolean { if (!Array.isArray(content)) { return false; } return content.some((candidate) => { if ( !candidate || typeof candidate !== "object" || Array.isArray(candidate) ) { return false; } const block = candidate as Record; if (block.type === "text") { return typeof block.text === "string" && block.text.trim().length > 0; } // Keep the image block even when MIME/source metadata is incomplete. The // renderer owns load failure presentation and must be able to show a // retryable placeholder for an expired or malformed external URL. return block.type === "image"; }); } function stringRecordValue(record: unknown, key: string): string | null { if (!record || typeof record !== "object") { return null; } const value = (record as Record)[key]; return typeof value === "string" && value.trim() ? value.trim() : null; } function firstPresentString( ...values: Array ): string | null { for (const value of values) { const normalized = value?.trim(); if (normalized) { return normalized; } } return null; } function normalizeStatusToken(value: string | null): string { return (value ?? "") .trim() .toLowerCase() .replace(/[\s-]+/g, "_"); }