From e6901d01cd274d46e8a37d5e5556ece3362e5afe Mon Sep 17 00:00:00 2001 From: asemabdallah Date: Wed, 23 Sep 2026 15:16:58 +0300 Subject: [PATCH 1/2] fix: keep failed turns in saved history from breaking the chat Connecting to a Rich Threads conversation replays its saved events, including RUN_ERROR from any earlier failed turn. CopilotKit reports those through onError as agent_run_error_event, so one failed reply made the conversation fail to load ("Could not load conversation") on every visit and showed a stale error banner. While history replays, ignore agent_run_error_event only. Connection failures (agent_connect_failed) still block loading, and errors from new turns still stop the run and the follow-up queue. --- apps/mobile/src/chat.tsx | 25 ++++++++++---- apps/mobile/src/conversation-run.ts | 7 +++- tests/conversation-sdk.test.ts | 52 +++++++++++++++++++++++++++-- 3 files changed, 74 insertions(+), 10 deletions(-) diff --git a/apps/mobile/src/chat.tsx b/apps/mobile/src/chat.tsx index 683332b1..da273899 100644 --- a/apps/mobile/src/chat.tsx +++ b/apps/mobile/src/chat.tsx @@ -26,7 +26,7 @@ import { BackgroundUpdates } from "./background-updates"; import { BrowserRunContext, BrowserToolCard } from "./browser-tool-card"; import { BrowserThreadCard } from "./computer"; import { ConversationQueue, type QueuedMessage } from "./conversation-queue"; -import { runConversationTurn } from "./conversation-run"; +import { replayedRunError, runConversationTurn } from "./conversation-run"; import { MailToolCard } from "./mail-tool-card"; import { FileThreadCard, TaskThreadCard } from "./thread-artifacts"; import { type Selection, useMuseThread } from "./threads"; @@ -200,6 +200,7 @@ export function ChatScreen({ const [saveError, setSaveError] = useState(""); const [historyError, setHistoryError] = useState(""); const [historyAttempt, setHistoryAttempt] = useState(0); + const replaying = useRef(false); useEffect(() => { if (!isReady) return; let active = true; @@ -213,12 +214,20 @@ export function ChatScreen({ async function hydrate() { try { if (richThreads) { - if (selection.existing) - await runConversationTurn( - agentId, - () => copilotkit.connectAgent({ agent }), - (onError) => copilotkit.subscribe({ onError }), - ); + if (selection.existing) { + // Replaying history re-emits past RUN_ERROR events; only connection failures block loading. + replaying.current = true; + try { + await runConversationTurn( + agentId, + () => copilotkit.connectAgent({ agent }), + (onError) => copilotkit.subscribe({ onError }), + [replayedRunError], + ); + } finally { + replaying.current = false; + } + } } else { const { messages } = await api.request<{ messages: Message[] }>("/api/conversation"); if (active) agent.setMessages(messages); @@ -299,6 +308,8 @@ export function ChatScreen({ const subscription = copilotkit.subscribe({ onError: (event) => { if (event.context?.agentId && event.context.agentId !== agentId) return; + // A failed turn saved in history is already over; it is not a failure of this session. + if (replaying.current && event.code === replayedRunError) return; const failure = event.error instanceof Error ? event.error : new Error(String(event.error)); setError(failure.message); }, diff --git a/apps/mobile/src/conversation-run.ts b/apps/mobile/src/conversation-run.ts index 73e633ac..9eeae17e 100644 --- a/apps/mobile/src/conversation-run.ts +++ b/apps/mobile/src/conversation-run.ts @@ -1,14 +1,19 @@ -type RunError = { error: unknown; context?: { agentId?: string } }; +type RunError = { error: unknown; code?: string; context?: { agentId?: string } }; + +/** A RUN_ERROR event from the thread's saved history, not a failure of this call. */ +export const replayedRunError = "agent_run_error_event"; /** CopilotKit emits run failures through onError even when runAgent resolves. */ export async function runConversationTurn( agentId: string, execute: () => Promise, subscribe: (listener: (event: RunError) => void) => { unsubscribe: () => void }, + ignore: readonly string[] = [], ) { let failure: Error | undefined; const subscription = subscribe((event) => { if (event.context?.agentId && event.context.agentId !== agentId) return; + if (event.code && ignore.includes(event.code)) return; failure = event.error instanceof Error ? event.error : new Error(String(event.error)); }); try { diff --git a/tests/conversation-sdk.test.ts b/tests/conversation-sdk.test.ts index f266b302..8f92d817 100644 --- a/tests/conversation-sdk.test.ts +++ b/tests/conversation-sdk.test.ts @@ -1,10 +1,11 @@ import assert from "node:assert/strict"; import { test } from "node:test"; import { AbstractAgent } from "@ag-ui/client"; +import { EventType } from "@ag-ui/core"; import { CopilotKitCore } from "@copilotkit/core"; -import { throwError } from "rxjs"; +import { of, throwError } from "rxjs"; import { ConversationQueue } from "../apps/mobile/src/conversation-queue.ts"; -import { runConversationTurn } from "../apps/mobile/src/conversation-run.ts"; +import { replayedRunError, runConversationTurn } from "../apps/mobile/src/conversation-run.ts"; test("an emitted CopilotKit run error stops the queue even when runAgent resolves", async () => { let attempts = 0; @@ -36,3 +37,50 @@ test("an emitted CopilotKit run error stops the queue even when runAgent resolve ["second"], ); }); + +test("a failed turn saved in thread history does not block loading the conversation", async () => { + class ReplayingAgent extends AbstractAgent { + run() { + return throwError(() => new Error("not used")); + } + connect() { + return of( + { type: EventType.RUN_STARTED, threadId: "thread", runId: "old-run" }, + { type: EventType.RUN_ERROR, message: "Missing Authentication header" }, + ); + } + } + const agent = new ReplayingAgent({ agentId: "default", threadId: "thread" }); + const core = new CopilotKitCore({ agents__unsafe_dev_only: { default: agent } }); + const connect = (ignore?: string[]) => + runConversationTurn( + "default", + () => core.connectAgent({ agent }), + (onError) => core.subscribe({ onError }), + ignore, + ); + await assert.rejects(connect(), /Missing Authentication header/); + await connect([replayedRunError]); +}); + +test("a connection failure still blocks loading the conversation", async () => { + class UnreachableAgent extends AbstractAgent { + run() { + return throwError(() => new Error("not used")); + } + connect() { + return throwError(() => new Error("Thread service unavailable")); + } + } + const agent = new UnreachableAgent({ agentId: "default", threadId: "thread" }); + const core = new CopilotKitCore({ agents__unsafe_dev_only: { default: agent } }); + await assert.rejects( + runConversationTurn( + "default", + () => core.connectAgent({ agent }), + (onError) => core.subscribe({ onError }), + [replayedRunError], + ), + /Thread service unavailable/, + ); +}); From 45f00bc710b7bc475846b8f58c532fb797535ba6 Mon Sep 17 00:00:00 2001 From: asemabdallah Date: Fri, 25 Sep 2026 23:09:35 +0300 Subject: [PATCH 2/2] fix: count history replays so an old load cannot end a newer one --- apps/mobile/src/chat.tsx | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/apps/mobile/src/chat.tsx b/apps/mobile/src/chat.tsx index da273899..cc42712e 100644 --- a/apps/mobile/src/chat.tsx +++ b/apps/mobile/src/chat.tsx @@ -200,7 +200,8 @@ export function ChatScreen({ const [saveError, setSaveError] = useState(""); const [historyError, setHistoryError] = useState(""); const [historyAttempt, setHistoryAttempt] = useState(0); - const replaying = useRef(false); + // Loads still replaying history. A count, so an old load finishing does not end a newer one. + const replaying = useRef(0); useEffect(() => { if (!isReady) return; let active = true; @@ -216,7 +217,7 @@ export function ChatScreen({ if (richThreads) { if (selection.existing) { // Replaying history re-emits past RUN_ERROR events; only connection failures block loading. - replaying.current = true; + replaying.current += 1; try { await runConversationTurn( agentId, @@ -225,7 +226,7 @@ export function ChatScreen({ [replayedRunError], ); } finally { - replaying.current = false; + replaying.current -= 1; } } } else { @@ -309,7 +310,7 @@ export function ChatScreen({ onError: (event) => { if (event.context?.agentId && event.context.agentId !== agentId) return; // A failed turn saved in history is already over; it is not a failure of this session. - if (replaying.current && event.code === replayedRunError) return; + if (replaying.current > 0 && event.code === replayedRunError) return; const failure = event.error instanceof Error ? event.error : new Error(String(event.error)); setError(failure.message); },