fix: MiMo/OpenCode turns with tool calls are no longer cut short, and a CLI's own error is no longer masked by EPIPE

Two ways a CLI run ended early or with the wrong reason:

- MiMo and OpenCode print a step_finish after every model step. A step that
  ends with reason tool-calls is followed by more steps, but the transport
  terminated the process 250 ms after the first step_finish, so every turn
  that used tools came back as a partial answer. Only a final step now ends
  the run.
- Codex reads its prompt from stdin. A CLI that exits at once (expired
  sign-in, rejected flag) while a prompt larger than the pipe buffer is still
  being written produces EPIPE, which was reported as LOCAL_AGENT_SEND_FAILED
  instead of the CLI's own message. A closed pipe now waits for the process to
  finish and reports its stderr (so a sign-in failure is recognised as one);
  a process that stays alive without reading is still a send failure after
  5 seconds.

Both regressions fail on the previous commit.

Checked with: cli-adapters (74 pass incl. cli-process-support).
This commit is contained in:
Ryder Sun committed 2026-10-16 11:29:28 +08:00
1 parent bf49afcd92
commit 958d18152b
2 files changed
+98 -2

No files matched your search

+29 -2
View File
@@ -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(
@@ -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',
)
})