From 12c38dbdf2d92bb15cf5d29a752251a334b9e488 Mon Sep 17 00:00:00 2001 From: wuyangfan <1102042793@qq.com> Date: Tue, 26 May 2026 23:03:26 +0800 Subject: [PATCH] fix(web): preserve streamed output when a run ends canceled When the daemon marks a run as canceled after SIGTERM, still call onDone with accumulated stdout/agent text so ProjectView can flush the assistant turn and compute linked-repo file diffs. Fixes #2760 --- apps/web/src/providers/daemon.ts | 11 ++++- apps/web/tests/providers/sse.test.ts | 63 ++++++++++++++++++++++++++++ 2 files changed, 73 insertions(+), 1 deletion(-) diff --git a/apps/web/src/providers/daemon.ts b/apps/web/src/providers/daemon.ts index 5f23f3df1..cadfb1553 100644 --- a/apps/web/src/providers/daemon.ts +++ b/apps/web/src/providers/daemon.ts @@ -592,7 +592,16 @@ async function consumeDaemonRun({ } } - if (endStatus === 'canceled') return; + if (endStatus === 'canceled') { + // OpenCode and other agents can emit useful output before the daemon + // SIGTERM's the child (shutdown, user cancel, long-run timeout). The + // run status is still `canceled`, but abandoning `acc` here drops the + // assistant text and skips the onDone file-diff path in ProjectView. + if (acc.trim()) { + handlers.onDone(acc); + } + return; + } // Trust the server's authoritative success declaration. When the server // explicitly sets `status: 'succeeded'` (either in the SSE end payload diff --git a/apps/web/tests/providers/sse.test.ts b/apps/web/tests/providers/sse.test.ts index ebf849dcc..edab51cb3 100644 --- a/apps/web/tests/providers/sse.test.ts +++ b/apps/web/tests/providers/sse.test.ts @@ -481,6 +481,69 @@ describe('streamViaDaemon', () => { expect(handlers.onDone).not.toHaveBeenCalled(); }); + it('preserves streamed output when a run ends as canceled', async () => { + const handlers = createDaemonHandlers(); + vi.stubGlobal( + 'fetch', + vi.fn() + .mockResolvedValueOnce(jsonResponse({ runId: 'run-1' })) + .mockResolvedValueOnce( + sseResponse( + [ + 'event: stdout', + 'data: {"chunk":"partial output"}', + '', + 'event: end', + 'data: {"code":null,"signal":"SIGTERM","status":"canceled"}', + '', + '', + ].join('\n'), + ), + ), + ); + + await streamViaDaemon({ + agentId: 'mock', + history: [{ id: '1', role: 'user', content: 'hello' }], + systemPrompt: '', + signal: new AbortController().signal, + handlers, + }); + + expect(handlers.onDone).toHaveBeenCalledWith('partial output'); + expect(handlers.onError).not.toHaveBeenCalled(); + }); + + it('does not call onDone when a canceled run produced no output', async () => { + const handlers = createDaemonHandlers(); + vi.stubGlobal( + 'fetch', + vi.fn() + .mockResolvedValueOnce(jsonResponse({ runId: 'run-1' })) + .mockResolvedValueOnce( + sseResponse( + [ + 'event: end', + 'data: {"code":null,"signal":"SIGTERM","status":"canceled"}', + '', + '', + ].join('\n'), + ), + ), + ); + + await streamViaDaemon({ + agentId: 'mock', + history: [{ id: '1', role: 'user', content: 'hello' }], + systemPrompt: '', + signal: new AbortController().signal, + handlers, + }); + + expect(handlers.onDone).not.toHaveBeenCalled(); + expect(handlers.onError).not.toHaveBeenCalled(); + }); + it('keeps the daemon run alive when the browser-side stream aborts', async () => { const handlers = createDaemonHandlers(); const controller = new AbortController();