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<