From 1f01c26e0470b0c7494a911f2cb10253212f4624 Mon Sep 17 00:00:00 2001 From: mesalogo Date: Sat, 5 Sep 2026 16:28:45 +0800 Subject: [PATCH] fix: keep replies streaming while switching windows MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Switching away from the desktop window and back could mark a reply that was still being generated as "上次运行意外中断", freeze its output partway through, and push the next message into the pending queue even though nothing had actually failed. The conversation area also shifted on each switch because the OpenCode runtime controls were unmounted and re-detected on every refresh. Reading the conversation store no longer treats an in-progress reply as interrupted; only startup recovery marks a run that an application exit truly cut off. Runtime feature detection is now keyed on the resolved runtime scope, so the OpenCode toolbar and manual Compact control stay in place across refreshes. No request timeout or recovery behavior changed. Release note: 修复来回切换桌面窗口时,正在生成的回复被误判为"上次运行意外中断"、输出中途停住、之后发送的消息只能进入待发送队列的问题。使用 OpenCode Runtime 时,会话区也不再因重复检测专属功能而抖动。 --- src/main/assistant/assistant-database.test.ts | 84 +++++++- src/main/assistant/assistant-database.ts | 50 ++--- src/renderer/src/App.test.tsx | 198 ++++++++++++++++++ src/renderer/src/App.tsx | 33 ++- 4 files changed, 316 insertions(+), 49 deletions(-) diff --git a/src/main/assistant/assistant-database.test.ts b/src/main/assistant/assistant-database.test.ts index b70693c6..7e3cdbb1 100644 --- a/src/main/assistant/assistant-database.test.ts +++ b/src/main/assistant/assistant-database.test.ts @@ -4082,8 +4082,7 @@ describe('AssistantDatabase', () => { }), expect.objectContaining({ role: 'assistant', - state: 'error', - status: expect.stringContaining('意外中断'), + state: 'streaming', reasoning: '先分析发布范围', contextCompression: { state: 'completed', @@ -4100,7 +4099,7 @@ describe('AssistantDatabase', () => { type: 'tool', tool: expect.objectContaining({ callId: 'call-1', - state: 'interrupted' + state: 'running' }) }), expect.objectContaining({ @@ -5437,6 +5436,85 @@ describe('AssistantDatabase', () => { durable.close() }) + it('keeps a live streaming message streaming until startup recovery runs', async () => { + const directory = await mkdtemp( + join(tmpdir(), 'goodbuddy-live-streaming-read-') + ) + temporaryDirectories.push(directory) + const databasePath = join(directory, 'assistant.sqlite') + const conversationId = + '00000000-0000-4000-8000-000000000971' + const assistantMessageId = + '00000000-0000-4000-8000-000000000972' + const database = new AssistantDatabase(databasePath) + database.initialize('C:\\Workspace') + database.saveLocalConversations([ + { + header: { + id: conversationId, + title: '流式进行中', + updatedAt: 1_775_000_000_000 + }, + messages: [ + { + id: assistantMessageId, + role: 'assistant', + content: '正在回答', + createdAt: 1_775_000_000_001, + state: 'streaming', + status: '正在生成', + tools: [ + { + callId: 'call-1', + name: 'read', + state: 'running', + summary: '正在调用' + } + ] + } + ] + } + ]) + + const liveExpectation = { + id: assistantMessageId, + state: 'streaming', + status: '正在生成', + tools: [ + expect.objectContaining({ + callId: 'call-1', + state: 'running' + }) + ] + } + // Reading during a live run must not manufacture an interruption; the + // renderer keeps saving the streaming message while the run continues. + expect( + database.getConversation(conversationId).messages[0] + ).toMatchObject(liveExpectation) + expect( + database.listConversations()[0]?.messages[0] + ).toMatchObject(liveExpectation) + database.close() + + const recovered = new AssistantDatabase(databasePath) + recovered.initialize('C:\\Workspace') + expect( + recovered.getConversation(conversationId).messages[0] + ).toMatchObject({ + id: assistantMessageId, + state: 'error', + status: '上次运行意外中断,可以重新发送问题', + tools: [ + expect.objectContaining({ + callId: 'call-1', + state: 'interrupted' + }) + ] + }) + recovered.close() + }) + it('keeps duplicate chat replies out of artifact listings', async () => { const database = await createDatabase() const project = database.listProjects()[0]! diff --git a/src/main/assistant/assistant-database.ts b/src/main/assistant/assistant-database.ts index cb99dd22..eb64e3af 100644 --- a/src/main/assistant/assistant-database.ts +++ b/src/main/assistant/assistant-database.ts @@ -211,7 +211,6 @@ type MessageRow = { state: ConversationSnapshot['messages'][number]['state'] metadata_json: string created_at: string - remote_recoverable?: number } type MagicNoteRow = { @@ -1110,38 +1109,31 @@ function toConversationSnapshot( : {}), title: conversation.title, updatedAt: Date.parse(conversation.updated_at), + // Startup recovery is the only authority for interruption: it persists + // the terminal state once. Deriving it while reading would also hit + // messages that are streaming right now, because the renderer keeps + // saving them during a live run. messages: messages.map((message) => { const metadata = JSON.parse( message.metadata_json ) as MessageMetadata - const interrupted = - message.state === 'streaming' && - message.remote_recoverable !== 1 return { id: message.id, queueItemId: metadata.queueItemId, role: message.role, content: message.content, reasoning: metadata.reasoning, - blocks: interrupted - ? interruptActiveToolBlocks(metadata.blocks) - : metadata.blocks, + blocks: metadata.blocks, displayCaptureTruncated: metadata.displayCaptureTruncated, createdAt: metadata.createdAt ?? Date.parse(message.created_at), - state: interrupted ? ('error' as const) : message.state, - status: interrupted - ? interruptedMessageStatus - : metadata.status, + state: message.state, + status: metadata.status, contextCompression: metadata.contextCompression, contextCompressions: metadata.contextCompressions, - tools: interrupted - ? interruptActiveTools(metadata.tools) - : metadata.tools, - subagents: interrupted - ? interruptActiveSubagents(metadata.subagents) - : metadata.subagents, + tools: metadata.tools, + subagents: metadata.subagents, sources: metadata.sources, sourceReferences: metadata.sourceReferences, knowledgeRetrieval: metadata.knowledgeRetrieval, @@ -2604,18 +2596,10 @@ export class AssistantDatabase { .all() as ConversationRow[] const messageStatement = database.prepare( `SELECT id, conversation_id, role, content, state, metadata_json, - created_at, remote_recoverable + created_at FROM ( SELECT id, conversation_id, role, content, state, metadata_json, - created_at, sequence, - EXISTS( - SELECT 1 FROM tasks - WHERE tasks.id = messages.request_id - AND tasks.remote_recoverable = 1 - AND tasks.status IN ( - 'running', 'waiting_approval', 'interrupted' - ) - ) AS remote_recoverable + created_at, sequence FROM messages WHERE conversation_id = ? ORDER BY sequence DESC @@ -2651,18 +2635,10 @@ export class AssistantDatabase { const messages = database .prepare( `SELECT id, conversation_id, role, content, state, metadata_json, - created_at, remote_recoverable + created_at FROM ( SELECT id, conversation_id, role, content, state, - metadata_json, created_at, sequence, - EXISTS( - SELECT 1 FROM tasks - WHERE tasks.id = messages.request_id - AND tasks.remote_recoverable = 1 - AND tasks.status IN ( - 'running', 'waiting_approval', 'interrupted' - ) - ) AS remote_recoverable + metadata_json, created_at, sequence FROM messages WHERE conversation_id = ? ORDER BY sequence DESC diff --git a/src/renderer/src/App.test.tsx b/src/renderer/src/App.test.tsx index 7aa15acf..dab07db5 100644 --- a/src/renderer/src/App.test.tsx +++ b/src/renderer/src/App.test.tsx @@ -6818,6 +6818,68 @@ describe('App', () => { screen.getByText('聚焦后读到的最终回复') ).toBeInTheDocument() }) + + it('keeps streaming through window switches while the store still reports the run', async () => { + const started = await startStreamingRun() + + act(() => { + agentListener?.({ + requestId: started.requestId, + type: 'text', + delta: '第一段回答' + }) + }) + expect(await screen.findByText('第一段回答')).toBeInTheDocument() + + // The renderer keeps saving the streaming message, so a refresh reads + // it back as streaming. That must not be reported as an interruption. + vi.mocked(api.conversations.list).mockResolvedValue([ + persistedSnapshot(started, { + state: 'streaming', + content: '第一段回答' + }) + ]) + act(() => { + window.dispatchEvent( + new Event('visibilitychange', { bubbles: true }) + ) + window.dispatchEvent(new Event('focus')) + }) + await waitFor(() => + expect(api.conversations.list).toHaveBeenCalledTimes(2) + ) + + expect( + screen.queryByText('上次运行意外中断,可以重新发送问题') + ).not.toBeInTheDocument() + expect( + screen.queryByRole('button', { name: '重新编辑并发送' }) + ).not.toBeInTheDocument() + expect(api.conversationQueue.ready).not.toHaveBeenCalled() + + // The run is still live, so later deltas and the terminal event must + // still reach the message. + act(() => { + agentListener?.({ + requestId: started.requestId, + type: 'text', + delta: ',第二段回答' + }) + agentListener?.({ + requestId: started.requestId, + type: 'done' + }) + }) + expect( + await screen.findByText('第一段回答,第二段回答') + ).toBeInTheDocument() + await waitFor(() => + expect(api.conversationQueue.ready).toHaveBeenCalledWith( + started.conversationId + ) + ) + expect(screen.getByLabelText('发送')).toBeInTheDocument() + }) }) it('retries a failed recovery with a new request ID and cleans up its listener', async () => { @@ -7694,6 +7756,142 @@ describe('App', () => { ) }) + it('keeps native OpenCode controls mounted across conversation refreshes', async () => { + const conversationId = '00000000-0000-4000-8000-000000000761' + const settings = await api.settings.getRuntime() + vi.mocked(api.settings.getRuntime).mockResolvedValueOnce({ + ...settings, + provider: 'opencode', + opencodeEmbedded: true, + opencodeModelSource: { kind: 'platform' } + }) + vi.mocked(api.agent.getStatus).mockResolvedValue({ + id: 'opencode', + label: 'OpenCode', + available: true, + supportsToolExecution: true, + detail: 'Ready' + }) + vi.mocked( + api.runtimeCustomization.getSettings + ).mockResolvedValueOnce({ + opencode: {}, + continue: { presets: [] } + }) + vi.mocked( + api.runtimeCustomization.getNativeSnapshot + ).mockResolvedValueOnce({ + provider: 'opencode', + available: true, + inventoryStatus: 'available', + detail: 'Ready', + agents: [ + { + id: 'planner', + name: 'Planner', + description: 'Plan before editing', + mode: 'primary', + native: true, + hidden: false + } + ], + tools: [], + toolsSupported: true, + commands: [], + lsp: [], + formatters: [], + mcpServers: [], + skills: [], + rules: [], + prompts: [], + resources: [], + resourcesSupported: true, + context: { + strategy: 'native', + manualCompact: true, + detail: 'OpenCode native context' + } + }) + // Every read returns freshly parsed rows, so the conversation and its + // nested runtime selection change identity on each refresh even when the + // selection itself is unchanged. The store keeps the persisted timestamp + // in step with the local one, so the incoming copy wins each merge. + vi.mocked(api.conversations.list).mockImplementation(async () => [ + { + id: conversationId, + projectId, + runtimeSelection: { provider: 'opencode' }, + title: 'OpenCode 会话', + updatedAt: 1_775_000_000_000, + messages: [ + { + id: '00000000-0000-4000-8000-000000000762', + role: 'assistant', + content: '已就绪', + createdAt: 1_775_000_000_000, + state: 'complete' + } + ] + } + ]) + let conversationsChangedListener: (() => void) | undefined + vi.mocked(api.conversations.onChanged).mockImplementation( + (listener) => { + conversationsChangedListener = listener + return () => { + if (conversationsChangedListener === listener) { + conversationsChangedListener = undefined + } + } + } + ) + + render() + + const runtimeToolbar = await screen.findByRole('group', { + name: 'OpenCode 专属功能' + }) + expect(await screen.findByText('已就绪')).toBeInTheDocument() + await waitFor(() => + expect( + api.runtimeCustomization.getNativeSnapshot + ).toHaveBeenCalled() + ) + vi.mocked( + api.runtimeCustomization.getNativeSnapshot + ).mockClear() + + // The resolved runtime scope is unchanged, so the runtime controls must + // not unmount and refetch, which visibly shifted the conversation + // layout on every window switch. + const refreshesBefore = vi.mocked(api.conversations.list).mock.calls + .length + act(() => { + conversationsChangedListener?.() + window.dispatchEvent(new Event('focus')) + }) + await waitFor(() => + expect( + vi.mocked(api.conversations.list).mock.calls.length + ).toBeGreaterThan(refreshesBefore) + ) + // Let the merged conversation render before asserting that the runtime + // scope stayed stable. + await act(async () => { + await Promise.resolve() + }) + + expect( + screen.getByRole('group', { name: 'OpenCode 专属功能' }) + ).toBe(runtimeToolbar) + expect( + screen.getByRole('button', { name: /OpenCode Runtime Agent/u }) + ).toBeInTheDocument() + expect( + api.runtimeCustomization.getNativeSnapshot + ).not.toHaveBeenCalled() + }) + it('fills editable Continue Prompts and submits the selected preset', async () => { const presetId = '00000000-0000-4000-8000-000000000721' const promptId = '00000000-0000-4000-8000-000000000722' diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index 3ea6efc2..893d69b2 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -3393,7 +3393,24 @@ function App(): React.JSX.Element { ) : undefined + // Conversation refreshes replace the conversation object, so the runtime + // selection identity changes without the selection itself changing. Keying + // this effect on the resolved scope keeps the runtime controls mounted + // instead of clearing and refetching the snapshot on every refresh. + const runtimeNativeScopeKey = [ + activeConversation?.remote ? 'remote' : 'local', + activeProjectUsesManagedSsh ? 'managed-ssh' : 'direct', + activeProjectId ?? '', + activeRuntimeSelectionKey + ].join('\u0000') + const runtimeNativeScopeKeyRef = useRef(undefined) + useEffect(() => { + if (runtimeNativeScopeKeyRef.current === runtimeNativeScopeKey) { + return + } + runtimeNativeScopeKeyRef.current = runtimeNativeScopeKey + const selection = activeRuntimeSelectionRef.current const requestId = runtimeCustomizationRequestRef.current + 1 runtimeCustomizationRequestRef.current = requestId queueMicrotask(() => { @@ -3407,22 +3424,21 @@ function App(): React.JSX.Element { setSelectedContinuePreset('') }) if ( - !activeRuntimeSelection || + !selection || activeConversation?.remote || activeProjectUsesManagedSsh || - (activeRuntimeSelection.provider !== 'opencode' && - activeRuntimeSelection.provider !== 'continue') + (selection.provider !== 'opencode' && + selection.provider !== 'continue') ) { return } - const provider = activeRuntimeSelection.provider + const provider = selection.provider void Promise.all([ window.goodbuddy.runtimeCustomization.getSettings(), window.goodbuddy.runtimeCustomization.getNativeSnapshot({ provider, - ...('profileId' in activeRuntimeSelection && - activeRuntimeSelection.profileId - ? { profileId: activeRuntimeSelection.profileId } + ...('profileId' in selection && selection.profileId + ? { profileId: selection.profileId } : {}), ...(activeProjectId ? { projectId: activeProjectId } : {}) }) @@ -3445,8 +3461,7 @@ function App(): React.JSX.Element { activeConversation?.remote, activeProjectUsesManagedSsh, activeProjectId, - activeRuntimeSelection, - activeRuntimeSelectionKey + runtimeNativeScopeKey ]) const runtimeAgentOptions = useMemo<