fix: align channel runtime and settings UI
This commit is contained in:
@@ -2,7 +2,8 @@ import type { ResolvedRuntimeSettings } from '../runtime-settings-store'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
applyRuntimeSelection,
|
||||
getConfiguredRuntimeTarget
|
||||
getConfiguredRuntimeTarget,
|
||||
resolveConfiguredAgentRuntimeSelection
|
||||
} from './runtime-selection'
|
||||
|
||||
const defaultProfileId = '00000000-0000-4000-8000-000000000001'
|
||||
@@ -148,6 +149,41 @@ describe('runtime selection', () => {
|
||||
).toThrow('自动启动')
|
||||
})
|
||||
|
||||
it('resolves Agent Runtime backends from the global Runtime configuration', () => {
|
||||
const base = settings()
|
||||
const configured = settings({
|
||||
opencodeModelProfile: base.modelProfiles[1],
|
||||
continueModelProfile: base.modelProfiles[2]
|
||||
})
|
||||
|
||||
expect(
|
||||
resolveConfiguredAgentRuntimeSelection(configured, {
|
||||
provider: 'opencode',
|
||||
profileId: defaultProfileId
|
||||
})
|
||||
).toEqual({
|
||||
provider: 'opencode',
|
||||
profileId: secondProfileId
|
||||
})
|
||||
expect(
|
||||
resolveConfiguredAgentRuntimeSelection(configured, {
|
||||
provider: 'continue'
|
||||
})
|
||||
).toEqual({
|
||||
provider: 'continue',
|
||||
profileId: responsesProfileId
|
||||
})
|
||||
expect(
|
||||
resolveConfiguredAgentRuntimeSelection(configured, {
|
||||
provider: 'model',
|
||||
profileId: defaultProfileId
|
||||
})
|
||||
).toEqual({
|
||||
provider: 'model',
|
||||
profileId: defaultProfileId
|
||||
})
|
||||
})
|
||||
|
||||
it('routes legacy automatic settings through local OpenCode when the Server is blank', () => {
|
||||
expect(getConfiguredRuntimeTarget(settings())).toBe('opencode')
|
||||
expect(
|
||||
|
||||
@@ -35,6 +35,26 @@ export function getConfiguredRuntimeTarget(
|
||||
return 'model'
|
||||
}
|
||||
|
||||
export function resolveConfiguredAgentRuntimeSelection(
|
||||
settings: ResolvedRuntimeSettings,
|
||||
selection: AgentRuntimeSelection
|
||||
): AgentRuntimeSelection {
|
||||
if (
|
||||
selection.provider !== 'opencode' &&
|
||||
selection.provider !== 'continue'
|
||||
) {
|
||||
return selection
|
||||
}
|
||||
const profile =
|
||||
selection.provider === 'opencode'
|
||||
? settings.opencodeModelProfile
|
||||
: settings.continueModelProfile
|
||||
return {
|
||||
provider: selection.provider,
|
||||
...(profile ? { profileId: profile.id } : {})
|
||||
}
|
||||
}
|
||||
|
||||
export function applyRuntimeSelection(
|
||||
settings: ResolvedRuntimeSettings,
|
||||
selection: AgentRuntimeSelection
|
||||
|
||||
@@ -1185,8 +1185,8 @@ describe('AssistantDatabase', () => {
|
||||
rootPath: channelProject.rootPath,
|
||||
defaultWorkMode: channelProject.defaultWorkMode,
|
||||
runtimeSelection: {
|
||||
provider: 'model',
|
||||
profileId: removedProfileId
|
||||
provider: 'opencode',
|
||||
profileId: runtimeProfileId
|
||||
}
|
||||
})
|
||||
const imageChannelProject = database.ensureChannelProjects(
|
||||
@@ -1257,8 +1257,7 @@ describe('AssistantDatabase', () => {
|
||||
{ provider: 'model', profileId: runtimeProfileId }
|
||||
])
|
||||
expect(database.getProject(channelProject.id).runtimeSelection).toEqual({
|
||||
provider: 'model',
|
||||
profileId: defaultProfileId
|
||||
provider: 'opencode'
|
||||
})
|
||||
expect(
|
||||
database.getProject(imageChannelProject.id).runtimeSelection
|
||||
|
||||
+23
-5
@@ -973,15 +973,18 @@ describe('registerIpcHandlers agent terminal state', () => {
|
||||
respond: vi.fn(),
|
||||
clear: vi.fn()
|
||||
}
|
||||
const getResolvedSettings = vi.fn(
|
||||
async (): Promise<Record<string, unknown>> => ({
|
||||
toolApproval,
|
||||
subagentSmartRoutingEnabled: smartRoutingEnabled
|
||||
})
|
||||
)
|
||||
const dispose = registerIpcHandlers(
|
||||
window as never,
|
||||
runtime as never,
|
||||
'CommandOrControl+Shift+Space',
|
||||
{
|
||||
getResolvedSettings: vi.fn(async () => ({
|
||||
toolApproval,
|
||||
subagentSmartRoutingEnabled: smartRoutingEnabled
|
||||
}))
|
||||
getResolvedSettings
|
||||
} as never,
|
||||
{} as never,
|
||||
contextManager as never,
|
||||
@@ -1009,6 +1012,7 @@ describe('registerIpcHandlers agent terminal state', () => {
|
||||
assistantDatabase,
|
||||
contextManager,
|
||||
dispose,
|
||||
getResolvedSettings,
|
||||
clearHandler: electronMocks.handlers.get(
|
||||
ipcChannels.appClearLocalData
|
||||
),
|
||||
@@ -2240,6 +2244,8 @@ describe('registerIpcHandlers agent terminal state', () => {
|
||||
|
||||
it('routes remote Execute to a configured Agent Runtime without a GoodBuddy approval callback', async () => {
|
||||
let receivedAuthorize: unknown = 'not-called'
|
||||
const configuredProfileId =
|
||||
'00000000-0000-4000-8000-000000000019'
|
||||
const selectedRuntime = {
|
||||
runtimeId: 'continue',
|
||||
capability: 'chat',
|
||||
@@ -2282,6 +2288,11 @@ describe('registerIpcHandlers agent terminal state', () => {
|
||||
false,
|
||||
selectedRuntimes
|
||||
)
|
||||
harness.getResolvedSettings.mockResolvedValue({
|
||||
toolApproval: 'always',
|
||||
subagentSmartRoutingEnabled: false,
|
||||
continueModelProfile: { id: configuredProfileId }
|
||||
})
|
||||
vi.mocked(
|
||||
harness.assistantDatabase.listProjects
|
||||
).mockReturnValue([
|
||||
@@ -2323,9 +2334,16 @@ describe('registerIpcHandlers agent terminal state', () => {
|
||||
output: 'Continue 已执行'
|
||||
})
|
||||
expect(selectedRuntimes.getRuntime).toHaveBeenCalledWith(
|
||||
{ provider: 'continue' },
|
||||
{ provider: 'continue', profileId: configuredProfileId },
|
||||
'C:\\ProjectWorkspace'
|
||||
)
|
||||
expect(
|
||||
harness.assistantDatabase.getOrCreateRemoteConversation
|
||||
).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
runtimeSelection: { provider: 'continue' }
|
||||
})
|
||||
)
|
||||
expect(receivedAuthorize).toBeUndefined()
|
||||
expect(harness.approvalBroker.request).not.toHaveBeenCalled()
|
||||
await harness.dispose()
|
||||
|
||||
+16
-3
@@ -121,6 +121,7 @@ import {
|
||||
createDefaultModelRuntime,
|
||||
createModelProfileRuntime
|
||||
} from './agent/create-runtime'
|
||||
import { resolveConfiguredAgentRuntimeSelection } from './agent/runtime-selection'
|
||||
import { safeToolErrorDetail } from './agent/approval-summary'
|
||||
import { ReasoningTagStreamParser } from './agent/reasoning-stream'
|
||||
import type { BundledRuntimePaths } from './agent/bundled-runtimes'
|
||||
@@ -576,6 +577,7 @@ export function registerIpcHandlers(
|
||||
const resolveRequestRuntime = async (
|
||||
request: Pick<AgentRequest, 'projectId' | 'runtimeSelection'> & {
|
||||
workspaceOverride?: string
|
||||
followConfiguredAgentRuntime?: boolean
|
||||
}
|
||||
): Promise<AgentRuntime> => {
|
||||
const projectWorkspace =
|
||||
@@ -586,8 +588,14 @@ export function registerIpcHandlers(
|
||||
if (!selectedRuntimes || (!request.runtimeSelection && !projectWorkspace)) {
|
||||
return runtime
|
||||
}
|
||||
const selection =
|
||||
let selection =
|
||||
request.runtimeSelection ?? ({ provider: 'auto' } as const)
|
||||
if (request.followConfiguredAgentRuntime) {
|
||||
selection = resolveConfiguredAgentRuntimeSelection(
|
||||
await settingsStore.getResolvedSettings(),
|
||||
selection
|
||||
)
|
||||
}
|
||||
return projectWorkspace
|
||||
? selectedRuntimes.getRuntime(selection, projectWorkspace)
|
||||
: selectedRuntimes.getRuntime(selection)
|
||||
@@ -830,6 +838,7 @@ export function registerIpcHandlers(
|
||||
rootPath: string
|
||||
conversationId: string
|
||||
runtimeSelection: AgentRuntimeSelection
|
||||
followConfiguredAgentRuntime?: boolean
|
||||
runtime?: AgentRuntime
|
||||
taskId?: string
|
||||
contextIds?: string[]
|
||||
@@ -888,7 +897,9 @@ export function registerIpcHandlers(
|
||||
(await resolveRequestRuntime({
|
||||
projectId: schedule.projectId,
|
||||
runtimeSelection: remoteContext?.runtimeSelection,
|
||||
workspaceOverride: remoteContext?.rootPath
|
||||
workspaceOverride: remoteContext?.rootPath,
|
||||
followConfiguredAgentRuntime:
|
||||
remoteContext?.followConfiguredAgentRuntime
|
||||
}))
|
||||
const agentRuntimeSelected = isAgentRuntime(requestRuntime)
|
||||
const channelToolPolicy =
|
||||
@@ -1455,7 +1466,8 @@ export function registerIpcHandlers(
|
||||
executionRuntime = await resolveRequestRuntime({
|
||||
projectId: project.id,
|
||||
runtimeSelection,
|
||||
workspaceOverride: project.rootPath
|
||||
workspaceOverride: project.rootPath,
|
||||
followConfiguredAgentRuntime: true
|
||||
})
|
||||
executionStatus = await executionRuntime.getStatus()
|
||||
} catch (error) {
|
||||
@@ -1546,6 +1558,7 @@ export function registerIpcHandlers(
|
||||
rootPath: project.rootPath,
|
||||
conversationId: remoteConversation.id,
|
||||
runtimeSelection,
|
||||
followConfiguredAgentRuntime: true,
|
||||
runtime: executionRuntime,
|
||||
taskId: remoteTaskId,
|
||||
contextIds,
|
||||
|
||||
@@ -863,6 +863,11 @@ describe('App', () => {
|
||||
provider: 'model',
|
||||
profileId: modelProfileId
|
||||
})
|
||||
expect(
|
||||
screen
|
||||
.getByText('正在连接 Agent Runtime')
|
||||
.querySelector('.message__status-dot')
|
||||
).toHaveClass('message__status-dot--active')
|
||||
const userMessage = screen
|
||||
.getAllByText('帮我分析项目')
|
||||
.map((element) => element.closest('article'))
|
||||
@@ -2179,6 +2184,15 @@ describe('App', () => {
|
||||
})
|
||||
|
||||
expect(await screen.findByText('已取消')).toBeInTheDocument()
|
||||
const cancelledStatus = screen
|
||||
.getAllByText('请求已取消')
|
||||
.find((element) => element.classList.contains('message__status'))
|
||||
expect(cancelledStatus).toBeDefined()
|
||||
const cancelledDot = cancelledStatus?.querySelector(
|
||||
'.message__status-dot'
|
||||
)
|
||||
expect(cancelledDot).toHaveClass('message__status-dot')
|
||||
expect(cancelledDot).not.toHaveClass('message__status-dot--active')
|
||||
fireEvent.click(screen.getByText('任务与活动'))
|
||||
expect((await screen.findAllByText('已取消')).length).toBeGreaterThan(0)
|
||||
fireEvent.click(screen.getByRole('button', { name: '进行中' }))
|
||||
@@ -2187,6 +2201,37 @@ describe('App', () => {
|
||||
).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('keeps persisted completed message statuses static', async () => {
|
||||
vi.mocked(api.conversations.list).mockResolvedValueOnce([
|
||||
{
|
||||
id: '00000000-0000-4000-8000-000000000210',
|
||||
projectId,
|
||||
title: '已完成会话',
|
||||
updatedAt: 1_775_000_000_000,
|
||||
messages: [
|
||||
{
|
||||
id: '00000000-0000-4000-8000-000000000211',
|
||||
role: 'assistant',
|
||||
content: '任务结果',
|
||||
createdAt: 1_775_000_000_000,
|
||||
state: 'complete',
|
||||
status: '任务已完成'
|
||||
}
|
||||
]
|
||||
}
|
||||
])
|
||||
|
||||
render(<App />)
|
||||
|
||||
const completedStatus = await screen.findByText('任务已完成')
|
||||
expect(
|
||||
completedStatus.querySelector('.message__status-dot')
|
||||
).toHaveClass('message__status-dot')
|
||||
expect(
|
||||
completedStatus.querySelector('.message__status-dot')
|
||||
).not.toHaveClass('message__status-dot--active')
|
||||
})
|
||||
|
||||
it('switches runtime profiles from the composer dropdown', async () => {
|
||||
render(<App />)
|
||||
|
||||
|
||||
@@ -4925,7 +4925,14 @@ function App(): React.JSX.Element {
|
||||
: 'message__status'
|
||||
}
|
||||
>
|
||||
<span className="thinking-dot" />
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className={
|
||||
message.state === 'streaming'
|
||||
? 'message__status-dot message__status-dot--active'
|
||||
: 'message__status-dot'
|
||||
}
|
||||
/>
|
||||
{message.status}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -339,6 +339,50 @@ describe('ChannelSettingsSection', () => {
|
||||
expect(trigger).toHaveFocus()
|
||||
})
|
||||
|
||||
it('renders disconnecting a configured Weixin binding as a danger action', async () => {
|
||||
const configuredSnapshot: ChannelSettingsSnapshot = {
|
||||
...snapshot,
|
||||
weixin: {
|
||||
enabled: true,
|
||||
bindingConfigured: true,
|
||||
accountDisplay: '微信用户',
|
||||
source: 'encrypted',
|
||||
status: { state: 'running' }
|
||||
}
|
||||
}
|
||||
const api = bindingApi()
|
||||
Object.defineProperty(window, 'goodbuddy', {
|
||||
configurable: true,
|
||||
value: {
|
||||
channels: {
|
||||
...api,
|
||||
getSnapshot: vi.fn(async () => configuredSnapshot),
|
||||
apply: vi.fn(),
|
||||
testConnection: vi.fn()
|
||||
},
|
||||
projects: {
|
||||
list: vi.fn(async () => projects),
|
||||
update: vi.fn()
|
||||
},
|
||||
settings: settingsApi()
|
||||
} as unknown as DesktopApi
|
||||
})
|
||||
|
||||
render(<ChannelSettingsSection />)
|
||||
const disconnect = await screen.findByRole('button', {
|
||||
name: '断开本机绑定'
|
||||
})
|
||||
expect(disconnect).toHaveClass(
|
||||
'danger-button',
|
||||
'danger-button--quiet'
|
||||
)
|
||||
|
||||
fireEvent.click(disconnect)
|
||||
await waitFor(() =>
|
||||
expect(api.disconnectWeixin).toHaveBeenCalledOnce()
|
||||
)
|
||||
})
|
||||
|
||||
it('shows Weixin verification failures inside the QR dialog', async () => {
|
||||
Object.defineProperty(window, 'goodbuddy', {
|
||||
configurable: true,
|
||||
@@ -451,6 +495,11 @@ describe('ChannelSettingsSection', () => {
|
||||
value: agentRuntimeSelectionKey({ provider: 'opencode' })
|
||||
}
|
||||
})
|
||||
expect(
|
||||
screen.getByText(
|
||||
'通过 OpenCode Agent Runtime 运行,并跟随“Agent Runtime”设置中的全局 OpenCode 配置。'
|
||||
)
|
||||
).toBeInTheDocument()
|
||||
fireEvent.click(
|
||||
screen.getByRole('button', { name: '保存通道设置' })
|
||||
)
|
||||
|
||||
@@ -239,15 +239,7 @@ function runtimeSelectionDescription(
|
||||
}
|
||||
const runtimeLabel =
|
||||
selection.provider === 'opencode' ? 'OpenCode' : 'Continue'
|
||||
const profile =
|
||||
'profileId' in selection
|
||||
? settings.modelProfiles.find(
|
||||
(candidate) => candidate.id === selection.profileId
|
||||
)
|
||||
: undefined
|
||||
return profile
|
||||
? `通过 ${runtimeLabel} Agent Runtime 运行,并使用 ${profile.name}。`
|
||||
: `通过 ${runtimeLabel} Agent Runtime 及其当前模型配置运行。`
|
||||
return `通过 ${runtimeLabel} Agent Runtime 运行,并跟随“Agent Runtime”设置中的全局 ${runtimeLabel} 配置。`
|
||||
}
|
||||
|
||||
function ChannelProjectControls({
|
||||
@@ -887,7 +879,7 @@ function WeixinChannelEditor({
|
||||
</button>
|
||||
{settings.bindingConfigured && (
|
||||
<button
|
||||
className="danger-ghost"
|
||||
className="danger-button danger-button--quiet"
|
||||
disabled={busy}
|
||||
onClick={onDisconnect}
|
||||
type="button"
|
||||
|
||||
@@ -2816,14 +2816,18 @@ textarea:focus-visible {
|
||||
color: #ff4d4f;
|
||||
}
|
||||
|
||||
.thinking-dot {
|
||||
.message__status-dot {
|
||||
flex: 0 0 auto;
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
margin-top: 4px;
|
||||
border-radius: 50%;
|
||||
background: currentColor;
|
||||
}
|
||||
|
||||
.message__status-dot--active {
|
||||
animation: pulse 1.1s ease-in-out infinite;
|
||||
background: #1677ff;
|
||||
background: var(--accent-solid);
|
||||
}
|
||||
|
||||
.tool-execution-list {
|
||||
@@ -4191,6 +4195,10 @@ details.settings-section > :not(summary) {
|
||||
margin-left: var(--space-4);
|
||||
}
|
||||
|
||||
details.settings-section > :not(summary) + :not(summary) {
|
||||
margin-top: var(--space-3);
|
||||
}
|
||||
|
||||
.model-connection-manager {
|
||||
display: grid;
|
||||
min-width: 0;
|
||||
|
||||
@@ -80,6 +80,12 @@ export function repairChannelRuntimeSelection(
|
||||
if (selection.provider === 'auto') {
|
||||
return defaultDirectSelection
|
||||
}
|
||||
if (
|
||||
selection.provider === 'opencode' ||
|
||||
selection.provider === 'continue'
|
||||
) {
|
||||
return { provider: selection.provider }
|
||||
}
|
||||
const repaired = repairAgentRuntimeSelection(selection, settings)
|
||||
if (repaired.provider !== 'model') {
|
||||
return repaired
|
||||
|
||||
Reference in New Issue
Block a user