diff --git a/apps/web/src/app/data-tasks/__tests__/conversation-restore.test.ts b/apps/web/src/app/data-tasks/__tests__/conversation-restore.test.ts index f0e1db07..bff348b6 100644 --- a/apps/web/src/app/data-tasks/__tests__/conversation-restore.test.ts +++ b/apps/web/src/app/data-tasks/__tests__/conversation-restore.test.ts @@ -1191,6 +1191,7 @@ describe("shouldRestoreConversationMessages", () => { shouldRestoreConversationMessages({ conversationMemoryEnabled: true, isRunning: false, + replaceExistingMessages: true, agentMessages: [{ id: "msg-user-1", role: "user", content: "old question" }], dto, }), @@ -1230,6 +1231,7 @@ describe("shouldRestoreConversationMessages", () => { shouldRestoreConversationMessages({ conversationMemoryEnabled: true, isRunning: false, + replaceExistingMessages: false, agentMessages: [ ...conversationToAgentMessages(dto), { @@ -1276,6 +1278,7 @@ describe("shouldRestoreConversationMessages", () => { shouldRestoreConversationMessages({ conversationMemoryEnabled: true, isRunning: false, + replaceExistingMessages: false, agentMessages: [ ...conversationToAgentMessages(dto), { @@ -1293,6 +1296,40 @@ describe("shouldRestoreConversationMessages", () => { }), ).toBe(false); }); + + it("does not replace an existing transcript during a same-thread background refresh", () => { + const dto: SessionConversationDto = { + sessionId: "thread-1", + messages: [ + { + id: "persisted-user-1", + runId: "run-1", + role: "user", + source: "client", + messageId: "persisted-user-message-1", + contentText: "你好", + position: 1, + createdAt: "2026-06-25T10:00:01Z", + }, + ], + runEventRefs: [], + toolCalls: [], + }; + + expect( + shouldRestoreConversationMessages({ + conversationMemoryEnabled: true, + isRunning: false, + replaceExistingMessages: false, + agentMessages: [ + { id: "live-user-1", role: "user", content: "你好" }, + { id: "live-assistant-1", role: "assistant", content: "你好,有什么可以帮你?" }, + { id: "live-user-2", role: "user", content: "继续" }, + ], + dto, + }), + ).toBe(false); + }); }); describe("shouldHydrateLiveRunFromConversation", () => { diff --git a/apps/web/src/app/data-tasks/components/chat/DataTaskChatInput.tsx b/apps/web/src/app/data-tasks/components/chat/DataTaskChatInput.tsx index b633322f..9929055b 100644 --- a/apps/web/src/app/data-tasks/components/chat/DataTaskChatInput.tsx +++ b/apps/web/src/app/data-tasks/components/chat/DataTaskChatInput.tsx @@ -64,7 +64,7 @@ type DataTaskChatInputProps = CopilotChatInputProps & { onEditQueuedPrompt?: (id: string, text: string) => void; onDeleteQueuedPrompt?: (id: string) => void; onSendQueuedPromptNow?: (id: string) => void; - /** Client-side send/upload failure shown above the composer (never silent). */ + /** Current run or client-side send/upload failure shown above the composer. */ submitError?: string | null; /** Session lock / remote-busy prompt rendered above the composer. */ sessionLockSlot?: ReactNode; diff --git a/apps/web/src/app/data-tasks/components/chat/DataTaskChatInputBindingsContext.tsx b/apps/web/src/app/data-tasks/components/chat/DataTaskChatInputBindingsContext.tsx index 2fadc11d..e7c1ac0e 100644 --- a/apps/web/src/app/data-tasks/components/chat/DataTaskChatInputBindingsContext.tsx +++ b/apps/web/src/app/data-tasks/components/chat/DataTaskChatInputBindingsContext.tsx @@ -52,6 +52,7 @@ export type DataTaskChatInputBindings = { onUserMessageSubmitted: (text: string) => void; liveRunStatus: LiveRunStatus; liveRunRunId: string | null; + liveRunErrorMessage: string | null; onCancelRun?: () => Promise | void; stopActiveRun?: () => Promise; stopActiveChatRunRef?: MutableRefObject<(() => void) | undefined>; diff --git a/apps/web/src/app/data-tasks/components/chat/SessionConversationRestore.tsx b/apps/web/src/app/data-tasks/components/chat/SessionConversationRestore.tsx index 29f9a0de..cb25dc6e 100644 --- a/apps/web/src/app/data-tasks/components/chat/SessionConversationRestore.tsx +++ b/apps/web/src/app/data-tasks/components/chat/SessionConversationRestore.tsx @@ -55,6 +55,7 @@ export function SessionConversationRestore({ const { setThreadRestoring, markThreadRestored } = useConversationRestoreGate(); const fetchGenerationRef = useRef(0); const prevThreadIdRef = useRef(undefined); + const messageReplacementThreadRef = useRef(undefined); const agentRef = useRef(agent); agentRef.current = agent; const restoreRunActive = isConversationRestoreRunActive({ @@ -88,6 +89,7 @@ export function SessionConversationRestore({ return; } agentRef.current.setMessages([]); + messageReplacementThreadRef.current = threadId; clearRestoredInterrupts(threadId); clearPendingCollaborationInterrupt(threadId); clearConversationBranchSnapshot(threadId); @@ -138,6 +140,9 @@ export function SessionConversationRestore({ shouldRestoreConversationMessages({ conversationMemoryEnabled, isRunning: restoreRunActive, + replaceExistingMessages: + messageReplacementThreadRef.current === threadId || + (currentAgent.messages?.length ?? 0) === 0, agentMessages: currentAgent.messages, dto: conversation, }) @@ -186,6 +191,9 @@ export function SessionConversationRestore({ } } finally { if (!cancelled && fetchGenerationRef.current === generation) { + if (messageReplacementThreadRef.current === threadId) { + messageReplacementThreadRef.current = undefined; + } markThreadRestored(threadId); setThreadRestoring(threadId, false); } diff --git a/apps/web/src/app/data-tasks/conversation-restore.ts b/apps/web/src/app/data-tasks/conversation-restore.ts index 259ea8f7..97b2751a 100644 --- a/apps/web/src/app/data-tasks/conversation-restore.ts +++ b/apps/web/src/app/data-tasks/conversation-restore.ts @@ -239,12 +239,20 @@ function hasLocalMessagesAfterRestoredPrefix( export function shouldRestoreConversationMessages(input: { conversationMemoryEnabled: boolean; isRunning: boolean; + replaceExistingMessages: boolean; agentMessages: unknown; dto: SessionConversationDto; }): boolean { if (!input.conversationMemoryEnabled || input.isRunning) { return false; } + if ( + !input.replaceExistingMessages && + Array.isArray(input.agentMessages) && + input.agentMessages.length > 0 + ) { + return false; + } const expected = conversationToAgentMessages(input.dto); if (expected.length === 0) { return false; diff --git a/apps/web/src/app/data-tasks/data-tasks-app.tsx b/apps/web/src/app/data-tasks/data-tasks-app.tsx index 331b417e..f1f6edf8 100755 --- a/apps/web/src/app/data-tasks/data-tasks-app.tsx +++ b/apps/web/src/app/data-tasks/data-tasks-app.tsx @@ -879,7 +879,7 @@ function StableDataTaskChatInput({ onEditQueuedPrompt={handleEditQueuedPrompt} onDeleteQueuedPrompt={handleDeleteQueuedPrompt} onSendQueuedPromptNow={handleSendQueuedPromptNow} - submitError={submitError} + submitError={submitError ?? bindings.liveRunErrorMessage} showDisclaimer={false} sessionLockSlot={sessionLockSlot} /> @@ -1966,6 +1966,7 @@ function DataTaskWorkspace({ onUserMessageSubmitted: handleUserMessageSubmitted, liveRunStatus: liveRun.runStatus, liveRunRunId: liveRun.runId ?? null, + liveRunErrorMessage: liveRun.errorMessage ?? null, onCancelRun: cancelCurrentRun, stopActiveRun, stopActiveChatRunRef, @@ -2002,6 +2003,7 @@ function DataTaskWorkspace({ toggleSessionResourceItem, sessionStartedHints, workspaceConfig, + liveRun.errorMessage, liveRun.runId, liveRun.runStatus, runCancelBusy,