fix: stabilize Runtime workflows for 0.12.3

OpenCode status checks could cold-start throwaway servers, unrelated
conversations could block each other, and model, Continue, or remote paths
could replay or duplicate work under failure and backlog conditions. Settings
could also lose edits or overwrite unrelated model fields.

This update makes status checks passive, keeps per-conversation ordering while
allowing concurrency, preserves native long-running work with bounded display
capture and backpressure, narrows retries to known pre-dispatch failures, and
aligns remote Ask permissions without reducing Execute. It also keeps
Embedding updates narrow, improves settings and browser guidance, removes the
unused legacy Main gateway state, and records the validated Desktop 0.12.3 and
Agent 0.11.17 release metadata.

Release note: 改善 OpenCode 切换、多会话长任务、模型重试、远程 Ask/Execute 与设置保存可靠性,并完成 Desktop 0.12.3 / Agent 0.11.17 发布准备。
This commit is contained in:
mesalogo
2026-09-04 08:05:29 +08:00
parent 374968d50b
commit 4490e5fd15
60 changed files with 1639 additions and 3839 deletions
+8 -3
View File
@@ -3694,7 +3694,7 @@ describe('App', () => {
expect(screen.getByText('项目:默认项目')).toHaveClass('scope-badge')
})
it('keeps more than 100 tool activities in one response', async () => {
it('keeps a bounded display copy without stopping a long tool chain', async () => {
render(<App />)
fireEvent.change(screen.getByLabelText('向 GoodBuddy 提问'), {
@@ -3708,7 +3708,7 @@ describe('App', () => {
}
act(() => {
for (let index = 0; index < 101; index += 1) {
for (let index = 0; index < 501; index += 1) {
agentListener?.({
requestId: request.requestId,
type: 'tool',
@@ -3721,7 +3721,12 @@ describe('App', () => {
})
expect(
screen.getByRole('region', { name: '工具执行,共 101 项' })
screen.getByRole('region', { name: '工具执行,共 500 项' })
).toBeInTheDocument()
expect(
screen.getByText(
'活动记录过多,后续详情未在本地保留;Runtime 任务仍继续执行'
)
).toBeInTheDocument()
})
+82 -17
View File
@@ -128,6 +128,8 @@ import {
conversationMessageBlocksSchema,
conversationSubagentActivitySchema,
interactiveWorkModes,
maximumConversationMessageBlocks,
maximumConversationRetainedActivities,
normalizeInteractiveWorkMode,
projectChannelLabels
} from '../../shared/assistant-contracts'
@@ -636,6 +638,9 @@ function appendMessageContentBlock(
}
return current
}
if (current.length >= maximumConversationMessageBlocks) {
return blocks
}
current.push({
id: crypto.randomUUID(),
type,
@@ -665,6 +670,9 @@ function upsertMessageToolBlock(
: block
)
}
if (blocks.length >= maximumConversationMessageBlocks) {
return blocks
}
return [
...blocks,
{
@@ -710,6 +718,9 @@ function upsertMessageSubagentBlock(
: block
)
}
if (blocks.length >= maximumConversationMessageBlocks) {
return blocks
}
return [
...blocks,
{
@@ -738,6 +749,14 @@ function terminalizeMessageToolBlocks(
)
}
function canRetainMessageActivity(message: Message): boolean {
return (
(message.tools?.length ?? 0) +
(message.subagents?.length ?? 0) <
maximumConversationRetainedActivities
)
}
function isErrorRepresentedByFailedTool(
tools: ToolActivity[] | undefined,
errorMessage: string
@@ -1373,6 +1392,7 @@ function toConversationMessage(message: Message): ConversationMessage {
content: message.content,
reasoning: message.reasoning,
blocks: message.blocks,
displayCaptureTruncated: message.displayCaptureTruncated,
createdAt: message.createdAt,
state: message.state,
status: message.status,
@@ -4316,14 +4336,22 @@ function App(): React.JSX.Element {
maxMessageContentLength - message.content.length
)
const acceptedDelta = event.delta.slice(0, remaining)
const blocks = appendMessageContentBlock(
message.blocks,
'text',
acceptedDelta
)
return {
...message,
content: `${message.content}${acceptedDelta}`,
blocks: appendMessageContentBlock(
message.blocks,
'text',
acceptedDelta
),
blocks,
displayCaptureTruncated:
message.displayCaptureTruncated ||
Boolean(
acceptedDelta &&
message.blocks &&
blocks === message.blocks
),
status:
event.delta.length > remaining
? tRef.current('chat.status.responseTruncated')
@@ -4340,15 +4368,23 @@ function App(): React.JSX.Element {
maxMessageContentLength - currentReasoning.length
)
)
const blocks = appendMessageContentBlock(
message.blocks,
'reasoning',
acceptedDelta
)
return {
...message,
reasoning: `${currentReasoning}${acceptedDelta}`,
status: undefined,
blocks: appendMessageContentBlock(
message.blocks,
'reasoning',
acceptedDelta
)
blocks,
displayCaptureTruncated:
message.displayCaptureTruncated ||
Boolean(
acceptedDelta &&
message.blocks &&
blocks === message.blocks
)
}
})
} else if (event.type === 'context-metrics') {
@@ -4486,13 +4522,23 @@ function App(): React.JSX.Element {
}
if (index >= 0) {
tools[index] = tool
} else {
} else if (!canRetainMessageActivity(message)) {
return {
...message,
displayCaptureTruncated: true
}
}
if (index < 0) {
tools.push(tool)
}
const blocks = upsertMessageToolBlock(message.blocks, tool)
return {
...message,
tools,
blocks: upsertMessageToolBlock(message.blocks, tool)
blocks,
displayCaptureTruncated:
message.displayCaptureTruncated ||
Boolean(message.blocks && blocks === message.blocks)
}
})
} else if (event.type === 'subagent') {
@@ -4623,9 +4669,29 @@ function App(): React.JSX.Element {
if (index >= 0) {
subagents[index] = subagent
} else {
const replacesTool = Boolean(
event.runtimeCallId &&
message.tools?.some(
(tool) => tool.callId === event.runtimeCallId
)
)
if (
!replacesTool &&
!canRetainMessageActivity(message)
) {
return {
...message,
displayCaptureTruncated: true
}
}
subagents.push(subagent)
}
const runtimeCallId = event.runtimeCallId
const blocks = upsertMessageSubagentBlock(
message.blocks,
event.childTaskId,
runtimeCallId
)
return {
...message,
subagents,
@@ -4634,11 +4700,10 @@ function App(): React.JSX.Element {
(tool) => tool.callId !== runtimeCallId
)
: message.tools,
blocks: upsertMessageSubagentBlock(
message.blocks,
event.childTaskId,
runtimeCallId
)
blocks,
displayCaptureTruncated:
message.displayCaptureTruncated ||
Boolean(message.blocks && blocks === message.blocks)
}
})
} else if (event.type === 'approval') {
+14
View File
@@ -52,6 +52,7 @@ export type Message = {
content: string
reasoning?: string
blocks?: ConversationMessageBlock[]
displayCaptureTruncated?: boolean
createdAt: number
state: 'streaming' | 'complete' | 'error'
status?: string
@@ -941,6 +942,19 @@ function ChatMessageRowView({
value={message.question}
/>
)}
{message.displayCaptureTruncated && (
<div
aria-live="polite"
className="message__status"
role="status"
>
<span
aria-hidden="true"
className="message__status-dot"
/>
{t('chat.status.displayCaptureTruncated')}
</div>
)}
{message.status && (
<div
aria-atomic="true"
+2
View File
@@ -620,6 +620,8 @@ export function McpSettingsSection({
? t('mcp.browser.unsupported')
: !featureAvailable
? t('mcp.builtin.serverSummaryDisabled')
: server.id === 'builtin-browser'
? t('mcp.builtin.serverSummaryExecuteOnly')
: server.access === 'mixed'
? t('mcp.builtin.serverSummaryMixed')
: t('mcp.builtin.serverSummaryReadOnly')}
+40
View File
@@ -1862,6 +1862,43 @@ describe('SettingsPanel runtime files', () => {
expect(screen.queryByText('设置已保存')).not.toBeInTheDocument()
})
it('blocks draft edits while settings are being saved', async () => {
let finishSave!: (settings: RuntimeSettings) => void
updateRuntime.mockImplementationOnce(
async () =>
await new Promise<RuntimeSettings>((resolve) => {
finishSave = resolve
})
)
render(
<SettingsPanel
{...heartbeatSettingsProps}
open
onClearLocalData={vi.fn(async () => {})}
onClose={vi.fn()}
onSaved={vi.fn()}
/>
)
fireEvent.click(screen.getByRole('tab', { name: '模型连接' }))
await screen.findByDisplayValue('默认模型')
fireEvent.click(screen.getByRole('button', { name: '保存设置' }))
const settingsBody = screen
.getByRole('tabpanel')
.closest('.settings-panel__body')
await waitFor(() => {
expect(settingsBody).toHaveAttribute('inert')
expect(settingsBody).toHaveAttribute('aria-busy', 'true')
})
act(() => finishSave(runtimeSettings))
await waitFor(() => {
expect(settingsBody).not.toHaveAttribute('inert')
expect(settingsBody).toHaveAttribute('aria-busy', 'false')
})
})
it('submits configured model values while environment values are effective', async () => {
getRuntime.mockResolvedValueOnce({
...runtimeSettings,
@@ -5123,6 +5160,9 @@ describe('SettingsPanel runtime files', () => {
true
)
)
expect(
within(browserBuiltinCard!).getByText('仅 Execute')
).toBeInTheDocument()
const builtinBrowserToggle = screen.getByRole('button', {
name: '展开服务器 内置浏览器'
})
+5 -1
View File
@@ -2028,7 +2028,11 @@ export function SettingsPanel({
</div>
</div>
<div className="settings-panel__body">
<div
aria-busy={saving || testing}
className="settings-panel__body"
inert={saving || testing ? true : undefined}
>
<nav
aria-label={t('center.categoriesAriaLabel')}
aria-orientation="vertical"
@@ -336,6 +336,8 @@ export const app = {
scrollToBottom: 'Scroll to bottom',
status: {
responseTruncated: 'The response was too long and was truncated locally',
displayCaptureTruncated:
'Later activity details were not retained locally; the Runtime task continued',
savingImage: 'Image generated; saving the result',
taskCompleted: 'Task completed',
taskFailed: 'Task failed',
@@ -211,6 +211,7 @@ export const integrations = {
runtimeAssignmentUnsupportedAriaLabel:
'{{name}} cannot be assigned to {{runtime}} because this runtime does not support built-in MCP',
unsupportedSuffix: ' (not supported yet)',
serverSummaryExecuteOnly: 'Execute only',
serverSummaryMixed: 'Read and write by mode',
serverSummaryReadOnly: 'Read-only',
serverSummaryDisabled: 'Disabled · Enable Magic Notes first',
@@ -319,6 +319,8 @@ export const app = {
scrollToBottom: '到底部',
status: {
responseTruncated: '回答过长,已在本地截断显示',
displayCaptureTruncated:
'活动记录过多,后续详情未在本地保留;Runtime 任务仍继续执行',
savingImage: '图片已生成,正在保存结果',
taskCompleted: '任务执行完成',
taskFailed: '任务执行失败',
@@ -195,6 +195,7 @@ export const integrations = {
runtimeAssignmentUnsupportedAriaLabel:
'{{name}} 无法分配给 {{runtime}},当前 Runtime 不支持内置 MCP',
unsupportedSuffix: '(暂不支持)',
serverSummaryExecuteOnly: '仅 Execute',
serverSummaryMixed: '按模式读写',
serverSummaryReadOnly: '只读',
serverSummaryDisabled: '未启用 · 需要开启魔法笔记',