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<