fix(browser): mark killed/failed job_output status lines as failed

job_output always terminates its read with the tool-jobs status line
[status: killed] / [status: failed, detail], and a background job that
dies settles with isError false — so the read showed a green OK for a
dead job. tailStatusOf now treats the two terminal failure statuses as
failures; completed/running/stopping stay notices.

This goes beyond the official chat UI, which renders job_output as a
generic card and shows OK too.
This commit is contained in:
Bowen Liang committed 2026-08-24 23:22:39 +08:00
1 parent 3f37450a0f
commit 79950c9822
2 files changed
+64 -1

No files matched your search

+5 -1
View File
@@ -362,8 +362,11 @@ function callSummaryOf(conv: ConversationNodeLike | undefined): string | null {
* - persistent shells: `[shell killed by signal: X]`, `[shell exited: code N]` — riding LAST, after the
* `[exit code: N]` of the command whose failure killed the shell, so the command marker is re-checked
* on the preceding text
* - job_output: `[status: killed]` / `[status: failed, detail]` — the tool-jobs status line always terminates
* the read; a killed/failed background job settles with `isError` false, so only the line flags the loss
* End-anchored like dsh's own parseExitStatus, so marker text quoted inside the output (e.g. a cat'ed log)
* is not a failure. A clean shell exit (code 0 or code-less) is a session-loss notice, not a failure.
* is not a failure. A clean shell exit (code 0 or code-less) or a live/completed job status is a notice,
* not a failure.
* The parsed exit code feeds the FAILED run-state pill.
*/
function tailStatusOf(conv: ConversationNodeLike | undefined): { fail: boolean; exit: number | null } {
@@ -383,6 +386,7 @@ function tailStatusOf(conv: ConversationNodeLike | undefined): { fail: boolean;
const exit = /\[exit code:\s*(\d+)\]$/.exec(tail)
if (exit !== null) return { fail: true, exit: Number(exit[1]) }
if (/\[killed by signal: [^\]\n]+\]$/.test(tail)) return { fail: true, exit: null }
if (/\[status: (?:killed|failed)(?:, [^\]\n]*)?\]$/.test(tail)) return { fail: true, exit: null }
}
return { fail: false, exit: null }
}
+59
View File
@@ -326,3 +326,62 @@ test('tool result: persistent-shell death markers mark the run FAILED, a clean s
console.log('✔ persistent-shell marker test passed (shell death fails, clean exit stays a notice)')
})
test('tool result: a terminal job_output status line marks a killed/failed background job FAILED', async () => {
bed.dataValue = {
...snapshot,
nodes: [
...snapshot.nodes,
// job_output always ends its read with the tool-jobs `[status: ...]` line; killed/failed settle with
// isError false, so the line is the only failure signal.
{ seq: 3, cat: 'tool', tool: 'job_output', tokens: 8, time: 66000 },
{ seq: 4, cat: 'tool', tool: 'job_output', tokens: 8, time: 67000 },
{ seq: 5, cat: 'tool', tool: 'job_output', tokens: 8, time: 68000 },
{ seq: 6, cat: 'tool', tool: 'job_output', tokens: 8, time: 69000 },
],
}
bed.useSessionHolder = (sel) => sel({
nodes: [
{
kind: 'tool-result', seq: 3,
call: { name: 'job_output', argsRaw: '{"job_id":"j1"}' },
content: [{ type: 'text', text: 'partial output\n[status: killed]' }],
},
{
kind: 'tool-result', seq: 4,
call: { name: 'job_output', argsRaw: '{"job_id":"j2"}' },
content: [{ type: 'text', text: 'boom\n[status: failed, exited 1]' }],
},
{
kind: 'tool-result', seq: 5,
call: { name: 'job_output', argsRaw: '{"job_id":"j3"}' },
content: [{ type: 'text', text: 'all done\n[status: completed]' }],
},
{
kind: 'tool-result', seq: 6,
call: { name: 'job_output', argsRaw: '{"job_id":"j4","wait":true}' },
content: [{ type: 'text', text: '(no new output)\n[status: running]' }],
},
],
})
brSlots[1][1]('tool')
let tr = renderView()
const toolRows = byClass(tr, 'lc-br-elem-row')
assert.equal(toolRows.length, 4, 'all four job_output reads listed (newest first)')
assert.equal(byClass(toolRows[0], 'lc-br-err-dot').length, 0, 'running job stays a notice')
assert.equal(byClass(toolRows[1], 'lc-br-err-dot').length, 0, 'completed job carries no error dot')
assert.equal(byClass(toolRows[2], 'lc-br-err-dot').length, 1, 'failed job carries the red error dot')
assert.equal(byClass(toolRows[3], 'lc-br-err-dot').length, 1, 'killed job carries the red error dot')
brSlots[2][1]('n4')
tr = renderView()
const errPill = byClass(tr, 'lc-ts-call-err')[0]
assert.ok(errPill, 'failed job read carries the red Failed pill')
assert.doesNotMatch(textOf(errPill), /exit/, 'a job status line carries no exit code for the pill')
brSlots[2][1]('n5')
tr = renderView()
assert.ok(byClass(tr, 'lc-ts-call-ok')[0], 'completed job read keeps the green OK pill')
console.log('✔ job status-line test passed (killed/failed fail, completed/running stay notices)')
})