@@ -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
- }
-}