Yield between stream progress events

This commit is contained in:
rcourtman
2026-06-06 09:36:25 +01:00
parent 1ba987bcda
commit e91fc46f38
4 changed files with 69 additions and 17 deletions
@@ -247,6 +247,16 @@ runtime cost control, and shared AI transport surfaces.
Pulse's browser drawer adapts that model by keeping the transcript rows
typed and stable while the active-turn footer remains a replacing live
status slot for provider and workflow progress between visible parts.
The same OpenCode commit applies each streamed session event as its own
state mutation in `packages/opencode/src/cli/cmd/tui/context/sync-v2.tsx`
(`apply(event)` and the `session.next.tool.*` cases), so Pulse's shared
browser SSE consumer must treat opted-in non-text Assistant progress events
as paint checkpoints. `frontend-modern/src/api/streaming.ts` must not drain
queued workflow/tool/model events from separate stream reads without yielding
to the browser, or the drawer will show those steps only after a batch has
already finished. Token content and hidden reasoning may continue to opt out
of those checkpoints through the caller predicate so answer streaming remains
fast.
OpenCode-parity Assistant UX work must reference OpenCode's actual source
implementation for message parts, tool-state mutation, progress rendering,
and model/session selection before changing Pulse behavior; parity means
@@ -464,12 +464,12 @@ payload shape change when the portal presents compact client rows.
visible tool row from pending to running/waiting to completed/error, or
cancel a runtime-policy-hidden pending row, without inventing a
browser-only event shape. The shared frontend SSE consumer in
`frontend-modern/src/api/streaming.ts` may insert a bounded task break
between already-buffered Assistant progress/tool/status events so coalesced
HTTP chunks preserve visible event order, but that pacing is an API-client
consumption rule only: it must not change event payload shape, event order,
timeout handling, reader cleanup, parse-error handling, or ordinary
content-token throughput.
`frontend-modern/src/api/streaming.ts` may insert a bounded task break after
opted-in Assistant progress/tool/status events, including events that arrive
through separate queued stream reads rather than the same decoded HTTP chunk.
That pacing is an API-client consumption rule only: it must not change event
payload shape, event order, timeout handling, reader cleanup, parse-error
handling, or ordinary content-token throughput.
Cold Assistant streams must include a typed `session` event backed by
`internal/ai/chat.SessionData` as soon as the HTTP SSE writer is ready and
an immediate neutral `workflow_state` preparation event before backend
@@ -14,6 +14,23 @@ const makeEventStreamResponse = (body: string) =>
},
}) as unknown as Response;
const makeChunkedEventStreamResponse = (chunks: string[]) => {
const read = vi.fn();
for (const chunk of chunks) {
read.mockResolvedValueOnce({ done: false, value: new TextEncoder().encode(chunk) });
}
read.mockResolvedValueOnce({ done: true, value: undefined });
return {
body: {
getReader: () => ({
read,
releaseLock: vi.fn(),
}),
},
} as unknown as Response;
};
const flushMicrotasks = async () => {
for (let attempt = 0; attempt < 20; attempt += 1) {
await Promise.resolve();
@@ -100,4 +117,38 @@ describe('consumeJSONEventStream', () => {
expect(events).toEqual(['content', 'tool_progress', 'done']);
expect(vi.getTimerCount()).toBe(0);
});
it('yields to the browser between matching events that arrive in separate queued reads', async () => {
vi.useFakeTimers();
const events: string[] = [];
const streamPromise = consumeJSONEventStream<{ type: string }>(
makeChunkedEventStreamResponse([
'data: {"type":"tool_start"}\n\n',
'data: {"type":"tool_progress"}\n\n',
'data: {"type":"done"}\n\n',
]),
{
onEvent: (event) => {
events.push(event.type);
return event.type === 'done';
},
yieldBetweenEvents: (event) => event.type !== 'content',
},
);
await flushMicrotasks();
expect(events).toEqual(['tool_start']);
expect(vi.getTimerCount()).toBeGreaterThan(0);
await vi.advanceTimersByTimeAsync(1);
await flushMicrotasks();
expect(events).toEqual(['tool_start', 'tool_progress']);
expect(vi.getTimerCount()).toBeGreaterThan(0);
await vi.advanceTimersByTimeAsync(1);
await streamPromise;
expect(events).toEqual(['tool_start', 'tool_progress', 'done']);
expect(vi.getTimerCount()).toBe(0);
});
});
+2 -11
View File
@@ -18,9 +18,8 @@ const yieldToBrowser = () =>
const shouldYieldAfterEvent = <T,>(
event: T,
options: JSONEventStreamOptions<T>,
hasBufferedEventAfterThisOne: boolean,
) => {
if (!hasBufferedEventAfterThisOne || !options.yieldBetweenEvents) return false;
if (!options.yieldBetweenEvents) return false;
if (typeof options.yieldBetweenEvents === 'function') {
return options.yieldBetweenEvents(event);
}
@@ -62,11 +61,6 @@ export async function consumeJSONEventStream<T>(
const messages = normalizedBuffer.split('\n\n');
buffer = messages.pop() || '';
const messageHasData = (message: string) =>
!!message
.split('\n')
.find((line) => line.startsWith('data: ') && !!line.slice(6).trim());
for (let messageIndex = 0; messageIndex < messages.length; messageIndex += 1) {
const message = messages[messageIndex];
if (!message.trim() || message.trim().startsWith(':')) {
@@ -90,10 +84,7 @@ export async function consumeJSONEventStream<T>(
if (options.onEvent(event)) {
return true;
}
const hasBufferedEventAfterThisOne =
dataLines.slice(lineIndex + 1).some((nextLine) => !!nextLine.slice(6).trim()) ||
messages.slice(messageIndex + 1).some(messageHasData);
if (shouldYieldAfterEvent(event, options, hasBufferedEventAfterThisOne)) {
if (shouldYieldAfterEvent(event, options)) {
await yieldToBrowser();
}
}