@@ -19,10 +19,12 @@ import {
1919 runTurn ,
2020 type SandboxAgentDeps ,
2121} from "../../src/engines/sandbox_agent.ts" ;
22+ import { SessionContinuityStore } from "../../src/engines/sandbox_agent/session-continuity.ts" ;
2223
2324interface FakeOptions {
2425 probeError ?: Error ;
2526 createOtelError ?: Error ;
27+ stopReasons ?: string [ ] ;
2628}
2729
2830/** One fake otel run per createOtel call, recording which updates it handled. */
@@ -40,9 +42,11 @@ function fakeHarness(options: FakeOptions = {}) {
4042 sandboxDisposed : 0 ,
4143 sessionDestroyed : 0 ,
4244 permissionReplies : [ ] as Array < { id : string ; reply : string } > ,
45+ appendedTurnIndexes : [ ] as number [ ] ,
4346 logs : [ ] as string [ ] ,
4447 runs : [ ] as FakeRun [ ] ,
4548 } ;
49+ const continuityStore = new SessionContinuityStore ( ) ;
4650
4751 // Captured session-lifetime handlers, so tests can fire events/permissions at any moment
4852 // (during a turn, between turns).
@@ -53,6 +57,7 @@ function fakeHarness(options: FakeOptions = {}) {
5357
5458 const session = {
5559 id : "session-1" ,
60+ agentSessionId : "agent-session-1" ,
5661 onEvent ( handler : ( event : any ) => void ) {
5762 captured . onEvent = handler ;
5863 } ,
@@ -65,7 +70,7 @@ function fakeHarness(options: FakeOptions = {}) {
6570 async prompt ( _blocks : any ) {
6671 calls . promptCount += 1 ;
6772 return {
68- stopReason : "complete" ,
73+ stopReason : options . stopReasons ?. [ calls . promptCount - 1 ] ?? "complete" ,
6974 usage : { inputTokens : 1 , outputTokens : 1 } ,
7075 } ;
7176 } ,
@@ -163,6 +168,11 @@ function fakeHarness(options: FakeOptions = {}) {
163168 return { kind : "deny" } as const ;
164169 } ,
165170 } ) ,
171+ sessionContinuityStore : continuityStore ,
172+ hydrateHarnessSessionFromDurable : async ( ) => { } ,
173+ appendSessionTurn : async ( _sessionId , _harness , turnIndex ) => {
174+ calls . appendedTurnIndexes . push ( turnIndex ) ;
175+ } ,
166176 } ;
167177
168178 return { calls, deps, captured } ;
@@ -173,6 +183,15 @@ const request: AgentRunRequest = {
173183 messages : [ { role : "user" , content : "hello" } ] ,
174184} ;
175185
186+ const continuityRequest : AgentRunRequest = {
187+ ...request ,
188+ sessionId : "sess-1" ,
189+ streamId : "stream-1" ,
190+ telemetry : {
191+ exporters : { otlp : { headers : { authorization : "ApiKey abc" } } } ,
192+ } as any ,
193+ } ;
194+
176195function updateEvent ( update : Record < string , unknown > ) {
177196 return { payload : { update } } ;
178197}
@@ -282,6 +301,93 @@ describe("acquireEnvironment / runTurn split", () => {
282301 } ) ;
283302} ) ;
284303
304+ describe ( "conversation turn indexes" , ( ) => {
305+ it ( "advances on every completed turn served by the same warm environment" , async ( ) => {
306+ const { calls, deps } = fakeHarness ( ) ;
307+ const acquired = await acquireEnvironment ( continuityRequest , deps ) ;
308+ assert . equal ( acquired . ok , true ) ;
309+ if ( ! acquired . ok ) return ;
310+
311+ for ( let turn = 0 ; turn < 4 ; turn += 1 ) {
312+ const result = await runTurn (
313+ acquired . env ,
314+ {
315+ ...continuityRequest ,
316+ messages : [ { role : "user" , content : "turn " + turn } ] ,
317+ } ,
318+ undefined ,
319+ undefined ,
320+ { continuation : turn > 0 } ,
321+ ) ;
322+ assert . equal ( result . ok , true ) ;
323+ }
324+
325+ assert . deepEqual ( calls . appendedTurnIndexes , [ 0 , 1 , 2 , 3 ] ) ;
326+ await acquired . env . destroy ( ) ;
327+ } ) ;
328+
329+ it ( "shares strictly increasing indexes across two environments for one session" , async ( ) => {
330+ const { calls, deps } = fakeHarness ( {
331+ stopReasons : [ "paused" , "complete" , "complete" , "complete" ] ,
332+ } ) ;
333+ const first = await acquireEnvironment ( continuityRequest , deps ) ;
334+ const second = await acquireEnvironment ( continuityRequest , deps ) ;
335+ assert . equal ( first . ok , true ) ;
336+ assert . equal ( second . ok , true ) ;
337+ if ( ! first . ok || ! second . ok ) return ;
338+
339+ const parked = await runTurn (
340+ first . env ,
341+ continuityRequest ,
342+ undefined ,
343+ undefined ,
344+ { } ,
345+ ) ;
346+ assert . equal ( parked . stopReason , "paused" ) ;
347+ await runTurn ( second . env , continuityRequest , undefined , undefined , { } ) ;
348+ await runTurn ( first . env , continuityRequest , undefined , undefined , {
349+ continuation : true ,
350+ } ) ;
351+ await runTurn ( second . env , continuityRequest , undefined , undefined , {
352+ continuation : true ,
353+ } ) ;
354+
355+ assert . deepEqual ( calls . appendedTurnIndexes , [ 0 , 1 , 2 ] ) ;
356+ await first . env . destroy ( ) ;
357+ await second . env . destroy ( ) ;
358+ } ) ;
359+
360+ it ( "does not consume an index until a paused conversation turn completes" , async ( ) => {
361+ const { calls, deps } = fakeHarness ( {
362+ stopReasons : [ "paused" , "complete" ] ,
363+ } ) ;
364+ const acquired = await acquireEnvironment ( continuityRequest , deps ) ;
365+ assert . equal ( acquired . ok , true ) ;
366+ if ( ! acquired . ok ) return ;
367+
368+ const parked = await runTurn (
369+ acquired . env ,
370+ continuityRequest ,
371+ undefined ,
372+ undefined ,
373+ { } ,
374+ ) ;
375+ assert . equal ( parked . stopReason , "paused" ) ;
376+ assert . deepEqual ( calls . appendedTurnIndexes , [ ] ) ;
377+
378+ const resumed = await runTurn (
379+ acquired . env ,
380+ continuityRequest ,
381+ undefined ,
382+ undefined ,
383+ { continuation : true } ,
384+ ) ;
385+ assert . equal ( resumed . ok , true ) ;
386+ assert . deepEqual ( calls . appendedTurnIndexes , [ 0 ] ) ;
387+ await acquired . env . destroy ( ) ;
388+ } ) ;
389+ } ) ;
390+
285391describe ( "session-lifetime listener demux (currentTurn swap)" , ( ) => {
286392 it ( "each turn's sink sees only its own events; between-turns events are dropped by decision" , async ( ) => {
287393 const { calls, deps, captured } = fakeHarness ( ) ;
0 commit comments