From 79950c9822c921882dfee2d6567c5fdbc9727d40 Mon Sep 17 00:00:00 2001 From: Bowen Liang Date: Mon, 24 Aug 2026 23:22:39 +0800 Subject: [PATCH] fix(browser): mark killed/failed job_output status lines as failed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- src/client/components/browser.tsx | 6 +++- tests/assistant-blocks.spec.ts | 59 +++++++++++++++++++++++++++++++ 2 files changed, 64 insertions(+), 1 deletion(-) diff --git a/src/client/components/browser.tsx b/src/client/components/browser.tsx index 48885d4..ef70cb0 100644 --- a/src/client/components/browser.tsx +++ b/src/client/components/browser.tsx @@ -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 } } diff --git a/tests/assistant-blocks.spec.ts b/tests/assistant-blocks.spec.ts index e3a9daf..d0af026 100644 --- a/tests/assistant-blocks.spec.ts +++ b/tests/assistant-blocks.spec.ts @@ -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)') +})