77 type CreateSessionInput ,
88 createSession ,
99 getSession ,
10+ interruptSession ,
1011 listSessionEvents ,
1112 openSessionStream ,
1213 sendSessionEvents ,
@@ -59,6 +60,39 @@ export interface RunManagedAgentResult {
5960}
6061
6162type Terminal = { status : 'complete' | 'error' ; reason ?: string }
63+ /** Result of handling one event: an optional terminal signal, plus whether the event must be retried. */
64+ type HandleResult = { terminal ?: Terminal ; retry ?: boolean }
65+
66+ /**
67+ * The most recent lifecycle event (`session.status_*` / `session.error`) in a
68+ * chronological history, or `undefined`. The events list is authoritative and
69+ * ordered, so the last such event reflects the session's current state — used
70+ * to recompute the pending-action state without depending on the order events
71+ * happened to arrive across the live stream and catch-up.
72+ */
73+ function findLastLifecycleEvent (
74+ history : AnthropicSessionEvent [ ]
75+ ) : AnthropicSessionEvent | undefined {
76+ for ( let i = history . length - 1 ; i >= 0 ; i -- ) {
77+ const type = history [ i ] . type
78+ if ( type && ( type . startsWith ( 'session.status_' ) || type === 'session.error' ) ) {
79+ return history [ i ]
80+ }
81+ }
82+ return undefined
83+ }
84+
85+ /** Best-effort `user.interrupt` for a session Sim is abandoning (cancel / cap). Never throws. */
86+ async function interruptQuietly ( apiKey : string , sessionId : string ) : Promise < void > {
87+ try {
88+ await interruptSession ( { apiKey, sessionId } )
89+ } catch ( err ) {
90+ logger . warn ( 'Failed to interrupt managed agent session on cancel' , {
91+ sessionId,
92+ error : getErrorMessage ( err ) ,
93+ } )
94+ }
95+ }
6296
6397export async function runManagedAgentSession (
6498 input : RunManagedAgentInput
@@ -128,7 +162,6 @@ export async function runManagedAgentSession(
128162 let terminal : Terminal | null = null
129163
130164 const process = async ( event : AnthropicSessionEvent ) : Promise < boolean > => {
131- if ( event . id ) seenIds . add ( event . id )
132165 if (
133166 event . type === 'agent.message' ||
134167 event . type === 'agent.custom_tool_use' ||
@@ -143,8 +176,12 @@ export async function runManagedAgentSession(
143176 requiresActionOutstanding = true
144177 }
145178 const outcome = await handleEvent ( { event, assistantText, apiKey, sessionId, signal } )
146- if ( outcome ) {
147- terminal = outcome
179+ // Mark the event seen only once fully handled. A custom-tool reply that
180+ // failed to send stays unseen so the next catch-up retries it instead of
181+ // stranding the session on an unanswered requires_action pause.
182+ if ( event . id && ! outcome . retry ) seenIds . add ( event . id )
183+ if ( outcome . terminal ) {
184+ terminal = outcome . terminal
148185 return true
149186 }
150187 return false
@@ -154,6 +191,9 @@ export async function runManagedAgentSession(
154191 for ( let iteration = 0 ; iteration < MAX_RECONNECT_ITERATIONS && ! terminal ; iteration ++ ) {
155192 if ( signal ?. aborted ) break
156193 if ( Date . now ( ) - startedAt > MAX_SESSION_MS ) {
194+ // Giving up on a session that may still be running — stop it so it
195+ // does not keep consuming the workspace key past our cap.
196+ await interruptQuietly ( apiKey , sessionId )
157197 terminal = {
158198 status : 'error' ,
159199 reason : `Session did not reach a terminal state within ${ Math . floor ( MAX_SESSION_MS / 1000 ) } s.` ,
@@ -191,6 +231,20 @@ export async function runManagedAgentSession(
191231 }
192232 if ( terminal || signal ?. aborted ) break
193233
234+ // Recompute the pending-action state from the chronological history,
235+ // which is authoritative and ordered. This overrides any out-of-order
236+ // flag flip made while processing catch-up events — an older
237+ // `agent.message` recovered after the live stream must not clear a newer
238+ // `requires_action` pause and let a still-waiting session report done.
239+ // Falls back to the stream-tracked flag when the history carries no
240+ // lifecycle events (e.g. status changes not persisted to the list).
241+ const lastLifecycle = findLastLifecycleEvent ( history )
242+ if ( lastLifecycle ) {
243+ requiresActionOutstanding =
244+ lastLifecycle . type === 'session.status_idle' &&
245+ lastLifecycle . stop_reason ?. type === 'requires_action'
246+ }
247+
194248 // Still no terminal event. Consult the authoritative session status: a
195249 // finished session reports `idle`/`terminated`; a working one reports
196250 // `running`. `idle` counts as complete only once the agent has actually
@@ -216,6 +270,7 @@ export async function runManagedAgentSession(
216270 }
217271 } catch ( error ) {
218272 if ( signal ?. aborted ) {
273+ await interruptQuietly ( apiKey , sessionId )
219274 return { ok : false , content : assistantText . value , sessionId, error : 'aborted' }
220275 }
221276 logger . error ( 'Managed agent stream failed' , { sessionId, error : getErrorMessage ( error ) } )
@@ -228,6 +283,7 @@ export async function runManagedAgentSession(
228283 }
229284
230285 if ( signal ?. aborted ) {
286+ await interruptQuietly ( apiKey , sessionId )
231287 return { ok : false , content : assistantText . value , sessionId, error : 'aborted' }
232288 }
233289 if ( ! terminal || terminal . status === 'error' ) {
@@ -260,7 +316,7 @@ async function handleEvent(args: {
260316 apiKey : string
261317 sessionId : string
262318 signal ?: AbortSignal
263- } ) : Promise < Terminal | null > {
319+ } ) : Promise < HandleResult > {
264320 const { event, assistantText, apiKey, sessionId, signal } = args
265321
266322 if ( event . type === 'agent.message' ) {
@@ -271,7 +327,7 @@ async function handleEvent(args: {
271327 }
272328 }
273329 }
274- return null
330+ return { }
275331 }
276332
277333 if ( event . type === 'agent.custom_tool_use' ) {
@@ -281,7 +337,7 @@ async function handleEvent(args: {
281337 logger . warn ( 'Managed Agent custom_tool_use arrived without an id — skipping reply' , {
282338 sessionId,
283339 } )
284- return null
340+ return { }
285341 }
286342 logger . warn (
287343 `Managed Agent invoked a custom tool "${ event . name ?? '<unknown>' } " that Sim does not provide — replying with error`
@@ -310,27 +366,37 @@ async function handleEvent(args: {
310366 sessionId,
311367 error : getErrorMessage ( err ) ,
312368 } )
369+ // Leave the event unseen so the next catch-up retries the reply rather
370+ // than stranding the session on this unanswered tool call.
371+ return { retry : true }
313372 }
314- return null
373+ return { }
315374 }
316375
317376 if ( event . type === 'session.status_terminated' ) {
318- return { status : 'error' , reason : event . error ?. message ?? 'session_terminated' }
377+ return { terminal : { status : 'error' , reason : event . error ?. message ?? 'session_terminated' } }
319378 }
320379
321380 if ( event . type === 'session.status_idle' ) {
322381 const stop = event . stop_reason ?. type
323382 // `requires_action` is not terminal — the session is paused for a pending
324383 // tool call and will emit a terminal event once it resolves.
325- if ( stop === 'requires_action' ) return null
326- if ( stop === 'retries_exhausted' ) return { status : 'error' , reason : 'retries_exhausted' }
384+ if ( stop === 'requires_action' ) return { }
385+ if ( stop === 'retries_exhausted' ) {
386+ return { terminal : { status : 'error' , reason : 'retries_exhausted' } }
387+ }
327388 // `end_turn` (or an unspecified idle) means the agent finished its turn.
328- return { status : 'complete' }
389+ return { terminal : { status : 'complete' } }
329390 }
330391
331392 if ( event . type === 'session.error' ) {
332- return { status : 'error' , reason : event . error ?. message ?? event . message ?? 'session_error' }
393+ return {
394+ terminal : {
395+ status : 'error' ,
396+ reason : event . error ?. message ?? event . message ?? 'session_error' ,
397+ } ,
398+ }
333399 }
334400
335- return null
401+ return { }
336402}
0 commit comments