@@ -432,6 +432,8 @@ export function registerChatRoutes(app: Application, ctx: AppContext): void {
432432 REQUEST_TIMEOUT_MS ,
433433 'load tool overrides' ,
434434 ) ) as Record < string , boolean > | null ;
435+ // Fail-closed by design: null (discovery failed) or verified-empty (no
436+ // tool IDs to disable) both 503. A real backend always exposes tools.
435437 if ( DISABLE_TOOLS && ( ! toolOverrides || Object . keys ( toolOverrides ) . length === 0 ) ) {
436438 try {
437439 await activeClient . session . delete ( { path : { id : sessionId as string } } ) ;
@@ -484,8 +486,12 @@ export function registerChatRoutes(app: Application, ctx: AppContext): void {
484486 } ;
485487 }
486488
487- if ( stream ) {
488-
489+ if ( stream ) {
490+ // Intentional: buffer visible text while any external tool contract is
491+ // declared so `<function_calls>` / `<external__*>` markup fragments are
492+ // never streamed incrementally before the parser can classify them.
493+ // Trade-off: pure-text turns with tools declared also emit in one batch
494+ // at completion instead of incrementally.
489495 const shouldBufferExternalStream = externalToolRegistry . length > 0 ;
490496 let filterContentDelta = createToolCallFilter ( {
491497 disableTools : DISABLE_TOOLS ,
@@ -1032,6 +1038,10 @@ export function registerChatRoutes(app: Application, ctx: AppContext): void {
10321038 }
10331039 break ;
10341040 }
1041+ // Fail-closed: any backend error fails even when partial content exists.
1042+ // Returning partial text as success would hide the upstream failure and
1043+ // diverge from stream paths (which throw on snapshot.error). Retry above
1044+ // already covers the no-output transient case.
10351045 if ( error != null ) {
10361046 if ( / ^ R e q u e s t t i m e o u t a f t e r / . test ( toErrorMessage ( error ) ) ) throw error ;
10371047 const normalizedDiscovery = normalizeBackendError ( error ) ;
0 commit comments