@@ -2267,6 +2267,106 @@ describe("AgentServer HTTP Mode", () => {
22672267 expect ( prompt ) . toHaveBeenCalledTimes ( 4 ) ;
22682268 } , 20000 ) ;
22692269
2270+ it ( "steers an active turn without emitting a separate turn completion" , async ( ) => {
2271+ const s = createServer ( ) ;
2272+ await s . start ( ) ;
2273+ const prompt = vi . fn ( async ( ) => ( {
2274+ stopReason : "end_turn" ,
2275+ _meta : { steer : true } ,
2276+ } ) ) ;
2277+ const broadcastTurnComplete = vi . fn ( ) ;
2278+ const resetTurnMessages = vi . fn ( ) ;
2279+ const serverInternals = s as unknown as {
2280+ activeOwnedTurnCount : number ;
2281+ broadcastTurnComplete : typeof broadcastTurnComplete ;
2282+ session : {
2283+ clientConnection : { prompt : typeof prompt } ;
2284+ logWriter : { resetTurnMessages : typeof resetTurnMessages } ;
2285+ } ;
2286+ } ;
2287+ serverInternals . activeOwnedTurnCount = 1 ;
2288+ serverInternals . broadcastTurnComplete = broadcastTurnComplete ;
2289+ serverInternals . session . clientConnection . prompt = prompt ;
2290+ serverInternals . session . logWriter . resetTurnMessages = resetTurnMessages ;
2291+
2292+ const response = await fetch ( `http://localhost:${ port } /command` , {
2293+ method : "POST" ,
2294+ headers : {
2295+ Authorization : `Bearer ${ createToken ( ) } ` ,
2296+ "Content-Type" : "application/json" ,
2297+ } ,
2298+ body : JSON . stringify ( {
2299+ jsonrpc : "2.0" ,
2300+ id : "steer-1" ,
2301+ method : "user_message" ,
2302+ params : { content : "change direction" , steer : true } ,
2303+ } ) ,
2304+ } ) ;
2305+
2306+ expect ( response . status ) . toBe ( 200 ) ;
2307+ await expect ( response . json ( ) ) . resolves . toMatchObject ( {
2308+ result : { stopReason : "steered" , steered : true } ,
2309+ } ) ;
2310+ expect ( prompt ) . toHaveBeenCalledWith (
2311+ expect . objectContaining ( {
2312+ _meta : expect . objectContaining ( { steer : true } ) ,
2313+ } ) ,
2314+ ) ;
2315+ expect ( broadcastTurnComplete ) . not . toHaveBeenCalled ( ) ;
2316+ expect ( resetTurnMessages ) . not . toHaveBeenCalled ( ) ;
2317+ } , 20000 ) ;
2318+
2319+ it ( "declines steering without blocking on a fallback normal turn" , async ( ) => {
2320+ const s = createServer ( ) ;
2321+ await s . start ( ) ;
2322+ const prompt = vi . fn ( ) ;
2323+ const broadcastTurnComplete = vi . fn ( ) ;
2324+ const resetTurnMessages = vi . fn ( ) ;
2325+ const serverInternals = s as unknown as {
2326+ activeOwnedTurnCount : number ;
2327+ broadcastTurnComplete : typeof broadcastTurnComplete ;
2328+ session : {
2329+ clientConnection : { prompt : typeof prompt } ;
2330+ logWriter : { resetTurnMessages : typeof resetTurnMessages } ;
2331+ } ;
2332+ } ;
2333+ serverInternals . activeOwnedTurnCount = 1 ;
2334+ prompt . mockImplementationOnce ( async ( ) => {
2335+ serverInternals . activeOwnedTurnCount = 0 ;
2336+ return { stopReason : "end_turn" , _meta : { steer : false } } ;
2337+ } ) ;
2338+ serverInternals . broadcastTurnComplete = broadcastTurnComplete ;
2339+ serverInternals . session . clientConnection . prompt = prompt ;
2340+ serverInternals . session . logWriter . resetTurnMessages = resetTurnMessages ;
2341+
2342+ const response = await fetch ( `http://localhost:${ port } /command` , {
2343+ method : "POST" ,
2344+ headers : {
2345+ Authorization : `Bearer ${ createToken ( ) } ` ,
2346+ "Content-Type" : "application/json" ,
2347+ } ,
2348+ body : JSON . stringify ( {
2349+ jsonrpc : "2.0" ,
2350+ id : "steer-race" ,
2351+ method : "user_message" ,
2352+ params : { content : "continue normally" , steer : true } ,
2353+ } ) ,
2354+ } ) ;
2355+
2356+ expect ( response . status ) . toBe ( 200 ) ;
2357+ await expect ( response . json ( ) ) . resolves . toMatchObject ( {
2358+ result : { stopReason : "steer_declined" , steered : false } ,
2359+ } ) ;
2360+ expect ( prompt ) . toHaveBeenCalledTimes ( 1 ) ;
2361+ expect ( prompt . mock . calls [ 0 ] ?. [ 0 ] ) . toEqual (
2362+ expect . objectContaining ( {
2363+ _meta : expect . objectContaining ( { steer : true } ) ,
2364+ } ) ,
2365+ ) ;
2366+ expect ( resetTurnMessages ) . not . toHaveBeenCalled ( ) ;
2367+ expect ( broadcastTurnComplete ) . not . toHaveBeenCalled ( ) ;
2368+ } , 20000 ) ;
2369+
22702370 it ( "redelivers a messageId whose first delivery failed before producing a turn" , async ( ) => {
22712371 const s = createServer ( ) ;
22722372 await s . start ( ) ;
@@ -2307,6 +2407,163 @@ describe("AgentServer HTTP Mode", () => {
23072407 expect ( prompt ) . toHaveBeenCalledTimes ( 2 ) ;
23082408 } , 20000 ) ;
23092409
2410+ it ( "keeps a recoverable delivery committed across an ambiguous retry" , async ( ) => {
2411+ const s = createServer ( ) ;
2412+ await s . start ( ) ;
2413+ const prompt = vi
2414+ . fn ( )
2415+ . mockRejectedValue ( new Error ( "API Error: The operation timed out." ) ) ;
2416+ const serverInternals = s as unknown as {
2417+ session : { clientConnection : { prompt : typeof prompt } } ;
2418+ } ;
2419+ serverInternals . session . clientConnection . prompt = prompt ;
2420+
2421+ const token = createToken ( ) ;
2422+ const send = async ( requestId : string ) =>
2423+ fetch ( `http://localhost:${ port } /command` , {
2424+ method : "POST" ,
2425+ headers : {
2426+ Authorization : `Bearer ${ token } ` ,
2427+ "Content-Type" : "application/json" ,
2428+ } ,
2429+ body : JSON . stringify ( {
2430+ jsonrpc : "2.0" ,
2431+ id : requestId ,
2432+ method : "user_message" ,
2433+ params : {
2434+ content : "do the thing" ,
2435+ messageId : "m-recoverable" ,
2436+ } ,
2437+ } ) ,
2438+ } ) ;
2439+
2440+ const first = await send ( "first-attempt" ) ;
2441+ await expect ( first . json ( ) ) . resolves . toMatchObject ( {
2442+ result : { stopReason : "error_recoverable" } ,
2443+ } ) ;
2444+ expect ( prompt ) . toHaveBeenCalledTimes ( 1 ) ;
2445+
2446+ const retry = await send ( "ambiguous-retry" ) ;
2447+ await expect ( retry . json ( ) ) . resolves . toMatchObject ( {
2448+ result : { stopReason : "duplicate_delivery" , duplicate : true } ,
2449+ } ) ;
2450+ expect ( prompt ) . toHaveBeenCalledTimes ( 1 ) ;
2451+ } , 20000 ) ;
2452+
2453+ it ( "shares a failed in-flight messageId outcome with concurrent retries" , async ( ) => {
2454+ const s = createServer ( ) ;
2455+ await s . start ( ) ;
2456+ let rejectFirstDelivery ! : ( error : Error ) => void ;
2457+ const prompt = vi
2458+ . fn ( )
2459+ . mockImplementationOnce (
2460+ ( ) =>
2461+ new Promise ( ( _resolve , reject ) => {
2462+ rejectFirstDelivery = reject ;
2463+ } ) ,
2464+ )
2465+ . mockResolvedValueOnce ( { stopReason : "end_turn" } ) ;
2466+ const serverInternals = s as unknown as {
2467+ logger : { info : ( ...args : unknown [ ] ) => void } ;
2468+ session : { clientConnection : { prompt : typeof prompt } } ;
2469+ } ;
2470+ serverInternals . session . clientConnection . prompt = prompt ;
2471+ const infoLog = vi . spyOn ( serverInternals . logger , "info" ) ;
2472+
2473+ const token = createToken ( ) ;
2474+ const send = async ( requestId : string ) =>
2475+ fetch ( `http://localhost:${ port } /command` , {
2476+ method : "POST" ,
2477+ headers : {
2478+ Authorization : `Bearer ${ token } ` ,
2479+ "Content-Type" : "application/json" ,
2480+ } ,
2481+ body : JSON . stringify ( {
2482+ jsonrpc : "2.0" ,
2483+ id : requestId ,
2484+ method : "user_message" ,
2485+ params : { content : "do the thing" , messageId : "m-concurrent" } ,
2486+ } ) ,
2487+ } ) ;
2488+
2489+ const firstResponse = send ( "first-attempt" ) ;
2490+ await vi . waitFor ( ( ) => expect ( prompt ) . toHaveBeenCalledTimes ( 1 ) ) ;
2491+
2492+ let retrySettled = false ;
2493+ const retryResponse = send ( "concurrent-retry" ) . finally ( ( ) => {
2494+ retrySettled = true ;
2495+ } ) ;
2496+ await vi . waitFor ( ( ) => {
2497+ expect ( infoLog ) . toHaveBeenCalledWith (
2498+ "Awaiting in-flight user_message delivery" ,
2499+ { messageId : "m-concurrent" } ,
2500+ ) ;
2501+ expect ( prompt ) . toHaveBeenCalledTimes ( 1 ) ;
2502+ expect ( retrySettled ) . toBe ( false ) ;
2503+ } ) ;
2504+
2505+ rejectFirstDelivery ( new Error ( "sdk connection lost" ) ) ;
2506+ const [ first , retry ] = await Promise . all ( [ firstResponse , retryResponse ] ) ;
2507+ await expect ( first . json ( ) ) . resolves . toMatchObject ( {
2508+ error : { message : "sdk connection lost" } ,
2509+ } ) ;
2510+ await expect ( retry . json ( ) ) . resolves . toMatchObject ( {
2511+ error : { message : "sdk connection lost" } ,
2512+ } ) ;
2513+ expect ( prompt ) . toHaveBeenCalledTimes ( 1 ) ;
2514+ } , 20000 ) ;
2515+
2516+ it ( "keeps an accepted messageId committed when teardown clears the active session" , async ( ) => {
2517+ const s = createServer ( ) ;
2518+ await s . start ( ) ;
2519+ let finishPrompt ! : ( result : { stopReason : "end_turn" } ) => void ;
2520+ const prompt = vi . fn (
2521+ ( ) =>
2522+ new Promise < { stopReason : "end_turn" } > ( ( resolve ) => {
2523+ finishPrompt = resolve ;
2524+ } ) ,
2525+ ) ;
2526+ const serverInternals = s as unknown as {
2527+ session : { clientConnection : { prompt : typeof prompt } } | null ;
2528+ } ;
2529+ const acceptedSession = serverInternals . session ;
2530+ if ( ! acceptedSession ) throw new Error ( "expected active test session" ) ;
2531+ acceptedSession . clientConnection . prompt = prompt ;
2532+
2533+ const token = createToken ( ) ;
2534+ const send = async ( requestId : string ) =>
2535+ fetch ( `http://localhost:${ port } /command` , {
2536+ method : "POST" ,
2537+ headers : {
2538+ Authorization : `Bearer ${ token } ` ,
2539+ "Content-Type" : "application/json" ,
2540+ } ,
2541+ body : JSON . stringify ( {
2542+ jsonrpc : "2.0" ,
2543+ id : requestId ,
2544+ method : "user_message" ,
2545+ params : { content : "do the thing" , messageId : "m-teardown" } ,
2546+ } ) ,
2547+ } ) ;
2548+
2549+ const firstResponse = send ( "first-attempt" ) ;
2550+ await vi . waitFor ( ( ) => expect ( prompt ) . toHaveBeenCalledTimes ( 1 ) ) ;
2551+
2552+ serverInternals . session = null ;
2553+ finishPrompt ( { stopReason : "end_turn" } ) ;
2554+ const first = await firstResponse ;
2555+ await expect ( first . json ( ) ) . resolves . toMatchObject ( {
2556+ result : { stopReason : "end_turn" } ,
2557+ } ) ;
2558+
2559+ serverInternals . session = acceptedSession ;
2560+ const retry = await send ( "retry" ) ;
2561+ await expect ( retry . json ( ) ) . resolves . toMatchObject ( {
2562+ result : { stopReason : "duplicate_delivery" , duplicate : true } ,
2563+ } ) ;
2564+ expect ( prompt ) . toHaveBeenCalledTimes ( 1 ) ;
2565+ } , 20000 ) ;
2566+
23102567 // Shared plumbing for the relay-echo tests: install a controllable
23112568 // prompt, stub the log writer so relayAgentResponse has an answer to
23122569 // relay, and spy on the relay_message client call.
@@ -2447,6 +2704,7 @@ describe("AgentServer HTTP Mode", () => {
24472704 expect ( runStarted ?. notification ?. params ) . toMatchObject ( {
24482705 runId : "test-run-id" ,
24492706 taskId : "test-task-id" ,
2707+ steering : "native" ,
24502708 } ) ;
24512709 // Agent reports its semver so clients can gate UI features
24522710 // against agent capabilities (e.g. `>=0.40.1`). The exact value
0 commit comments