defmodule GenAgent.Backends.Codex.EventTranslator do @moduledoc """ Translates a list of `CodexWrapper.JsonLineEvent` values (the output of a single Codex turn) into a list of `GenAgent.Event` values. Unlike the Claude translator, this module works on the **whole turn at once** because the Codex backend does not use streaming: it calls `CodexWrapper.Exec.execute_json/2` (or its resume equivalent), which returns the full event list after the CLI has exited. This lets the translator do a stateful first pass to extract the `thread_id` (which the Codex CLI emits in its first `thread.started` event, not in the terminal event) and inject it into the `:result` event emitted at the end. ## Event mapping * `thread.started` -- captured for `thread_id`, no GenAgent event emitted directly. The captured id is threaded into the final `:result` event as `session_id`. * `turn.started` -- filtered. * `item.completed` with `item.type == "agent_message"` -- emits a `:text` event with the item's text content. * `item.completed` with `item.type == "tool_call"` or similar -- emits a `:tool_use` event. (Exact shape depends on what Codex surfaces; we pass the raw item through in `:data`.) * `turn.completed` -- emits a `:usage` event (if token counts are present) followed by a terminal `:result` event carrying the captured `thread_id` as `session_id`. * `error` or `turn.failed` -- emits a terminal `:error` event. * Unknown event types -- filtered. If a turn's event list contains no `turn.completed` and no error event, the translator emits nothing terminal. The state machine's `no_terminal_event` guard will then deliver `{:error, :no_terminal_event}` to the caller. """ alias CodexWrapper.JsonLineEvent alias GenAgent.Event @doc """ Translate a full turn's worth of events. """ @spec translate([JsonLineEvent.t()]) :: [Event.t()] def translate(events) when is_list(events) do thread_id = extract_thread_id(events) events |> Enum.flat_map(&translate_one(&1, thread_id)) end # --------------------------------------------------------------------------- # Per-event translation # --------------------------------------------------------------------------- defp translate_one(%JsonLineEvent{event_type: "thread.started"}, _thread_id), do: [] defp translate_one(%JsonLineEvent{event_type: "turn.started"}, _thread_id), do: [] defp translate_one( %JsonLineEvent{event_type: "item.completed", data: %{"item" => item}}, _thread_id ) when is_map(item) do translate_item(item) end defp translate_one( %JsonLineEvent{event_type: "turn.completed", data: data}, thread_id ) do usage_events = case extract_usage(data) do nil -> [] usage -> [Event.new(:usage, usage)] end # Intentionally omit :text. Codex's terminal event carries no # assembled text -- the agent's response arrives as earlier # `item.completed` -> `:text` events. `GenAgent.Response.from_events` # falls back to concatenating :text deltas when the :result event # has no :text key, which is exactly what we want. result_data = %{session_id: thread_id} |> drop_nil_values() usage_events ++ [Event.new(:result, result_data)] end defp translate_one( %JsonLineEvent{event_type: "turn.failed", data: data}, _thread_id ) do reason = data["error"] || data["message"] || :unknown [Event.new(:error, %{reason: reason, data: data})] end defp translate_one( %JsonLineEvent{event_type: "error", data: data}, _thread_id ) do reason = data["error"] || data["message"] || :unknown [Event.new(:error, %{reason: reason, data: data})] end defp translate_one(%JsonLineEvent{}, _thread_id), do: [] # --------------------------------------------------------------------------- # item.completed -- per item.type # --------------------------------------------------------------------------- defp translate_item(%{"type" => "agent_message", "text" => text}) when is_binary(text) do [Event.new(:text, %{text: text})] end defp translate_item(%{"type" => "tool_call"} = item) do [Event.new(:tool_use, item)] end defp translate_item(%{"type" => "tool_result"} = item) do [Event.new(:tool_result, item)] end defp translate_item(_), do: [] # --------------------------------------------------------------------------- # Helpers # --------------------------------------------------------------------------- defp extract_thread_id(events) do Enum.find_value(events, fn %JsonLineEvent{event_type: "thread.started", data: %{"thread_id" => tid}} -> tid _ -> nil end) end defp extract_usage(%{"usage" => %{} = usage}) do input = usage["input_tokens"] output = usage["output_tokens"] cached = usage["cached_input_tokens"] case {input, output} do {nil, nil} -> nil _ -> %{ input_tokens: input, output_tokens: output, cached_input_tokens: cached } |> drop_nil_values() end end defp extract_usage(_), do: nil defp drop_nil_values(map) do map |> Enum.reject(fn {_k, v} -> is_nil(v) end) |> Map.new() end end