diff --git a/desktop/src/agents/cli/cli-adapters.cjs b/desktop/src/agents/cli/cli-adapters.cjs index d59477a..2db7654 100644 --- a/desktop/src/agents/cli/cli-adapters.cjs +++ b/desktop/src/agents/cli/cli-adapters.cjs @@ -53,6 +53,17 @@ const MAX_STDOUT_CAPTURE_BYTES = 10 * 1024 * 1024 const MAX_STDERR_CAPTURE_BYTES = 1024 * 1024 const OUTPUT_TRUNCATION_MARKER = Buffer.from('\n[output truncated]\n') +const BROKEN_INPUT_GRACE_MS = 5000 + +// A step that ends because the model asked for tools is followed by more +// steps (the tools run, then the model continues); only the last step's finish +// ends the run. Ending the process after the first one cut every tool-using +// MiMo or OpenCode turn short. +function stepFinishContinues(event) { + const reason = String(event?.part?.reason || event?.reason || '').toLowerCase() + return /^tool[-_ ]?(calls?|use)$/.test(reason) +} + function createBoundedOutputCapture(maxBytes) { const headLimit = Math.floor(maxBytes / 2) const tailLimit = maxBytes - headLimit @@ -245,6 +256,7 @@ async function runAgent(agent, prompt, workdir, options = {}) { let stopRequested = false let timeoutRequested = false let inputError = null + let brokenInputTimer = null let timeout let forceKillTimeout let forceSettleTimeout @@ -294,6 +306,7 @@ async function runAgent(agent, prompt, workdir, options = {}) { clearTimeout(forceSettleTimeout) clearTimeout(terminalKillTimer) clearTimeout(terminalForceKillTimer) + clearTimeout(brokenInputTimer) options.signal?.removeEventListener('abort', abort) callback() } @@ -315,7 +328,8 @@ async function runAgent(agent, prompt, workdir, options = {}) { (['workbuddy', 'claude', 'qwen'].includes(agent.kind) && event.type === 'result') || (agent.kind === 'pi' && event.type === 'agent_end') || (agent.kind === 'codex' && event.type === 'turn.completed') - || (['mimo', 'opencode'].includes(agent.kind) && event.type === 'step_finish') + || (['mimo', 'opencode'].includes(agent.kind) && event.type === 'step_finish' + && !stepFinishContinues(event)) || (agent.kind === 'gemini' && event.type === 'result') || (agent.kind === 'kimi' && event.type === 'session.resume_hint') ) @@ -384,10 +398,23 @@ async function runAgent(agent, prompt, workdir, options = {}) { }) child.stdin.on('error', (error) => { if (settled || inputError) return - inputError = agentExecutionError( + const sendFailure = () => agentExecutionError( 'LOCAL_AGENT_SEND_FAILED', redactChildSecrets(error?.message || error, childEnv), ) + // A closed pipe usually means the CLI exited before it read the whole + // prompt (expired sign-in, rejected flag). Its own output says why, so + // let the process finish and report that. Only a process that stays + // alive without reading its input is a send failure. + if (['EPIPE', 'ERR_STREAM_DESTROYED', 'ECONNRESET'].includes(error?.code)) { + brokenInputTimer ||= setTimeout(() => { + if (settled || inputError) return + inputError = sendFailure() + abort() + }, BROKEN_INPUT_GRACE_MS) + return + } + inputError = sendFailure() abort() }) child.on('error', error => finish(() => rejectTerminal( diff --git a/desktop/test/agents/cli/cli-adapters.test.cjs b/desktop/test/agents/cli/cli-adapters.test.cjs index 701fa9a..261c318 100644 --- a/desktop/test/agents/cli/cli-adapters.test.cjs +++ b/desktop/test/agents/cli/cli-adapters.test.cjs @@ -2149,3 +2149,72 @@ process.stdout.write(JSON.stringify({ '--json', ]) }) + +test('MiMo and OpenCode keep running after a step that ended in tool calls', async (t) => { + for (const kind of ['mimo', 'opencode']) { + await t.test(kind, async (t) => { + const directory = fs.mkdtempSync(path.join(os.tmpdir(), `meldwork-${kind}-multistep-`)) + const cli = executable(directory, `${kind}-multistep.cjs`, ` +const send = value => process.stdout.write(JSON.stringify(value) + '\\n') +send({ type: 'step_start', sessionID: '${kind}-multistep-session', part: { type: 'step-start' } }) +send({ type: 'text', sessionID: '${kind}-multistep-session', part: { type: 'text', text: 'Reading the files first. ' } }) +// The model asked for tools: this step is over, the run is not. +send({ type: 'step_finish', sessionID: '${kind}-multistep-session', part: { type: 'step-finish', reason: 'tool-calls' } }) +setTimeout(() => { + send({ type: 'step_start', sessionID: '${kind}-multistep-session', part: { type: 'step-start' } }) + send({ type: 'text', sessionID: '${kind}-multistep-session', part: { type: 'text', text: 'Final answer after the tools.' } }) + send({ type: 'step_finish', sessionID: '${kind}-multistep-session', part: { type: 'step-finish', reason: 'stop' } }) +}, 900) +`) + t.after(() => fs.rmSync(directory, { recursive: true, force: true })) + + const result = await within(runAgent( + { kind, executable: cli, name: kind }, + 'inspect and answer', + directory, + { loadAcpSdkFn: async () => { throw new Error('ACP SDK unavailable') } }, + ), 15000) + + assert.match(result.text, /Reading the files first\./) + assert.match(result.text, /Final answer after the tools\./) + assert.equal(result.outcome, 'completed') + }) + } +}) + +test('a CLI that exits before reading its prompt reports its own error, not a broken pipe', async (t) => { + const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'meldwork-codex-early-exit-')) + // Codex reads the prompt from stdin. A CLI that rejects the run at startup + // exits at once; a prompt larger than the pipe buffer then hits EPIPE. + const cli = executable(directory, 'codex-early-exit.cjs', ` +process.stderr.write('Not logged in. Please run codex login.\\n') +process.exit(1) +`) + t.after(() => fs.rmSync(directory, { recursive: true, force: true })) + const prompt = `Review this document.\n${'lorem ipsum '.repeat(300000)}` + + await assert.rejects( + within(runAgent({ kind: 'codex', executable: cli, name: 'Codex' }, prompt, directory, {}), 15000), + (error) => { + assert.equal(error.code, 'LOCAL_AGENT_AUTH_REQUIRED') + assert.notEqual(error.message, 'LOCAL_AGENT_SEND_FAILED') + return true + }, + ) +}) + +test('a CLI that stays alive without reading its prompt still fails as a send failure', async (t) => { + const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'meldwork-codex-no-reader-')) + const cli = executable(directory, 'codex-no-reader.cjs', ` +// Closes its input without reading it, then idles. +require('node:fs').closeSync(0) +setInterval(() => {}, 1000) +`) + t.after(() => fs.rmSync(directory, { recursive: true, force: true })) + const prompt = `Review this document.\n${'lorem ipsum '.repeat(300000)}` + + await assert.rejects( + within(runAgent({ kind: 'codex', executable: cli, name: 'Codex' }, prompt, directory, {}), 20000), + error => error.message === 'LOCAL_AGENT_SEND_FAILED', + ) +})