From 27319baa42012a36c60df6dc299c4d9df2c89937 Mon Sep 17 00:00:00 2001 From: Josh Rotenberg Date: Sun, 5 Apr 2026 20:55:43 -0700 Subject: [PATCH 1/2] chore: add dialyxir --- .github/workflows/ci.yml | 4 ++++ mix.exs | 4 +++- mix.lock | 2 ++ 3 files changed, 9 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c934d8f..9e75d39 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -34,6 +34,7 @@ jobs: path: | deps _build + priv/plts key: mix-${{ matrix.elixir }}-${{ matrix.otp }}-${{ hashFiles('mix.lock') }} restore-keys: | mix-${{ matrix.elixir }}-${{ matrix.otp }}- @@ -50,6 +51,9 @@ jobs: - name: Credo run: mix credo --strict + - name: Dialyzer + run: mix dialyzer + - name: Run tests run: mix test diff --git a/mix.exs b/mix.exs index 9070a56..9e44da7 100644 --- a/mix.exs +++ b/mix.exs @@ -11,6 +11,7 @@ defmodule CodexWrapperEx.MixProject do elixir: "~> 1.19", start_permanent: Mix.env() == :prod, deps: deps(), + dialyzer: [plt_file: {:no_warn, "_build/dev/dialyxir_#{System.otp_release()}.plt"}], docs: docs(), package: package(), name: "CodexWrapper", @@ -28,7 +29,8 @@ defmodule CodexWrapperEx.MixProject do [ {:jason, "~> 1.4"}, {:ex_doc, "~> 0.35", only: :dev, runtime: false}, - {:credo, "~> 1.7", only: [:dev, :test], runtime: false} + {:credo, "~> 1.7", only: [:dev, :test], runtime: false}, + {:dialyxir, "~> 1.4", only: [:dev, :test], runtime: false} ] end diff --git a/mix.lock b/mix.lock index 1b57543..56479af 100644 --- a/mix.lock +++ b/mix.lock @@ -1,7 +1,9 @@ %{ "bunt": {:hex, :bunt, "1.0.0", "081c2c665f086849e6d57900292b3a161727ab40431219529f13c4ddcf3e7a44", [:mix], [], "hexpm", "dc5f86aa08a5f6fa6b8096f0735c4e76d54ae5c9fa2c143e5a1fc7c1cd9bb6b5"}, "credo": {:hex, :credo, "1.7.17", "f92b6aa5b26301eaa5a35e4d48ebf5aa1e7094ac00ae38f87086c562caf8a22f", [:mix], [{:bunt, "~> 0.2.1 or ~> 1.0", [hex: :bunt, repo: "hexpm", optional: false]}, {:file_system, "~> 0.2 or ~> 1.0", [hex: :file_system, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "1eb5645c835f0b6c9b5410f94b5a185057bcf6d62a9c2b476da971cde8749645"}, + "dialyxir": {:hex, :dialyxir, "1.4.7", "dda948fcee52962e4b6c5b4b16b2d8fa7d50d8645bbae8b8685c3f9ecb7f5f4d", [:mix], [{:erlex, ">= 0.2.8", [hex: :erlex, repo: "hexpm", optional: false]}], "hexpm", "b34527202e6eb8cee198efec110996c25c5898f43a4094df157f8d28f27d9efe"}, "earmark_parser": {:hex, :earmark_parser, "1.4.44", "f20830dd6b5c77afe2b063777ddbbff09f9759396500cdbe7523efd58d7a339c", [:mix], [], "hexpm", "4778ac752b4701a5599215f7030989c989ffdc4f6df457c5f36938cc2d2a2750"}, + "erlex": {:hex, :erlex, "0.2.8", "cd8116f20f3c0afe376d1e8d1f0ae2452337729f68be016ea544a72f767d9c12", [:mix], [], "hexpm", "9d66ff9fedf69e49dc3fd12831e12a8a37b76f8651dd21cd45fcf5561a8a7590"}, "ex_doc": {:hex, :ex_doc, "0.40.1", "67542e4b6dde74811cfd580e2c0149b78010fd13001fda7cfeb2b2c2ffb1344d", [:mix], [{:earmark_parser, "~> 1.4.44", [hex: :earmark_parser, repo: "hexpm", optional: false]}, {:makeup_c, ">= 0.1.0", [hex: :makeup_c, repo: "hexpm", optional: true]}, {:makeup_elixir, "~> 0.14 or ~> 1.0", [hex: :makeup_elixir, repo: "hexpm", optional: false]}, {:makeup_erlang, "~> 0.1 or ~> 1.0", [hex: :makeup_erlang, repo: "hexpm", optional: false]}, {:makeup_html, ">= 0.1.0", [hex: :makeup_html, repo: "hexpm", optional: true]}], "hexpm", "bcef0e2d360d93ac19f01a85d58f91752d930c0a30e2681145feea6bd3516e00"}, "file_system": {:hex, :file_system, "1.1.1", "31864f4685b0148f25bd3fbef2b1228457c0c89024ad67f7a81a3ffbc0bbad3a", [:mix], [], "hexpm", "7a15ff97dfe526aeefb090a7a9d3d03aa907e100e262a0f8f7746b78f8f87a5d"}, "jason": {:hex, :jason, "1.4.4", "b9226785a9aa77b6857ca22832cffa5d5011a667207eb2a0ad56adb5db443b8a", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "c5eb0cab91f094599f94d55bc63409236a8ec69a21a67814529e8d5f6cc90b3b"}, From 31686a49a5bd8081860d3fc0cae3b2fb9fb824d4 Mon Sep 17 00:00:00 2001 From: Josh Rotenberg Date: Fri, 10 Apr 2026 16:44:05 -0700 Subject: [PATCH 2/2] fix: Session.stream/3 returns the session unchanged MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Dialyzer (newly enabled on this branch) flagged Session.stream/3 as invalid_contract: the function was storing a make_ref() in the `session_id` field, which is typed as `String.t() | nil`. Beyond the type mismatch, the underlying multi-turn-via-stream mechanism was broken in three ways: 1. The reference placeholder meant the caller's returned session had a non-binary session_id, so any follow-up Session.send/3 would fail the `when is_binary(sid)` guard in execute_turn. 2. maybe_capture_session_id/3 sent {ref, :session_id, sid} messages to the parent process but nothing ever received them. 3. It looked up "session_id" in event data, but Codex 0.119+ emits the thread identifier as "thread_id", not "session_id", so even if the capture wired up correctly it would see nil. The function is untested and the chain `Session.stream -> Session.send` could never have worked. Rather than rebuilding the capture machinery, return the session unchanged and document that `stream/3` does not thread session_id — callers who need multi-turn continuity should use `send/3`, which runs the turn synchronously and updates session_id from the final events. Removes the now-unused maybe_capture_session_id/3 helper. Note: Session.send/3 has a related (but separate) bug where `extract_session_id` also looks for "session_id" instead of "thread_id", which means multi-turn via Session.send is silently broken against Codex 0.119+. That is a runtime bug, not a dialyzer issue, and will be fixed in a follow-up. --- lib/codex_wrapper/session.ex | 27 ++++++++++----------------- 1 file changed, 10 insertions(+), 17 deletions(-) diff --git a/lib/codex_wrapper/session.ex b/lib/codex_wrapper/session.ex index 290179b..0caf2f5 100644 --- a/lib/codex_wrapper/session.ex +++ b/lib/codex_wrapper/session.ex @@ -97,20 +97,20 @@ defmodule CodexWrapper.Session do @doc """ Send a message and return a stream of events. - Returns `{updated_session, stream}`. The session_id is captured from - the stream, so consume the stream before sending the next message. + Returns `{session, stream}`. The returned `session` is the **same + session passed in** — this function does *not* thread `session_id` + across turns. If you need multi-turn continuity, use `send/3` + instead, which runs the turn synchronously and updates `session_id` + from the final events. + + Use `stream/3` when you want to observe events from a single turn + (for example, to render intermediate output as the CLI produces it) + and do not need to chain into a follow-up turn on the same thread. """ @spec stream(t(), String.t(), keyword()) :: {t(), Enumerable.t()} def stream(%__MODULE__{} = session, prompt, opts \\ []) do raw_stream = build_stream(session, prompt, opts) - - session_ref = make_ref() - parent = self() - - wrapped_stream = - Stream.each(raw_stream, &maybe_capture_session_id(&1, session_ref, parent)) - - {%{session | session_id: session_ref}, wrapped_stream} + {session, raw_stream} end @doc """ @@ -260,13 +260,6 @@ defmodule CodexWrapper.Session do end) end - defp maybe_capture_session_id(event, ref, parent) do - case JsonLineEvent.get(event, "session_id") do - nil -> :ok - sid -> Kernel.send(parent, {ref, :session_id, sid}) - end - end - defp events_to_result(events) do stdout = Enum.map_join(events, "\n", fn event -> event.raw end)