Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions packages/cli/src/ui/hooks/useGeminiStream.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4035,6 +4035,9 @@ describe('useGeminiStream', () => {
await act(async () => {
await Promise.resolve();
await Promise.resolve();
// Flush the macrotask yield (setImmediate) added after addItem()

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] No regression test asserts that the yield is a macrotask (not a microtask). All 9 updated tests flush via vi.advanceTimersByTimeAsync(0) and then assert sendMessageStream.toHaveBeenCalledTimes(1) — but if someone replaces setImmediate with Promise.resolve() (or removes the yield entirely), the two await Promise.resolve() lines already present would flush the microtask, and all assertions still pass. The input lag silently returns with no test failure.

Consider adding a test that asserts the ordering invariant:

it('yields via setImmediate so React can render before API call', async () => {
  vi.useFakeTimers();
  // ... setup ...
  act(() => { void result.current.submitQuery('hello'); });
  await act(async () => {
    await Promise.resolve();
    await Promise.resolve();
  });
  // setImmediate hasn't fired yet — sendMessageStream must NOT be called
  expect(mockSendMessageStream).not.toHaveBeenCalled();
  await act(async () => { await vi.advanceTimersByTimeAsync(0); });
  expect(mockSendMessageStream).toHaveBeenCalledTimes(1);
});

This guards against the yield being weakened to a microtask in the future.

— qwen3.7-max via Qwen Code /review

// so that sendMessageStream is actually invoked.
await vi.advanceTimersByTimeAsync(0);
});

expect(mockSendMessageStream).toHaveBeenCalledTimes(1);
Expand Down Expand Up @@ -4163,6 +4166,8 @@ describe('useGeminiStream', () => {
await act(async () => {
await Promise.resolve();
await Promise.resolve();
// Flush the macrotask yield (setImmediate) added after addItem()
await vi.advanceTimersByTimeAsync(0);
});

expect(mockSendMessageStream).toHaveBeenCalledTimes(1);
Expand Down Expand Up @@ -4223,6 +4228,8 @@ describe('useGeminiStream', () => {
await act(async () => {
await Promise.resolve();
await Promise.resolve();
// Flush the macrotask yield (setImmediate) added after addItem()
await vi.advanceTimersByTimeAsync(0);
});

await act(async () => {
Expand Down Expand Up @@ -4361,6 +4368,8 @@ describe('useGeminiStream', () => {
await act(async () => {
await Promise.resolve();
await Promise.resolve();
// Flush the macrotask yield (setImmediate) added after addItem()
await vi.advanceTimersByTimeAsync(0);
});

expect(mockSendMessageStream).toHaveBeenCalledTimes(1);
Expand Down Expand Up @@ -4711,6 +4720,8 @@ describe('useGeminiStream', () => {
await act(async () => {
await Promise.resolve();
await Promise.resolve();
// Flush the macrotask yield (setImmediate) added after addItem()
await vi.advanceTimersByTimeAsync(0);
});

// Cancel without advancing the throttle timer; the cancel-time
Expand Down Expand Up @@ -4828,6 +4839,8 @@ describe('useGeminiStream', () => {
await act(async () => {
await Promise.resolve();
await Promise.resolve();
// Flush the macrotask yield (setImmediate) added after addItem()
await vi.advanceTimersByTimeAsync(0);
});

// Sanity: the throttle has not fired yet.
Expand Down Expand Up @@ -6743,6 +6756,8 @@ describe('useGeminiStream', () => {
void result.current.submitQuery('think then retry');
await Promise.resolve();
await Promise.resolve();
// Flush the macrotask yield (setImmediate) added after addItem()
await vi.advanceTimersByTimeAsync(0);
});

// Advance past STREAM_UPDATE_THROTTLE_MS (60ms) so the thought
Expand Down Expand Up @@ -6840,6 +6855,8 @@ describe('useGeminiStream', () => {

await act(async () => {
await Promise.resolve();
// Flush the macrotask yield (setImmediate) added after addItem()
await vi.advanceTimersByTimeAsync(0);
});

const findErrorItem = () =>
Expand Down Expand Up @@ -6955,6 +6972,12 @@ describe('useGeminiStream', () => {
void result.current.submitQuery('Trigger retry after countdown');
});

await act(async () => {
await Promise.resolve();
// Flush the macrotask yield (setImmediate) added after addItem()
await vi.advanceTimersByTimeAsync(0);
});

let errorItem = result.current.pendingHistoryItems.find(
(item) => item.type === MessageType.ERROR,
) as { hint?: string } | undefined;
Expand Down
12 changes: 12 additions & 0 deletions packages/cli/src/ui/hooks/useGeminiStream.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1036,6 +1036,18 @@ export const useGeminiStream = (
id: insertedId,
text: trimmedQuery,
};

// Yield via macrotask to let Ink/React flush the user message
// render before continuing with @-command processing and API
// call. React 19.2.4 (Ink 7.0.3) schedules renders via
// MessageChannel.postMessage (a macrotask), so a microtask yield
// (await Promise.resolve()) does NOT give React a chance to
// render — the continuation runs first. setImmediate fires in
// the check phase after I/O events (where MessageChannel
// delivers its postMessage), guaranteeing React renders first
// without the ~1ms timer overhead of setTimeout(0).
// Only needed for non-Cron submissions since Cron skips addItem().
await new Promise((r) => setImmediate(r));
}

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] The setImmediate yield creates a sub-millisecond window where the user could cancel (press Escape). After the yield, execution falls through to handleAtCommand, applyVisionBridgeIfNeeded, and eventually sendMessageStream without re-checking turnCancelledRef. While the abort signal IS propagated to all downstream calls (so the API call fails cleanly rather than succeeding with stale data), adding an early re-check is consistent with the entry guard at line 922 and avoids unnecessary downstream work.

Suggested change
}
await new Promise((r) => setImmediate(r));
if (turnCancelledRef.current) {
return { queryToSend: null, shouldProceed: false };
}

— qwen3.7-max via Qwen Code /review


// Handle @-commands (which might involve tool calls)
Expand Down
Loading