This commit is contained in:
吴杨帆 2026-05-31 01:23:29 -04:00 committed by GitHub
commit bfc04a2ecd
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 73 additions and 1 deletions

View file

@ -782,7 +782,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 // Trust the server's authoritative success declaration. When the server
// explicitly sets `status: 'succeeded'` (either in the SSE end payload // explicitly sets `status: 'succeeded'` (either in the SSE end payload

View file

@ -709,6 +709,69 @@ describe('streamViaDaemon', () => {
expect(handlers.onDone).not.toHaveBeenCalled(); 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 () => { it('keeps the daemon run alive when the browser-side stream aborts', async () => {
const handlers = createDaemonHandlers(); const handlers = createDaemonHandlers();
const controller = new AbortController(); const controller = new AbortController();