fix(agents): enable OpenCodeReview direct chats

Allow ready OpenCodeReview installations to appear in the sidebar and be opened as direct conversations for user review instructions.

Route explicit OpenCodeReview sends through the read-only code-review runtime so writable direct conversation defaults do not create a capability mismatch, while continuing to avoid native session persistence.

Verification: npm --prefix frontend test; npm --prefix desktop test; npm --prefix frontend run build; npm --prefix frontend run build:desktop; npm --prefix desktop run pack.
This commit is contained in:
Ryder Sun committed 2026-08-13 16:32:20 +08:00
1 parent c475b50c93
commit 9fb52fb710
14 files changed
+148 -66

No files matched your search

+1 -1
View File
@@ -93,7 +93,7 @@ Specialized review:
**OpenCodeReview**
Availability depends on the installed Agent, supported version, authentication state, and declared capabilities. OpenCodeReview is a specialized review target rather than a general conversation Agent.
Availability depends on the installed Agent, supported version, authentication state, and declared capabilities. OpenCodeReview can be shown in the left navigation and opened as a direct chat for review instructions, while its runtime stays scoped to read-only diff-focused code review.
Each Agent keeps its own capabilities, provider configuration, permission model, and session behavior. Availability depends on the installed Agent, its version, and its authentication state.
+1 -1
View File
@@ -92,7 +92,7 @@ Meldwork 当前已支持:
专项审查:
**OpenCodeReview**
能否实际使用取决于本机安装情况、受支持版本、认证状态和 Agent 声明的能力。OpenCodeReview 是专项审查目标,不作为通用对话 Agent 使用。
能否实际使用取决于本机安装情况、受支持版本、认证状态和 Agent 声明的能力。OpenCodeReview 可以显示在左侧导航栏,也可以作为单聊接收审查指令;运行时仍限定为只读的 Git diff 代码审查。
每个 Agent 保留自己的能力、Provider、权限模型和 Session 行为。能否使用取决于本机是否安装、版本是否兼容,以及对应认证是否就绪。
+15 -8
View File
@@ -7,6 +7,7 @@ const {
AGENT_PERMISSION_MODES,
AGENT_TOOL_CLASSES,
agentRuntimeCapabilities,
isCodeReviewAgentKind,
} = require('./agent-runtime-contract.cjs')
const { parseFitMatrix } = require('../evaluations/eval-records.cjs')
const { cleanInline, isSupportedAgentKind } = require('../workspace/local-workspace-contracts.cjs')
@@ -331,18 +332,24 @@ class AgentRouter {
)) {
throw routingError('LOCAL_AGENT_ROUTING_CONTEXT_CONFLICT')
}
const requirements = normalizeRoutingRequirements(input.routingRequirements, {
domains: effectiveMode === 'automatic' ? ['general'] : [],
inputTypes,
outputTypes: ['text'],
permissionMode: group.allowWrite === true ? 'workspace-write' : 'read-only',
minContextChars,
collaboration: mode === 'auto' ? 'required' : 'none',
})
const approvedKinds = targetList(group.agentKinds)
const explicitTargets = effectiveMode === 'explicit' && !requestedTargets.length
? approvedKinds
: requestedTargets
const explicitReadOnlyTargets = effectiveMode === 'explicit'
&& explicitTargets.length > 0
&& explicitTargets.every(kind => isCodeReviewAgentKind(kind))
const permissionMode = explicitReadOnlyTargets
? 'read-only'
: group.allowWrite === true ? 'workspace-write' : 'read-only'
const requirements = normalizeRoutingRequirements(input.routingRequirements, {
domains: effectiveMode === 'automatic' ? ['general'] : [],
inputTypes,
outputTypes: ['text'],
permissionMode,
minContextChars,
collaboration: mode === 'auto' ? 'required' : 'none',
})
if (explicitTargets.some(kind => !approvedKinds.includes(kind))) {
const kind = explicitTargets.find(target => !approvedKinds.includes(target))
throw routingError('LOCAL_MESSAGE_TARGET_OUT_OF_GROUP', { kind })
@@ -119,6 +119,7 @@ const AGENT_RUNTIME_CAPABILITIES = Object.freeze({
})
const DEFAULT_RUNTIME_CAPABILITIES = generalProfile({ resumable: false })
const REVIEW_ONLY_AGENT_KINDS = Object.freeze(new Set())
function boundedValues(value, allowed, fallback) {
if (!Array.isArray(value) || !value.length) return [...fallback]
@@ -298,6 +299,10 @@ function requireTerminalAgentResult(result) {
}
function isReviewOnlyAgentKind(kind) {
return REVIEW_ONLY_AGENT_KINDS.has(kind)
}
function isCodeReviewAgentKind(kind) {
return agentRuntimeCapabilities(kind).task === 'code_review'
}
@@ -316,6 +321,7 @@ module.exports = {
agentRuntimeCapabilities,
agentRuntimeError,
agentRuntimeFailure,
isCodeReviewAgentKind,
isReviewOnlyAgentKind,
normalizeAgentResult,
normalizeExternalRunRef,
@@ -1,6 +1,5 @@
const {
agentRuntimeCapabilities,
isReviewOnlyAgentKind,
} = require('../agents/agent-runtime-contract.cjs')
const RECENT_VERIFICATION_MS = 24 * 60 * 60 * 1000
@@ -137,8 +136,7 @@ class LocalWorkspaceAgentCatalog {
task: capabilities.task,
resumable: capabilities.resumable,
capabilities,
showInSidebar: capabilities.task === 'general'
&& available
showInSidebar: available
&& (typeof preferred === 'boolean' ? preferred : true),
}
})
@@ -151,7 +149,6 @@ class LocalWorkspaceAgentCatalog {
setSidebarVisibility(kind, visible) {
const agent = this.detectedAgents().find(item => item.kind === kind)
if (!agent) throw new Error('LOCAL_AGENT_NOT_INSTALLED')
if (isReviewOnlyAgentKind(agent.kind)) throw new Error('LOCAL_AGENT_REVIEW_ONLY')
if (visible && !agent.available) throw new Error('LOCAL_AGENT_UNAVAILABLE')
const state = this.state()
state.agentPreferences[kind] = { showInSidebar: Boolean(visible) }
@@ -186,8 +183,7 @@ class LocalWorkspaceAgentCatalog {
? 'runtime-auth-failure'
: 'unverified'
const preferred = state.agentPreferences[kind]?.showInSidebar
agent.showInSidebar = !isReviewOnlyAgentKind(agent.kind)
&& agent.available
agent.showInSidebar = agent.available
&& (typeof preferred === 'boolean' ? preferred : true)
}
this.save()
@@ -1,6 +1,6 @@
const { createHash, randomUUID } = require('node:crypto')
const {
isReviewOnlyAgentKind,
isCodeReviewAgentKind,
requireTerminalAgentResult,
} = require('../agents/agent-runtime-contract.cjs')
const {
@@ -273,9 +273,6 @@ class LocalWorkspaceAgentInvocation {
}
async invoke(group, kind, mode, signal, threadRootId = '', context = {}) {
if (isReviewOnlyAgentKind(kind) && context.taskType !== 'code_review') {
throw new Error('LOCAL_AGENT_REVIEW_ONLY')
}
const activeRun = this.activeRuns.get(group.id)
const taskId = cleanText(activeRun?.taskId || context.taskId || threadRootId, 120)
if (!taskId) throw new Error('LOCAL_RUN_TASK_INVALID')
@@ -326,10 +323,7 @@ class LocalWorkspaceAgentInvocation {
) {
const agent = this.detectedAgents().find(item => item.kind === kind && item.available)
if (!agent) throw new Error('LOCAL_AGENT_UNAVAILABLE')
const reviewOnly = isReviewOnlyAgentKind(kind)
if (reviewOnly && context.taskType !== 'code_review') {
throw new Error('LOCAL_AGENT_REVIEW_ONLY')
}
const reviewOnly = isCodeReviewAgentKind(kind)
const isolated = isolatedInvocationContext(context)
const internal = context.internal === true
if (internal && !isolated) throw new Error('LOCAL_RUN_INTERNAL_CONTEXT_INVALID')
@@ -1,5 +1,4 @@
const path = require('node:path')
const { isReviewOnlyAgentKind } = require('../agents/agent-runtime-contract.cjs')
const { normalizeTraceCapsule } = require('../runs/run-harness.cjs')
const { normalizeRoutingDecision } = require('../agents/agent-routing.cjs')
const {
@@ -72,7 +71,7 @@ class LocalWorkspaceConversations {
createGroup(input) {
const available = new Set(this.detectedAgents()
.filter(agent => agent.available && !isReviewOnlyAgentKind(agent.kind))
.filter(agent => agent.available)
.map(agent => agent.kind))
const conversationType = input.conversationType === 'direct' ? 'direct' : 'group'
const directAgentKind = conversationType === 'direct'
@@ -118,7 +117,7 @@ class LocalWorkspaceConversations {
let nextAgentKinds = group.agentKinds
if (input.agentKinds != null) {
const available = new Set(this.detectedAgents()
.filter(agent => agent.available && !isReviewOnlyAgentKind(agent.kind))
.filter(agent => agent.available)
.map(agent => agent.kind))
const kinds = [...new Set(input.agentKinds.filter(kind => available.has(kind)))]
if (!kinds.length) throw new Error('LOCAL_GROUP_AGENT_REQUIRED')
@@ -107,6 +107,25 @@ test('explicit specialized Agents are not assigned an invented general-domain re
)
})
test('explicit OpenCodeReview targets keep writable direct conversations routable as read-only review work', () => {
const decision = new AgentRouter().route(request({
agents: [agent('opencodereview')],
group: {
id: 'group-routing',
agentKinds: ['opencodereview'],
allowWrite: true,
},
input: { targetKinds: ['opencodereview'] },
}))
assert.deepEqual(decision.selectedKinds, ['opencodereview'])
assert.equal(decision.requirements.permissionMode, 'read-only')
assert.deepEqual(
decision.candidates.find(candidate => candidate.kind === 'opencodereview').exclusions,
[],
)
})
test('explicit Connector Agents use their declared runtime capabilities', () => {
const connector = agent('custom-bbbbbbbbbbbbbbbb', {
capabilities: {
@@ -5,6 +5,7 @@ const {
AGENT_RUNTIME_CAPABILITIES,
agentRuntimeError,
agentRuntimeFailure,
isCodeReviewAgentKind,
isReviewOnlyAgentKind,
normalizeExternalRunRef,
outcomeForAcpStopReason,
@@ -27,7 +28,8 @@ test('runtime capabilities cover every supported built-in Agent', () => {
assert.ok(Number.isSafeInteger(capabilities.contextLimitChars), kind)
assert.equal(typeof capabilities.resumable, 'boolean', kind)
}
assert.equal(isReviewOnlyAgentKind('opencodereview'), true)
assert.equal(isCodeReviewAgentKind('opencodereview'), true)
assert.equal(isReviewOnlyAgentKind('opencodereview'), false)
assert.equal(AGENT_RUNTIME_CAPABILITIES.opencodereview.resumable, false)
})
@@ -270,17 +270,16 @@ test('sidebar and runtime credential mutations preserve persistence and event or
assert.equal(agents()[0].availabilitySource, 'unverified')
})
test('review-only Agents cannot be enabled in the conversation sidebar', () => {
test('OpenCodeReview can be enabled in the conversation sidebar', () => {
const reviewAgent = {
kind: 'opencodereview', available: true, credentialState: 'ready', showInSidebar: false,
}
const { catalog, events } = fixture({ initialAgents: [reviewAgent] })
const { agents, catalog, events, state } = fixture({ initialAgents: [reviewAgent] })
assert.throws(
() => catalog.setSidebarVisibility('opencodereview', true),
{ message: 'LOCAL_AGENT_REVIEW_ONLY' },
)
assert.deepEqual(events, [])
assert.deepEqual(catalog.setSidebarVisibility('opencodereview', true), { agents: [reviewAgent] })
assert.deepEqual(events, ['save', 'emit', 'snapshot'])
assert.equal(state.agentPreferences.opencodereview.showInSidebar, true)
assert.equal(agents()[0].showInSidebar, true)
})
test('runtime credential cleanup removes only missing entries and never emits', () => {
+34 -23
View File
@@ -96,8 +96,8 @@ test('workspace startup rejects orphaned Human Gates after restoring interrupted
})
})
test('review-only Agents cannot join ordinary direct or group conversations', async (t) => {
const { directory, options } = fixture()
test('OpenCodeReview can be shown in the sidebar and receive direct instructions', async (t) => {
const { directory, calls, options } = fixture()
t.after(() => fs.rmSync(directory, { recursive: true, force: true }))
options.detectAgents = async () => [
{ kind: 'codex', name: 'Codex', executable: '/tmp/codex', version: '1.0.0' },
@@ -105,35 +105,46 @@ test('review-only Agents cannot join ordinary direct or group conversations', as
kind: 'opencodereview', name: 'OpenCodeReview', executable: '/tmp/ocr', version: '1.8.6',
},
]
options.runAgent = async (agent, prompt, workdir, runOptions) => {
calls.push({ agent, prompt, workdir, runOptions })
return {
text: 'Review completed.', sessionRef: 'ignored-review-session', outcome: 'completed',
externalRunRef: 'ocr-review-123',
}
}
const workspace = new LocalWorkspace(options)
const snapshot = await workspace.refreshAgents()
const reviewAgent = snapshot.agents.find(agent => agent.kind === 'opencodereview')
assert.equal(reviewAgent.available, true)
assert.equal(reviewAgent.task, 'code_review')
assert.equal(reviewAgent.showInSidebar, false)
assert.equal(reviewAgent.showInSidebar, true)
assert.throws(() => workspace.createGroup({
workspace.setSidebarVisibility('opencodereview', false)
assert.equal(workspace.snapshot().agents.find(agent => agent.kind === 'opencodereview').showInSidebar, false)
workspace.setSidebarVisibility('opencodereview', true)
assert.equal(workspace.snapshot().agents.find(agent => agent.kind === 'opencodereview').showInSidebar, true)
const directGroup = workspace.createGroup({
conversationType: 'direct', directAgentKind: 'opencodereview', workdir: directory,
}), { message: 'LOCAL_GROUP_AGENT_REQUIRED' })
assert.throws(() => workspace.createGroup({
name: 'General discussion', agentKinds: ['opencodereview'], workdir: directory,
}), { message: 'LOCAL_GROUP_AGENT_REQUIRED' })
const generalGroup = workspace.createGroup({
name: 'General discussion', agentKinds: ['codex'], workdir: directory,
})
assert.throws(
() => workspace.updateGroup(generalGroup.id, { agentKinds: ['opencodereview'] }),
{ message: 'LOCAL_GROUP_AGENT_REQUIRED' },
)
await assert.rejects(
workspace.invokeAgent(
{ id: 'forged-group', workdir: directory, allowWrite: false },
'opencodereview',
'manual',
new AbortController().signal,
),
{ message: 'LOCAL_AGENT_REVIEW_ONLY' },
)
assert.deepEqual(directGroup.agentKinds, ['opencodereview'])
await workspace.sendMessage({
groupId: directGroup.id,
text: 'Review the current diff and focus on regressions.',
targetKinds: ['opencodereview'],
})
assert.equal(calls.length, 1)
assert.equal(calls[0].agent.kind, 'opencodereview')
assert.match(calls[0].prompt, /Review the current diff and focus on regressions\./)
assert.equal(calls[0].runOptions.sandbox, 'read-only')
assert.equal(calls[0].runOptions.sessionRef, '')
assert.equal(Object.keys(workspace.state.sessions).length, 0)
assert.equal(Object.keys(workspace.state.sessionMeta).length, 0)
const reply = workspace.snapshot().messages.find(message => message.role === 'agent')
assert.equal(reply.agentKind, 'opencodereview')
assert.equal(reply.trace.context.externalRunRef, 'ocr-review-123')
})
test('an explicit internal code-review task runs once and remains read-only', async (t) => {
+30 -5
View File
@@ -103,27 +103,46 @@ describe('RoundRelay workbench', () => {
wrapper.unmount()
})
it('keeps review-only Agents in Settings but out of ordinary conversations', async () => {
const { wrapper } = await mountApp(({ state }) => {
it('keeps OpenCodeReview visible for ordinary conversations and direct instructions', async () => {
const { wrapper, bridge } = await mountApp(({ state, bridge: desktopBridge }) => {
state.agents.push({
kind: 'opencodereview',
installed: true,
available: true,
credentialState: 'ready',
showInSidebar: false,
showInSidebar: true,
task: 'code_review',
resumable: false,
version: '1.8.6',
})
desktopBridge.localWorkspace.createGroup.mockImplementation(async input => ({
id: 'direct-opencodereview',
createdAt: '2026-07-29T08:00:00Z',
updatedAt: '2026-07-29T08:00:00Z',
...structuredClone(input),
}))
})
expect(wrapper.findAll('.home-agent-item').map(item => item.text())).not.toContain('OpenCodeReview')
expect(wrapper.findAll('.home-agent-item').some(item => item.text().includes('OpenCodeReview'))).toBe(true)
await wrapper.get('.new-group-button').trigger('click')
expect(wrapper.findAll('.agent-choice').map(choice => choice.text())).not.toContain('OpenCodeReview')
expect(wrapper.findAll('.agent-choice').some(choice => choice.text().includes('OpenCodeReview'))).toBe(true)
await wrapper.get('.modal-header .icon-button').trigger('click')
await flushPromises()
expect(wrapper.findAll('.sidebar-agent').some(agent => agent.text().includes('OpenCodeReview'))).toBe(true)
await wrapper.get('.sidebar-settings-entry').trigger('click')
expect(wrapper.get('.agent-manager').text()).toContain('OpenCodeReview')
const card = wrapper.findAll('.settings-agent-card')
.find(node => node.text().includes('OpenCodeReview'))
await card.findAll('button').find(button => button.text().includes('Open chat')).trigger('click')
await flushPromises()
expect(bridge.localWorkspace.createGroup).toHaveBeenCalledWith(expect.objectContaining({
conversationType: 'direct',
directAgentKind: 'opencodereview',
name: 'OpenCodeReview',
allowWrite: true,
}))
wrapper.unmount()
})
@@ -806,6 +825,12 @@ describe('RoundRelay workbench', () => {
expect(source).toMatch(/\.agent-card-meta-row\s*\{[^}]*display:\s*flex;[^}]*padding:\s*0 16px 14px 77px;/s)
expect(source).toMatch(/\.settings-agent-actions\s*\{[^}]*flex-wrap:\s*nowrap;[^}]*padding:\s*0;/s)
expect(source).toMatch(/\.settings-agent-card\.focused\s*\{[^}]*box-shadow:\s*none;/s)
expect(source).toMatch(/\.message-row\.agent \.message-body:hover \.message-meta-actions > button,[^{]+\.message-row\.agent \.message-body:hover \.message-footer-actions > button/s)
expect(source).toMatch(/\.message-meta-actions > button::after,[^{]+\.message-footer-actions > button::after,[^{]+\.code-copy-button::after\s*\{[^}]*bottom:\s*calc\(100% \+ 6px\);[^}]*background:\s*var\(--surface-raised\);/s)
expect(source).toMatch(/\.message-footer-actions > button::after\s*\{[^}]*top:\s*calc\(100% \+ 6px\);[^}]*bottom:\s*auto;/s)
expect(source).toMatch(/\.conversation-link\s*\{[^}]*grid-template-columns:\s*26px minmax\(0, 1fr\) 58px;/s)
expect(source).toMatch(/\.conversation-empty-wordmark\s*\{[^}]*width:\s*min\(276px, 60vw\);/s)
expect(source).toMatch(/\.conversation-empty-copy\s*\{[^}]*width:\s*min\(920px, 100%\);[^}]*min-height:\s*60px;/s)
expect(source).not.toContain('img[src$="/hermes.svg"],\n:root[data-theme="dark"] img[src$="/opencode.svg"]')
expect(composerSource).toContain('composer-attachment-button')
expect(composerSource).toContain('composer-skill-button')
@@ -17,6 +17,32 @@ function catalogFor(agent) {
}
describe('Agent compatibility state', () => {
it('keeps ready OpenCodeReview available for sidebar and composer targets', () => {
setLocale('en')
const catalog = useAgentCatalog({
activeGroup: ref(null),
directGroupsFor: () => [],
installCatalog: ref({ agents: [{ kind: 'opencodereview', installed: true }] }),
snapshot: ref({
agents: [{
kind: 'opencodereview',
installed: true,
available: true,
credentialState: 'ready',
showInSidebar: true,
task: 'code_review',
version: '1.8.6',
}],
}),
t,
theme: ref('light'),
})
expect(catalog.readyAgents.value.map(agent => agent.kind)).toContain('opencodereview')
expect(catalog.sidebarAgents.value.map(agent => agent.kind)).toContain('opencodereview')
expect(catalog.readyAgentKinds.value.has('opencodereview')).toBe(true)
})
it.each([
['LOCAL_AGENT_VERSION_UNSUPPORTED', 'Unsupported version'],
['LOCAL_AGENT_REQUIRED_CAPABILITY_MISSING', 'Required capability missing'],
+1 -3
View File
@@ -40,9 +40,7 @@ export function useAgentCatalog({
agents: mergedCatalog.value.filter(agent => !agent.custom),
},
])
const readyAgents = computed(() => mergedCatalog.value.filter(
agent => agent.ready && agent.task !== 'code_review',
))
const readyAgents = computed(() => mergedCatalog.value.filter(agent => agent.ready))
const readyAgentSignature = computed(() => readyAgents.value.map(agent => agent.kind).join('\u0000'))
const readyAgentKinds = computed(() => new Set(readyAgents.value.map(agent => agent.kind)))
const readyCount = computed(() => readyAgents.value.length)