Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
52 changes: 52 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -142,8 +142,28 @@ session = CodexWrapper.Session.new(config, model: "o3")

CodexWrapper.Session.turn_count(session)
#=> 2

turn = CodexWrapper.Session.last_turn(session)
# Also available as session.last_turn
turn.status
#=> :completed
turn.final_message
#=> "Added tests for lib/foo.ex"

CodexWrapper.Session.total_usage(session)
#=> %{input_tokens: 120, cached_input_tokens: 100, cache_write_input_tokens: nil,
# output_tokens: 7, reasoning_output_tokens: nil}
```

`send/3` keeps its raw `%Result{}` return value and result history. The updated
session exposes the typed JSON turn as `session.last_turn` or
`Session.last_turn/1`. `SessionServer.last_turn/1` and
`SessionServer.total_usage/1` provide the same access through a session server.
Usage totals sum reported nonnegative integers from recorded turns. A field
is `nil` if it was never reported; the whole total is `nil` if no recorded
turn included usage. Nonzero exits leave the session unchanged and do not
contribute to usage totals.

Session options are the exec options in the table under
[Exec options](#exec-options) and apply to every turn. Resumed turns run
`codex exec resume`, which rejects `--profile`, `--cd`, `--add-dir`,
Expand All @@ -153,6 +173,38 @@ Session options are the exec options in the table under
are emitted on resumed turns as `-c sandbox_mode="..."` and
`-c approval_policy="..."`.

## Typed turn results

`CodexWrapper.Turn.from_result/1` projects an existing `--json` result without
starting a process. `%Result{}` continues to hold the raw stdout, stderr and
exit code.

```elixir
config = CodexWrapper.Config.new()
exec = CodexWrapper.Exec.new("Summarize the changes") |> CodexWrapper.Exec.json()
{:ok, result} = CodexWrapper.Exec.execute(exec, config)
turn = CodexWrapper.Turn.from_result(result)

