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:
@@ -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
@@ -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') {
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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')}
|
||||
|
||||
@@ -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: '展开服务器 内置浏览器'
|
||||
})
|
||||
|
||||
@@ -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: '未启用 · 需要开启魔法笔记',
|
||||
|
||||
Reference in New Issue
Block a user