diff --git a/desktop/src/agents/agent-permission-policy.cjs b/desktop/src/agents/agent-permission-policy.cjs index a171489..1afe9f5 100644 --- a/desktop/src/agents/agent-permission-policy.cjs +++ b/desktop/src/agents/agent-permission-policy.cjs @@ -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, diff --git a/desktop/src/agents/cli/cli-acp-runner.cjs b/desktop/src/agents/cli/cli-acp-runner.cjs index bca5d63..941b652 100644 --- a/desktop/src/agents/cli/cli-acp-runner.cjs +++ b/desktop/src/agents/cli/cli-acp-runner.cjs @@ -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, }) diff --git a/desktop/src/agents/cli/cli-adapters.cjs b/desktop/src/agents/cli/cli-adapters.cjs index 64ee270..a618a61 100644 --- a/desktop/src/agents/cli/cli-adapters.cjs +++ b/desktop/src/agents/cli/cli-adapters.cjs @@ -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) } }) diff --git a/desktop/src/agents/cli/cli-invocations.cjs b/desktop/src/agents/cli/cli-invocations.cjs index 719ca11..94b65c5 100644 --- a/desktop/src/agents/cli/cli-invocations.cjs +++ b/desktop/src/agents/cli/cli-invocations.cjs @@ -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', } } diff --git a/desktop/src/agents/cli/cli-output-repetition.cjs b/desktop/src/agents/cli/cli-output-repetition.cjs new file mode 100644 index 0000000..9ffc3fc --- /dev/null +++ b/desktop/src/agents/cli/cli-output-repetition.cjs @@ -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 } diff --git a/desktop/src/agents/cli/cli-process-support.cjs b/desktop/src/agents/cli/cli-process-support.cjs index f3ca53a..2e1ce74 100644 --- a/desktop/src/agents/cli/cli-process-support.cjs +++ b/desktop/src/agents/cli/cli-process-support.cjs @@ -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 = '') { diff --git a/desktop/src/agents/local-agent-readiness.cjs b/desktop/src/agents/local-agent-readiness.cjs index a6c81d8..25a42fd 100644 --- a/desktop/src/agents/local-agent-readiness.cjs +++ b/desktop/src/agents/local-agent-readiness.cjs @@ -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', diff --git a/desktop/test/agents/agent-permission-policy.test.cjs b/desktop/test/agents/agent-permission-policy.test.cjs index 37d036f..8266495 100644 --- a/desktop/test/agents/agent-permission-policy.test.cjs +++ b/desktop/test/agents/agent-permission-policy.test.cjs @@ -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( diff --git a/desktop/test/agents/cli/cli-acp-kimi-plan.test.cjs b/desktop/test/agents/cli/cli-acp-kimi-plan.test.cjs new file mode 100644 index 0000000..8894116 --- /dev/null +++ b/desktop/test/agents/cli/cli-acp-kimi-plan.test.cjs @@ -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.') +}) diff --git a/desktop/test/agents/cli/cli-acp-mimo-terminal.test.cjs b/desktop/test/agents/cli/cli-acp-mimo-terminal.test.cjs new file mode 100644 index 0000000..8857cba --- /dev/null +++ b/desktop/test/agents/cli/cli-acp-mimo-terminal.test.cjs @@ -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) +}) diff --git a/desktop/test/agents/cli/cli-acp-runaway.test.cjs b/desktop/test/agents/cli/cli-acp-runaway.test.cjs new file mode 100644 index 0000000..a9cf7ba --- /dev/null +++ b/desktop/test/agents/cli/cli-acp-runaway.test.cjs @@ -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(''), /已确认/) +}) diff --git a/desktop/test/agents/cli/cli-adapters-agent-protocols.test.cjs b/desktop/test/agents/cli/cli-adapters-agent-protocols.test.cjs index 71e6b11..58da1f5 100644 --- a/desktop/test/agents/cli/cli-adapters-agent-protocols.test.cjs +++ b/desktop/test/agents/cli/cli-adapters-agent-protocols.test.cjs @@ -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( diff --git a/desktop/test/agents/cli/cli-native-config-environment.test.cjs b/desktop/test/agents/cli/cli-native-config-environment.test.cjs new file mode 100644 index 0000000..bf07e65 --- /dev/null +++ b/desktop/test/agents/cli/cli-native-config-environment.test.cjs @@ -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) + } +}) diff --git a/desktop/test/agents/cli/cli-output-repetition.test.cjs b/desktop/test/agents/cli/cli-output-repetition.test.cjs new file mode 100644 index 0000000..bd212f6 --- /dev/null +++ b/desktop/test/agents/cli/cli-output-repetition.test.cjs @@ -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) + } +}) diff --git a/desktop/test/workspace/local-workspace-v4-natural-discussion.test.cjs b/desktop/test/workspace/local-workspace-v4-natural-discussion.test.cjs index d90ae19..926bdfc 100644 --- a/desktop/test/workspace/local-workspace-v4-natural-discussion.test.cjs +++ b/desktop/test/workspace/local-workspace-v4-natural-discussion.test.cjs @@ -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' diff --git a/frontend/src/__tests__/meldwork/composerContext.spec.js b/frontend/src/__tests__/meldwork/composerContext.spec.js index 395e220..e2a875d 100644 --- a/frontend/src/__tests__/meldwork/composerContext.spec.js +++ b/frontend/src/__tests__/meldwork/composerContext.spec.js @@ -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 = { diff --git a/frontend/src/__tests__/meldwork/i18n.spec.js b/frontend/src/__tests__/meldwork/i18n.spec.js index e92ced7..d0a2343 100644 --- a/frontend/src/__tests__/meldwork/i18n.spec.js +++ b/frontend/src/__tests__/meldwork/i18n.spec.js @@ -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'))) diff --git a/frontend/src/composables/useComposerContext.js b/frontend/src/composables/useComposerContext.js index 06c6e0b..9e98a75 100644 --- a/frontend/src/composables/useComposerContext.js +++ b/frontend/src/composables/useComposerContext.js @@ -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() diff --git a/frontend/src/i18n.js b/frontend/src/i18n.js index 129536f..08131b4 100644 --- a/frontend/src/i18n.js +++ b/frontend/src/i18n.js @@ -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', diff --git a/frontend/src/locales/en.js b/frontend/src/locales/en.js index cc44ee2..a7bb1f2 100644 --- a/frontend/src/locales/en.js +++ b/frontend/src/locales/en.js @@ -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.', diff --git a/frontend/src/locales/zh.js b/frontend/src/locales/zh.js index 21a1d6d..3e8506c 100644 --- a/frontend/src/locales/zh.js +++ b/frontend/src/locales/zh.js @@ -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 个字符。',