@@ -43,6 +43,7 @@ import { AgentBlockHandler } from '@/executor/handlers/agent/handler'
4343import * as agentMemory from '@/executor/handlers/agent/memory'
4444import type { AgentInputs , Message } from '@/executor/handlers/agent/types'
4545import type { ExecutionContext , StreamingExecution } from '@/executor/types'
46+ import { projectResolvedSecretDiagnosticError } from '@/executor/utils/resolved-secret-content-projection'
4647import { ResolvedSecretTraceRegistry } from '@/executor/utils/resolved-secret-trace-registry'
4748import {
4849 getEncryptedConversationMessage ,
@@ -1303,6 +1304,111 @@ describe('AgentBlockHandler', () => {
13031304 expect ( blockLog ) . toMatchObject ( { modelFallbacks : [ 'gpt-4o' ] } )
13041305 } )
13051306
1307+ describe ( 'a fallback whose provider cannot take the attached file' , ( ) => {
1308+ const attachment = {
1309+ id : 'file-1' ,
1310+ name : 'example.png' ,
1311+ key : 'execution/test-workspace/test-workflow/exec-1/example.png' ,
1312+ url : 'https://storage.example.com/expired' ,
1313+ size : 8 ,
1314+ type : 'image/png' ,
1315+ context : 'execution' ,
1316+ base64 : 'iVBORw0KGgo=' ,
1317+ }
1318+
1319+ /** A secret only the primary's tool call activates, so only its failed attempt's registries know it. */
1320+ const contextWithToolSecretCatalog = ( blockLog : ReturnType < typeof openLog > ) => ( {
1321+ ...mockContext ,
1322+ executionId : 'exec-1' ,
1323+ blockLogs : [ blockLog ] ,
1324+ resolvedSecretTraceRegistry : new ResolvedSecretTraceRegistry ( [
1325+ { name : 'TOKEN' , plaintext : 'tool-secret' , encryptedValue : 'encrypted-tool-secret' } ,
1326+ ] ) ,
1327+ } )
1328+
1329+ const inputsWithAttachment = ( fallbackModels : Array < { model : string } > ) => ( {
1330+ ...baseInputs ,
1331+ files : [ attachment ] ,
1332+ fallbackModels,
1333+ } )
1334+
1335+ let hydrate : ReturnType < typeof vi . spyOn >
1336+ const primaryError = new Error ( 'overloaded after reading tool-secret' )
1337+
1338+ beforeEach ( ( ) => {
1339+ mockGetProviderFromModel . mockImplementation ( ( model : string ) =>
1340+ model . startsWith ( 'deepseek' ) ? 'deepseek' : providerFor ( model )
1341+ )
1342+ hydrate = vi
1343+ . spyOn ( userFileBase64 , 'hydrateUserFilesWithBase64' )
1344+ . mockImplementation ( async ( files ) => files )
1345+ mockExecuteProviderRequest . mockImplementationOnce ( async ( _provider , _request , options ) => {
1346+ const runtimeRegistry = options . resolvedSecretTraceRegistry as ResolvedSecretTraceRegistry
1347+ runtimeRegistry . recordResolved ( 'TOKEN' , 'tool-secret' , { propagated : true } )
1348+ throw primaryError
1349+ } )
1350+ } )
1351+
1352+ afterEach ( ( ) => {
1353+ hydrate . mockRestore ( )
1354+ } )
1355+
1356+ it ( "is skipped, and the primary's error rethrows against its failed attempt's registries" , async ( ) => {
1357+ const blockLog = openLog ( )
1358+ blockLog . modelFallbacks = [ 'stale-from-earlier-run' ]
1359+ const ctx = contextWithToolSecretCatalog ( blockLog )
1360+
1361+ await expect (
1362+ handler . execute ( ctx , mockBlock , inputsWithAttachment ( [ { model : 'deepseek-chat' } ] ) )
1363+ ) . rejects . toBe ( primaryError )
1364+
1365+ expect ( mockExecuteProviderRequest ) . toHaveBeenCalledTimes ( 1 )
1366+ /** Only the final error failed, and a skip is not a failed try, so the log records nothing. */
1367+ expect ( blockLog . modelFallbacks ) . toBeUndefined ( )
1368+ for ( const registry of [
1369+ ctx . errorResolvedSecretTraceRegistry ,
1370+ ctx . resolvedSecretTraceRegistry ,
1371+ ] ) {
1372+ expect ( projectResolvedSecretDiagnosticError ( primaryError , registry ) ) . toMatchObject ( {
1373+ error : 'overloaded after reading {{TOKEN}}' ,
1374+ } )
1375+ }
1376+ } )
1377+
1378+ it ( 'is skipped for a later fallback that starts from the settled registries and answers' , async ( ) => {
1379+ const blockLog = openLog ( )
1380+ const ctx = contextWithToolSecretCatalog ( blockLog )
1381+ const fallbackRegistries : Array < ResolvedSecretTraceRegistry | undefined > = [ ]
1382+ mockExecuteProviderRequest . mockImplementationOnce ( async ( _provider , _request , options ) => {
1383+ const executionContext = options . executionContext as ExecutionContext
1384+ fallbackRegistries . push (
1385+ executionContext . errorResolvedSecretTraceRegistry ,
1386+ executionContext . resolvedSecretTraceRegistry
1387+ )
1388+ return providerResponse ( 'claude-sonnet-5' , 'from fallback' )
1389+ } )
1390+
1391+ const result = await handler . execute (
1392+ ctx ,
1393+ mockBlock ,
1394+ inputsWithAttachment ( [ { model : 'deepseek-chat' } , { model : 'claude-sonnet-5' } ] )
1395+ )
1396+
1397+ expect ( mockExecuteProviderRequest . mock . calls . map ( ( [ provider ] ) => provider ) ) . toEqual ( [
1398+ 'openai' ,
1399+ 'anthropic' ,
1400+ ] )
1401+ expect ( ( result as { model : string } ) . model ) . toBe ( 'claude-sonnet-5' )
1402+ /** Only the primary's tool call activated the secret, so registries from the settled inputs leave it unredacted. */
1403+ expect ( fallbackRegistries ) . toHaveLength ( 2 )
1404+ for ( const registry of fallbackRegistries ) {
1405+ expect ( projectResolvedSecretDiagnosticError ( primaryError , registry ) ) . toMatchObject ( {
1406+ error : 'overloaded after reading tool-secret' ,
1407+ } )
1408+ }
1409+ } )
1410+ } )
1411+
13061412 it ( 'skips sim-auto, duplicates, the primary itself, and unusable providers' , async ( ) => {
13071413 mockExecuteProviderRequest
13081414 . mockRejectedValueOnce ( new Error ( 'down' ) )
0 commit comments