@@ -74,6 +74,35 @@ def _extract_summary(text: str, max_len: int = 300) -> str:
7474async def convert_claude_code_to_agentex_events (
7575 lines : AsyncIterator [str | dict [str , Any ]],
7676 on_result : Callable [[dict [str , Any ]], Awaitable [None ]] | None = None ,
77+ on_init : Callable [[dict [str , Any ]], Awaitable [None ]] | None = None ,
78+ ) -> AsyncIterator [StreamTaskMessageStart | StreamTaskMessageDelta | StreamTaskMessageFull | StreamTaskMessageDone ]:
79+ """Public tap: convert a claude-code ``stream-json`` line stream to events.
80+
81+ Thin wrapper over :func:`_convert_claude_code_impl` that owns the
82+ cancellation backstop: a ``finally`` closes the underlying ``lines`` iterator
83+ (when it exposes ``aclose``) whenever this generator is closed — including on
84+ the ``GeneratorExit``/``CancelledError`` raised when a consuming task is
85+ cancelled mid-turn by an interrupt. This terminates the CLI stdout handle /
86+ subprocess instead of leaking it. Kept as a wrapper (rather than a
87+ ``try/finally`` inside the impl) so the large parser body stays untouched.
88+ """
89+ inner = _convert_claude_code_impl (lines , on_result = on_result , on_init = on_init )
90+ try :
91+ async for event in inner :
92+ yield event
93+ finally :
94+ inner_aclose = getattr (inner , "aclose" , None )
95+ if inner_aclose is not None :
96+ await inner_aclose ()
97+ aclose = getattr (lines , "aclose" , None )
98+ if aclose is not None :
99+ await aclose ()
100+
101+
102+ async def _convert_claude_code_impl (
103+ lines : AsyncIterator [str | dict [str , Any ]],
104+ on_result : Callable [[dict [str , Any ]], Awaitable [None ]] | None = None ,
105+ on_init : Callable [[dict [str , Any ]], Awaitable [None ]] | None = None ,
77106) -> AsyncIterator [StreamTaskMessageStart | StreamTaskMessageDelta | StreamTaskMessageFull | StreamTaskMessageDone ]:
78107 """Convert a claude-code ``stream-json`` line stream into Agentex ``StreamTaskMessage*`` events.
79108
@@ -85,7 +114,20 @@ async def convert_claude_code_to_agentex_events(
85114 caller can capture usage and cost. It is awaited before the generator
86115 continues. When ``None``, the result envelope is silently dropped.
87116
117+ ``on_init`` is called with the ``system``/``init`` envelope the moment it
118+ arrives (the FIRST envelope of a claude-code stream), so the caller can
119+ capture ``session_id`` EARLY — before the turn completes. This is what makes
120+ an interrupted-before-completion turn resumable: the terminal ``result``
121+ envelope (which ``on_result`` reads) never arrives when a turn is cut short,
122+ so relying on it alone loses the session id. Mirrors how the inline
123+ ``claude_agents`` activity captures session_id from its SystemMessage init.
124+ When ``None``, the init envelope's session metadata is not surfaced early.
125+
88126 Envelope → canonical mapping is documented in this module's docstring.
127+
128+ A ``finally`` closes the underlying ``lines`` iterator (when it exposes
129+ ``aclose``) as a backstop, so a cancellation mid-turn (e.g. an interrupt that
130+ cancels the consuming task) does not leak the CLI stdout handle / subprocess.
89131 """
90132 next_index = 0
91133 tool_call_count = 0
@@ -355,9 +397,14 @@ async def convert_claude_code_to_agentex_events(
355397 # system / init — session metadata (ignored at this layer)
356398 # -----------------------------------------------------------------------
357399 elif evt_type == "system" :
358- # Session ID tracking and MCP status logging are provider concerns.
359- # This pure parser layer intentionally emits nothing for system events.
360- pass
400+ # Session ID tracking and MCP status logging are provider concerns:
401+ # this pure parser layer emits no StreamTaskMessage for system events.
402+ # It DOES surface the init envelope's session metadata early via
403+ # on_init so a caller can capture session_id before the turn's
404+ # terminal ``result`` arrives — required for resuming a turn that was
405+ # interrupted before completion (no ``result`` is ever emitted then).
406+ if on_init is not None and evt .get ("subtype" ) == "init" :
407+ await on_init (evt )
361408
362409 # -----------------------------------------------------------------------
363410 # result — carries usage + cost; fired to on_result, not emitted as msgs
0 commit comments