@@ -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`
0 commit comments