defmodule Codex.Items do @moduledoc """ Typed representations of thread items emitted by the Codex runtime. This module provides helpers to convert JSON-style maps (with string keys) into structs and back, keeping status fields normalised as atoms. """ alias __MODULE__.{ AgentMessage, CollabAgentToolCall, Compaction, CommandExecution, ContextCompaction, DynamicToolCall, Error, FileChange, GhostSnapshot, ImageGeneration, ImageView, McpToolCall, Plan, RawResponseItem, Reasoning, ReviewMode, TodoList, UserMessage, WebSearch } alias Codex.Protocol.CollabAgentState @type t :: AgentMessage.t() | Plan.t() | Reasoning.t() | CommandExecution.t() | FileChange.t() | UserMessage.t() | ImageView.t() | ReviewMode.t() | McpToolCall.t() | DynamicToolCall.t() | CollabAgentToolCall.t() | WebSearch.t() | ImageGeneration.t() | TodoList.t() | Error.t() | ContextCompaction.t() | GhostSnapshot.t() | Compaction.t() | RawResponseItem.t() defmodule AgentMessage do @moduledoc """ Assistant-authored message item emitted by the Codex runtime, with optional parsed payloads for structured output experiments. """ @enforce_keys [:text] defstruct id: nil, type: :agent_message, text: nil, parsed: nil, phase: nil @type t :: %__MODULE__{ id: String.t() | nil, type: :agent_message, text: String.t(), parsed: map() | list() | nil, phase: String.t() | nil } end defmodule Plan do @moduledoc """ Planning item emitted when the agent publishes a plan as a first-class thread item. """ @enforce_keys [:text] defstruct id: nil, type: :plan, text: nil @type t :: %__MODULE__{ id: String.t() | nil, type: :plan, text: String.t() } end defmodule Reasoning do @moduledoc """ Intermediate reasoning trace shared as part of tool or agent transparency. """ @enforce_keys [:text] defstruct id: nil, type: :reasoning, text: nil, summary: [], content: [] @type t :: %__MODULE__{ id: String.t() | nil, type: :reasoning, text: String.t(), summary: [String.t()], content: [String.t()] } end defmodule CommandExecution do @moduledoc """ Captures an execution request made by the agent, including aggregated output and status metadata. """ @enforce_keys [:command] defstruct id: nil, type: :command_execution, command: nil, cwd: nil, process_id: nil, command_actions: [], aggregated_output: "", exit_code: nil, status: :in_progress, duration_ms: nil @type status :: :in_progress | :completed | :failed | :declined @type t :: %__MODULE__{ id: String.t() | nil, type: :command_execution, command: String.t(), cwd: String.t() | nil, process_id: String.t() | nil, command_actions: [map()], aggregated_output: String.t(), exit_code: integer() | nil, status: status(), duration_ms: integer() | nil } end defmodule FileChange do @moduledoc """ Represents a file diff emitted by the agent, including per-path change metadata and completion status. """ @enforce_keys [:changes, :status] defstruct id: nil, type: :file_change, changes: [], status: :completed @type change_kind :: :add | :delete | :update @type change :: %{ required(:path) => String.t(), required(:kind) => change_kind(), optional(:diff) => String.t(), optional(:move_path) => String.t() | nil } @type status :: :in_progress | :completed | :failed | :declined @type t :: %__MODULE__{ id: String.t() | nil, type: :file_change, changes: [change()], status: status() } end defmodule McpToolCall do @moduledoc """ Metadata describing a tool invocation routed through an MCP server. """ @enforce_keys [:server, :tool] defstruct id: nil, type: :mcp_tool_call, server: nil, tool: nil, arguments: nil, result: nil, error: nil, status: :in_progress, duration_ms: nil @type status :: :in_progress | :completed | :failed @type t :: %__MODULE__{ id: String.t() | nil, type: :mcp_tool_call, server: String.t(), tool: String.t(), arguments: map() | list() | nil, result: map() | nil, error: map() | nil, status: status(), duration_ms: integer() | nil } end defmodule DynamicToolCall do @moduledoc """ Metadata describing a dynamic tool invocation handled outside the static tool registry. """ @enforce_keys [:tool] defstruct id: nil, type: :dynamic_tool_call, tool: nil, arguments: nil, status: :in_progress, content_items: nil, success: nil, duration_ms: nil @type status :: :in_progress | :completed | :failed @type t :: %__MODULE__{ id: String.t() | nil, type: :dynamic_tool_call, tool: String.t(), arguments: map() | list() | String.t() | nil, status: status(), content_items: [map()] | nil, success: boolean() | nil, duration_ms: integer() | nil } end defmodule CollabAgentToolCall do @moduledoc """ Represents a collaboration tool call between agents. """ @enforce_keys [:tool, :sender_thread_id] defstruct id: nil, type: :collab_agent_tool_call, tool: nil, tool_kind: :unknown, status: :in_progress, sender_thread_id: nil, receiver_thread_ids: [], prompt: nil, model: nil, reasoning_effort: nil, agents_states: %{} @type status :: :in_progress | :completed | :failed @type t :: %__MODULE__{ id: String.t() | nil, type: :collab_agent_tool_call, tool: String.t(), tool_kind: :spawn_agent | :send_input | :resume_agent | :wait | :close_agent | :unknown, status: status(), sender_thread_id: String.t(), receiver_thread_ids: [String.t()], prompt: String.t() | nil, model: String.t() | nil, reasoning_effort: String.t() | atom() | nil, agents_states: %{optional(String.t()) => CollabAgentState.t()} } end defmodule UserMessage do @moduledoc """ User-authored message item carrying a list of input blocks. """ @enforce_keys [:content] defstruct id: nil, type: :user_message, content: [] @type t :: %__MODULE__{ id: String.t() | nil, type: :user_message, content: [map()] } end defmodule ImageView do @moduledoc """ An image view event emitted by the app-server when it renders a local image. """ @enforce_keys [:path] defstruct id: nil, type: :image_view, path: nil @type t :: %__MODULE__{ id: String.t() | nil, type: :image_view, path: String.t() } end defmodule ImageGeneration do @moduledoc """ Image generation result item emitted by the app-server. """ @enforce_keys [:status, :result] defstruct id: nil, type: :image_generation, status: nil, revised_prompt: nil, result: nil, saved_path: nil @type t :: %__MODULE__{ id: String.t() | nil, type: :image_generation, status: String.t(), revised_prompt: String.t() | nil, result: String.t(), saved_path: String.t() | nil } end defmodule ReviewMode do @moduledoc """ Indicates that review mode has been entered or exited. """ @enforce_keys [:review] defstruct id: nil, type: :review_mode, entered: true, review: "" @type t :: %__MODULE__{ id: String.t() | nil, type: :review_mode, entered: boolean(), review: String.t() } end defmodule WebSearch do @moduledoc """ Records a web search request issued by the agent, preserving the original query. """ @enforce_keys [:query] defstruct id: nil, type: :web_search, query: nil, action: nil @type t :: %__MODULE__{ id: String.t() | nil, type: :web_search, query: String.t(), action: String.t() | map() | nil } end defmodule TodoList do @moduledoc """ Structured checklist shared by the agent to track outstanding follow-up items. """ @enforce_keys [:items] defstruct id: nil, type: :todo_list, items: [] @type todo_item :: %{text: String.t(), completed: boolean()} @type t :: %__MODULE__{ id: String.t() | nil, type: :todo_list, items: [todo_item()] } end defmodule Error do @moduledoc """ Normalised error record describing failures surfaced during a turn. """ @enforce_keys [:message] defstruct id: nil, type: :error, message: nil @type t :: %__MODULE__{ id: String.t() | nil, type: :error, message: String.t() } end defmodule ContextCompaction do @moduledoc """ Marker item emitted when context compaction occurred. """ defstruct id: nil, type: :context_compaction @type t :: %__MODULE__{ id: String.t() | nil, type: :context_compaction } end defmodule GhostSnapshot do @moduledoc """ Raw response item describing a ghost snapshot captured for undo. """ @enforce_keys [:ghost_commit] defstruct id: nil, type: :ghost_snapshot, ghost_commit: nil @type t :: %__MODULE__{ id: String.t() | nil, type: :ghost_snapshot, ghost_commit: map() } end defmodule Compaction do @moduledoc """ Raw response item emitted when compaction summaries are generated. """ @enforce_keys [:encrypted_content] defstruct id: nil, type: :compaction, encrypted_content: nil @type t :: %__MODULE__{ id: String.t() | nil, type: :compaction, encrypted_content: String.t() } end defmodule RawResponseItem do @moduledoc """ Fallback container for unparsed raw response items. """ @enforce_keys [:type, :payload] defstruct type: nil, payload: %{} @type t :: %__MODULE__{ type: String.t(), payload: map() } end @command_status_map %{ "in_progress" => :in_progress, "completed" => :completed, "failed" => :failed, "declined" => :declined } @file_change_status_map %{ "in_progress" => :in_progress, "completed" => :completed, "failed" => :failed, "declined" => :declined } @file_change_kind_map %{ "add" => :add, "delete" => :delete, "update" => :update } @mcp_status_map %{ "in_progress" => :in_progress, "completed" => :completed, "failed" => :failed } @dynamic_tool_status_map %{ "in_progress" => :in_progress, "completed" => :completed, "failed" => :failed } @collab_tool_status_map %{ "in_progress" => :in_progress, "completed" => :completed, "failed" => :failed } @doc """ Parses a JSON-decoded map into a typed thread item struct. """ @spec parse!(map()) :: t() def parse!(%{"type" => "agent_message"} = map), do: parse_agent_message(map) def parse!(%{"type" => "plan"} = map), do: parse_plan(map) def parse!(%{"type" => "reasoning"} = map), do: parse_reasoning(map) def parse!(%{"type" => "command_execution"} = map), do: parse_command_execution(map) def parse!(%{"type" => "file_change"} = map), do: parse_file_change(map) def parse!(%{"type" => "user_message"} = map), do: parse_user_message(map) def parse!(%{"type" => "image_view"} = map), do: parse_image_view(map) def parse!(%{"type" => "review_mode"} = map), do: parse_review_mode(map) def parse!(%{"type" => "mcp_tool_call"} = map), do: parse_mcp_tool_call(map) def parse!(%{"type" => "dynamic_tool_call"} = map), do: parse_dynamic_tool_call(map) def parse!(%{"type" => "collab_agent_tool_call"} = map), do: parse_collab_agent_tool_call(map) def parse!(%{"type" => "web_search"} = map), do: parse_web_search(map) def parse!(%{"type" => "image_generation"} = map), do: parse_image_generation(map) def parse!(%{"type" => "todo_list"} = map), do: parse_todo_list(map) def parse!(%{"type" => "error"} = map), do: parse_error(map) def parse!(%{"type" => "context_compaction"} = map), do: parse_context_compaction(map) def parse!(%{"type" => type} = map) when type in ["ghost_snapshot", "compaction"] do {:ok, item} = parse_raw_response_item(map) item end def parse!(%{"type" => "compaction_summary"} = map) do {:ok, item} = parse_raw_response_item(map) item end def parse!(%{type: type} = map), do: parse!(Map.put(map, "type", type)) def parse!(%{"type" => other}) do raise ArgumentError, "unsupported thread item type #{inspect(other)}" end def parse!(value) do raise ArgumentError, "expected thread item map, got: #{inspect(value)}" end @doc """ Converts a typed item struct back into its JSON-serialisable map representation. """ @spec to_map(t()) :: map() def to_map(%AgentMessage{} = item) do base_item_map(item, "agent_message") |> maybe_put("text", item.text) |> maybe_put("phase", item.phase) end def to_map(%Plan{} = item) do base_item_map(item, "plan") |> maybe_put("text", item.text) end def to_map(%Reasoning{} = item) do base_item_map(item, "reasoning") |> maybe_put("text", item.text) |> maybe_put("summary", item.summary) |> maybe_put("content", item.content) end def to_map(%CommandExecution{} = item) do base_item_map(item, "command_execution") |> maybe_put("command", item.command) |> maybe_put("cwd", item.cwd) |> maybe_put("process_id", item.process_id) |> maybe_put("command_actions", item.command_actions) |> maybe_put("aggregated_output", item.aggregated_output) |> maybe_put("exit_code", item.exit_code) |> maybe_put("status", status_to_string(item.status, @command_status_map)) |> maybe_put("duration_ms", item.duration_ms) end def to_map(%FileChange{} = item) do base_item_map(item, "file_change") |> maybe_put("status", status_to_string(item.status, @file_change_status_map)) |> maybe_put( "changes", Enum.map(item.changes, fn %{path: path, kind: kind} = change -> %{ "path" => path, "kind" => kind_to_string(kind, @file_change_kind_map) } |> maybe_put("diff", Map.get(change, :diff)) |> maybe_put("move_path", Map.get(change, :move_path)) end) ) end def to_map(%UserMessage{} = item) do base_item_map(item, "user_message") |> maybe_put("content", item.content) end def to_map(%ImageView{} = item) do base_item_map(item, "image_view") |> maybe_put("path", item.path) end def to_map(%ReviewMode{} = item) do base_item_map(item, "review_mode") |> maybe_put("entered", item.entered) |> maybe_put("review", item.review) end def to_map(%McpToolCall{} = item) do base_item_map(item, "mcp_tool_call") |> maybe_put("server", item.server) |> maybe_put("tool", item.tool) |> maybe_put("arguments", item.arguments) |> maybe_put("result", item.result) |> maybe_put("error", item.error) |> maybe_put("status", status_to_string(item.status, @mcp_status_map)) |> maybe_put("duration_ms", item.duration_ms) end def to_map(%DynamicToolCall{} = item) do base_item_map(item, "dynamic_tool_call") |> maybe_put("tool", item.tool) |> maybe_put("arguments", item.arguments) |> maybe_put("status", status_to_string(item.status, @dynamic_tool_status_map)) |> maybe_put("content_items", item.content_items) |> maybe_put("success", item.success) |> maybe_put("duration_ms", item.duration_ms) end def to_map(%CollabAgentToolCall{} = item) do base_item_map(item, "collab_agent_tool_call") |> maybe_put("tool", item.tool) |> maybe_put("status", status_to_string(item.status, @collab_tool_status_map)) |> maybe_put("sender_thread_id", item.sender_thread_id) |> maybe_put("receiver_thread_ids", item.receiver_thread_ids) |> maybe_put("prompt", item.prompt) |> maybe_put("model", item.model) |> maybe_put("reasoning_effort", item.reasoning_effort) |> maybe_put("agents_states", encode_collab_agent_states(item.agents_states)) end def to_map(%WebSearch{} = item) do base_item_map(item, "web_search") |> maybe_put("query", item.query) |> maybe_put("action", item.action) end def to_map(%ImageGeneration{} = item) do base_item_map(item, "image_generation") |> maybe_put("status", item.status) |> maybe_put("revised_prompt", item.revised_prompt) |> maybe_put("result", item.result) |> maybe_put("saved_path", item.saved_path) end def to_map(%TodoList{} = item) do base_item_map(item, "todo_list") |> maybe_put( "items", Enum.map(item.items, fn %{text: text, completed: completed} -> %{"text" => text, "completed" => completed} end) ) end def to_map(%Error{} = item) do base_item_map(item, "error") |> maybe_put("message", item.message) end def to_map(%ContextCompaction{} = item) do base_item_map(item, "context_compaction") end def to_map(%GhostSnapshot{} = item) do %{"type" => "ghost_snapshot"} |> maybe_put("id", item.id) |> maybe_put("ghost_commit", item.ghost_commit) end def to_map(%Compaction{} = item) do %{"type" => "compaction"} |> maybe_put("id", item.id) |> maybe_put("encrypted_content", item.encrypted_content) end def to_map(%RawResponseItem{} = item) do payload = if is_map(item.payload), do: item.payload, else: %{} Map.put(payload, "type", item.type) end @doc """ Parses a raw response item (snake_case response output) into a typed struct. """ @spec parse_raw_response_item(map()) :: {:ok, t()} | {:error, term()} def parse_raw_response_item(%{"type" => type} = map) when is_binary(type) do type |> String.trim() |> parse_raw_response_item(map) end def parse_raw_response_item(_), do: {:error, :invalid_raw_response_item} defp parse_raw_response_item("ghost_snapshot", map) do {:ok, %GhostSnapshot{ id: get(map, :id), ghost_commit: get(map, :ghost_commit) || get(map, :ghostCommit) || %{} }} end defp parse_raw_response_item(type, map) when type in ["compaction", "compaction_summary"] do {:ok, %Compaction{ id: get(map, :id), encrypted_content: get(map, :encrypted_content) || get(map, :encryptedContent) || "" }} end defp parse_raw_response_item(type, map) do {:ok, %RawResponseItem{type: type, payload: Map.delete(map, "type")}} end defp parse_agent_message(map) do %AgentMessage{ id: get(map, :id), text: get(map, :text) || "", parsed: get(map, :parsed), phase: get(map, :phase) } end defp parse_plan(map) do %Plan{ id: get(map, :id), text: get(map, :text) || "" } end defp parse_reasoning(map) do summary = normalize_reasoning_part(get(map, :summary)) content = normalize_reasoning_part(get(map, :content)) %Reasoning{ id: get(map, :id), text: get(map, :text) || join_reasoning_text(summary, content) || "", summary: summary, content: content } end defp parse_command_execution(map) do %CommandExecution{ id: get(map, :id), command: get(map, :command) || "", cwd: get(map, :cwd), process_id: get(map, :process_id), command_actions: get(map, :command_actions) || [], aggregated_output: get(map, :aggregated_output) || "", exit_code: get(map, :exit_code), status: parse_status(get(map, :status), @command_status_map, :in_progress), duration_ms: get(map, :duration_ms) } end defp parse_file_change(map) do %FileChange{ id: get(map, :id), status: parse_status(get(map, :status), @file_change_status_map, :completed), changes: map |> get(:changes, []) |> Enum.map(fn change -> %{ path: get(change, :path) || "", kind: parse_kind(get(change, :kind), @file_change_kind_map), diff: get(change, :diff), move_path: get(change, :move_path) } end) } end defp parse_user_message(map) do %UserMessage{ id: get(map, :id), content: get(map, :content) || [] } end defp parse_image_view(map) do %ImageView{ id: get(map, :id), path: get(map, :path) || "" } end defp parse_review_mode(map) do %ReviewMode{ id: get(map, :id), entered: !!get(map, :entered), review: get(map, :review) || "" } end defp parse_mcp_tool_call(map) do %McpToolCall{ id: get(map, :id), server: get(map, :server) || "", tool: get(map, :tool) || "", arguments: get(map, :arguments), result: get(map, :result), error: get(map, :error), status: parse_status(get(map, :status), @mcp_status_map, :in_progress), duration_ms: get(map, :duration_ms) } end defp parse_dynamic_tool_call(map) do %DynamicToolCall{ id: get(map, :id), tool: get(map, :tool) || "", arguments: get(map, :arguments), status: parse_status(get(map, :status), @dynamic_tool_status_map, :in_progress), content_items: get(map, :content_items), success: get(map, :success), duration_ms: get(map, :duration_ms) } end defp parse_collab_agent_tool_call(map) do tool = normalize_collab_tool(get(map, :tool)) %CollabAgentToolCall{ id: get(map, :id), tool: tool, tool_kind: normalize_collab_tool_kind(tool), status: parse_status(get(map, :status), @collab_tool_status_map, :in_progress), sender_thread_id: get(map, :sender_thread_id) || "", receiver_thread_ids: get(map, :receiver_thread_ids) || [], prompt: get(map, :prompt), model: get(map, :model), reasoning_effort: normalize_reasoning_effort(get(map, :reasoning_effort)), agents_states: normalize_collab_agent_states(get(map, :agents_states)) } end defp parse_web_search(map) do %WebSearch{ id: get(map, :id), query: get(map, :query) || "", action: get(map, :action) } end defp parse_image_generation(map) do %ImageGeneration{ id: get(map, :id), status: get(map, :status) || "", revised_prompt: get(map, :revised_prompt), result: get(map, :result) || "", saved_path: get(map, :saved_path) } end defp parse_todo_list(map) do %TodoList{ id: get(map, :id), items: map |> get(:items, []) |> Enum.map(fn item -> %{ text: get(item, :text) || "", completed: !!get(item, :completed) } end) } end defp parse_error(map) do %Error{ id: get(map, :id), message: get(map, :message) || "" } end defp parse_context_compaction(map) do %ContextCompaction{ id: get(map, :id) } end defp normalize_reasoning_part(nil), do: [] defp normalize_reasoning_part(list) when is_list(list), do: Enum.map(list, &to_string/1) defp normalize_reasoning_part(value) when is_binary(value), do: [value] defp normalize_reasoning_part(value), do: [to_string(value)] defp join_reasoning_text(summary, content) do parts = [] |> maybe_concat_lines(summary) |> maybe_concat_lines(content) if parts == [] do nil else Enum.join(parts, "\n") end end defp maybe_concat_lines(parts, lines) when is_list(lines) do parts ++ Enum.map(lines, &to_string/1) end defp base_item_map(item, type) do %{"type" => type} |> maybe_put("id", item.id) end defp parse_status(nil, _mapping, default), do: default defp parse_status(value, mapping, default) when is_atom(value) do value |> Atom.to_string() |> parse_status(mapping, default) end defp parse_status(value, mapping, default) when is_binary(value) do normalized = case value do "inProgress" -> "in_progress" other -> other end Map.get(mapping, normalized, default) end defp parse_status(value, _mapping, default) do value |> to_string() |> parse_status(%{}, default) end defp parse_kind(nil, mapping), do: mapping["update"] defp parse_kind(kind, mapping) when is_atom(kind) do parse_kind(Atom.to_string(kind), mapping) end defp parse_kind(kind, mapping) when is_binary(kind) do Map.get(mapping, kind, :update) end defp status_to_string(nil, _mapping), do: nil defp status_to_string(atom, _mapping) when is_atom(atom), do: Atom.to_string(atom) defp status_to_string(value, _mapping) when is_binary(value), do: value defp kind_to_string(nil, _mapping), do: "update" defp kind_to_string(atom, _mapping) when is_atom(atom), do: Atom.to_string(atom) defp kind_to_string(value, _mapping) when is_binary(value), do: value defp get(map, key), do: get(map, key, nil) defp get(map, key, default) when is_atom(key) do case Map.fetch(map, key) do {:ok, value} -> value :error -> get(map, Atom.to_string(key), default) end end defp get(map, key, default) when is_binary(key) do case Map.fetch(map, key) do {:ok, value} -> value :error -> fetch_atom_key(map, key, default) end end defp fetch_atom_key(map, key, default) do key |> String.to_existing_atom() |> then(&Map.get(map, &1, default)) rescue ArgumentError -> default end defp maybe_put(map, _key, nil), do: map defp maybe_put(map, key, value), do: Map.put(map, key, value) defp normalize_reasoning_effort(nil), do: nil defp normalize_reasoning_effort(value) do case Codex.Models.normalize_reasoning_effort(value) do {:ok, nil} -> nil {:ok, effort} -> effort _ -> value end end defp normalize_collab_agent_states(%{} = states) do states |> Enum.map(fn {thread_id, state} -> {to_string(thread_id), CollabAgentState.from_map(state)} end) |> Map.new() end defp normalize_collab_agent_states(_), do: %{} defp encode_collab_agent_states(%{} = states) do states |> Enum.map(fn {thread_id, state} -> {to_string(thread_id), encode_collab_agent_state(state)} end) |> Map.new() end defp encode_collab_agent_states(_), do: %{} defp encode_collab_agent_state(%CollabAgentState{} = state), do: CollabAgentState.to_map(state) defp encode_collab_agent_state(state), do: state defp normalize_collab_tool_kind("spawn"), do: :spawn_agent defp normalize_collab_tool_kind("spawnAgent"), do: :spawn_agent defp normalize_collab_tool_kind("spawn_agent"), do: :spawn_agent defp normalize_collab_tool_kind("sendInput"), do: :send_input defp normalize_collab_tool_kind("send_input"), do: :send_input defp normalize_collab_tool_kind("resumeAgent"), do: :resume_agent defp normalize_collab_tool_kind("resume_agent"), do: :resume_agent defp normalize_collab_tool_kind("wait"), do: :wait defp normalize_collab_tool_kind("closeAgent"), do: :close_agent defp normalize_collab_tool_kind("close_agent"), do: :close_agent defp normalize_collab_tool_kind(_), do: :unknown defp normalize_collab_tool(%{} = tool) do get(tool, :type) || get(tool, :name) || inspect(tool) end defp normalize_collab_tool(tool) when is_atom(tool), do: Atom.to_string(tool) defp normalize_collab_tool(tool) when is_binary(tool), do: tool defp normalize_collab_tool(_tool), do: "" end