turn.status # :completed, :failed or :no_terminal
turn.final_message # last completed agent message from the last turn, or nil
turn.thread_id # thread.started identity, or nil
turn.usage # five token counters, or nil
turn.error # native error data, or nil
turn.events # parsed events, including unrecognized fields
```

The last `turn.completed` or `turn.failed` sets the native turn outcome. A
top-level `error` fails the turn if no terminal event follows it. A nonzero
process exit makes the projection `:failed`, including after a completion
event. `CodexWrapper.exec/2` and `CodexWrapper.review/1` still return
`{:ok, %Result{success: false}}` for a nonzero exit; runner errors return
`{:error, reason}`.

Usage has the keys `:input_tokens`, `:cached_input_tokens`,
`:cache_write_input_tokens`, `:output_tokens` and `:reasoning_output_tokens`.
Only nonnegative integers are retained; missing or invalid counters are
`nil`. Original values and any extra fields remain in `turn.events`.

## IEx REPL

Use Codex conversationally from IEx:
Expand Down
12 changes: 8 additions & 4 deletions lib/codex_wrapper.ex
Original file line number Diff line number Diff line change
Expand Up @@ -92,8 +92,10 @@ defmodule CodexWrapper do
Execute a prompt non-interactively and return the result.

Convenience wrapper that builds a `Config` and `Exec` from keyword options.
Returns `{:ok, %Result{}}` when codex ran, or `{:error, reason}` when it
could not be run or timed out.
Returns `{:ok, %Result{}}` when codex ran, including a nonzero exit:
`{:ok, %Result{success: false, exit_code: code}}`. Returns `{:error, reason}`
when it could not be run or timed out. For `--json` output, use
`CodexWrapper.Turn.from_result/1` to inspect the turn outcome.

## Options

Expand Down Expand Up @@ -202,8 +204,10 @@ defmodule CodexWrapper do
Run a code review via `codex exec review`.

Convenience wrapper that builds a `Config` and `Review` from keyword options.
Returns `{:ok, %Result{}}` when codex ran, or `{:error, reason}` when it
could not be run or timed out.
Returns `{:ok, %Result{}}` when codex ran, including a nonzero exit:
`{:ok, %Result{success: false, exit_code: code}}`. Returns `{:error, reason}`
when it could not be run or timed out. For `--json` output, use
`CodexWrapper.Turn.from_result/1` to inspect the turn outcome.

## Options

Expand Down
39 changes: 24 additions & 15 deletions lib/codex_wrapper/evidenced_execution.ex
Original file line number Diff line number Diff line change
@@ -1,6 +1,15 @@
defmodule CodexWrapper.EvidencedExecution do
@moduledoc false
alias CodexWrapper.{Command, Config, ExecutionObservation, Result, Runner, SessionObservation}
alias CodexWrapper.{
Command,
Config,
ExecutionObservation,
JsonLineEvent,
Result,
Runner,
SessionObservation,
Turn
}

@default_output 1_048_576
@default_timeout 300_000
Expand Down Expand Up @@ -138,7 +147,6 @@ defmodule CodexWrapper.EvidencedExecution do
session: session,
buffer: "",
dropping?: false,
parsed: nil,
stdout: "",
stderr: "",
bytes: 0,
Expand Down Expand Up @@ -298,13 +306,7 @@ defmodule CodexWrapper.EvidencedExecution do
%{state | session: nil}
end

parsed =
case Jason.decode(state.buffer) do
{:ok, %{"type" => type} = value} when type in ["turn.completed", "turn.failed"] -> value
_ -> state.parsed
end

%{state | buffer: "", parsed: parsed}
%{state | buffer: ""}
end

defp finish(state, {:returned, {outcome, evidence}}, execution) when is_map(evidence) do
Expand Down Expand Up @@ -348,7 +350,7 @@ defmodule CodexWrapper.EvidencedExecution do
outcome: outcome,
stdout: streams.stdout,
stderr: streams.stderr,
parsed_result: streams.parsed
parsed_result: parsed_result(streams.stdout)
})
)

Expand All @@ -366,7 +368,7 @@ defmodule CodexWrapper.EvidencedExecution do
outcome: {:error, {kind, reason}},
stdout: state.stdout,
stderr: state.stderr,
parsed_result: state.parsed,
parsed_result: parsed_result(state.stdout),
termination: {:runner_exception, kind}
})

Expand All @@ -386,12 +388,19 @@ defmodule CodexWrapper.EvidencedExecution do
defp parsed_result(stdout) do
stdout
|> :binary.split("\n", [:global])
|> Enum.reduce(nil, fn line, result ->
case Jason.decode(line) do
{:ok, %{"type" => type} = value} when type in ["turn.completed", "turn.failed"] -> value
_ -> result
|> Enum.filter(&(byte_size(&1) <= SessionObservation.max_line_bytes()))
|> Enum.flat_map(fn line ->
case JsonLineEvent.parse(line) do
{:ok, event} -> [event]
{:error, _} -> []
end
end)
|> Turn.from_events()
|> Map.fetch!(:terminal_event)
|> case do
%JsonLineEvent{data: data} -> data
nil -> nil
end
end

defp bounded_result({:ok, {_stdout, code, _stderr}}, streams),
Expand Down
6 changes: 4 additions & 2 deletions lib/codex_wrapper/execution_observation.ex
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,10 @@ defmodule CodexWrapper.ExecutionObservation do

Sent as `{reference, observation}` by the execution caller before returning.
A preceding `SessionObservation` establishes identity only. `:outcome` is the
legacy execution result before JSON/fork convenience projections; `:parsed_result` is the last bounded native
`turn.completed` or `turn.failed` JSON object, independent of process outcome.
legacy execution result before JSON/fork convenience projections.
`:parsed_result` is the last bounded native `turn.completed` or `turn.failed`
JSON object, selected through `CodexWrapper.Turn` independently of process
outcome. It retains the native field names and values, including usage.

`:child_status` and `:child_signal` are raw native reports, never inferred
from JSON, transport closure, or a pre-spawn failure. `:termination` records
Expand Down
78 changes: 62 additions & 16 deletions lib/codex_wrapper/session.ex
Original file line number Diff line number Diff line change
Expand Up @@ -20,23 +20,30 @@ defmodule CodexWrapper.Session do
CodexWrapper.Session.turns(session)
#=> [%Result{...}, %Result{...}]

# Inspect the typed turn and cumulative token usage
CodexWrapper.Session.last_turn(session)
#=> %CodexWrapper.Turn{status: :completed, ...}
CodexWrapper.Session.total_usage(session)

# Resume a previous session
session = CodexWrapper.Session.resume(config, "session-id-abc")
"""

alias CodexWrapper.Commands.Archive
alias CodexWrapper.{Config, Exec, ExecResume, JsonLineEvent, Options, Result, Telemetry}
alias CodexWrapper.{Config, Exec, ExecResume, JsonLineEvent, Options, Result, Telemetry, Turn}

@type t :: %__MODULE__{
config: Config.t(),
session_id: String.t() | nil,
history: [Result.t()],
last_turn: Turn.t() | nil,
exec_opts: keyword()
}

defstruct [
:config,
:session_id,
:last_turn,
history: [],
exec_opts: []
]
Expand Down Expand Up @@ -90,6 +97,11 @@ defmodule CodexWrapper.Session do
Internally uses `--json` and takes the session ID from the turn's
`thread.started` event.

The updated session exposes the typed projection as `session.last_turn`
and through `last_turn/1`. The returned result and history remain raw
`%Result{}` values. Inspect the projection's `status` for the JSON turn
outcome, which may be `:failed` even when codex exited 0.

Returns:

* `{:ok, session, result}` when codex exited 0. `result` carries the
Expand All @@ -108,9 +120,15 @@ defmodule CodexWrapper.Session do
| {:thread_mismatch, String.t(), String.t(), Result.t()}
| term()}
def send(%__MODULE__{} = session, prompt, opts \\ []) do
with {:ok, result, events} <- execute_turn(session, prompt, opts),
{:ok, session_id} <- next_session_id(session.session_id, events, result) do
updated = %{session | session_id: session_id, history: session.history ++ [result]}
with {:ok, result, turn} <- execute_turn(session, prompt, opts),
{:ok, session_id} <- next_session_id(session.session_id, turn.thread_id, result) do
updated = %{
session
| session_id: session_id,
history: session.history ++ [result],
last_turn: turn
}

{:ok, updated, result}
end
end
Expand Down Expand Up @@ -170,6 +188,34 @@ defmodule CodexWrapper.Session do
@spec total_cost(t()) :: float() | nil
def total_cost(%__MODULE__{}), do: nil

@doc """
Sum the reported token usage across recorded turns.

Returns `nil` when no turn reported a usage map. Otherwise returns the
five usage fields from `CodexWrapper.Turn`, summing only reported
nonnegative integers. A field is `nil` when no turn reported a valid
value for it; an explicitly reported zero remains zero. Missing values
are not estimates, so a total may cover fewer turns than the history.

Turns rejected by `send/3`, including nonzero exits, are not recorded
and do not contribute to these totals.
"""
@spec total_usage(t()) :: Turn.usage() | nil
def total_usage(%__MODULE__{history: history}) do
Enum.reduce(history, nil, fn result, total ->
result |> Turn.from_result() |> Map.fetch!(:usage) |> sum_usage(total)
end)
end

@doc """
Get the typed projection of the last recorded turn, if any.

Failed calls to `send/3` leave this projection unchanged, along with the
rest of the session. The raw result remains available via `last_result/1`.
"""
@spec last_turn(t()) :: Turn.t() | nil
def last_turn(%__MODULE__{last_turn: turn}), do: turn

@doc """
Get the last result, if any.
"""
Expand Down Expand Up @@ -267,7 +313,7 @@ defmodule CodexWrapper.Session do
end

defp turn_outcome({:ok, %Result{exit_code: 0} = result}),
do: {:ok, result, JsonLineEvent.parse_lines(result.stdout)}
do: {:ok, result, Turn.from_result(result)}

defp turn_outcome({:ok, %Result{exit_code: code} = result}),
do: {:error, {:exit, code, result}}
Expand All @@ -277,8 +323,8 @@ defmodule CodexWrapper.Session do
# A first turn adopts the reported thread. A resumed turn keeps its id when
# the turn reports none or the same one. A session resumed by name adopts
# the UUID codex reports; one resumed by UUID refuses a different id.
defp next_session_id(current, events, result) do
case {current, extract_session_id(events)} do
defp next_session_id(current, reported, result) do
case {current, reported} do
{current, nil} -> {:ok, current}
{nil, reported} -> {:ok, reported}
{same, same} -> {:ok, same}
Expand All @@ -302,16 +348,16 @@ defmodule CodexWrapper.Session do
# applies through `SessionObservation.parse/1`.
@doc false
@spec extract_session_id([JsonLineEvent.t()]) :: String.t() | nil
def extract_session_id(events) do
Enum.find_value(events, fn
%JsonLineEvent{event_type: "thread.started"} = event ->
thread_id(JsonLineEvent.get(event, "thread_id"))
def extract_session_id(events), do: Turn.from_events(events).thread_id

_event ->
nil
defp sum_usage(nil, total), do: total
defp sum_usage(usage, nil), do: usage

defp sum_usage(usage, total) do
Map.merge(total, usage, fn
_key, nil, value -> value
_key, value, nil -> value
_key, previous, value -> previous + value
end)
end

defp thread_id(id) when is_binary(id), do: if(String.trim(id) != "", do: id)
defp thread_id(_id), do: nil
end
31 changes: 30 additions & 1 deletion lib/codex_wrapper/session_server.ex
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ defmodule CodexWrapper.SessionServer do

use GenServer

alias CodexWrapper.{Config, Result, Session}
alias CodexWrapper.{Config, Result, Session, Turn}

@type server :: GenServer.server()

Expand Down Expand Up @@ -81,6 +81,9 @@ defmodule CodexWrapper.SessionServer do
returns: `{:error, {:exit, code, result}}` for a nonzero exit and
`{:error, {:thread_mismatch, expected, reported, result}}` for a
different thread ID. A failed turn leaves the session unchanged.

Use `last_turn/1` to inspect the typed JSON turn outcome and `total_usage/1`
to get reported usage totals after a successful call.
"""
@spec send_message(server(), String.t(), keyword()) ::
{:ok, Result.t()} | {:error, term()}
Expand Down Expand Up @@ -115,6 +118,24 @@ defmodule CodexWrapper.SessionServer do
GenServer.call(server, :total_cost)
end

@doc """
Sum reported token usage across recorded turns.

See `CodexWrapper.Session.total_usage/1` for missing-value semantics.
"""
@spec total_usage(server()) :: Turn.usage() | nil
def total_usage(server) do
GenServer.call(server, :total_usage)
end

@doc """
Get the typed projection of the last recorded turn, if any.
"""
@spec last_turn(server()) :: Turn.t() | nil
def last_turn(server) do
GenServer.call(server, :last_turn)
end

@doc """
Get the last result, if any.
"""
Expand Down Expand Up @@ -173,6 +194,14 @@ defmodule CodexWrapper.SessionServer do
{:reply, Session.total_cost(session), session}
end

def handle_call(:total_usage, _from, session) do
{:reply, Session.total_usage(session), session}
end

def handle_call(:last_turn, _from, session) do
{:reply, Session.last_turn(session), session}
end

def handle_call(:last_result, _from, session) do
{:reply, Session.last_result(session), session}
end
Expand Down
Loading
Loading