@@ -13,12 +13,7 @@ import { noopAuditStore, permissiveAuthorize } from "@intx/agent/testing";
1313import { getLogger } from "@intx/log" ;
1414import { createOptimizedContextStore } from "../session/optimized-context-store.js" ;
1515import { type } from "arktype" ;
16- import {
17- buildCodexSource ,
18- buildOpenAISource ,
19- buildXaiSource ,
20- type Config ,
21- } from "../config/index.js" ;
16+ import { type Config } from "../config/index.js" ;
2217import {
2318 loadLocalSettings ,
2419 resolveLocalSettingsPath ,
@@ -67,6 +62,11 @@ import { createAgentToolset, type AgentToolset, type OperatorResult } from "../a
6762import { createAgentWithLiveToolDispatch } from "../agent/live-tool-dispatch.js" ;
6863import { liveTelemetry } from "../telemetry/singleton.js" ;
6964import { createTurnObserver } from "../telemetry/ai-observability.js" ;
65+ import {
66+ CREDENTIAL_FAILURE_USER_MESSAGE ,
67+ isResolvedProviderFailureError ,
68+ terminalProviderFailureMessage ,
69+ } from "../inference-error-message.js" ;
7070import { collectToolPlugins , resolveToolPlugins } from "../plugins/tool-plugins.js" ;
7171import {
7272 expandExistingPluginMembers ,
@@ -120,6 +120,35 @@ export function formatCaughtError(err: unknown): string {
120120 return err instanceof Error ? err . message : String ( err ) ;
121121}
122122
123+ const SELECTED_PROVIDER_FAILURE = "SelectedProviderFailure" ;
124+
125+ export async function refreshSelectedProviderCredential < T > ( refresh : ( ) => Promise < T > ) : Promise < T > {
126+ try {
127+ return await refresh ( ) ;
128+ } catch ( cause ) {
129+ const error = new Error ( formatCaughtError ( cause ) , { cause } ) ;
130+ error . name = SELECTED_PROVIDER_FAILURE ;
131+ throw error ;
132+ }
133+ }
134+
135+ export function execUserFailureMessage (
136+ config : Config ,
137+ err : unknown ,
138+ providerFailureObserved : boolean ,
139+ ) : string {
140+ if ( err instanceof Error && err . name === SELECTED_PROVIDER_FAILURE ) {
141+ return CREDENTIAL_FAILURE_USER_MESSAGE ;
142+ }
143+ if ( providerFailureObserved || isResolvedProviderFailureError ( err ) ) {
144+ return terminalProviderFailureMessage (
145+ config . providerName ,
146+ config . settings ?. providers [ config . providerName ] ?. name ,
147+ ) ;
148+ }
149+ return formatCaughtError ( err ) ;
150+ }
151+
123152/**
124153 * Headless analogue of TUI `runtime-shutdown`: abort live workers, then close
125154 * the primary agent and dispose the toolset. `cancelAll` is fire-and-forget —
@@ -297,6 +326,7 @@ export async function runExec(config: Config): Promise<ExecResult> {
297326 let finalized = false ;
298327 let turnsUsed = 0 ;
299328 let runSink : RunSink | null = null ;
329+ let providerFailureObserved = false ;
300330
301331 const persist = async (
302332 status : "running" | "done" | "failed" | "cancelled" ,
@@ -586,56 +616,14 @@ export async function runExec(config: Config): Promise<ExecResult> {
586616
587617 const initialCodexProfile = codexProfileFromProviderName ( config . providerName ) ;
588618 const initialXaiProfile = xaiProfileFromProviderName ( config . providerName ) ;
589- const initialCodexAccountId = config . providers . find (
590- ( p ) => p . name === config . providerName ,
591- ) ?. codexAccountId ;
592-
593- const buildOpenAICompatibleInitialSource = ( ) : InferenceSource =>
594- buildOpenAISource ( {
595- id : config . providerName ,
596- baseURL : config . baseURL ,
597- apiKey : config . apiKey ,
598- model : config . model ,
599- ...( config . reasoningEffort !== undefined
600- ? { reasoningEffort : config . reasoningEffort }
601- : { } ) ,
602- } ) ;
603-
604- const buildSessionSources = ( ) : { sources : InferenceSource [ ] ; defaultSource : string } =>
605- buildSessionSourcesFromConfig ( config , sessionId ) ;
606-
607- const initialBundle = buildSessionSources ( ) ;
619+ const initialBundle = buildSessionSourcesFromConfig ( config , sessionId ) ;
608620 const liveSources = initialBundle . sources ;
609621 const liveDefaultSource = initialBundle . defaultSource ;
610-
611- const buildInitialSourceFallback = ( ) : InferenceSource =>
612- initialCodexProfile !== undefined
613- ? buildCodexSource ( {
614- id : config . providerName ,
615- apiKey : config . apiKey ,
616- model : config . model ,
617- sessionId,
618- ...( initialCodexAccountId !== undefined ? { accountId : initialCodexAccountId } : { } ) ,
619- ...( config . reasoningEffort !== undefined
620- ? { reasoningEffort : config . reasoningEffort }
621- : { } ) ,
622- } )
623- : initialXaiProfile !== undefined
624- ? buildXaiSource ( {
625- id : config . providerName ,
626- apiKey : config . apiKey ,
627- model : config . model ,
628- sessionId,
629- ...( config . reasoningEffort !== undefined
630- ? { reasoningEffort : config . reasoningEffort }
631- : { } ) ,
632- } )
633- : buildOpenAICompatibleInitialSource ( ) ;
634-
635- let liveSource : InferenceSource =
636- liveSources . find ( ( s ) => s . id === liveDefaultSource ) ??
637- liveSources [ 0 ] ??
638- buildInitialSourceFallback ( ) ;
622+ const selectedSource = liveSources [ 0 ] ;
623+ if ( selectedSource === undefined ) {
624+ throw new Error ( "Selected inference source was not assembled" ) ;
625+ }
626+ let liveSource : InferenceSource = selectedSource ;
639627
640628 // Refresh pinned Codex instructions before first inference, same as the
641629 // TUI path. Best-effort: a network failure falls back to the disk cache
@@ -651,15 +639,19 @@ export async function runExec(config: Config): Promise<ExecResult> {
651639
652640 // Refresh OAuth tokens before first inference when starting on codex/xai.
653641 if ( initialCodexProfile !== undefined ) {
654- const { access } = await getValidCodexToken ( initialCodexProfile ) ;
642+ const { access } = await refreshSelectedProviderCredential ( ( ) =>
643+ getValidCodexToken ( initialCodexProfile ) ,
644+ ) ;
655645 liveSource = { ...liveSource , apiKey : access } ;
656646 liveSubAgentProvider . current = {
657647 ...liveSubAgentProvider . current ,
658648 apiKey : access ,
659649 } ;
660650 }
661651 if ( initialXaiProfile !== undefined ) {
662- const { access } = await getValidXaiToken ( initialXaiProfile ) ;
652+ const { access } = await refreshSelectedProviderCredential ( ( ) =>
653+ getValidXaiToken ( initialXaiProfile ) ,
654+ ) ;
663655 liveSource = { ...liveSource , apiKey : access } ;
664656 liveSubAgentProvider . current = {
665657 ...liveSubAgentProvider . current ,
@@ -762,6 +754,11 @@ export async function runExec(config: Config): Promise<ExecResult> {
762754 // its partial output in partial.jsonl instead of vanishing.
763755 const cycleRecorder = createCycleTextRecorder ( ( ) => workdir ) ;
764756 const sink = ( event : ReactorEmittedEvent ) : void => {
757+ if ( event . type === "inference.start" || event . type === "inference.done" ) {
758+ providerFailureObserved = false ;
759+ } else if ( event . type === "inference.error" ) {
760+ providerFailureObserved = true ;
761+ }
765762 liveSink . sink ( event ) ;
766763 cycleRecorder . handleEvent ( event ) ;
767764 if ( event . type === "inference.text.delta" ) {
@@ -881,17 +878,24 @@ export async function runExec(config: Config): Promise<ExecResult> {
881878 } ) ;
882879
883880 if ( ! sendCompleted || runError !== undefined || summaryStatus === "failed" ) {
884- const message =
881+ const diagnosticMessage =
885882 runError ??
886883 ( summaryStatus === "cancelled" ? "run cancelled before completion" : "run failed" ) ;
887- stderr . write ( `Error: ${ message } \n` ) ;
884+ const userMessage =
885+ summaryStatus === "failed"
886+ ? terminalProviderFailureMessage (
887+ config . providerName ,
888+ config . settings ?. providers [ config . providerName ] ?. name ,
889+ )
890+ : diagnosticMessage ;
891+ stderr . write ( `Error: ${ userMessage } \n` ) ;
888892 const persistStatus = summaryStatus === "cancelled" ? "cancelled" : "failed" ;
889- await persist ( persistStatus , { error : message } ) ;
893+ await persist ( persistStatus , { error : diagnosticMessage } ) ;
890894 return {
891895 exitCode : 1 ,
892896 sessionId,
893897 text : textOut ,
894- error : message ,
898+ error : userMessage ,
895899 status : summaryStatus ,
896900 durationMs : finishedAt - startedAt ,
897901 turnsUsed : runSink . getTurnCount ( ) ,
@@ -916,15 +920,16 @@ export async function runExec(config: Config): Promise<ExecResult> {
916920 model : config . model ,
917921 } ;
918922 } catch ( err ) {
919- const message = err instanceof Error ? err . message : String ( err ) ;
920- logger . error ( "exec failed: {error}" , { error : message } ) ;
921- stderr . write ( `Error: ${ message } \n` ) ;
922- await persist ( "failed" , { error : message } ) ;
923+ const diagnosticMessage = formatCaughtError ( err ) ;
924+ logger . error ( "exec failed: {error}" , { error : diagnosticMessage } ) ;
925+ const userMessage = execUserFailureMessage ( config , err , providerFailureObserved ) ;
926+ stderr . write ( `Error: ${ userMessage } \n` ) ;
927+ await persist ( "failed" , { error : diagnosticMessage } ) ;
923928 return {
924929 exitCode : 1 ,
925930 sessionId,
926931 text : textOut ,
927- error : message ,
932+ error : userMessage ,
928933 status : "failed" ,
929934 durationMs : Date . now ( ) - startedAt ,
930935 turnsUsed : runSink ?. getTurnCount ( ) ?? turnsUsed ,
0 commit comments