66 createSubAgentLoopGuardError ,
77 failureClassForStopReason ,
88 isProviderFailureClass ,
9+ isReplaySafeToolName ,
910 isSubAgentLoopGuardError ,
1011 recoveryFor ,
1112} from "./terminal-failure.js" ;
@@ -108,6 +109,7 @@ describe("recoveryFor", () => {
108109 failureClass : "provider_retryable" ,
109110 attempt : 1 ,
110111 followupTurn : false ,
112+ replaySafe : true ,
111113 } ) ,
112114 ) . toEqual ( { available : true , reason : "eligible" } ) ;
113115 } ) ;
@@ -120,7 +122,12 @@ describe("recoveryFor", () => {
120122 "error" ,
121123 ] as const ) ( "%s is not_retryable" , ( failureClass ) => {
122124 expect (
123- recoveryFor ( { failureClass, attempt : 1 , followupTurn : false } ) ,
125+ recoveryFor ( {
126+ failureClass,
127+ attempt : 1 ,
128+ followupTurn : false ,
129+ replaySafe : true ,
130+ } ) ,
124131 ) . toEqual ( { available : false , reason : "not_retryable" } ) ;
125132 } ) ;
126133
@@ -131,6 +138,7 @@ describe("recoveryFor", () => {
131138 attempt : 1 ,
132139 followupTurn : false ,
133140 cause : providerError ( "retryable" ) ,
141+ replaySafe : true ,
134142 } ) ,
135143 ) . toEqual ( { available : true , reason : "eligible" } ) ;
136144 expect (
@@ -139,6 +147,7 @@ describe("recoveryFor", () => {
139147 attempt : 1 ,
140148 followupTurn : false ,
141149 cause : createSubAgentLoopGuardError ( providerError ( "timeout" ) ) ,
150+ replaySafe : true ,
142151 } ) ,
143152 ) . toEqual ( { available : true , reason : "eligible" } ) ;
144153 } ) ;
@@ -150,6 +159,7 @@ describe("recoveryFor", () => {
150159 attempt : 1 ,
151160 followupTurn : false ,
152161 cause : providerError ( "credential_failure" ) ,
162+ replaySafe : true ,
153163 } ) ,
154164 ) . toEqual ( { available : false , reason : "not_retryable" } ) ;
155165 expect (
@@ -158,6 +168,7 @@ describe("recoveryFor", () => {
158168 attempt : 1 ,
159169 followupTurn : false ,
160170 cause : new Error ( "doom" ) ,
171+ replaySafe : true ,
161172 } ) ,
162173 ) . toEqual ( { available : false , reason : "not_retryable" } ) ;
163174 expect (
@@ -166,6 +177,7 @@ describe("recoveryFor", () => {
166177 attempt : 1 ,
167178 followupTurn : true ,
168179 cause : providerError ( "retryable" ) ,
180+ replaySafe : true ,
169181 } ) ,
170182 ) . toEqual ( { available : false , reason : "not_retryable" } ) ;
171183 expect (
@@ -174,6 +186,7 @@ describe("recoveryFor", () => {
174186 attempt : 2 ,
175187 followupTurn : false ,
176188 cause : providerError ( "retryable" ) ,
189+ replaySafe : true ,
177190 } ) ,
178191 ) . toEqual ( { available : false , reason : "recovery_exhausted" } ) ;
179192 } ) ;
@@ -184,14 +197,50 @@ describe("recoveryFor", () => {
184197 failureClass : "provider_retryable" ,
185198 attempt : 1 ,
186199 followupTurn : true ,
200+ replaySafe : true ,
187201 } ) ,
188202 ) . toEqual ( { available : false , reason : "not_retryable" } ) ;
189203 } ) ;
190204
205+ test ( "a retryable failure after a non-replay-safe call has side effects" , ( ) => {
206+ expect (
207+ recoveryFor ( {
208+ failureClass : "provider_retryable" ,
209+ attempt : 1 ,
210+ followupTurn : false ,
211+ replaySafe : false ,
212+ } ) ,
213+ ) . toEqual ( { available : false , reason : "side_effects_completed" } ) ;
214+ } ) ;
215+
216+ test ( "only read, search, and web read tools are replay-safe" , ( ) => {
217+ for ( const name of [ "read_file" , "read" , "grep" , "search_files" ] ) {
218+ expect ( isReplaySafeToolName ( name ) ) . toBe ( true ) ;
219+ }
220+ for ( const name of [ "web_fetch" , "web_search" ] ) {
221+ expect ( isReplaySafeToolName ( name ) ) . toBe ( true ) ;
222+ }
223+ for ( const name of [
224+ "write_file" ,
225+ "edit_file" ,
226+ "run_shell" ,
227+ "bash" ,
228+ "mcp__server__tool" ,
229+ "some_unknown_tool" ,
230+ ] ) {
231+ expect ( isReplaySafeToolName ( name ) ) . toBe ( false ) ;
232+ }
233+ } ) ;
234+
191235 test ( "a failed recovery attempt is exhausted whatever its class" , ( ) => {
192236 for ( const failureClass of [ "provider_retryable" , "error" ] as const ) {
193237 expect (
194- recoveryFor ( { failureClass, attempt : 2 , followupTurn : false } ) ,
238+ recoveryFor ( {
239+ failureClass,
240+ attempt : 2 ,
241+ followupTurn : false ,
242+ replaySafe : true ,
243+ } ) ,
195244 ) . toEqual ( { available : false , reason : "recovery_exhausted" } ) ;
196245 }
197246 } ) ;
0 commit comments