4040 "AI_WORKSTATION_API_BASE_URL" ,
4141 "TOPIC_RADAR_BASE_URL" ,
4242)
43+ PASSING_TRACE_INTEGRITY_STATUSES = {
44+ "complete_clean" ,
45+ "complete_after_recovery" ,
46+ }
47+ TOOL_ITEM_TYPES = {
48+ "command_execution" ,
49+ "file_change" ,
50+ "image_generation" ,
51+ "mcp_tool_call" ,
52+ "web_search" ,
53+ }
54+ STREAM_DISCONNECT_MARKERS = (
55+ "responsestreamdisconnected" ,
56+ "response_stream_disconnected" ,
57+ "response stream disconnected" ,
58+ "stream disconnected" ,
59+ "stream closed before response.completed" ,
60+ )
61+ STREAM_DISCONNECT_CODES = {
62+ "responsestreamdisconnected" ,
63+ "responsestreamdisconnect" ,
64+ "streamdisconnected" ,
65+ "streamdisconnect" ,
66+ }
4367
4468
4569class HostEvalError (RuntimeError ):
@@ -303,6 +327,167 @@ def trace_text(stdout: str, stderr: str) -> str:
303327 return "\n " .join (collected )
304328
305329
330+ def _is_stream_disconnect_event (event : Mapping [str , Any ]) -> bool :
331+ """Recognize Codex response-stream interruptions, not helper/network errors."""
332+
333+ if event .get ("type" ) != "error" :
334+ return False
335+
336+ # Codex versions have emitted both a structured ``codexErrorInfo.code``
337+ # and a human-readable reconnect message. Prefer the structured code when
338+ # present, while retaining the known message forms for older launchers.
339+ def codes (value : Any ) -> Iterable [str ]:
340+ if isinstance (value , Mapping ):
341+ for key , nested in value .items ():
342+ normalized_key = str (key ).lower ().replace ("-" , "_" )
343+ if normalized_key in {"code" , "error_code" , "codex_error_code" }:
344+ if isinstance (nested , str ):
345+ yield nested
346+ yield from codes (nested )
347+ elif isinstance (value , list ):
348+ for nested in value :
349+ yield from codes (nested )
350+
351+ for code in codes (event ):
352+ normalized = "" .join (char for char in code .lower () if char .isalnum ())
353+ if normalized in STREAM_DISCONNECT_CODES :
354+ return True
355+
356+ text = "\n " .join (_collect_strings (event )).lower ().replace ("-" , "_" )
357+ return any (marker in text for marker in STREAM_DISCONNECT_MARKERS )
358+
359+
360+ def analyze_jsonl_trace (
361+ stdout : str ,
362+ * ,
363+ exit_code : int | None ,
364+ runtime_status : str ,
365+ timed_out : bool ,
366+ stdout_truncated : bool ,
367+ stderr_truncated : bool ,
368+ worktree_clean_after : bool ,
369+ ) -> dict [str , Any ]:
370+ """Classify one Codex JSONL lifecycle from observable process evidence."""
371+
372+ events : list [Mapping [str , Any ]] = []
373+ invalid_jsonl_line_count = 0
374+ for line in stdout .splitlines ():
375+ stripped = line .strip ()
376+ if not stripped :
377+ continue
378+ try :
379+ value = json .loads (stripped )
380+ except json .JSONDecodeError :
381+ invalid_jsonl_line_count += 1
382+ continue
383+ if not isinstance (value , Mapping ):
384+ invalid_jsonl_line_count += 1
385+ continue
386+ events .append (value )
387+
388+ turn_started_positions : list [int ] = []
389+ turn_completed_positions : list [int ] = []
390+ turn_failed_count = 0
391+ error_event_count = 0
392+ stream_disconnect_event_count = 0
393+ non_stream_error_event_count = 0
394+ started_items : dict [str , tuple [int , str ]] = {}
395+ completed_item_ids : set [str ] = set ()
396+ tool_activity_positions : list [int ] = []
397+ final_agent_message_position : int | None = None
398+
399+ for position , event in enumerate (events ):
400+ event_type = str (event .get ("type" ) or "" )
401+ item_value = event .get ("item" )
402+ item = item_value if isinstance (item_value , Mapping ) else {}
403+ item_type = str (item .get ("type" ) or "" )
404+ item_id_value = item .get ("id" )
405+ item_id = str (item_id_value ) if item_id_value not in {None , "" } else ""
406+
407+ if event_type == "turn.started" :
408+ turn_started_positions .append (position )
409+ elif event_type == "turn.completed" :
410+ turn_completed_positions .append (position )
411+ elif event_type == "turn.failed" :
412+ turn_failed_count += 1
413+ elif event_type == "error" :
414+ error_event_count += 1
415+ if _is_stream_disconnect_event (event ):
416+ stream_disconnect_event_count += 1
417+ else :
418+ non_stream_error_event_count += 1
419+
420+ if event_type == "item.started" :
421+ key = item_id or f"<missing-id@{ position } >"
422+ started_items [key ] = (position , item_type )
423+ elif event_type == "item.completed" and item_id :
424+ completed_item_ids .add (item_id )
425+
426+ if item_type in TOOL_ITEM_TYPES and event_type in {"item.started" , "item.completed" }:
427+ tool_activity_positions .append (position )
428+ if event_type == "item.completed" and item_type == "agent_message" :
429+ final_agent_message_position = position
430+
431+ incomplete_item_ids = sorted (
432+ item_id for item_id in started_items if item_id not in completed_item_ids
433+ )
434+ tool_activity_after_final_agent_message = bool (
435+ final_agent_message_position is not None
436+ and any (position > final_agent_message_position for position in tool_activity_positions )
437+ )
438+ last_event_type = str (events [- 1 ].get ("type" ) or "" ) if events else None
439+ lifecycle_complete = bool (
440+ exit_code == 0
441+ and runtime_status == "completed"
442+ and not timed_out
443+ and not stdout_truncated
444+ and not stderr_truncated
445+ and invalid_jsonl_line_count == 0
446+ and len (turn_started_positions ) == 1
447+ and len (turn_completed_positions ) == 1
448+ and turn_failed_count == 0
449+ and non_stream_error_event_count == 0
450+ and not incomplete_item_ids
451+ and final_agent_message_position is not None
452+ and not tool_activity_after_final_agent_message
453+ and turn_completed_positions [0 ] > final_agent_message_position
454+ and last_event_type == "turn.completed"
455+ and worktree_clean_after
456+ )
457+ stream_disconnect_observed = stream_disconnect_event_count > 0
458+ stream_recovered = lifecycle_complete and stream_disconnect_observed
459+ stream_terminal_failure = stream_disconnect_observed and not stream_recovered
460+ if lifecycle_complete :
461+ trace_integrity_status = (
462+ "complete_after_recovery"
463+ if stream_disconnect_observed
464+ else "complete_clean"
465+ )
466+ else :
467+ trace_integrity_status = "incomplete_or_failed"
468+
469+ return {
470+ "jsonl_event_count" : len (events ),
471+ "invalid_jsonl_line_count" : invalid_jsonl_line_count ,
472+ "turn_started_count" : len (turn_started_positions ),
473+ "turn_completed_count" : len (turn_completed_positions ),
474+ "turn_failed_count" : turn_failed_count ,
475+ "error_event_count" : error_event_count ,
476+ "stream_disconnect_event_count" : stream_disconnect_event_count ,
477+ "non_stream_error_event_count" : non_stream_error_event_count ,
478+ "final_agent_message_observed" : final_agent_message_position is not None ,
479+ "tool_activity_after_final_agent_message" : tool_activity_after_final_agent_message ,
480+ "incomplete_item_ids" : incomplete_item_ids ,
481+ "last_event_type" : last_event_type ,
482+ "trace_integrity_status" : trace_integrity_status ,
483+ "stream_disconnect_observed" : stream_disconnect_observed ,
484+ "stream_recovered" : stream_recovered ,
485+ "stream_terminal_failure" : stream_terminal_failure ,
486+ # Preserve the old diagnostic field as an observation, not a gate.
487+ "stream_disconnected" : stream_disconnect_observed ,
488+ }
489+
490+
306491def observe_tokens (text : str , expected_workflow : Sequence [str ]) -> dict [str , Any ]:
307492 observed_skills = [
308493 skill for skill in TOPIC_INTELLIGENCE_SKILLS if skill in text
@@ -343,7 +528,7 @@ def classify_observation(case: EvalCase, observation: Mapping[str, Any]) -> str:
343528def _result_is_gate_failure (result : Mapping [str , Any ], * , strict_observation : bool = False ) -> bool :
344529 return str (result .get ("runtime_status" )) != "completed" or str (result .get ("route_observation" )) in {
345530 "fail_unexpected_skill" , "fail_wrong_skill_observed" , "partial_workflow_observed"
346- } or bool ( result .get ("stream_disconnected" )) or result .get ("worktree_clean_after" ) is False or (strict_observation and str (result .get ("route_observation" )) in {"unobservable" , "fail_unobservable" })
531+ } or result .get ("trace_integrity_status" ) not in PASSING_TRACE_INTEGRITY_STATUSES or result .get ("worktree_clean_after" ) is not True or (strict_observation and str (result .get ("route_observation" )) in {"unobservable" , "fail_unobservable" })
347532
348533
349534def _truncate (text : str , limit : int ) -> tuple [str , bool ]:
@@ -389,6 +574,7 @@ def run_case(
389574 "stdout_truncated" : False ,
390575 "stderr_truncated" : False ,
391576 "worktree_clean_after" : None ,
577+ "trace_integrity_status" : "not_run" ,
392578 }
393579
394580 start = time .monotonic ()
@@ -430,15 +616,14 @@ def run_case(
430616 stdout , stdout_truncated = _truncate (stdout , max_output_chars )
431617 stderr , stderr_truncated = _truncate (stderr , max_output_chars )
432618 clean_after = not _clean_worktree_status (cwd )
433- lowered_trace = searchable .lower ()
434- stream_disconnected = any (
435- marker in lowered_trace
436- for marker in (
437- "stream disconnected" ,
438- "stream disconnect" ,
439- "response stream disconnected" ,
440- "stream closed before response.completed" ,
441- )
619+ trace_integrity = analyze_jsonl_trace (
620+ stdout ,
621+ exit_code = exit_code ,
622+ runtime_status = runtime_status ,
623+ timed_out = timed_out ,
624+ stdout_truncated = stdout_truncated ,
625+ stderr_truncated = stderr_truncated ,
626+ worktree_clean_after = clean_after ,
442627 )
443628
444629 return {
@@ -461,7 +646,7 @@ def run_case(
461646 "stdout_truncated" : stdout_truncated ,
462647 "stderr_truncated" : stderr_truncated ,
463648 "worktree_clean_after" : clean_after ,
464- "stream_disconnected" : stream_disconnected ,
649+ ** trace_integrity ,
465650 }
466651
467652
0 commit comments