@@ -31,6 +31,31 @@ import { getAllTabularData, clearTabularData } from '../../../services/tabularDa
3131// Shared Components
3232import PanelIconButton from '../../shared/PanelIconButton.jsx' ;
3333
34+ // Ask-queue safety bounds. MAX_WIZARD_QUEUE caps how many asks can pile up
35+ // behind a running one; MAX_CONSECUTIVE_ASK_ERRORS pauses auto-draining after
36+ // this many back-to-back hard failures so a broken config can't drain a whole
37+ // queue into wasted API calls.
38+ const MAX_WIZARD_QUEUE = 10 ;
39+ const MAX_CONSECUTIVE_ASK_ERRORS = 3 ;
40+
41+ /**
42+ * Read a wizard iteration-cap setting from localStorage, preserving an explicit
43+ * 0 (= ∞ intent; the server clamps it to the per-tier hard ceiling). Only an
44+ * absent/empty/NaN value falls back to the default — note Number(null) is 0, so
45+ * the raw string must be guarded before coercion (a plain `||`/`??` would either
46+ * drop the intended 0 or treat "missing" as 0).
47+ */
48+ function readWizardIterations ( key , def ) {
49+ try {
50+ const raw = localStorage . getItem ( key ) ;
51+ if ( raw == null || raw === '' ) return def ;
52+ const n = Number ( raw ) ;
53+ return Number . isFinite ( n ) ? n : def ;
54+ } catch {
55+ return def ;
56+ }
57+ }
58+
3459/**
3560 * Build the update object for a node from a server enrichment match.
3661 * Shared between single and batch enrichment.
@@ -528,6 +553,11 @@ const LeftAIView = ({ compact = false,
528553 const wizardSendQueueRef = React . useRef ( [ ] ) ; // [{ message, opts, conversationId }]
529554 const pendingDrainRef = React . useRef ( null ) ; // ask waiting for its tab to become active
530555 const [ queuedSendCount , setQueuedSendCount ] = React . useState ( 0 ) ;
556+ // Ask-queue circuit breaker: count consecutive hard failures so a queue of
557+ // asks that all error can't silently burn API calls one after another. Paused
558+ // draining resumes only when the user manually sends again or hits Stop.
559+ const consecutiveAskErrorsRef = React . useRef ( 0 ) ;
560+ const queuePausedRef = React . useRef ( false ) ;
531561 const [ wizardStage , setWizardStage ] = React . useState ( null ) ; // Track current wizard stage
532562 const [ druidInstance , setDruidInstance ] = React . useState ( null ) ; // Druid cognitive state manager
533563 // Synchronously hydrate conversations from localStorage to avoid async race conditions.
@@ -1278,6 +1308,7 @@ const LeftAIView = ({ compact = false,
12781308 // two runs ever contend for the shared run state.
12791309 React . useEffect ( ( ) => {
12801310 if ( isProcessing ) return ;
1311+ if ( queuePausedRef . current ) return ; // breaker tripped — wait for manual resume
12811312 if ( pendingDrainRef . current ) return ; // a drained ask is already awaiting its tab
12821313 if ( wizardSendQueueRef . current . length === 0 ) return ;
12831314 const next = wizardSendQueueRef . current . shift ( ) ;
@@ -1739,22 +1770,56 @@ const LeftAIView = ({ compact = false,
17391770 }
17401771 } ;
17411772
1773+ // Record a hard ask failure. After MAX_CONSECUTIVE_ASK_ERRORS in a row, trip the
1774+ // breaker: clear the queue and pause auto-draining so a broken config (bad key,
1775+ // endpoint down) can't drain every queued ask into wasted API calls.
1776+ const recordAskFailure = ( ) => {
1777+ consecutiveAskErrorsRef . current += 1 ;
1778+ const hasQueued = wizardSendQueueRef . current . length > 0 || ! ! pendingDrainRef . current ;
1779+ if ( consecutiveAskErrorsRef . current >= MAX_CONSECUTIVE_ASK_ERRORS && hasQueued ) {
1780+ queuePausedRef . current = true ;
1781+ wizardSendQueueRef . current = [ ] ;
1782+ pendingDrainRef . current = null ;
1783+ setQueuedSendCount ( 0 ) ;
1784+ addMessage ( 'system' , `Paused queued asks after ${ MAX_CONSECUTIVE_ASK_ERRORS } consecutive errors. Fix the issue, then send again to resume.` ) ;
1785+ }
1786+ } ;
1787+
17421788 const handleSendMessage = async ( overrideInput , sendOptions ) => {
17431789 const inputToUse = typeof overrideInput === 'string' ? overrideInput : currentInput ;
17441790 if ( ! inputToUse . trim ( ) && pendingAttachments . length === 0 ) return ;
17451791 // A run is already in flight — queue this ask (bound to the tab it targets)
17461792 // rather than dropping it or letting a second run clobber the shared run state.
17471793 // drainWizardQueue() below picks it up when the current run completes.
17481794 if ( isProcessingRef . current ) {
1749- wizardSendQueueRef . current . push ( {
1795+ const q = wizardSendQueueRef . current ;
1796+ const targetConv = activeConversationIdRef . current ;
1797+ // Dedup: don't queue an ask identical to one already waiting (same text +
1798+ // tab). Guards against double-dispatch (e.g. a programmatic event firing twice).
1799+ const isDupe = q . some ( item => item . message === inputToUse && item . conversationId === targetConv ) ;
1800+ if ( isDupe ) {
1801+ if ( typeof overrideInput !== 'string' ) setCurrentInput ( '' ) ;
1802+ return ;
1803+ }
1804+ // Bound the queue so runaway enqueueing can't pile up unbounded work.
1805+ if ( q . length >= MAX_WIZARD_QUEUE ) {
1806+ addMessage ( 'system' , `Ask queue is full (${ MAX_WIZARD_QUEUE } ). This ask was not queued — wait for the current runs to finish.` ) ;
1807+ if ( typeof overrideInput !== 'string' ) setCurrentInput ( '' ) ;
1808+ return ;
1809+ }
1810+ q . push ( {
17501811 message : inputToUse ,
17511812 opts : sendOptions ,
1752- conversationId : activeConversationIdRef . current ,
1813+ conversationId : targetConv ,
17531814 } ) ;
1754- setQueuedSendCount ( wizardSendQueueRef . current . length ) ;
1815+ setQueuedSendCount ( q . length ) ;
17551816 if ( typeof overrideInput !== 'string' ) setCurrentInput ( '' ) ;
17561817 return ;
17571818 }
1819+ // A manual (non-queued) send means the user is actively driving — clear any
1820+ // tripped breaker so queued asks can drain again after this run.
1821+ queuePausedRef . current = false ;
1822+ consecutiveAskErrorsRef . current = 0 ;
17581823 // Optional display-override path used by programmatic dispatchers (e.g., the Ask The Wizard
17591824 // chip). When set, the UI shows displayContent (a short summary) and the LLM's
17601825 // conversation-history replay uses replayContent (defaults to displayContent), while the
@@ -1907,9 +1972,11 @@ const LeftAIView = ({ compact = false,
19071972 try {
19081973 // Reuse the autonomous agent handler but with Druid prompt
19091974 await handleAutonomousAgent ( messagePayload , 'druid' ) ;
1975+ consecutiveAskErrorsRef . current = 0 ; // a completed ask resets the breaker
19101976 } catch ( error ) {
19111977 console . error ( 'Druid error:' , error ) ;
19121978 addMessage ( 'system' , `Druid error: ${ error . message } ` ) ;
1979+ recordAskFailure ( ) ;
19131980 } finally {
19141981 setIsProcessing ( false ) ;
19151982 }
@@ -1931,9 +1998,11 @@ const LeftAIView = ({ compact = false,
19311998 } else {
19321999 await handleQuestion ( messagePayload ) ;
19332000 }
2001+ consecutiveAskErrorsRef . current = 0 ; // a completed ask resets the breaker
19342002 } catch ( error ) {
19352003 console . error ( '[AI Collaboration] Error processing message:' , error ) ;
19362004 addMessage ( 'system' , `Error: ${ error . message } ` ) ;
2005+ recordAskFailure ( ) ;
19372006 } finally {
19382007 setIsProcessing ( false ) ;
19392008 setCurrentAgentRequest ( null ) ;
@@ -1946,6 +2015,9 @@ const LeftAIView = ({ compact = false,
19462015 wizardSendQueueRef . current = [ ] ;
19472016 pendingDrainRef . current = null ;
19482017 setQueuedSendCount ( 0 ) ;
2018+ // Reset the error breaker so a fresh send after Stop drains normally.
2019+ queuePausedRef . current = false ;
2020+ consecutiveAskErrorsRef . current = 0 ;
19492021 if ( currentAgentRequest ) {
19502022 currentAgentRequest . abort ( ) ;
19512023 setCurrentAgentRequest ( null ) ;
@@ -2227,8 +2299,11 @@ const LeftAIView = ({ compact = false,
22272299 model : apiConfig . model ,
22282300 settings : {
22292301 ...apiConfig . settings ,
2230- maxIterationsLocal : Number ( localStorage . getItem ( 'rs.wizard.maxIterationsLocal' ) ) || 177 ,
2231- maxIterationsCloud : Number ( localStorage . getItem ( 'rs.wizard.maxIterationsCloud' ) ) || 77 ,
2302+ // Preserve an explicit 0 (= ∞ intent, clamped to the tier ceiling
2303+ // server-side) instead of coercing it back to the default via ||.
2304+ // Absent/empty/NaN → default. Number(null) is 0, so guard raw first.
2305+ maxIterationsLocal : readWizardIterations ( 'rs.wizard.maxIterationsLocal' , 177 ) ,
2306+ maxIterationsCloud : readWizardIterations ( 'rs.wizard.maxIterationsCloud' , 77 ) ,
22322307 } ,
22332308 modelTier : apiConfig . modelTier || 'large'
22342309 } : null
@@ -2415,6 +2490,14 @@ const LeftAIView = ({ compact = false,
24152490 const openThinkIdx = blocks . findLastIndex ( b => b . type === 'thinking' && ! b . collapsed ) ;
24162491 if ( openThinkIdx >= 0 ) blocks [ openThinkIdx ] = { ...blocks [ openThinkIdx ] , collapsed : true } ;
24172492 blocks . push ( { type : 'steering' , kind : event . kind || 'nudge' , content : event . content || '' } ) ;
2493+ } else if ( event . type === 'usage' ) {
2494+ // Running per-ask token totals from the agent loop. Each event
2495+ // carries the cumulative ask totals, so last-write wins.
2496+ msg . tokenUsage = {
2497+ promptTokens : event . askPromptTokens || 0 ,
2498+ completionTokens : event . askCompletionTokens || 0 ,
2499+ totalTokens : event . askTotalTokens || 0
2500+ } ;
24182501 } else if ( event . type === 'error' ) {
24192502 blocks . push ( { type : 'text' , content : `Error: ${ event . message } ` } ) ;
24202503 msg . content = `Error: ${ event . message } ` ;
@@ -2780,6 +2863,9 @@ const LeftAIView = ({ compact = false,
27802863 meta . push ( `\n Complete: ${ msg . metadata . isComplete } ` ) ;
27812864 }
27822865 }
2866+ if ( msg . tokenUsage ?. totalTokens > 0 ) {
2867+ meta . push ( `\n Tokens: ${ msg . tokenUsage . totalTokens . toLocaleString ( ) } (prompt ${ msg . tokenUsage . promptTokens . toLocaleString ( ) } + completion ${ msg . tokenUsage . completionTokens . toLocaleString ( ) } )` ) ;
2868+ }
27832869
27842870 if ( meta . length > 0 ) {
27852871 text += meta . join ( '' ) ;
@@ -3442,6 +3528,14 @@ const LeftAIView = ({ compact = false,
34423528 ) : null }
34433529 { hasDefinitiveContent && < div className = "ai-message-timestamp" style = { { display : 'flex' , alignItems : 'center' , gap : '6px' , width : '100%' , justifyContent : message . sender === 'user' ? 'flex-end' : 'flex-start' } } >
34443530 { new Date ( message . timestamp ) . toLocaleTimeString ( ) }
3531+ { message . sender === 'ai' && message . tokenUsage ?. totalTokens > 0 && (
3532+ < span
3533+ style = { { opacity : 0.6 , fontSize : '11px' } }
3534+ title = { `Prompt ${ message . tokenUsage . promptTokens . toLocaleString ( ) } + completion ${ message . tokenUsage . completionTokens . toLocaleString ( ) } tokens across this ask` }
3535+ >
3536+ · { message . tokenUsage . totalTokens . toLocaleString ( ) } tok
3537+ </ span >
3538+ ) }
34453539 { message . sender === 'user' && (
34463540 < button
34473541 onClick = { ( ) => {
0 commit comments