diff --git a/bin/muse-proxy.mjs b/bin/muse-proxy.mjs index 32f0d28b3..f25d83701 100644 --- a/bin/muse-proxy.mjs +++ b/bin/muse-proxy.mjs @@ -396,9 +396,18 @@ const server = http.createServer((req, res) => { } } - const failed = (code !== 0 && fullContent.length === 0) || (terminalState && terminalState !== 'completed' && fullContent.length === 0); + // Failure is judged by the run's authoritative terminal state when + // one was emitted, otherwise by the exit code (#672). Partial + // content must not mask a failure: a run that failed midway used to + // be reported as a successful 'stop' carrying the truncated text, + // because both clauses of the old predicate required empty content. + const failed = terminalState ? terminalState !== 'completed' : code !== 0; if (failed) { - const detail = (stderrTail.trim().split('\n').pop() || `muse exec exited with code ${code}`).trim(); + const stderrLine = stderrTail.trim().split('\n').pop(); + const fallback = terminalState && terminalState !== 'completed' + ? `muse run terminated with state '${terminalState}'` + : `muse exec exited with code ${code}`; + const detail = (stderrLine || fallback).trim(); const errorBody = { error: { message: `Muse execution failed: ${detail}` } }; if (stream) { // The 200 + text/event-stream headers are already on the wire, diff --git a/bin/muse-proxy.test.mjs b/bin/muse-proxy.test.mjs index 36a77e36a..500801eb8 100644 --- a/bin/muse-proxy.test.mjs +++ b/bin/muse-proxy.test.mjs @@ -29,6 +29,17 @@ if grep -q FAILME "$prompt" 2>/dev/null; then echo "stub muse: auth expired" >&2 exit 3 fi +if grep -q FAILPARTIAL "$prompt" 2>/dev/null; then + printf '%s\\n' '{"payload_type":"run.output.delta","payload":{"text":"partial answer"}}' + printf '%s\\n' '{"payload_type":"run.terminal.failed","payload":{"terminal":"failed","text":"partial answer"}}' + echo "stub muse: run failed midway" >&2 + exit 0 +fi +if grep -q CRASHPARTIAL "$prompt" 2>/dev/null; then + printf '%s\\n' '{"payload_type":"run.output.delta","payload":{"text":"partial answer"}}' + echo "stub muse: crashed midway" >&2 + exit 3 +fi if grep -q DIVERGE "$prompt" 2>/dev/null; then printf '%s\\n' '{"payload_type":"run.output.delta","payload":{"text":"abc"}}' printf '%s\\n' '{"payload_type":"run.terminal.completed","payload":{"terminal":"completed","text":"xyz-longer"}}' @@ -145,6 +156,40 @@ test('execution failure: stream gets an SSE-framed error + [DONE], non-stream ge assert.equal(dataLines[dataLines.length - 1], 'data: [DONE]'); }); +test('partial output then failure: masked success becomes an error in both modes (#672)', async (t) => { + const { port, stop } = await startProxy(); + t.after(stop); + + for (const marker of ['FAILPARTIAL', 'CRASHPARTIAL']) { + const messages = [{ role: 'user', content: `please ${marker} now` }]; + + const plainRes = await fetch(`http://127.0.0.1:${port}/v1/chat/completions`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ messages }) + }); + assert.equal(plainRes.status, 500, `${marker}: non-stream must be a 500, not a 200 'stop'`); + const plainBody = JSON.parse(await plainRes.text()); + assert.match(plainBody.error.message, /Muse execution failed: stub muse:/); + + const streamRes = await fetch(`http://127.0.0.1:${port}/v1/chat/completions`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ messages, stream: true }) + }); + assert.equal(streamRes.status, 200); + const sse = await streamRes.text(); + const dataLines = sse.split('\n').filter(l => l.startsWith('data: ')); + assert.equal(dataLines[dataLines.length - 1], 'data: [DONE]', `${marker}: stream must end with [DONE]`); + const events = dataLines.slice(0, -1).map(l => JSON.parse(l.slice('data: '.length))); + const errorEvent = events.find(e => e.error); + assert.ok(errorEvent, `${marker}: stream must carry an error event, got: ${JSON.stringify(sse)}`); + assert.match(errorEvent.error.message, /Muse execution failed: stub muse:/); + const stopChunk = events.find(e => e.choices && e.choices[0].finish_reason === 'stop'); + assert.equal(stopChunk, undefined, `${marker}: no chunk may claim finish_reason 'stop'`); + } +}); + test('divergent terminal text: non-stream uses it, stream keeps the deltas already sent', async (t) => { const { port, stop } = await startProxy(); t.after(stop);