Skip to content

Commit f753bd3

Browse files
he-yufengdoudouOUC
authored andcommitted
fix(cli): submit fast tool results after stream end (#5071)
1 parent f227f22 commit f753bd3

2 files changed

Lines changed: 259 additions & 9 deletions

File tree

packages/cli/src/ui/hooks/useGeminiStream.test.tsx

Lines changed: 236 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1323,9 +1323,9 @@ describe('useGeminiStream', () => {
13231323
expect(client.recordCompletedToolCall).not.toHaveBeenCalled();
13241324
});
13251325

1326-
it('runs Race A dedup BEFORE the isResponding early-return (regression guard)', async () => {
1326+
it('runs Race A dedup BEFORE the active-stream early-return (regression guard)', async () => {
13271327
// The dedup block in handleCompletedTools is intentionally placed
1328-
// ABOVE the `if (isResponding) return;` early-return: the scheduler's
1328+
// ABOVE the active-stream early-return: the scheduler's
13291329
// `onAllToolCallsComplete` is single-shot per batch, so if the dedup
13301330
// sat below the guard a tool whose result was already paired in
13311331
// history would be left in `completed-but-not-submitted` forever
@@ -1472,8 +1472,8 @@ describe('useGeminiStream', () => {
14721472
});
14731473

14741474
// The dedup MUST still fire — markToolsAsSubmitted called with the
1475-
// deduped callId — even though the early-return on isResponding
1476-
// would otherwise skip every later branch.
1475+
// deduped callId — even though the active-stream guard would
1476+
// otherwise skip every later branch.
14771477
await waitFor(() => {
14781478
expect(mockMarkToolsAsSubmitted).toHaveBeenCalledWith([
14791479
'call_race_A_responding',
@@ -1488,6 +1488,238 @@ describe('useGeminiStream', () => {
14881488
releaseStream();
14891489
});
14901490

1491+
it('submits a fast tool result after the stream ended but before React replaces the callback', async () => {
1492+
const responseParts: Part[] = [
1493+
{
1494+
functionResponse: {
1495+
id: 'call_fast_after_stream',
1496+
name: 'read_file',
1497+
response: { error: 'ENOENT: missing file' },
1498+
},
1499+
},
1500+
];
1501+
const fastFailedTool = {
1502+
request: {
1503+
callId: 'call_fast_after_stream',
1504+
name: 'read_file',
1505+
args: { path: '/tmp/missing.txt' },
1506+
isClientInitiated: false,
1507+
prompt_id: 'prompt-fast-after-stream',
1508+
},
1509+
status: 'error',
1510+
responseSubmittedToGemini: false,
1511+
response: {
1512+
callId: 'call_fast_after_stream',
1513+
responseParts,
1514+
resultDisplay: undefined,
1515+
error: new Error('ENOENT: missing file'),
1516+
errorType: ToolErrorType.UNHANDLED_EXCEPTION,
1517+
},
1518+
tool: {
1519+
name: 'read_file',
1520+
displayName: 'ReadFile',
1521+
description: 'Read a file',
1522+
build: vi.fn(),
1523+
} as any,
1524+
invocation: {
1525+
getDescription: () => 'read /tmp/missing.txt',
1526+
} as unknown as AnyToolInvocation,
1527+
} as unknown as TrackedCompletedToolCall;
1528+
1529+
const client = new MockedGeminiClientClass(mockConfig);
1530+
let capturedOnComplete:
1531+
| ((completedTools: TrackedToolCall[]) => Promise<void>)
1532+
| null = null;
1533+
mockUseReactToolScheduler.mockImplementation((onComplete) => {
1534+
capturedOnComplete = onComplete;
1535+
return [[], mockScheduleToolCalls, mockMarkToolsAsSubmitted];
1536+
});
1537+
1538+
let releaseStream!: () => void;
1539+
const holdStream = new Promise<void>((resolve) => {
1540+
releaseStream = resolve;
1541+
});
1542+
// eslint-disable-next-line require-yield
1543+
const heldStream = (async function* () {
1544+
await holdStream;
1545+
})();
1546+
mockSendMessageStream.mockReturnValue(heldStream);
1547+
1548+
const { result } = renderHook(() =>
1549+
useGeminiStream(
1550+
client,
1551+
[],
1552+
mockAddItem,
1553+
mockConfig,
1554+
mockLoadedSettings,
1555+
mockOnDebugMessage,
1556+
mockHandleSlashCommand,
1557+
false,
1558+
() => 'vscode' as EditorType,
1559+
() => {},
1560+
() => Promise.resolve(),
1561+
false,
1562+
() => {},
1563+
() => {},
1564+
() => {},
1565+
() => {},
1566+
80,
1567+
24,
1568+
),
1569+
);
1570+
1571+
let submitPromise: Promise<unknown> | undefined;
1572+
act(() => {
1573+
submitPromise = result.current.submitQuery('edit the missing file');
1574+
});
1575+
await act(async () => {
1576+
await Promise.resolve();
1577+
await Promise.resolve();
1578+
});
1579+
1580+
// Save the callback from the render where React state still says
1581+
// "responding". The scheduler can call this stale closure if a tool
1582+
// finishes immediately after the stream returns.
1583+
const staleOnComplete = capturedOnComplete;
1584+
expect(mockSendMessageStream).toHaveBeenCalledTimes(1);
1585+
1586+
releaseStream();
1587+
await act(async () => {
1588+
await submitPromise;
1589+
});
1590+
1591+
const staleCompletedOnComplete = staleOnComplete as
1592+
| ((completedTools: TrackedCompletedToolCall[]) => Promise<void>)
1593+
| null;
1594+
await act(async () => {
1595+
await staleCompletedOnComplete?.([fastFailedTool]);
1596+
});
1597+
1598+
await waitFor(() => {
1599+
expect(mockSendMessageStream).toHaveBeenCalledTimes(2);
1600+
});
1601+
expect(mockSendMessageStream).toHaveBeenNthCalledWith(
1602+
2,
1603+
responseParts,
1604+
expect.any(AbortSignal),
1605+
'prompt-fast-after-stream',
1606+
expect.objectContaining({ type: SendMessageType.ToolResult }),
1607+
);
1608+
expect(mockMarkToolsAsSubmitted).toHaveBeenCalledWith([
1609+
'call_fast_after_stream',
1610+
]);
1611+
});
1612+
1613+
it('drops a fast tool result after cancellation even if the stale callback runs later', async () => {
1614+
const responseParts: Part[] = [
1615+
{
1616+
functionResponse: {
1617+
id: 'call_fast_after_cancel',
1618+
name: 'read_file',
1619+
response: { output: 'secret file contents' },
1620+
},
1621+
},
1622+
];
1623+
const fastToolAfterCancel = {
1624+
request: {
1625+
callId: 'call_fast_after_cancel',
1626+
name: 'read_file',
1627+
args: { path: '/tmp/secret.txt' },
1628+
isClientInitiated: false,
1629+
prompt_id: 'prompt-fast-after-cancel',
1630+
},
1631+
status: 'success',
1632+
responseSubmittedToGemini: false,
1633+
response: {
1634+
callId: 'call_fast_after_cancel',
1635+
responseParts,
1636+
resultDisplay: undefined,
1637+
error: undefined,
1638+
errorType: undefined,
1639+
},
1640+
tool: {
1641+
name: 'read_file',
1642+
displayName: 'ReadFile',
1643+
description: 'Read a file',
1644+
build: vi.fn(),
1645+
} as any,
1646+
invocation: {
1647+
getDescription: () => 'read /tmp/secret.txt',
1648+
} as unknown as AnyToolInvocation,
1649+
} as unknown as TrackedCompletedToolCall;
1650+
1651+
const client = new MockedGeminiClientClass(mockConfig);
1652+
let capturedOnComplete:
1653+
| ((completedTools: TrackedCompletedToolCall[]) => Promise<void>)
1654+
| null = null;
1655+
mockUseReactToolScheduler.mockImplementation((onComplete) => {
1656+
capturedOnComplete = onComplete;
1657+
return [[], mockScheduleToolCalls, mockMarkToolsAsSubmitted];
1658+
});
1659+
1660+
let releaseStream!: () => void;
1661+
const holdStream = new Promise<void>((resolve) => {
1662+
releaseStream = resolve;
1663+
});
1664+
// eslint-disable-next-line require-yield
1665+
const heldStream = (async function* () {
1666+
await holdStream;
1667+
})();
1668+
mockSendMessageStream.mockReturnValue(heldStream);
1669+
1670+
const { result } = renderHook(() =>
1671+
useGeminiStream(
1672+
client,
1673+
[],
1674+
mockAddItem,
1675+
mockConfig,
1676+
mockLoadedSettings,
1677+
mockOnDebugMessage,
1678+
mockHandleSlashCommand,
1679+
false,
1680+
() => 'vscode' as EditorType,
1681+
() => {},
1682+
() => Promise.resolve(),
1683+
false,
1684+
() => {},
1685+
() => {},
1686+
() => {},
1687+
() => {},
1688+
80,
1689+
24,
1690+
),
1691+
);
1692+
1693+
let submitPromise: Promise<unknown> | undefined;
1694+
act(() => {
1695+
submitPromise = result.current.submitQuery('read the file');
1696+
});
1697+
await act(async () => {
1698+
await Promise.resolve();
1699+
await Promise.resolve();
1700+
});
1701+
1702+
const staleOnComplete = capturedOnComplete;
1703+
expect(mockSendMessageStream).toHaveBeenCalledTimes(1);
1704+
1705+
act(() => {
1706+
result.current.cancelOngoingRequest();
1707+
});
1708+
releaseStream();
1709+
await act(async () => {
1710+
await submitPromise;
1711+
});
1712+
1713+
await act(async () => {
1714+
await staleOnComplete?.([fastToolAfterCancel]);
1715+
});
1716+
1717+
expect(mockSendMessageStream).toHaveBeenCalledTimes(1);
1718+
expect(mockMarkToolsAsSubmitted).toHaveBeenCalledWith([
1719+
'call_fast_after_cancel',
1720+
]);
1721+
});
1722+
14911723
it('handles a mixed batch (one deduped + one non-deduped) without double-counting telemetry', async () => {
14921724
// The dedup filter on `geminiTools` (`!historyCallIdsWithResponse.has(callId)`)
14931725
// is the only thing preventing double `recordCompletedToolCall`

packages/cli/src/ui/hooks/useGeminiStream.ts

Lines changed: 23 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -347,6 +347,8 @@ export const useGeminiStream = (
347347
const lastPromptErroredRef = useRef(false);
348348
const dualOutput = useDualOutput();
349349
const [isResponding, setIsResponding] = useState<boolean>(false);
350+
// React state can lag by one render; this tracks the actual stream lifetime.
351+
const activeModelStreamsRef = useRef(0);
350352
const [thought, setThought] = useState<ThoughtSummary | null>(null);
351353
// Hold the latest history in a ref so handleCompletedTools can read it
352354
// without depending on `history` (which would recreate the tool scheduler
@@ -1855,6 +1857,7 @@ export const useGeminiStream = (
18551857
logUserRetry(config, new UserRetryEvent(prompt_id));
18561858
}
18571859

1860+
activeModelStreamsRef.current += 1;
18581861
setIsResponding(true);
18591862
setInitError(null);
18601863
// Entering "requesting" phase — no content yet for this API call.
@@ -1969,7 +1972,13 @@ export const useGeminiStream = (
19691972
}
19701973
} finally {
19711974
submitPromptOnCompleteRef.current = null;
1972-
setIsResponding(false);
1975+
activeModelStreamsRef.current = Math.max(
1976+
0,
1977+
activeModelStreamsRef.current - 1,
1978+
);
1979+
if (activeModelStreamsRef.current === 0) {
1980+
setIsResponding(false);
1981+
}
19731982
isSubmittingQueryRef.current = false;
19741983
}
19751984
});
@@ -2114,14 +2123,14 @@ export const useGeminiStream = (
21142123
},
21152124
);
21162125

2117-
// History-based dedup MUST run before the `isResponding` early-return.
2126+
// History-based dedup MUST run before the active-stream early-return.
21182127
// If a synthetic `functionResponse` for this callId is already in
21192128
// chat.history (planted on session-load by
21202129
// `client.repairOrphanedToolUseTurnsInHistory` or on every
21212130
// `chat.sendMessageStream` push by the inline repair pass), the
21222131
// in-flight scheduler result must be marked submitted NOW —
21232132
// `useReactToolScheduler.allToolCallsCompleteHandler` is single-shot
2124-
// per batch, so a later isResponding=true early-return would leave
2133+
// per batch, so a later active-stream early-return would leave
21252134
// the tool stuck in `completed-but-not-submitted` forever (Race A
21262135
// surfaced in PR #4176 review). The real result is dropped on the
21272136
// wire — same trade-off upstream Claude Code makes when its
@@ -2182,7 +2191,7 @@ export const useGeminiStream = (
21822191
markToolsAsSubmitted(dedupedCallIds);
21832192
}
21842193

2185-
if (isResponding) {
2194+
if (activeModelStreamsRef.current > 0) {
21862195
return;
21872196
}
21882197

@@ -2234,6 +2243,16 @@ export const useGeminiStream = (
22342243
return;
22352244
}
22362245

2246+
if (
2247+
turnCancelledRef.current ||
2248+
abortControllerRef.current?.signal.aborted
2249+
) {
2250+
markToolsAsSubmitted(
2251+
geminiTools.map((toolCall) => toolCall.request.callId),
2252+
);
2253+
return;
2254+
}
2255+
22372256
// If all the tools were cancelled, don't submit a response to Gemini.
22382257
const allToolsCancelled = geminiTools.every(
22392258
(tc) => tc.status === 'cancelled',
@@ -2409,7 +2428,6 @@ export const useGeminiStream = (
24092428
submitQuery(responsesToSend, SendMessageType.ToolResult, prompt_ids[0]);
24102429
},
24112430
[
2412-
isResponding,
24132431
submitQuery,
24142432
markToolsAsSubmitted,
24152433
geminiClient,

0 commit comments

Comments
 (0)