fix: finish native Agent queries and preserve discussion state

Keep Kimi read-only queries in manual approval mode rather than forcing a
planning cycle, and prefer its terminal rejection over feedback-free revision.
Preserve MiMo's same-message tail after the terminal RPC, forward supported
native configuration variables, cancel repetitive native output while retaining
partial content, and preserve each group's selected composer mode.

Validation: frontend 370 tests; desktop 1655 tests; web and desktop builds;
desktop pack and packaged signature verification. Audited all 13 installed
CLIs. A real five-Agent unlimited discussion completed 14 native calls through
an isolated Magpie null-tool-call fix. Five CLIs still have native configuration
failures; production Magpie remains unchanged. Electron UI acceptance awaits
macOS unlock; this commit does not certify production acceptance or publish a
release.
This commit is contained in:
Ryder Sun committed 2026-10-27 21:09:46 +08:00
1 parent 4c522c0997
commit bd395b7a2f
21 files changed
+428 -21

No files matched your search

@@ -8,6 +8,12 @@ function sha256(value) {
// Tool kinds an Agent may use without approval during a read-only turn.
const READ_ONLY_SAFE_TOOL_KINDS = new Set(['read', 'search', 'fetch', 'think'])
function terminalPlanRejection(options = []) {
// Kimi's first reject option is "Revise", which loops without feedback.
return options.find(option => option?.optionId === 'plan_reject_and_exit'
&& ['reject_once', 'reject_always'].includes(option.kind))
}
// Automatic answer to an Agent's permission request when the run is not
// waiting for a person. Returns the response to send, `null` when the request
// is refused but the Agent offered nothing to refuse with, or `undefined`
@@ -19,7 +25,8 @@ const READ_ONLY_SAFE_TOOL_KINDS = new Set(['read', 'search', 'fetch', 'think'])
function automaticPermissionResponse(request, { allowWrite }) {
const options = Array.isArray(request?.options) ? request.options : []
if (!allowWrite && !READ_ONLY_SAFE_TOOL_KINDS.has(request?.toolCall?.kind)) {
const rejection = options.find(candidate => candidate?.kind === 'reject_once')
const rejection = terminalPlanRejection(options)
|| options.find(candidate => candidate?.kind === 'reject_once')
|| options.find(candidate => candidate?.kind === 'reject_always')
return rejection?.optionId ? { status: 'rejected', optionId: rejection.optionId } : null
}
@@ -69,6 +76,7 @@ function assertResumedPermissionBinding(resumed, currentBinding, sessionRef) {
}
module.exports = {
terminalPlanRejection,
READ_ONLY_SAFE_TOOL_KINDS,
assertResumedPermissionBinding,
automaticPermissionResponse,
+35 -6
View File
@@ -23,12 +23,16 @@ const {
} = require('./cli-process-support.cjs')
const { createAcpOutboundPayload } = require('../../collaboration/outbound-payload.cjs')
const { redactSecrets } = require('../../security/secret-redaction.cjs')
const { createOutputRepetitionGuard } = require('./cli-output-repetition.cjs')
const { terminalPlanRejection } = require('../agent-permission-policy.cjs')
const ACP_CANCEL_GRACE_MS = 250
// After a stop or timeout the Agent gets this long to answer the cancelled
// prompt. A clean cancellation keeps the long-lived process and its native
// session; only an Agent that ignores cancel is torn down.
const ACP_CANCEL_SETTLE_MS = 3000
// MiMo can flush the last delta of a message just after its prompt response.
const MIMO_TERMINAL_DRAIN_MS = 50
const ACP_INITIALIZE_TIMEOUT_MS = 60 * 1000
const ACP_STDERR_TAIL_BYTES = 64 * 1024
// Process identity must not change when only volatile shell values change.
@@ -355,7 +359,7 @@ function boundedAcpStream(
}
function permissionRejection(options) {
return (options || []).find(option => (
return terminalPlanRejection(options || []) || (options || []).find(option => (
['reject_once', 'reject_always'].includes(option.kind)
|| /reject|deny/i.test(`${option.optionId || ''} ${option.name || ''}`)
))
@@ -621,7 +625,7 @@ function runtimeSignature(agent, workdir, spec, profile, childEnv) {
workdir,
command: spec.command,
args: spec.args,
// The session mode (read-only plan versus write) is applied on every turn
// The native session mode is applied on every turn
// with session/set_mode, so it does not identify a process. Anything that
// really needs a separate process (a permission policy in the environment)
// is already part of the environment below.
@@ -746,6 +750,10 @@ async function createAcpRuntime(input) {
const client = {
async requestPermission(params) {
const turn = runtime.activeTurn
if (runtime.agent.kind === 'mimo' && runtime.replyState.promptTerminalSequence != null) {
runtime.replyState.permissionPendingAtPromptTerminal = true
return { outcome: { outcome: 'cancelled' } }
}
const pending = permissionOutcome(params, turn?.options || {}, childEnv)
turn?.pendingPermissionCallbacks.add(pending)
pending.then(
@@ -762,7 +770,18 @@ async function createAcpRuntime(input) {
if (params.sessionId !== runtime.protocolSessionRef) return
if (runtime.replyState.promptTerminalSequence != null
&& Number.isInteger(sequence)
&& sequence > runtime.replyState.promptTerminalSequence) return
&& sequence > runtime.replyState.promptTerminalSequence
&& !(runtime.agent.kind === 'mimo'
&& params.update?.sessionUpdate === 'agent_message_chunk'
&& turn.lastMessageId
&& params.update.messageId === turn.lastMessageId)) return
if (params.update?.sessionUpdate === 'agent_message_chunk' && params.update.messageId) {
turn.lastMessageId = params.update.messageId
}
if (turn.repetitionGuard?.(params.update)) {
turn.stopRepeatedOutput()
return
}
turn.structuredOutput.ingest?.(params)
for (const event of turn.profile.mapEvent(params.update, turn.runtimeState)) {
turn.runtimeEvents.emit(event)
@@ -878,6 +897,7 @@ async function runAcpTurn(runtime, prompt, options, spec, profile) {
noteActivity,
outboundPayloadFailed: false,
pendingPermissionCallbacks: new Set(),
repetitionGuard: ['opencode', 'mimo', 'hermes'].includes(runtime.agent.kind) ? createOutputRepetitionGuard() : null,
}
runtime.activeTurn = turn
runtime.stderr = []
@@ -905,7 +925,7 @@ async function runAcpTurn(runtime, prompt, options, spec, profile) {
let cancelPromise = Promise.resolve()
let rejectAbort
const abortPromise = new Promise((_, reject) => { rejectAbort = reject })
const abort = () => {
const abort = (error = agentExecutionError('LOCAL_AGENT_EXECUTION_STOPPED')) => {
if (abortRequested) return
abortRequested = true
if (protocolSessionRef) {
@@ -915,7 +935,12 @@ async function runAcpTurn(runtime, prompt, options, spec, profile) {
})).catch(() => {})
} catch { /* the ACP stream has already closed */ }
}
rejectAbort(agentExecutionError('LOCAL_AGENT_EXECUTION_STOPPED'))
rejectAbort(error instanceof Error ? error : agentExecutionError('LOCAL_AGENT_EXECUTION_STOPPED'))
}
turn.stopRepeatedOutput = () => {
runtime.replyState.collecting = false
abort(agentExecutionError('LOCAL_AGENT_REPEATED_OUTPUT',
'The Agent repeated the same response without tool progress; the native turn was cancelled.'))
}
const operation = promise => Promise.race([promise, abortPromise, runtime.failurePromise])
const timeout = setTimeout(() => {
@@ -970,6 +995,9 @@ async function runAcpTurn(runtime, prompt, options, spec, profile) {
}))
promptRequest.catch(() => {})
promptResult = await operation(promptRequest)
if (runtime.agent.kind === 'mimo' && turn.lastMessageId) {
await operation(new Promise(resolve => setTimeout(resolve, MIMO_TERMINAL_DRAIN_MS)))
}
} finally {
runtime.replyState.collecting = false
}
@@ -1038,7 +1066,8 @@ async function runAcpTurn(runtime, prompt, options, spec, profile) {
ACP_CANCEL_SETTLE_MS,
)
}
if (caughtError && promptSettled && !runtime.closed && !runtime.failureError) {
if (caughtError && caughtError.code !== 'LOCAL_AGENT_REPEATED_OUTPUT'
&& promptSettled && !runtime.closed && !runtime.failureError) {
Object.defineProperty(caughtError, 'acpRuntimeReusable', {
value: true, enumerable: false, configurable: true,
})
+9
View File
@@ -30,6 +30,7 @@ const {
shutdownAcpSessionRuntime,
} = require('./cli-acp-runner.cjs')
const { withOpenClawGateway } = require('./cli-openclaw-gateway.cjs')
const { createOutputRepetitionGuard } = require('./cli-output-repetition.cjs')
const { imageAttachmentLimit, invocation } = require('./cli-invocations.cjs')
const {
connectorLimitedRuntimeEvent,
@@ -226,6 +227,8 @@ async function runAgent(agent, prompt, workdir, options = {}) {
}))
}
const childEnv = childEnvironment(agent, workdir, options, platform)
const repetitionGuard = ['opencode', 'mimo', 'hermes'].includes(agent.kind)
? createOutputRepetitionGuard() : null
const runtimeEvents = createRuntimeEventEmitter({ ...options, onActivity: noteActivity }, childEnv)
const capabilityWarning = connectorLimitedRuntimeEvent(agent.kind, profile)
if (capabilityWarning) runtimeEvents.emit(capabilityWarning)
@@ -296,6 +299,12 @@ async function runAgent(agent, prompt, workdir, options = {}) {
const progressEvent = profile.mapProgress(event)
if (progressEvent) emitProgress(progressEvent)
for (const runtimeEvent of profile.mapEvent(event, runtimeState)) {
if (stopRequested || settled) return
if (repetitionGuard?.(runtimeEvent)) {
inputError = agentExecutionError('LOCAL_AGENT_REPEATED_OUTPUT')
abort()
return
}
runtimeEvents.emit(runtimeEvent)
}
})
+3 -1
View File
@@ -197,7 +197,9 @@ function invocation(kind, executable, workdir, sessionRef = '', options = {}) {
return {
command: executable,
args: ['acp'],
acpMode: 'plan',
// Native plan mode requires a plan/approval cycle even for a query.
// Manual mode keeps writes behind the ACP permission callback.
acpMode: 'default',
eventTransport: 'acp',
}
}
@@ -0,0 +1,38 @@
// Detect a runaway native turn, rather than deduplicating its visible answer.
// Only consecutive long repetitions count; real tool activity resets the window.
function createOutputRepetitionGuard() {
let text = ''
let unchecked = 0
return update => {
const type = update?.sessionUpdate || update?.type
if (['tool_call', 'tool_call_update', 'tool_start', 'tool_result_summary'].includes(type)) {
text = ''
unchecked = 0
return false
}
const value = type === 'answer_delta' ? update.delta
: type === 'agent_message_chunk' && update.content?.type === 'text' ? update.content.text : ''
const chunk = String(value || '').replace(/\s/g, '')
if (!chunk) return false
text = (text + chunk).slice(-8192)
unchecked += chunk.length
if (unchecked < 32 || text.length < 64) return false
unchecked = 0
if (new Set(text).size < 6) return false
const anchor = text.slice(-8)
let previous = text.lastIndexOf(anchor, text.length - 16)
while (previous >= 0) {
const period = text.length - 8 - previous
if (period > 2048) break
const copies = period < 80 ? 8 : 4
if (period * copies <= text.length) {
const block = text.slice(-period)
if (new Set(block).size >= 6 && text.endsWith(block.repeat(copies))) return true
}
previous = previous > 0 ? text.lastIndexOf(anchor, previous - 1) : -1
}
return false
}
}
module.exports = { createOutputRepetitionGuard }
@@ -26,7 +26,7 @@ const OPENCLAW_RUNTIME_PATH_KEYS = Object.freeze([
'OPENCLAW_CONFIG_PATH',
'OPENCLAW_WORKSPACE_DIR',
])
const CONTROLLED_RUNTIME_ENV = /^(?:AWS_|AZURE_|GOOGLE_|GEMINI_|ANTHROPIC_|OPENAI_|OPENROUTER_|CODEBUDDY_|HERMES_|PI_|KIMI_|MIMO_|DASHSCOPE_|CLAUDE_|OCR_|CLOUDSDK_|VERTEX_|CLOUD_ML_|MOONSHOT_|CONNECTOR_TEST_|MELDWORK_TEST_)/
const CONTROLLED_RUNTIME_ENV = /^(?:AWS_|AZURE_|GOOGLE_|GEMINI_|ANTHROPIC_|OPENAI_|OPENROUTER_|CODEX_|CODEBUDDY_|HERMES_|PI_|KIMI_|MIMO_|MIMOCODE_|OPENCODE_|QWEN_|DASHSCOPE_|CLAUDE_|OCR_|CLOUDSDK_|VERTEX_|CLOUD_ML_|MOONSHOT_|CONNECTOR_TEST_|MELDWORK_TEST_)/
const BLOCKED_RUNTIME_ENV = /^(?:NODE_OPTIONS|NODE_PATH|LD_PRELOAD|LD_LIBRARY_PATH|DYLD_|ELECTRON_RUN_AS_NODE)$/
function agentExecutionError(code, diagnostic = '') {
+8 -3
View File
@@ -63,6 +63,7 @@ const RUNTIME_ENV_KEYS = Object.freeze({
hermes: [
'OPENAI_API_KEY', 'OPENROUTER_API_KEY', 'ANTHROPIC_API_KEY',
'OPENAI_BASE_URL', 'OPENAI_MODEL', 'HERMES_INFERENCE_PROVIDER', 'HERMES_INFERENCE_MODEL',
'HERMES_HOME',
],
openclaw: ['OPENAI_API_KEY', 'OPENROUTER_API_KEY'],
workbuddy: [
@@ -78,9 +79,12 @@ const RUNTIME_ENV_KEYS = Object.freeze({
kimi: [
'MOONSHOT_API_KEY', 'KIMI_API_KEY', 'KIMI_MODEL_API_KEY',
'KIMI_MODEL_BASE_URL', 'KIMI_MODEL_NAME',
'KIMI_MODEL_PROVIDER_TYPE', 'KIMI_MODEL_CAPABILITIES',
'KIMI_MODEL_MAX_CONTEXT_SIZE', 'KIMI_MODEL_MAX_OUTPUT_SIZE',
'KIMI_CODE_HOME',
'OPENAI_API_KEY', 'OPENAI_BASE_URL', 'OPENAI_MODEL',
],
mimo: ['MIMO_API_KEY', 'MIMO_BASE_URL', 'MIMO_MODEL'],
mimo: ['MIMO_API_KEY', 'MIMO_BASE_URL', 'MIMO_MODEL', 'MIMOCODE_CONFIG_DIR', 'MIMOCODE_CONFIG_CONTENT'],
claude: [
'ANTHROPIC_API_KEY', 'ANTHROPIC_AUTH_TOKEN', 'CLAUDE_CODE_OAUTH_TOKEN',
'ANTHROPIC_BASE_URL', 'ANTHROPIC_MODEL', 'CLAUDE_CONFIG_DIR',
@@ -98,11 +102,12 @@ const RUNTIME_ENV_KEYS = Object.freeze({
'AZURE_CLIENT_CERTIFICATE_PATH', 'AZURE_CLIENT_CERTIFICATE_PASSWORD', 'AZURE_FEDERATED_TOKEN_FILE',
'AZURE_CONFIG_DIR',
],
qwen: ['DASHSCOPE_API_KEY', 'OPENAI_API_KEY', 'OPENAI_BASE_URL', 'OPENAI_MODEL'],
gemini: ['GEMINI_API_KEY', 'GOOGLE_API_KEY', 'GEMINI_MODEL', ...GOOGLE_CLOUD_ENV_KEYS],
qwen: ['DASHSCOPE_API_KEY', 'OPENAI_API_KEY', 'OPENAI_BASE_URL', 'OPENAI_MODEL', 'QWEN_HOME'],
gemini: ['GEMINI_API_KEY', 'GOOGLE_API_KEY', 'GEMINI_MODEL', 'GOOGLE_GEMINI_BASE_URL', ...GOOGLE_CLOUD_ENV_KEYS],
opencode: [
'OPENAI_API_KEY', 'ANTHROPIC_API_KEY', 'OPENROUTER_API_KEY',
'OPENAI_BASE_URL', 'OPENAI_MODEL',
'OPENCODE_CONFIG', 'OPENCODE_CONFIG_DIR', 'OPENCODE_CONFIG_CONTENT',
],
opencodereview: [
'OCR_LLM_TOKEN', 'OCR_LLM_URL', 'OCR_LLM_MODEL', 'OCR_USE_ANTHROPIC',
@@ -8,6 +8,20 @@ const options = [
{ optionId: 'reject', kind: 'reject_once', name: 'Reject' },
]
test('read-only plan rejection exits instead of requesting a revision without feedback', () => {
const request = { toolCall: { kind: 'switch_mode' }, options: [
{ optionId: 'plan_approve', kind: 'allow_once', name: 'Approve' },
{ optionId: 'plan_revise', kind: 'reject_once', name: 'Revise' },
{ optionId: 'plan_reject_and_exit', kind: 'reject_once', name: 'Reject and Exit' },
] }
assert.deepEqual(automaticPermissionResponse(request, { allowWrite: false }), {
status: 'rejected', optionId: 'plan_reject_and_exit',
})
assert.deepEqual(automaticPermissionResponse(request, { allowWrite: true }), {
status: 'approved', optionId: 'plan_approve',
})
})
test('read-only turns approve only tools without side effects', () => {
for (const kind of ['read', 'search', 'fetch', 'think']) {
assert.deepEqual(
@@ -0,0 +1,66 @@
const test = require('node:test')
const assert = require('node:assert/strict')
const fs = require('node:fs')
const os = require('node:os')
const path = require('node:path')
const adapters = require('../../../src/agents/cli/cli-adapters.cjs')
const { automaticPermissionResponse } = require('../../../src/agents/agent-permission-policy.cjs')
const { executable, within, readWhenReady } = require('../../support/cli-adapters-test-helpers.cjs')
test('Kimi read-only queries use manual mode and still reject writes', async t => {
const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'meldwork-kimi-query-'))
t.after(async () => { await adapters.shutdownAcpSessionRuntime(); fs.rmSync(directory, { recursive: true, force: true }) })
const cli = executable(directory, 'kimi', `
const send = value => console.log(JSON.stringify(value))
let promptId, mode
require('node:readline').createInterface({input:process.stdin}).on('line',line=>{
const m=JSON.parse(line)
if(m.method==='initialize')send({jsonrpc:'2.0',id:m.id,result:{protocolVersion:1}})
else if(m.method==='session/new')send({jsonrpc:'2.0',id:m.id,result:{sessionId:'query'}})
else if(m.method==='session/set_mode'){mode=m.params.modeId;send({jsonrpc:'2.0',id:m.id,result:{}})}
else if(m.method==='session/prompt'){
promptId=m.id
send({jsonrpc:'2.0',id:99,method:'session/request_permission',params:{sessionId:'query',toolCall:{toolCallId:'write',kind:'edit',status:'pending',title:'Write file'},options:[{optionId:'allow',kind:'allow_once',name:'Allow'},{optionId:'reject',kind:'reject_once',name:'Reject'}]}})
}else if(m.id===99&&m.result){
const text=mode==='default'&&m.result.outcome?.optionId==='reject'?'ANSWER_WITHOUT_WRITES':'WRONG_MODE_OR_PERMISSION'
send({jsonrpc:'2.0',method:'session/update',params:{sessionId:'query',update:{sessionUpdate:'agent_message_chunk',content:{type:'text',text}}}})
send({jsonrpc:'2.0',id:promptId,result:{stopReason:'end_turn'}})
}
})
`)
const result = await within(adapters.runAgent({kind:'kimi',executable:cli}, 'answer a query', directory, {
sandbox:'read-only', onPermissionRequest:request=>automaticPermissionResponse(request,{allowWrite:false}),
}),10000)
assert.equal(result.text,'ANSWER_WITHOUT_WRITES')
})
for (const callback of [false, true]) test(`Kimi plan review refuses and exits instead of revising without feedback: ${callback}`, async t => {
const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'meldwork-kimi-plan-'))
t.after(async () => { await adapters.shutdownAcpSessionRuntime(); fs.rmSync(directory, { recursive: true, force: true }) })
const selected = path.join(directory, 'selected')
const cli = executable(directory, 'kimi', `
const send = value => console.log(JSON.stringify(value))
const sessionId = 'kimi-plan-session'
let promptId
require('node:readline').createInterface({input:process.stdin}).on('line',line=>{
const m=JSON.parse(line)
if(m.method==='initialize')send({jsonrpc:'2.0',id:m.id,result:{protocolVersion:1}})
else if(m.method==='session/new')send({jsonrpc:'2.0',id:m.id,result:{sessionId}})
else if(m.method==='session/set_mode')send({jsonrpc:'2.0',id:m.id,result:{}})
else if(m.method==='session/prompt'){
promptId=m.id
send({jsonrpc:'2.0',method:'session/update',params:{sessionId,update:{sessionUpdate:'agent_message_chunk',content:{type:'text',text:'Initial analysis and proposed next steps.'}}}})
send({jsonrpc:'2.0',id:99,method:'session/request_permission',params:{sessionId,toolCall:{toolCallId:'plan',kind:'switch_mode',status:'pending',title:'ExitPlanMode'},options:[{optionId:'plan_approve',kind:'allow_once',name:'Approve'},{optionId:'plan_revise',kind:'reject_once',name:'Revise'},{optionId:'plan_reject_and_exit',kind:'reject_once',name:'Reject and Exit'}]}})
}else if(m.id===99&&m.result){
const id=m.result.outcome?.optionId
require('node:fs').writeFileSync(${JSON.stringify(selected)},id||'cancelled')
if(id==='plan_reject_and_exit')send({jsonrpc:'2.0',id:promptId,result:{stopReason:'end_turn'}})
}
})
`)
const result = await within(adapters.runAgent({ kind: 'kimi', executable: cli }, 'one independent contribution', directory, {
...(callback ? { onPermissionRequest: request => automaticPermissionResponse(request, { allowWrite: false }) } : {}),
}), 10000)
assert.equal(await readWhenReady(selected), 'plan_reject_and_exit')
assert.equal(result.text, 'Initial analysis and proposed next steps.')
})
@@ -0,0 +1,39 @@
const test = require('node:test')
const assert = require('node:assert/strict')
const fs = require('node:fs')
const os = require('node:os')
const path = require('node:path')
const adapters = require('../../../src/agents/cli/cli-adapters.cjs')
const { executable, within } = require('../../support/cli-adapters-test-helpers.cjs')
for (const latePermission of [false, true]) test(`MiMo drains the exact message tail and rejects late authority: ${latePermission}`, async t => {
const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'meldwork-mimo-terminal-'))
t.after(async () => { await adapters.shutdownAcpSessionRuntime(); fs.rmSync(directory, { recursive: true, force: true }) })
const cli = executable(directory, 'mimo', `
const send = value => console.log(JSON.stringify(value))
const sessionId = 'mimo-terminal-session'
const delta = (messageId, text) => send({jsonrpc:'2.0',method:'session/update',params:{sessionId,update:{sessionUpdate:'agent_message_chunk',messageId,content:{type:'text',text}}}})
require('node:readline').createInterface({input:process.stdin}).on('line',line=>{
const m=JSON.parse(line)
if(m.method==='initialize')send({jsonrpc:'2.0',id:m.id,result:{protocolVersion:1}})
else if(m.method==='session/new')send({jsonrpc:'2.0',id:m.id,result:{sessionId}})
else if(m.method==='session/set_mode')send({jsonrpc:'2.0',id:m.id,result:{}})
else if(m.method==='session/prompt'){
delta('answer-1','MELDWORK')
send({jsonrpc:'2.0',id:m.id,result:{stopReason:'end_turn'}})
setTimeout(()=>{
delta('answer-1','_OK')
delta('unsolicited-message','MUST NOT MERGE')
if(${latePermission})send({jsonrpc:'2.0',id:99,method:'session/request_permission',params:{sessionId,toolCall:{toolCallId:'late-write',kind:'edit',status:'pending'},options:[{optionId:'allow',kind:'allow_once',name:'Allow write'}]}})
},10)
}
})
`)
let approvals = 0
const run = within(adapters.runAgent({ kind: 'mimo', executable: cli }, 'return one answer', directory, {
sandbox: 'workspace-write', onPermissionRequest: async () => { approvals++; return { status: 'approved', optionId: 'allow' } },
}), 10000)
if (latePermission) await assert.rejects(run, { code: 'LOCAL_AGENT_PERMISSION_TERMINAL_RACE' })
else assert.equal((await run).text, 'MELDWORK_OK')
assert.equal(approvals, 0)
})
@@ -0,0 +1,63 @@
const test = require('node:test')
const assert = require('node:assert/strict')
const fs = require('node:fs')
const os = require('node:os')
const path = require('node:path')
const adapters = require('../../../src/agents/cli/cli-adapters.cjs')
const { normalizeFailure } = require('../../../src/runs/failure-policy.cjs')
const { executable, within, readWhenReady } = require('../../support/cli-adapters-test-helpers.cjs')
for (const kind of ['opencode', 'mimo']) test(`${kind} native output loops are cancelled, preserve streamed text and never auto-retry as success`, async t => {
const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'meldwork-acp-runaway-'))
t.after(async () => { await adapters.shutdownAcpSessionRuntime(); fs.rmSync(directory, { recursive: true, force: true }) })
const cancelled = path.join(directory, 'cancelled')
const cli = executable(directory, kind, `
const fs = require('node:fs')
if (process.argv.includes('--help')) { console.log('opencode acp'); process.exit(0) }
const send = value => process.stdout.write(JSON.stringify(value) + '\\n')
const text = '# 表决:赞成——全场一致通过\\n我投赞成票,确认讨论阶段完结。全场只读,工作区零改动。成果就绪,等待用户回复授权写入后按共识计划交付 AI 硬件创业公司调研报告全文以及一页执行摘要。'
let timer, request
require('node:readline').createInterface({input:process.stdin}).on('line', line => {
const message = JSON.parse(line), sessionId = 'native-loop-session'
if (message.method === 'initialize') send({jsonrpc:'2.0',id:message.id,result:{protocolVersion:1}})
else if (message.method === 'session/new') send({jsonrpc:'2.0',id:message.id,result:{sessionId}})
else if (message.method === 'session/set_mode') send({jsonrpc:'2.0',id:message.id,result:{}})
else if (message.method === 'session/prompt') {
request = message.id
timer = setInterval(() => send({jsonrpc:'2.0',method:'session/update',params:{sessionId,update:{sessionUpdate:'agent_message_chunk',content:{type:'text',text}}}}), 5)
} else if (message.method === 'session/cancel') {
clearInterval(timer); fs.writeFileSync(${JSON.stringify(cancelled)}, 'cancelled')
send({jsonrpc:'2.0',id:request,result:{stopReason:'cancelled'}})
}
})
`)
const events = []
await assert.rejects(within(adapters.runAgent({ kind, executable: cli }, 'return one answer', directory, {
onEvent: event => events.push(event),
}), 10000), error => {
assert.equal(error.code, 'LOCAL_AGENT_REPEATED_OUTPUT')
assert.equal(normalizeFailure(error).retryability, 'never')
return true
})
assert.equal(await readWhenReady(cancelled), 'cancelled')
const text = events.filter(event => event.type === 'answer_delta').map(event => event.delta).join('')
assert.match(text, /表决:赞成/)
assert.ok(text.length < 1500)
assert.ok(!events.some(event => event.status === 'completed'))
})
test('Hermes native JSON streaming is terminated when it repeats short acknowledgements forever', async t => {
const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'meldwork-hermes-runaway-'))
t.after(() => fs.rmSync(directory, { recursive: true, force: true }))
const terminated = path.join(directory, 'terminated')
const cli = executable(directory, 'hermes', `
process.on('SIGTERM', () => { require('node:fs').writeFileSync(${JSON.stringify(terminated)}, 'terminated'); process.exit(0) })
setInterval(() => console.log(JSON.stringify({type:'text',text:'已确认,随时待命。'})), 5)
`)
const events = []
await assert.rejects(within(adapters.runAgent({ kind: 'hermes', executable: cli, streamJsonAvailable: true }, 'one conclusion', directory, {
onEvent: event => events.push(event),
}), 10000), { code: 'LOCAL_AGENT_REPEATED_OUTPUT' })
assert.equal(await readWhenReady(terminated), 'terminated')
assert.match(events.filter(event => event.type === 'answer_delta').map(event => event.delta).join(''), /已确认/)
})
@@ -180,15 +180,15 @@ test('WorkBuddy JSON output returns the final reply and session id', () => {
})
})
test('Kimi uses ACP plan mode by default and prompt mode only after write authorization', () => {
test('Kimi uses ACP manual mode by default and prompt mode only after write authorization', () => {
const chat = invocation('kimi', '/tmp/kimi', '/tmp/work')
assert.deepEqual(chat.args, ['acp'])
assert.equal(chat.acpMode, 'plan')
assert.equal(chat.acpMode, 'default')
assert.equal(chat.promptArg, undefined)
const resumed = invocation('kimi', '/tmp/kimi', '/tmp/work', 'kimi-session')
assert.deepEqual(resumed.args, ['acp'])
assert.equal(resumed.acpMode, 'plan')
assert.equal(resumed.acpMode, 'default')
const writable = invocation('kimi', '/tmp/kimi', '/tmp/work', 'kimi-session', {
sandbox: 'workspace-write',
@@ -217,7 +217,7 @@ test('Kimi stream JSON output returns assistant text and the native session id',
})
})
test('Kimi ACP plan mode creates and resumes sessions while reporting incomplete turns', async (t) => {
test('Kimi ACP manual mode creates and resumes sessions while reporting incomplete turns', async (t) => {
const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'meldwork-kimi-acp-'))
const workdir = fs.realpathSync(directory)
const lifecycleFile = path.join(directory, 'lifecycle.log')
@@ -349,7 +349,7 @@ input.on('line', (line) => {
onEvent: event => createdEvents.push(event),
},
)
assert.equal(created.text, `new|plan|cancelled|first prompt|${workdir}`)
assert.equal(created.text, `new|default|cancelled|first prompt|${workdir}`)
assert.equal(created.sessionRef, 'kimi-acp-session')
assert.equal(created.outcome, 'completed')
assert.deepEqual(createdSessionRefs, [{
@@ -387,7 +387,7 @@ input.on('line', (line) => {
(answer, event) => event.replace === true ? event.delta : answer + event.delta,
'',
),
'new|plan|cancelled|first prompt|[path]',
'new|default|cancelled|first prompt|[path]',
)
// Thinking surfaces only as a text-free reasoning status event.
assert.equal(createdEvents.some(event => (
@@ -421,7 +421,7 @@ input.on('line', (line) => {
onSessionRef: (sessionRef, metadata) => resumedSessionRefs.push({ sessionRef, metadata }),
},
)
assert.equal(resumed.text, `resume|plan|cancelled|next prompt|${workdir}`)
assert.equal(resumed.text, `resume|default|cancelled|next prompt|${workdir}`)
assert.equal(resumed.sessionRef, 'kimi-acp-session')
assert.equal(resumed.outcome, 'completed')
assert.deepEqual(resumedSessionRefs, [{
@@ -450,7 +450,7 @@ input.on('line', (line) => {
onSessionRef: sessionRef => privateSessionRefs.push(sessionRef),
},
)
assert.equal(privateSession.text, `new|plan|cancelled|private session prompt|${workdir}`)
assert.equal(privateSession.text, `new|default|cancelled|private session prompt|${workdir}`)
assert.equal(privateSession.sessionRef, '')
assert.deepEqual(privateSessionRefs, [])
assert.deepEqual(
@@ -0,0 +1,25 @@
const test = require('node:test')
const assert = require('node:assert/strict')
const { nativeCredentialEnvironment } = require('../../../src/agents/local-agent-readiness.cjs')
const { childEnvironment } = require('../../../src/agents/cli/cli-process-support.cjs')
test('native configuration controls reach only the CLI that owns them', () => {
const entries = {
codex: { CODEX_HOME: '/native/codex' },
hermes: { HERMES_HOME: '/native/hermes' },
kimi: { KIMI_CODE_HOME: '/native/kimi', KIMI_MODEL_PROVIDER_TYPE: 'openai' },
mimo: { MIMOCODE_CONFIG_DIR: '/native/mimo', MIMOCODE_CONFIG_CONTENT: '{"model":"local/model"}' },
opencode: { OPENCODE_CONFIG: '/native/opencode.json', OPENCODE_CONFIG_DIR: '/native/opencode', OPENCODE_CONFIG_CONTENT: '{"model":"local/model"}' },
qwen: { QWEN_HOME: '/native/qwen' },
gemini: { GOOGLE_GEMINI_BASE_URL: 'http://127.0.0.1:3425' },
}
const shell = { ...Object.assign({}, ...Object.values(entries)), UNRELATED_SECRET: 'must-not-leak', NODE_OPTIONS: '--require /untrusted.js' }
for (const [kind, expected] of Object.entries(entries)) {
const selected = nativeCredentialEnvironment(kind, shell)
assert.deepEqual(selected, expected, kind)
const child = childEnvironment({ kind, executable: '/native/cli' }, '/workspace', { env: selected }, 'darwin')
for (const [key, value] of Object.entries(expected)) assert.equal(child[key], value, `${kind}: ${key}`)
assert.equal(child.NODE_OPTIONS, undefined)
assert.equal(child.UNRELATED_SECRET, undefined)
}
})
@@ -0,0 +1,52 @@
const test = require('node:test')
const assert = require('node:assert/strict')
const { createOutputRepetitionGuard } = require('../../../src/agents/cli/cli-output-repetition.cjs')
const answer = '# 表决:赞成——全场一致通过\n\n我投赞成票,确认讨论阶段完结。全场只读,工作区零改动。\n\n成果就绪,等待用户回复“**授权写入**”后按共识计划交付《AI 硬件创业公司调研报告.md》全文 + 一页执行摘要。'
const update = text => ({ sessionUpdate: 'agent_message_chunk', content: { type: 'text', text } })
test('detects the reported Chinese vote loop across arbitrary stream chunks', () => {
for (const size of [1, 7, 83, 257]) {
const guard = createOutputRepetitionGuard()
const text = answer.repeat(80)
let stoppedAt = 0
for (let offset = 0; offset < text.length; offset += size) {
if (guard(update(text.slice(offset, offset + size)))) { stoppedAt = offset + size; break }
}
assert.ok(stoppedAt > 0 && stoppedAt < answer.length * 7, `chunk size ${size}: ${stoppedAt}`)
}
})
test('does not reject ordinary long answers, repeated characters or three quotations', () => {
for (const text of [answer.repeat(3), '*'.repeat(30000), Array.from({ length: 500 }, (_, index) => `Company ${index}: evidence varies by company and round ${index}.\n`).join('')]) {
const guard = createOutputRepetitionGuard()
for (let offset = 0; offset < text.length; offset += 41) assert.equal(guard(update(text.slice(offset, offset + 41))), false)
}
})
test('real tools reset the window but repeated thoughts do not hide an output loop', () => {
const guard = createOutputRepetitionGuard()
for (let index = 0; index < 10; index++) {
assert.equal(guard(update(answer.repeat(2))), false)
guard({ sessionUpdate: 'tool_call', toolCallId: `tool-${index}`, status: 'in_progress' })
}
let repeated = false
for (let index = 0; index < 8 && !repeated; index++) {
guard({ sessionUpdate: 'agent_thought_chunk', content: { type: 'text', text: 'thinking' } })
repeated = guard(update(answer))
}
assert.equal(repeated, true)
})
test('independent turns do not share repetition state', () => {
for (let index = 0; index < 8; index++) assert.equal(createOutputRepetitionGuard()(update(answer.repeat(2))), false)
})
test('detects MiMo short acknowledgements and native JSON-stream answer deltas', () => {
for (const phrase of ['已确认,随时待命。', 'MELDWORK_OK']) {
const guard = createOutputRepetitionGuard()
let count = 0
while (++count < 20 && !guard({ type: 'answer_delta', delta: phrase })) {}
assert.ok(count < 20, phrase)
}
})
@@ -50,6 +50,36 @@ function decisionReply(text, status = 'completed', extra = {}) {
})}]]`
}
test('a native repetition failure preserves the partial proposal through reload', { timeout: 10000 }, async t => {
const { directory, options } = fixture()
t.after(() => fs.rmSync(directory, { recursive: true, force: true }))
const entered = deferred()
const release = deferred()
options.naturalAgentResponses = true
options.runAgent = async (agent, _prompt, _workdir, runOptions) => {
if (agent.kind === 'codex') return { text: 'Independent analysis completed.', outcome: 'completed' }
runOptions.onEvent({ type: 'answer_delta', status: 'running', delta: '已完成的初步研究结果。' })
entered.resolve()
await release.promise
throw new Error('LOCAL_AGENT_REPEATED_OUTPUT')
}
const workspace = new LocalWorkspace(options)
await workspace.refreshAgents()
const group = workspace.createGroup({ name: 'Repetition failure', agentKinds: ['codex', 'hermes'], workdir: directory })
const sending = workspace.sendMessage({ groupId: group.id, text: 'Research', mode: 'auto', protocol: 'v4',
targetKinds: group.agentKinds, discussionStyle: 'agent-led', maxRounds: 1 })
await entered.promise
const controller = workspace.activeRuns.get(group.id)
release.resolve()
await sending
await controller.promise
const reopened = new LocalWorkspace(options)
assert.ok(reopened.snapshot().messages.some(message => (
message.system?.params?.reason === 'LOCAL_AGENT_REPEATED_OUTPUT'
&& message.content.includes('已完成的初步研究结果。')
)))
})
function naturalPhase(prompt) {
if (prompt.includes('Work independently on the user task')) return 'proposal'
if (prompt.includes('Continue from the available peer responses')) return 'discussion'
@@ -50,6 +50,20 @@ afterEach(() => {
})
describe('composer context', () => {
it('keeps the selected discussion mode when the current group membership refreshes', () => {
const { activeGroup, composer, scope } = createComposer()
activeGroup.value = { id: 'group-auto', agentKinds: ['codex', 'hermes'] }
composer.resetComposerContext(activeGroup.value)
composer.discussionMode.value = 'auto-beta'
composer.resetComposerContext({ ...activeGroup.value, agentKinds: ['hermes', 'codex'] })
expect(composer.discussionMode.value).toBe('auto-beta')
composer.resetComposerContext({ id: 'other-group', agentKinds: ['codex', 'hermes'] })
expect(composer.discussionMode.value).toBe('auto')
composer.resetComposerContext(activeGroup.value)
expect(composer.discussionMode.value).toBe('auto-beta')
scope.stop()
})
it('treats an empty manual selection as the whole group', () => {
const { activeGroup, composer, scope } = createComposer()
activeGroup.value = {
@@ -141,6 +141,8 @@ describe('Meldwork i18n', () => {
.toBe('Sign in to this Agent or configure its credentials, then try again.')
expect(translateError(new Error('LOCAL_AGENT_PROCESS_FAILED')))
.toBe('The Agent process failed before completing the task.')
expect(translateError(new Error('LOCAL_AGENT_REPEATED_OUTPUT')))
.toContain('kept repeating the same response')
expect(translateError(new Error('LOCAL_AGENT_TIMEOUT')))
.toBe('This Agent took too long to respond. Try again or start a new conversation.')
expect(translateSystemMessage(systemMessage('LOCAL_AGENT_EXECUTION_STOPPED')))
@@ -159,6 +161,8 @@ describe('Meldwork i18n', () => {
.toBe('请先登录该 Agent 或完成凭据配置后再试。')
expect(translateError(new Error('LOCAL_AGENT_PROCESS_FAILED')))
.toBe('Agent 进程在任务完成前执行失败。')
expect(translateError(new Error('LOCAL_AGENT_REPEATED_OUTPUT')))
.toContain('持续重复相同回复')
expect(translateError(new Error('LOCAL_AGENT_TIMEOUT')))
.toBe('该 Agent 响应超时,请重试或新建会话后继续。')
expect(translateSystemMessage(systemMessage('LOCAL_AGENT_EXECUTION_STOPPED')))
@@ -253,6 +253,8 @@ export function useComposerContext({
// was typed. Attachments and mentions are still reset: they are tied to the
// conversation's members and to files staged for it.
const draftsByGroupId = new Map()
const modesByGroupId = new Map()
let contextGroupId = ''
function stashComposerDraft(groupId) {
const id = String(groupId || '')
@@ -269,10 +271,14 @@ export function useComposerContext({
function pruneComposerDrafts(groupIds) {
const live = new Set((Array.isArray(groupIds) ? groupIds : []).map(String))
for (const id of draftsByGroupId.keys()) if (!live.has(id)) draftsByGroupId.delete(id)
for (const id of modesByGroupId.keys()) if (!live.has(id)) modesByGroupId.delete(id)
}
function resetComposerContext(group) {
discussionMode.value = group?.conversationType === 'direct' ? 'manual' : 'auto'
if (contextGroupId) modesByGroupId.set(contextGroupId, discussionMode.value)
contextGroupId = String(group?.id || '')
discussionMode.value = group?.conversationType === 'direct'
? 'manual' : modesByGroupId.get(contextGroupId) || 'auto'
automaticTeamFormation.value = false
targetKinds.value = group?.conversationType === 'direct' ? [...group.agentKinds] : []
clearComposerContext()
+1
View File
@@ -105,6 +105,7 @@ export const DESKTOP_ERROR_MESSAGE_KEYS = Object.freeze({
LOCAL_AGENT_ACCESS_DENIED: 'error.agentAccessDenied',
LOCAL_AGENT_SESSION_INVALID: 'error.agentSessionInvalid',
LOCAL_AGENT_OUTPUT_LIMIT: 'error.agentOutputLimit',
LOCAL_AGENT_REPEATED_OUTPUT: 'error.agentRepeatedOutput',
LOCAL_AGENT_SEND_FAILED: 'error.agentSendFailed',
LOCAL_AGENT_REFUSED: 'error.agentRefused',
LOCAL_AGENT_PERMISSION_TERMINAL_RACE: 'error.agentPermissionRace',
+1
View File
@@ -896,6 +896,7 @@ export default {
'error.runCircuitBreaker': 'This run stopped after too many failed attempts.',
'error.agentAuthRequired': 'Sign in to this Agent or configure its credentials, then try again.',
'error.agentProcessFailed': 'The Agent process failed before completing the task.',
'error.agentRepeatedOutput': 'The Agent kept repeating the same response. This call was stopped and received output was preserved. Check the CLI model/provider configuration before trying again.',
'error.customAgentLimit': 'You can connect up to 32 custom Agents.',
'error.customAgentLabel': 'Enter a valid Agent name.',
'error.customAgentDescription': 'Keep the description within 240 characters.',
+1
View File
@@ -896,6 +896,7 @@ export default {
'error.runCircuitBreaker': '失败次数过多,本次运行已停止。',
'error.agentAuthRequired': '请先登录该 Agent 或完成凭据配置后再试。',
'error.agentProcessFailed': 'Agent 进程在任务完成前执行失败。',
'error.agentRepeatedOutput': '该 Agent 持续重复相同回复,本次调用已停止,已收到的内容已保留。请检查 CLI 的模型或服务商配置后再试。',
'error.customAgentLimit': '最多可接入 32 个自定义 Agent。',
'error.customAgentLabel': '请输入有效的 Agent 名称。',
'error.customAgentDescription': '能力描述不能超过 240 个字符。',