mirror of
https://github.com/Ryder-Sun/Meldwork.git
synced 2026-11-07 06:56:03 -10:00
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:
1 parent
bf49afcd92
commit
958d18152b
2 files changed
+98
-2
No files matched your search
@@ -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',
|
||||
)
|
||||
})
|
||||
Reference in new issue
Block a user