From 380f430d5a8d390ae7681c4af21b99b686fecd27 Mon Sep 17 00:00:00 2001 From: xintaofei Date: Tue, 10 Mar 2026 23:24:27 +0800 Subject: [PATCH] =?UTF-8?q?=E9=87=8D=E6=9E=84=E9=83=A8=E5=88=86=E4=BC=9A?= =?UTF-8?q?=E8=AF=9D=E6=B6=88=E6=81=AF=E5=A4=84=E7=90=86=E9=80=BB=E8=BE=91?= =?UTF-8?q?=EF=BC=8C=E4=BC=98=E5=8C=96=E4=BC=9A=E8=AF=9D=E6=B6=88=E6=81=AF?= =?UTF-8?q?=E6=B8=B2=E6=9F=93?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/chat/live-message-block.tsx | 50 --- .../conversation-detail-panel.tsx | 3 + src/components/message/message-list-view.tsx | 208 ++++------- src/contexts/conversation-runtime-context.tsx | 16 + src/i18n/messages/ar.json | 6 +- src/i18n/messages/de.json | 6 +- src/i18n/messages/en.json | 6 +- src/i18n/messages/es.json | 6 +- src/i18n/messages/fr.json | 6 +- src/i18n/messages/ja.json | 6 +- src/i18n/messages/ko.json | 6 +- src/i18n/messages/pt.json | 6 +- src/i18n/messages/zh-CN.json | 6 +- src/i18n/messages/zh-TW.json | 6 +- src/lib/adapters/ai-elements-adapter.ts | 323 +----------------- 15 files changed, 92 insertions(+), 568 deletions(-) delete mode 100644 src/components/chat/live-message-block.tsx diff --git a/src/components/chat/live-message-block.tsx b/src/components/chat/live-message-block.tsx deleted file mode 100644 index 2a9b08d..0000000 --- a/src/components/chat/live-message-block.tsx +++ /dev/null @@ -1,50 +0,0 @@ -"use client" - -import { memo, useMemo } from "react" -import { useTranslations } from "next-intl" -import type { LiveMessage } from "@/contexts/acp-connections-context" -import { ContentPartsRenderer } from "@/components/message/content-parts-renderer" -import { adaptLiveMessageFromAcp } from "@/lib/adapters/ai-elements-adapter" -import { Message, MessageContent } from "@/components/ai-elements/message" - -interface LiveMessageBlockProps { - message: LiveMessage - isStreaming?: boolean -} - -export const LiveMessageBlock = memo(function LiveMessageBlock({ - message, - isStreaming = true, -}: LiveMessageBlockProps) { - const t = useTranslations("Folder.chat.liveMessageBlock") - const sharedT = useTranslations("Folder.chat.shared") - const hasContent = message.content.length > 0 - const adapted = useMemo( - () => - adaptLiveMessageFromAcp(message, { - isLiveStreaming: isStreaming, - toolCallFailedText: sharedT("toolCallFailed"), - planUpdatedText: sharedT("planUpdated"), - }), - [message, isStreaming, sharedT] - ) - - return ( - - - {hasContent ? ( - - ) : ( -
- - - -
- )} -
-
- ) -}) diff --git a/src/components/conversations/conversation-detail-panel.tsx b/src/components/conversations/conversation-detail-panel.tsx index 1c5bd4e..196cc3c 100644 --- a/src/components/conversations/conversation-detail-panel.tsx +++ b/src/components/conversations/conversation-detail-panel.tsx @@ -540,6 +540,9 @@ const ConversationTabView = memo(function ConversationTabView({ connStatus={connStatus} isActive={isActive} sendSignal={sendSignal} + sessionStats={detail?.session_stats ?? null} + detailLoading={detailLoading} + detailError={detailError} /> ) diff --git a/src/components/message/message-list-view.tsx b/src/components/message/message-list-view.tsx index 44ae9a5..47e9397 100644 --- a/src/components/message/message-list-view.tsx +++ b/src/components/message/message-list-view.tsx @@ -1,17 +1,14 @@ "use client" import { memo, useCallback, useEffect, useMemo, useRef } from "react" -import { useDbMessageDetail } from "@/hooks/use-db-message-detail" import { useConversationRuntime } from "@/contexts/conversation-runtime-context" import { ContentPartsRenderer } from "./content-parts-renderer" import { adaptMessageTurns, - type AdaptedContentPart, type MessageGroup, type UserImageDisplay, type UserResourceDisplay, groupAdaptedMessages, - extractUserResourcesFromText, } from "@/lib/adapters/ai-elements-adapter" import { TurnStats } from "./turn-stats" import { LiveTurnStats } from "./live-turn-stats" @@ -27,7 +24,7 @@ import { buildPlanKey, extractLatestPlanEntriesFromMessages, } from "@/lib/agent-plan" -import type { ConnectionStatus } from "@/lib/types" +import type { ConnectionStatus, SessionStats } from "@/lib/types" import { VirtualizedMessageThread } from "@/components/message/virtualized-message-thread" import { useStickToBottomContext } from "use-stick-to-bottom" @@ -36,10 +33,12 @@ interface MessageListViewProps { connStatus?: ConnectionStatus | null isActive?: boolean sendSignal?: number + sessionStats?: SessionStats | null + detailLoading?: boolean + detailError?: string | null } interface ResolvedMessageGroup extends MessageGroup { - parts: AdaptedContentPart[] resources: UserResourceDisplay[] images: UserImageDisplay[] } @@ -56,75 +55,6 @@ type ThreadRenderItem = kind: "typing" } -function fallbackExtractUserResources( - group: MessageGroup, - attachedResourcesText: string -): { - parts: AdaptedContentPart[] - resources: UserResourceDisplay[] - images: UserImageDisplay[] -} { - if (group.role !== "user") { - return { - parts: group.parts, - resources: group.userResources ?? [], - images: group.userImages ?? [], - } - } - - const parsedResources: UserResourceDisplay[] = [] - const parsedParts: AdaptedContentPart[] = [] - - for (const part of group.parts) { - if (part.type !== "text") { - parsedParts.push(part) - continue - } - const extracted = extractUserResourcesFromText(part.text) - if (extracted.resources.length > 0) { - parsedResources.push(...extracted.resources) - if (extracted.text.length > 0) { - parsedParts.push({ type: "text", text: extracted.text }) - } - } else { - parsedParts.push(part) - } - } - - const resources = [...(group.userResources ?? []), ...parsedResources] - const dedupedResources: UserResourceDisplay[] = [] - const seen = new Set() - for (const resource of resources) { - const key = `${resource.name}::${resource.uri}` - if (seen.has(key)) continue - seen.add(key) - dedupedResources.push(resource) - } - - if (parsedParts.length === 0 && dedupedResources.length > 0) { - parsedParts.push({ type: "text", text: attachedResourcesText }) - } - - return { - parts: parsedParts, - resources: dedupedResources, - images: group.userImages ?? [], - } -} - -function resolveMessageGroup( - group: MessageGroup, - attachedResourcesText: string -): ResolvedMessageGroup { - const resolved = fallbackExtractUserResources(group, attachedResourcesText) - return { - ...group, - parts: resolved.parts, - resources: resolved.resources, - images: resolved.images, - } -} - const HistoricalMessageGroup = memo(function HistoricalMessageGroup({ group, dimmed = false, @@ -200,17 +130,18 @@ export function MessageListView({ connStatus, isActive = true, sendSignal = 0, + sessionStats = null, + detailLoading = false, + detailError = null, }: MessageListViewProps) { const t = useTranslations("Folder.chat.messageList") const sharedT = useTranslations("Folder.chat.shared") - const { detail, loading, error } = useDbMessageDetail(conversationId) const { getSession, getTimelineTurns } = useConversationRuntime() const session = getSession(conversationId) const liveMessage = session?.liveMessage ?? null const timelineTurns = getTimelineTurns(conversationId) const { setSessionStats } = useSessionStats() - const sessionStats = detail?.session_stats ?? null useEffect(() => { if (isActive) { @@ -218,88 +149,71 @@ export function MessageListView({ } }, [isActive, sessionStats, setSessionStats]) - const shouldUseSmoothResize = !(isActive && !loading && timelineTurns.length) - const attachedResourcesText = sharedT("attachedResources") - - const groupedTimeline = useMemo( - () => - timelineTurns.reduce< - Array<{ - phase: "persisted" | "optimistic" | "streaming" - turns: typeof timelineTurns - }> - >((acc, item) => { - const current = acc[acc.length - 1] - if (current && current.phase === item.phase) { - current.turns.push(item) - return acc - } - acc.push({ - phase: item.phase, - turns: [item], - }) - return acc - }, []), - [timelineTurns] + const shouldUseSmoothResize = !( + isActive && + !detailLoading && + timelineTurns.length ) - const threadItems = useMemo(() => { + const adapterText = useMemo( + () => ({ + attachedResources: sharedT("attachedResources"), + toolCallFailed: sharedT("toolCallFailed"), + }), + [sharedT] + ) + + const { threadItems, nonStreamingAdapted } = useMemo(() => { + const allTurns = timelineTurns.map((item) => item.turn) + const allAdapted = adaptMessageTurns(allTurns, adapterText) + + // Collect non-streaming adapted messages for plan extraction + const nonStreaming = allAdapted.filter( + (_, index) => timelineTurns[index].phase !== "streaming" + ) + + // Group adapted messages per phase-chunk to prevent merging + // assistant turns across phase boundaries (e.g. persisted + streaming). const items: ThreadRenderItem[] = [] - for ( - let chunkIndex = 0; - chunkIndex < groupedTimeline.length; - chunkIndex++ - ) { - const chunk = groupedTimeline[chunkIndex] - const adapted = adaptMessageTurns( - chunk.turns.map((item) => item.turn), - { - attachedResources: sharedT("attachedResources"), - toolCallFailed: sharedT("toolCallFailed"), - } - ) - const groups = groupAdaptedMessages(adapted).map((group) => - resolveMessageGroup(group, attachedResourcesText) - ) - for (let groupIndex = 0; groupIndex < groups.length; groupIndex++) { - const group = groups[groupIndex] + let chunkStart = 0 + while (chunkStart < allAdapted.length) { + const chunkPhase = timelineTurns[chunkStart].phase + let chunkEnd = chunkStart + 1 + while ( + chunkEnd < allAdapted.length && + timelineTurns[chunkEnd].phase === chunkPhase + ) { + chunkEnd++ + } + const chunkAdapted = allAdapted.slice(chunkStart, chunkEnd) + const groups = groupAdaptedMessages(chunkAdapted) + for (let i = 0; i < groups.length; i++) { + const group = groups[i] items.push({ - key: `${chunk.phase}-${chunkIndex}-${group.id}-${groupIndex}`, + key: `${chunkPhase}-${chunkStart}-${group.id}-${i}`, kind: "turn", - group, - phase: chunk.phase, + group: { + ...group, + resources: group.userResources ?? [], + images: group.userImages ?? [], + }, + phase: chunkPhase, }) } + chunkStart = chunkEnd } + const lastPhase = timelineTurns[timelineTurns.length - 1]?.phase ?? null if (connStatus === "prompting" && lastPhase === "optimistic") { items.push({ key: "pending-typing", kind: "typing" }) } - return items - }, [ - attachedResourcesText, - connStatus, - groupedTimeline, - sharedT, - timelineTurns, - ]) - const historicalMessages = useMemo( - () => - adaptMessageTurns( - timelineTurns - .filter((item) => item.phase !== "streaming") - .map((item) => item.turn), - { - attachedResources: sharedT("attachedResources"), - toolCallFailed: sharedT("toolCallFailed"), - } - ), - [sharedT, timelineTurns] - ) + return { threadItems: items, nonStreamingAdapted: nonStreaming } + }, [adapterText, connStatus, timelineTurns]) + const historicalPlanEntries = useMemo( - () => extractLatestPlanEntriesFromMessages(historicalMessages), - [historicalMessages] + () => extractLatestPlanEntriesFromMessages(nonStreamingAdapted), + [nonStreamingAdapted] ) const historicalPlanKey = useMemo( () => buildPlanKey(historicalPlanEntries), @@ -337,7 +251,7 @@ export function MessageListView({ const hasRenderableContent = threadItems.length > 0 || Boolean(liveMessage) - if (loading && !hasRenderableContent) { + if (detailLoading && !hasRenderableContent) { return (
@@ -348,12 +262,12 @@ export function MessageListView({ ) } - if (error && !hasRenderableContent) { + if (detailError && !hasRenderableContent) { return (

- {t("error", { message: error })} + {t("error", { message: detailError })}

diff --git a/src/contexts/conversation-runtime-context.tsx b/src/contexts/conversation-runtime-context.tsx index 30210b0..dba9d0c 100644 --- a/src/contexts/conversation-runtime-context.tsx +++ b/src/contexts/conversation-runtime-context.tsx @@ -306,6 +306,22 @@ function reducer( const current = state.byConversationId.get(action.conversationId) ?? createEmptySession(action.conversationId) + + // Guard: prevent stale liveMessage from ACP reconnects overriding + // persisted data. When a session has no active liveMessage and no + // pending interaction (idle or reconciling without a live turn), + // a SET_LIVE_MESSAGE from a reconnected ACP connection carries + // the completed response that is already in persistedTurns. + // Accepting it would cause duplicate assistant text in the timeline. + if ( + action.liveMessage !== null && + current.liveMessage === null && + current.syncState !== "awaiting_persist" && + current.persistedTurns.length > 0 + ) { + return state + } + const nextSession: ConversationRuntimeSession = { ...current, liveMessage: action.liveMessage, diff --git a/src/i18n/messages/ar.json b/src/i18n/messages/ar.json index d1361fc..1a2d3bb 100644 --- a/src/i18n/messages/ar.json +++ b/src/i18n/messages/ar.json @@ -1114,8 +1114,7 @@ }, "shared": { "attachedResources": "الموارد المرفقة", - "toolCallFailed": "فشل استدعاء الأداة", - "planUpdated": "تم تحديث الخطة" + "toolCallFailed": "فشل استدعاء الأداة" }, "messageThread": { "emptyTitle": "لا توجد رسائل بعد", @@ -1147,9 +1146,6 @@ "noEnabledAgents": "لا يوجد وكلاء مفعّلون", "openAgentsSettings": "فتح إعدادات الوكلاء" }, - "liveMessageBlock": { - "assistantThinkingAria": "المساعد يفكر" - }, "agentPlanOverlay": { "title": "خطة الوكيل", "collapsePlanAria": "طي الخطة", diff --git a/src/i18n/messages/de.json b/src/i18n/messages/de.json index 8eacaa5..8a753cc 100644 --- a/src/i18n/messages/de.json +++ b/src/i18n/messages/de.json @@ -1114,8 +1114,7 @@ }, "shared": { "attachedResources": "Angehängte Ressourcen", - "toolCallFailed": "Tool-Aufruf fehlgeschlagen", - "planUpdated": "Plan aktualisiert" + "toolCallFailed": "Tool-Aufruf fehlgeschlagen" }, "messageThread": { "emptyTitle": "Noch keine Nachrichten", @@ -1147,9 +1146,6 @@ "noEnabledAgents": "Keine aktivierten Agenten", "openAgentsSettings": "Agenten-Einstellungen öffnen" }, - "liveMessageBlock": { - "assistantThinkingAria": "Assistent denkt nach" - }, "agentPlanOverlay": { "title": "Agentenplan", "collapsePlanAria": "Plan einklappen", diff --git a/src/i18n/messages/en.json b/src/i18n/messages/en.json index 855e41f..bab22c9 100644 --- a/src/i18n/messages/en.json +++ b/src/i18n/messages/en.json @@ -1114,8 +1114,7 @@ }, "shared": { "attachedResources": "Attached resources", - "toolCallFailed": "Tool call failed", - "planUpdated": "Plan updated" + "toolCallFailed": "Tool call failed" }, "messageThread": { "emptyTitle": "No messages yet", @@ -1147,9 +1146,6 @@ "noEnabledAgents": "No enabled agents", "openAgentsSettings": "Open Agents settings" }, - "liveMessageBlock": { - "assistantThinkingAria": "Assistant is thinking" - }, "agentPlanOverlay": { "title": "Agent Plan", "collapsePlanAria": "Collapse plan", diff --git a/src/i18n/messages/es.json b/src/i18n/messages/es.json index e0b8bef..452002a 100644 --- a/src/i18n/messages/es.json +++ b/src/i18n/messages/es.json @@ -1114,8 +1114,7 @@ }, "shared": { "attachedResources": "Recursos adjuntos", - "toolCallFailed": "Falló la llamada de herramienta", - "planUpdated": "Plan actualizado" + "toolCallFailed": "Falló la llamada de herramienta" }, "messageThread": { "emptyTitle": "Aún no hay mensajes", @@ -1147,9 +1146,6 @@ "noEnabledAgents": "No hay agentes habilitados", "openAgentsSettings": "Abrir ajustes de agentes" }, - "liveMessageBlock": { - "assistantThinkingAria": "El asistente está pensando" - }, "agentPlanOverlay": { "title": "Plan del agente", "collapsePlanAria": "Contraer plan", diff --git a/src/i18n/messages/fr.json b/src/i18n/messages/fr.json index 7100398..cbac913 100644 --- a/src/i18n/messages/fr.json +++ b/src/i18n/messages/fr.json @@ -1114,8 +1114,7 @@ }, "shared": { "attachedResources": "Ressources jointes", - "toolCallFailed": "Échec de l'appel d'outil", - "planUpdated": "Plan mis à jour" + "toolCallFailed": "Échec de l'appel d'outil" }, "messageThread": { "emptyTitle": "Aucun message pour le moment", @@ -1147,9 +1146,6 @@ "noEnabledAgents": "Aucun agent activé", "openAgentsSettings": "Ouvrir les paramètres des agents" }, - "liveMessageBlock": { - "assistantThinkingAria": "L'assistant réfléchit" - }, "agentPlanOverlay": { "title": "Plan de l'agent", "collapsePlanAria": "Réduire le plan", diff --git a/src/i18n/messages/ja.json b/src/i18n/messages/ja.json index 6743a8f..4d9456c 100644 --- a/src/i18n/messages/ja.json +++ b/src/i18n/messages/ja.json @@ -1114,8 +1114,7 @@ }, "shared": { "attachedResources": "添付リソース", - "toolCallFailed": "ツール呼び出しに失敗しました", - "planUpdated": "プランを更新しました" + "toolCallFailed": "ツール呼び出しに失敗しました" }, "messageThread": { "emptyTitle": "まだメッセージはありません", @@ -1147,9 +1146,6 @@ "noEnabledAgents": "有効なエージェントがありません", "openAgentsSettings": "エージェント設定を開く" }, - "liveMessageBlock": { - "assistantThinkingAria": "アシスタントが考え中です" - }, "agentPlanOverlay": { "title": "エージェントプラン", "collapsePlanAria": "プランを折りたたむ", diff --git a/src/i18n/messages/ko.json b/src/i18n/messages/ko.json index 9b4ae00..88216e5 100644 --- a/src/i18n/messages/ko.json +++ b/src/i18n/messages/ko.json @@ -1114,8 +1114,7 @@ }, "shared": { "attachedResources": "첨부된 리소스", - "toolCallFailed": "도구 호출 실패", - "planUpdated": "계획이 업데이트되었습니다" + "toolCallFailed": "도구 호출 실패" }, "messageThread": { "emptyTitle": "아직 메시지가 없습니다", @@ -1147,9 +1146,6 @@ "noEnabledAgents": "활성화된 에이전트가 없습니다", "openAgentsSettings": "에이전트 설정 열기" }, - "liveMessageBlock": { - "assistantThinkingAria": "어시스턴트가 생각 중" - }, "agentPlanOverlay": { "title": "에이전트 계획", "collapsePlanAria": "계획 접기", diff --git a/src/i18n/messages/pt.json b/src/i18n/messages/pt.json index ec48359..e84e395 100644 --- a/src/i18n/messages/pt.json +++ b/src/i18n/messages/pt.json @@ -1114,8 +1114,7 @@ }, "shared": { "attachedResources": "Recursos anexados", - "toolCallFailed": "Falha na chamada da ferramenta", - "planUpdated": "Plano atualizado" + "toolCallFailed": "Falha na chamada da ferramenta" }, "messageThread": { "emptyTitle": "Ainda não há mensagens", @@ -1147,9 +1146,6 @@ "noEnabledAgents": "Nenhum agente habilitado", "openAgentsSettings": "Abrir configurações de agentes" }, - "liveMessageBlock": { - "assistantThinkingAria": "O assistente está pensando" - }, "agentPlanOverlay": { "title": "Plano do agente", "collapsePlanAria": "Recolher plano", diff --git a/src/i18n/messages/zh-CN.json b/src/i18n/messages/zh-CN.json index 0edd4e8..f20fa13 100644 --- a/src/i18n/messages/zh-CN.json +++ b/src/i18n/messages/zh-CN.json @@ -1114,8 +1114,7 @@ }, "shared": { "attachedResources": "附加资源", - "toolCallFailed": "工具调用失败", - "planUpdated": "计划已更新" + "toolCallFailed": "工具调用失败" }, "messageThread": { "emptyTitle": "暂无消息", @@ -1147,9 +1146,6 @@ "noEnabledAgents": "暂无已启用的 Agent", "openAgentsSettings": "打开 Agents 设置" }, - "liveMessageBlock": { - "assistantThinkingAria": "助手正在思考" - }, "agentPlanOverlay": { "title": "Agent 计划", "collapsePlanAria": "折叠计划", diff --git a/src/i18n/messages/zh-TW.json b/src/i18n/messages/zh-TW.json index 66640fd..de3c915 100644 --- a/src/i18n/messages/zh-TW.json +++ b/src/i18n/messages/zh-TW.json @@ -1114,8 +1114,7 @@ }, "shared": { "attachedResources": "附加資源", - "toolCallFailed": "工具呼叫失敗", - "planUpdated": "計畫已更新" + "toolCallFailed": "工具呼叫失敗" }, "messageThread": { "emptyTitle": "暫無訊息", @@ -1147,9 +1146,6 @@ "noEnabledAgents": "暫無已啟用的 Agent", "openAgentsSettings": "開啟 Agents 設定" }, - "liveMessageBlock": { - "assistantThinkingAria": "助手正在思考" - }, "agentPlanOverlay": { "title": "Agent 計畫", "collapsePlanAria": "摺疊計畫", diff --git a/src/lib/adapters/ai-elements-adapter.ts b/src/lib/adapters/ai-elements-adapter.ts index 5c23ec7..af45b58 100644 --- a/src/lib/adapters/ai-elements-adapter.ts +++ b/src/lib/adapters/ai-elements-adapter.ts @@ -4,8 +4,6 @@ import type { MessageRole, TurnUsage, } from "@/lib/types" -import type { LiveMessage } from "@/contexts/acp-connections-context" -import { inferLiveToolName } from "@/lib/tool-call-normalization" /** * Adapted content part types for AI SDK Elements components @@ -71,7 +69,6 @@ export interface AdaptedMessage { export interface AdapterMessageText { attachedResources: string toolCallFailed: string - planUpdated: string } type InlineToolSegment = @@ -606,7 +603,7 @@ function buildToolResultMap( */ export function adaptMessageTurn( turn: MessageTurn, - text: Pick + text: AdapterMessageText ): AdaptedMessage { const adaptedContent: AdaptedContentPart[] = [] const resultMap = buildToolResultMap(turn.blocks) @@ -733,7 +730,7 @@ export function adaptMessageTurn( */ export function adaptMessageTurns( turns: MessageTurn[], - text: Pick + text: AdapterMessageText ): AdaptedMessage[] { return turns.map((turn) => adaptMessageTurn(turn, text)) } @@ -819,319 +816,3 @@ export function groupAdaptedMessages( return groups } - -/** - * Map ACP tool call status to ToolCallState for display. - */ -function mapAcpStatusToToolCallState(status: string): ToolCallState { - switch (status) { - case "pending": - return "input-streaming" - case "in_progress": - return "input-available" - case "completed": - return "output-available" - case "failed": - return "output-error" - default: - return "input-available" - } -} - -function isReadToolName(toolName: string): boolean { - const normalized = toolName.trim().toLowerCase() - return normalized === "read" || normalized === "read file" -} - -function isTaskMarkdownToolName(toolName: string): boolean { - const normalized = toolName.trim().toLowerCase() - return ( - normalized === "task" || - normalized === "taskcreate" || - normalized === "taskupdate" || - normalized === "tasklist" || - normalized.includes("explore") - ) -} - -function looksLikeJsonPayload(text: string): boolean { - const trimmed = text.trimStart() - return trimmed.startsWith("{") || trimmed.startsWith("[") -} - -function collectReadOutputText(value: unknown, depth: number = 0): string[] { - if (depth > 6 || value === null || value === undefined) { - return [] - } - - if (typeof value === "string") { - return value.length > 0 ? [value] : [] - } - - if (Array.isArray(value)) { - return value.flatMap((item) => collectReadOutputText(item, depth + 1)) - } - - if (typeof value !== "object") { - return [] - } - - const obj = value as Record - const parts: string[] = [] - const type = typeof obj.type === "string" ? obj.type.toLowerCase() : null - const text = obj.text - - if ( - typeof text === "string" && - text.length > 0 && - (type === null || type === "text") - ) { - parts.push(text) - } - - for (const nestedKey of ["content", "output", "result", "data"]) { - parts.push(...collectReadOutputText(obj[nestedKey], depth + 1)) - } - - return parts -} - -function extractReadTextFromJsonOutput(output: string): string | null { - if (!looksLikeJsonPayload(output)) { - return null - } - - try { - const parsed: unknown = JSON.parse(output) - const parts = collectReadOutputText(parsed) - if (parts.length === 0) return null - const text = parts.join("\n") - return text.length > 0 ? text : null - } catch { - return null - } -} - -function decodeJsonTextValue(value: string): string { - try { - return JSON.parse(`"${value}"`) as string - } catch { - return value.replace(/\\"/g, '"').replace(/\\\\/g, "\\") - } -} - -function extractTextFromMalformedJsonOutput(output: string): string | null { - const textValues = Array.from( - output.matchAll(/"text"\s*:\s*"((?:[^"\\]|\\.)*)"/g) - ) - .map((match) => decodeJsonTextValue(match[1] ?? "")) - .map((value) => value.trim()) - .filter((value) => value.length > 0) - - if (textValues.length === 0) { - return null - } - - return textValues.join("\n") -} - -function stripWrappedMarkdownFence(text: string): string { - const normalized = text.replace(/\r\n/g, "\n") - const match = normalized.match( - /^\s*```[a-zA-Z0-9_-]*\s*\n([\s\S]*?)\n```\s*$/ - ) - if (!match) return text - return match[1] -} - -function normalizeReadDisplayText(text: string): string { - return stripWrappedMarkdownFence(text) -} - -function selectTaskMarkdownOutput(params: { - rawOutput: string | null - content: string | null - isFinalState: boolean -}): string | null { - for (const candidate of [params.content, params.rawOutput]) { - if (typeof candidate !== "string" || candidate.length === 0) continue - - const extractedFromJson = - extractReadTextFromJsonOutput(candidate) ?? - extractTextFromMalformedJsonOutput(candidate) - if (extractedFromJson) { - return normalizeReadDisplayText(extractedFromJson) - } - - if (!looksLikeJsonPayload(candidate)) { - return normalizeReadDisplayText(candidate) - } - } - - if (!params.isFinalState) return null - - const fallback = params.content ?? params.rawOutput - if (typeof fallback !== "string") return null - - const extracted = - extractReadTextFromJsonOutput(fallback) ?? - extractTextFromMalformedJsonOutput(fallback) - if (extracted) { - return normalizeReadDisplayText(extracted) - } - - if (!looksLikeJsonPayload(fallback)) { - return normalizeReadDisplayText(fallback) - } - - return null -} - -function selectLiveToolOutput(params: { - toolName: string - rawOutput: string | null - content: string | null - isFinalState: boolean -}): string | null { - if (isTaskMarkdownToolName(params.toolName)) { - return selectTaskMarkdownOutput(params) - } - - if (!isReadToolName(params.toolName)) { - return params.rawOutput ?? params.content - } - - for (const candidate of [params.content, params.rawOutput]) { - if (typeof candidate !== "string" || candidate.length === 0) continue - const extracted = extractReadTextFromJsonOutput(candidate) - if (extracted) return normalizeReadDisplayText(extracted) - if (!looksLikeJsonPayload(candidate)) - return normalizeReadDisplayText(candidate) - } - - if (!params.isFinalState) return null - const fallback = params.rawOutput ?? params.content - return typeof fallback === "string" - ? normalizeReadDisplayText(fallback) - : null -} - -function formatPlanEntries( - entries: Array<{ content: string; priority: string; status: string }>, - planUpdatedText: string -): string { - if (entries.length === 0) { - return planUpdatedText - } - const lines = entries.map( - (entry) => `- [${entry.status}] ${entry.content} (${entry.priority})` - ) - return `${planUpdatedText}:\n${lines.join("\n")}` -} - -interface AdaptLiveMessageOptions { - isLiveStreaming?: boolean - toolCallFailedText: string - planUpdatedText: string -} - -function isReasoningBlock(block: LiveMessage["content"][number]): boolean { - return block.type === "thinking" || block.type === "plan" -} - -function findLastReasoningIndex(message: LiveMessage): number { - for (let index = message.content.length - 1; index >= 0; index -= 1) { - if (isReasoningBlock(message.content[index])) { - return index - } - } - return -1 -} - -/** - * Transform a LiveMessage (from ACP) to AdaptedMessage format - * This is used for live streaming messages from the ACP protocol - */ -export function adaptLiveMessageFromAcp( - message: LiveMessage, - options: AdaptLiveMessageOptions -): AdaptedMessage { - const isLiveStreaming = options.isLiveStreaming ?? true - const adaptedContent: AdaptedContentPart[] = [] - const lastStreamingReasoningIndex = isLiveStreaming - ? findLastReasoningIndex(message) - : -1 - - message.content.forEach((block, index) => { - switch (block.type) { - case "text": - adaptedContent.push({ - type: "text", - text: block.text, - }) - break - - case "thinking": - adaptedContent.push({ - type: "reasoning", - content: block.text, - isStreaming: index === lastStreamingReasoningIndex, - }) - break - - case "tool_call": { - const { info } = block - const toolName = inferLiveToolName({ - title: info.title, - kind: info.kind, - rawInput: info.raw_input, - }) - const state = mapAcpStatusToToolCallState(info.status) - const isFinalState = - state === "output-available" || state === "output-error" - const hasExplicitOutput = - info.raw_output !== null || info.content !== null - const selectedOutput = selectLiveToolOutput({ - toolName, - rawOutput: info.raw_output, - content: info.content, - isFinalState, - }) - const output = isFinalState - ? selectedOutput - : hasExplicitOutput - ? selectedOutput - : null - adaptedContent.push({ - type: "tool-call", - toolCallId: info.tool_call_id, - toolName, - displayTitle: info.title, - input: info.raw_input, - state, - output, - errorText: - state === "output-error" - ? selectedOutput || options.toolCallFailedText - : undefined, - }) - break - } - - case "plan": - adaptedContent.push({ - type: "reasoning", - content: formatPlanEntries(block.entries, options.planUpdatedText), - isStreaming: index === lastStreamingReasoningIndex, - }) - break - } - }) - - return { - id: message.id, - role: message.role, - content: adaptedContent, - timestamp: new Date().toISOString(), // Live messages don't have timestamps - } -}