@@ -4798,6 +4798,149 @@ describe("runAgentTurnWithFallback", () => {
47984798 }
47994799 } ) ;
48004800
4801+ it ( "surfaces post-compaction context when the retried turn times out (#67750)" , async ( ) => {
4802+ // Embedded run announces a successful compaction (so autoCompactionCount > 0),
4803+ // then the retried turn throws. The fallback layer rejects with a structured
4804+ // FallbackSummaryError carrying timeout + billing-skip attempts. The user
4805+ // message must preserve that compaction succeeded plus the cause chain,
4806+ // not collapse to BILLING_ERROR_USER_MESSAGE or the generic /new copy.
4807+ state . runEmbeddedAgentMock . mockImplementationOnce ( async ( params : EmbeddedAgentParams ) => {
4808+ await params . onAgentEvent ?.( { stream : "compaction" , data : { phase : "start" } } ) ;
4809+ await params . onAgentEvent ?.( {
4810+ stream : "compaction" ,
4811+ data : { phase : "end" , completed : true } ,
4812+ } ) ;
4813+ throw new Error ( "LLM idle timeout (120s): no response from model" ) ;
4814+ } ) ;
4815+ state . runWithModelFallbackMock . mockImplementationOnce ( async ( params : FallbackRunnerParams ) => {
4816+ try {
4817+ await params . run ( "openai-codex" , "gpt-5.4" ) ;
4818+ } catch {
4819+ // Swallow the per-candidate error; the fallback layer reports the
4820+ // aggregate summary below, mirroring the production failure shape.
4821+ }
4822+ throw Object . assign (
4823+ new Error (
4824+ "All models failed (3): openai-codex/gpt-5.4: LLM request timed out. (timeout) | " +
4825+ "anthropic/claude-opus-4-7: Provider anthropic has billing issue (billing) | " +
4826+ "anthropic/claude-sonnet-4-7: Provider anthropic has billing issue (billing)" ,
4827+ ) ,
4828+ {
4829+ name : "FallbackSummaryError" ,
4830+ attempts : [
4831+ {
4832+ provider : "openai-codex" ,
4833+ model : "gpt-5.4" ,
4834+ error : "LLM request timed out." ,
4835+ reason : "timeout" ,
4836+ } ,
4837+ {
4838+ provider : "anthropic" ,
4839+ model : "claude-opus-4-7" ,
4840+ error : "Provider anthropic has billing issue" ,
4841+ reason : "billing" ,
4842+ } ,
4843+ {
4844+ provider : "anthropic" ,
4845+ model : "claude-sonnet-4-7" ,
4846+ error : "Provider anthropic has billing issue" ,
4847+ reason : "billing" ,
4848+ } ,
4849+ ] ,
4850+ soonestCooldownExpiry : null ,
4851+ } ,
4852+ ) ;
4853+ } ) ;
4854+
4855+ const runAgentTurnWithFallback = await getRunAgentTurnWithFallback ( ) ;
4856+ const result = await runAgentTurnWithFallback ( {
4857+ commandBody : "hello" ,
4858+ followupRun : createFollowupRun ( ) ,
4859+ sessionCtx : {
4860+ Provider : "whatsapp" ,
4861+ MessageSid : "msg" ,
4862+ } as unknown as TemplateContext ,
4863+ opts : { } ,
4864+ typingSignals : createMockTypingSignaler ( ) ,
4865+ blockReplyPipeline : null ,
4866+ blockStreamingEnabled : false ,
4867+ resolvedBlockStreamingBreak : "message_end" ,
4868+ applyReplyToMode : ( payload ) => payload ,
4869+ shouldEmitToolResult : ( ) => true ,
4870+ shouldEmitToolOutput : ( ) => false ,
4871+ pendingToolTasks : new Set ( ) ,
4872+ resetSessionAfterRoleOrderingConflict : async ( ) => false ,
4873+ isHeartbeat : false ,
4874+ sessionKey : "main" ,
4875+ getActiveSessionEntry : ( ) => undefined ,
4876+ resolvedVerboseLevel : "off" ,
4877+ } ) ;
4878+
4879+ expect ( result . kind ) . toBe ( "final" ) ;
4880+ if ( result . kind === "final" ) {
4881+ // Compaction context preserved.
4882+ expect ( result . payload . text ) . toContain ( "Auto-compaction succeeded" ) ;
4883+ // Retried turn cause preserved (billing was the resolved cause-specific copy).
4884+ expect ( result . payload . text ) . toContain ( "billing" ) ;
4885+ // Per-attempt summary preserved (timeout + billing skips).
4886+ expect ( result . payload . text ) . toContain ( "openai-codex/gpt-5.4 timed out" ) ;
4887+ expect ( result . payload . text ) . toContain ( "anthropic/claude-opus-4-7 skipped (billing)" ) ;
4888+ // Not the bare GENERIC_RUN_FAILURE_TEXT.
4889+ expect ( result . payload . text ) . not . toBe ( GENERIC_RUN_FAILURE_TEXT ) ;
4890+ // Not the unwrapped BILLING_ERROR_USER_MESSAGE on its own.
4891+ expect ( result . payload . text ) . not . toBe ( "billing" ) ;
4892+ }
4893+ } ) ;
4894+
4895+ it ( "surfaces post-compaction context for plain failures without a fallback summary" , async ( ) => {
4896+ // Plain (non-FallbackSummaryError) failure after a successful compaction.
4897+ // The generic fallback copy would normally erase compaction history; with
4898+ // the post-compaction wrap, the user still learns compaction succeeded and
4899+ // gets specific next-step guidance instead of just "/new".
4900+ state . runEmbeddedAgentMock . mockImplementationOnce ( async ( params : EmbeddedAgentParams ) => {
4901+ await params . onAgentEvent ?.( { stream : "compaction" , data : { phase : "start" } } ) ;
4902+ await params . onAgentEvent ?.( {
4903+ stream : "compaction" ,
4904+ data : { phase : "end" , completed : true } ,
4905+ } ) ;
4906+ throw new Error ( "INVALID_ARGUMENT: some opaque post-compaction failure" ) ;
4907+ } ) ;
4908+
4909+ const runAgentTurnWithFallback = await getRunAgentTurnWithFallback ( ) ;
4910+ const result = await runAgentTurnWithFallback ( {
4911+ ...createMinimalRunAgentTurnParams ( ) ,
4912+ } ) ;
4913+
4914+ expect ( result . kind ) . toBe ( "final" ) ;
4915+ if ( result . kind === "final" ) {
4916+ expect ( result . payload . text ) . toContain ( "Auto-compaction succeeded" ) ;
4917+ // Generic-runner-failure path → guidance, not bare /new fallback.
4918+ expect ( result . payload . text ) . toContain (
4919+ "The context was compacted but no candidate could finish the turn" ,
4920+ ) ;
4921+ expect ( result . payload . text ) . not . toBe ( GENERIC_RUN_FAILURE_TEXT ) ;
4922+ }
4923+ } ) ;
4924+
4925+ it ( "does not add post-compaction context when no compaction succeeded" , async ( ) => {
4926+ // Regression guard: ordinary unknown error without a prior successful
4927+ // compaction must still produce the generic fallback copy.
4928+ state . runEmbeddedAgentMock . mockRejectedValueOnce (
4929+ new Error ( "INVALID_ARGUMENT: some other failure" ) ,
4930+ ) ;
4931+
4932+ const runAgentTurnWithFallback = await getRunAgentTurnWithFallback ( ) ;
4933+ const result = await runAgentTurnWithFallback ( {
4934+ ...createMinimalRunAgentTurnParams ( ) ,
4935+ } ) ;
4936+
4937+ expect ( result . kind ) . toBe ( "final" ) ;
4938+ if ( result . kind === "final" ) {
4939+ expect ( result . payload . text ) . toBe ( GENERIC_RUN_FAILURE_TEXT ) ;
4940+ expect ( result . payload . text ) . not . toContain ( "Auto-compaction succeeded" ) ;
4941+ }
4942+ } ) ;
4943+
48014944 it ( "surfaces gateway restart text when fallback exhaustion wraps a drain error" , async ( ) => {
48024945 const { replyOperation, failMock } = createMockReplyOperation ( ) ;
48034946 state . runWithModelFallbackMock . mockRejectedValueOnce (
0 commit comments