From 9ae184744bc02c4c95e3d88266ddcc25f81e7579 Mon Sep 17 00:00:00 2001 From: Anton Kopylov Date: Sun, 19 Apr 2026 21:53:37 +0300 Subject: [PATCH 1/4] feat(context): Expose Codex rollout context snapshots Codex CLI writes token_count snapshots to rollout logs, but the SDK only exposed turn.completed usage from the JSON event stream. That made downstream apps treat cumulative usage like current context size. Add rollout parsing for the latest token_count entry and expose it as context_snapshot on Exec, AgentThread, and Turn. This keeps usage compatible while giving callers access to model_context_window, last_token_usage, and total_token_usage for real context reporting. --- README.md | 17 +++ lib/codex_sdk.rb | 1 + lib/codex_sdk/agent_thread.rb | 5 +- lib/codex_sdk/exec.rb | 31 +++- lib/codex_sdk/options.rb | 38 ++++- .../rollout_context_snapshot_reader.rb | 74 +++++++++ spec/codex_sdk/agent_thread_spec.rb | 33 ++++- spec/codex_sdk/exec_spec.rb | 17 +++ .../rollout_context_snapshot_reader_spec.rb | 140 ++++++++++++++++++ 9 files changed, 349 insertions(+), 7 deletions(-) create mode 100644 lib/codex_sdk/rollout_context_snapshot_reader.rb create mode 100644 spec/codex_sdk/rollout_context_snapshot_reader_spec.rb diff --git a/README.md b/README.md index 4ca83f8..e3f2e47 100644 --- a/README.md +++ b/README.md @@ -43,6 +43,7 @@ thread = client.start_thread( turn = thread.run("Explain this codebase") puts turn.final_response puts "Tokens used: #{turn.usage.input_tokens} in, #{turn.usage.output_tokens} out" +puts "Final context: #{turn.context_snapshot.context_tokens} / #{turn.context_snapshot.model_context_window}" # Streaming run - yields events as they arrive thread.run_streamed("Fix the failing tests") do |event| @@ -62,6 +63,8 @@ thread.run_streamed("Fix the failing tests") do |event| puts "Error: #{event.error_message}" end end + +puts "Final context: #{thread.context_snapshot.context_tokens} / #{thread.context_snapshot.model_context_window}" ``` ### Resume a thread @@ -120,6 +123,20 @@ client = CodexSDK::Client.new( | `Events::ItemCompleted` | Item finished, provides typed `item` | | `Events::Error` | Stream-level error, provides `message` | +## Context snapshots + +Codex CLI writes richer rollout logs under `~/.codex/sessions` (or `CODEX_HOME/sessions`). After a run completes, `Thread#run` and `Thread#run_streamed` expose a final `context_snapshot` derived from the latest `token_count` entry in those rollout files. + +```ruby +snapshot = thread.context_snapshot +snapshot.context_tokens # => current prompt/context footprint +snapshot.model_context_window # => model max context window +snapshot.last_token_usage.total_tokens +snapshot.total_token_usage.total_tokens +``` + +This is separate from `turn.completed.usage`, which is still the per-turn API usage reported by the JSON event stream. + ## Item types | Item | Fields | diff --git a/lib/codex_sdk.rb b/lib/codex_sdk.rb index 4228636..22d3c30 100644 --- a/lib/codex_sdk.rb +++ b/lib/codex_sdk.rb @@ -29,6 +29,7 @@ def initialize(message, line: nil) require_relative "codex_sdk/config_serializer" require_relative "codex_sdk/items" require_relative "codex_sdk/events" +require_relative "codex_sdk/rollout_context_snapshot_reader" require_relative "codex_sdk/exec" require_relative "codex_sdk/agent_thread" require_relative "codex_sdk/client" diff --git a/lib/codex_sdk/agent_thread.rb b/lib/codex_sdk/agent_thread.rb index dd8cb57..451953b 100644 --- a/lib/codex_sdk/agent_thread.rb +++ b/lib/codex_sdk/agent_thread.rb @@ -4,7 +4,7 @@ module CodexSDK class AgentThread - attr_reader :id + attr_reader :id, :context_snapshot def initialize(options, thread_options:, resume_id: nil) @options = options @@ -33,7 +33,7 @@ def run(input, turn_options: TurnOptions.new) end end - Turn.new(items: items, final_response: final_response, usage: usage) + Turn.new(items: items, final_response: final_response, usage: usage, context_snapshot: @context_snapshot) end # Streaming run: yields each event to the block as it arrives. @@ -61,6 +61,7 @@ def run_streamed(input, turn_options: TurnOptions.new, &block) block.call(event) end ensure + @context_snapshot = @exec&.context_snapshot cleanup_output_schema(output_schema_path) end diff --git a/lib/codex_sdk/exec.rb b/lib/codex_sdk/exec.rb index 71842f3..d9fabad 100644 --- a/lib/codex_sdk/exec.rb +++ b/lib/codex_sdk/exec.rb @@ -10,7 +10,7 @@ module CodexSDK class Exec SHUTDOWN_TIMEOUT = 10 # seconds to wait after SIGTERM before SIGKILL - attr_reader :pid + attr_reader :pid, :context_snapshot def initialize(options, thread_options: ThreadOptions.new) @options = options @@ -27,6 +27,9 @@ def initialize(options, thread_options: ThreadOptions.new) def run(prompt, resume_thread_id: nil, images: [], output_schema_path: nil, &block) args = build_args(resume_thread_id: resume_thread_id, images: images, output_schema_path: output_schema_path) env = build_env + sessions_root = codex_sessions_root(env) + started_at = Time.now + @context_snapshot = nil @stdin, @stdout, @stderr, @wait_thread = Open3.popen3(env, *args) @@ -64,6 +67,11 @@ def run(prompt, resume_thread_id: nil, images: [], output_schema_path: nil, &blo stderr: stderr_buf ) end + + @context_snapshot = read_context_snapshot( + sessions_root: sessions_root, + started_at: started_at + ) ensure cleanup end @@ -156,6 +164,27 @@ def find_codex_path path end + def codex_sessions_root(env) + if env["CODEX_HOME"] && !env["CODEX_HOME"].empty? + return File.join(env["CODEX_HOME"], "sessions") + end + + return unless env["HOME"] && !env["HOME"].empty? + + File.join(env["HOME"], ".codex", "sessions") + end + + def read_context_snapshot(sessions_root:, started_at:) + return unless sessions_root + + RolloutContextSnapshotReader.new( + sessions_root: sessions_root, + started_at: started_at + ).read + rescue StandardError + nil + end + def wait_for_exit(timeout) deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + timeout loop do diff --git a/lib/codex_sdk/options.rb b/lib/codex_sdk/options.rb index 335425b..d168f95 100644 --- a/lib/codex_sdk/options.rb +++ b/lib/codex_sdk/options.rb @@ -63,9 +63,43 @@ def initialize(input_tokens: 0, cached_input_tokens: 0, output_tokens: 0) end end + # Detailed token usage from rollout token_count snapshots. + TokenUsage = Data.define( + :input_tokens, + :cached_input_tokens, + :output_tokens, + :reasoning_output_tokens, + :total_tokens + ) do + def initialize( + input_tokens: 0, + cached_input_tokens: 0, + output_tokens: 0, + reasoning_output_tokens: 0, + total_tokens: 0 + ) + super + end + end + + # Final context snapshot derived from Codex rollout logs. + ContextSnapshot = Data.define(:model_context_window, :last_token_usage, :total_token_usage) do + def initialize( + model_context_window: 0, + last_token_usage: TokenUsage.new, + total_token_usage: TokenUsage.new + ) + super + end + + def context_tokens + last_token_usage.total_tokens + end + end + # Result of a blocking Thread#run call. - Turn = Data.define(:items, :final_response, :usage) do - def initialize(items: [], final_response: "", usage: nil) + Turn = Data.define(:items, :final_response, :usage, :context_snapshot) do + def initialize(items: [], final_response: "", usage: nil, context_snapshot: nil) super end end diff --git a/lib/codex_sdk/rollout_context_snapshot_reader.rb b/lib/codex_sdk/rollout_context_snapshot_reader.rb new file mode 100644 index 0000000..b749d45 --- /dev/null +++ b/lib/codex_sdk/rollout_context_snapshot_reader.rb @@ -0,0 +1,74 @@ +# frozen_string_literal: true + +require "json" + +module CodexSDK + class RolloutContextSnapshotReader + def initialize(sessions_root:, started_at:) + @sessions_root = sessions_root + @started_at = started_at + end + + def read + candidate_rollouts.reverse_each do |path| + snapshot = read_rollout(path) + return snapshot if snapshot + end + + nil + end + + private + + def candidate_rollouts + return [] unless Dir.exist?(@sessions_root) + + Dir.glob(File.join(@sessions_root, "**", "rollout-*.jsonl")) + .select { |path| candidate_rollout?(path) } + .sort_by { |path| File.mtime(path) } + end + + def candidate_rollout?(path) + return true unless @started_at + + File.mtime(path) >= (@started_at - 1) + end + + def read_rollout(path) + snapshot = nil + + File.foreach(path) do |line| + event = JSON.parse(line) + next unless event["type"] == "event_msg" + + payload = event["payload"] + next unless payload.is_a?(Hash) && payload["type"] == "token_count" + + info = payload["info"] + next unless info.is_a?(Hash) + + snapshot = ContextSnapshot.new( + model_context_window: info["model_context_window"].to_i, + last_token_usage: parse_usage(info["last_token_usage"]), + total_token_usage: parse_usage(info["total_token_usage"]) + ) + end + + snapshot + rescue Errno::ENOENT, JSON::ParserError + nil + end + + def parse_usage(data) + return TokenUsage.new unless data.is_a?(Hash) + + TokenUsage.new( + input_tokens: data["input_tokens"].to_i, + cached_input_tokens: data["cached_input_tokens"].to_i, + output_tokens: data["output_tokens"].to_i, + reasoning_output_tokens: data["reasoning_output_tokens"].to_i, + total_tokens: data["total_tokens"].to_i + ) + end + end +end diff --git a/spec/codex_sdk/agent_thread_spec.rb b/spec/codex_sdk/agent_thread_spec.rb index 49f3852..c074351 100644 --- a/spec/codex_sdk/agent_thread_spec.rb +++ b/spec/codex_sdk/agent_thread_spec.rb @@ -14,13 +14,22 @@ def stub_exec_run(events) describe "#run" do it "collects events and returns a Turn" do + context_snapshot = CodexSDK::ContextSnapshot.new( + model_context_window: 258_400, + last_token_usage: CodexSDK::TokenUsage.new(total_tokens: 12_345), + total_token_usage: CodexSDK::TokenUsage.new(total_tokens: 67_890) + ) events = [ CodexSDK::Events::ThreadStarted.new(thread_id: "t1"), CodexSDK::Events::TurnStarted.new, CodexSDK::Events::ItemCompleted.new(item: CodexSDK::Items::AgentMessage.new(id: "i0", text: "Hello!")), CodexSDK::Events::TurnCompleted.new(usage: CodexSDK::Usage.new(input_tokens: 50, output_tokens: 10)) ] - stub_exec_run(events) + exec = instance_double(CodexSDK::Exec, context_snapshot: context_snapshot) + allow(CodexSDK::Exec).to receive(:new).and_return(exec) + allow(exec).to receive(:run) do |_prompt, **_kwargs, &block| + events.each { |event| block.call(event) } + end thread = described_class.new(options, thread_options: thread_options) turn = thread.run("test prompt") @@ -29,6 +38,8 @@ def stub_exec_run(events) expect(turn.final_response).to eq("Hello!") expect(turn.usage.input_tokens).to eq(50) expect(turn.usage.output_tokens).to eq(10) + expect(turn.context_snapshot).to eq(context_snapshot) + expect(thread.context_snapshot).to eq(context_snapshot) expect(thread.id).to eq("t1") end @@ -97,11 +108,29 @@ def stub_exec_run(events) thread.run_streamed("test") { |_| } expect(thread.id).to eq("thread_xyz") end + + it "stores the final context snapshot after the stream ends" do + context_snapshot = CodexSDK::ContextSnapshot.new( + model_context_window: 258_400, + last_token_usage: CodexSDK::TokenUsage.new(total_tokens: 20_145), + total_token_usage: CodexSDK::TokenUsage.new(total_tokens: 28_198) + ) + exec = instance_double(CodexSDK::Exec, context_snapshot: context_snapshot) + allow(CodexSDK::Exec).to receive(:new).and_return(exec) + allow(exec).to receive(:run) do |_prompt, **_kwargs, &block| + block.call(CodexSDK::Events::ThreadStarted.new(thread_id: "thread_xyz")) + end + + thread = described_class.new(options, thread_options: thread_options) + thread.run_streamed("test") { |_| } + + expect(thread.context_snapshot).to eq(context_snapshot) + end end describe "#interrupt" do it "delegates to exec" do - exec = instance_double(CodexSDK::Exec, interrupt: nil) + exec = instance_double(CodexSDK::Exec, interrupt: nil, context_snapshot: nil) allow(CodexSDK::Exec).to receive(:new).and_return(exec) allow(exec).to receive(:run) diff --git a/spec/codex_sdk/exec_spec.rb b/spec/codex_sdk/exec_spec.rb index 553cc46..f72119f 100644 --- a/spec/codex_sdk/exec_spec.rb +++ b/spec/codex_sdk/exec_spec.rb @@ -98,6 +98,23 @@ def mock_popen3(stdout_lines:, stderr: "", exit_code: 0) "--dangerously-bypass-approvals-and-sandbox" ) end + + it "captures the final context snapshot after a successful run" do + snapshot = CodexSDK::ContextSnapshot.new( + model_context_window: 258_400, + last_token_usage: CodexSDK::TokenUsage.new(total_tokens: 20_145), + total_token_usage: CodexSDK::TokenUsage.new(total_tokens: 28_198) + ) + reader = instance_double(CodexSDK::RolloutContextSnapshotReader, read: snapshot) + + mock_popen3(stdout_lines: ['{"type":"turn.completed","usage":{"input_tokens":10,"cached_input_tokens":0,"output_tokens":5}}']) + allow(CodexSDK::RolloutContextSnapshotReader).to receive(:new).and_return(reader) + + exec = described_class.new(options, thread_options: thread_options) + exec.run("hello") { |_| } + + expect(exec.context_snapshot).to eq(snapshot) + end end describe "#interrupt" do diff --git a/spec/codex_sdk/rollout_context_snapshot_reader_spec.rb b/spec/codex_sdk/rollout_context_snapshot_reader_spec.rb new file mode 100644 index 0000000..d228d15 --- /dev/null +++ b/spec/codex_sdk/rollout_context_snapshot_reader_spec.rb @@ -0,0 +1,140 @@ +# frozen_string_literal: true + +require "spec_helper" +require "fileutils" +require "time" +require "tmpdir" + +RSpec.describe CodexSDK::RolloutContextSnapshotReader do + let(:sessions_root) { Dir.mktmpdir("codex-sessions") } + + after do + FileUtils.rm_rf(sessions_root) + end + + def write_rollout(relative_path, events, mtime:) + path = File.join(sessions_root, relative_path) + FileUtils.mkdir_p(File.dirname(path)) + File.write(path, events.map { |event| JSON.generate(event) }.join("\n") + "\n") + File.utime(mtime, mtime, path) + path + end + + it "returns the latest token_count snapshot from recent rollout files" do + started_at = Time.utc(2026, 4, 19, 10, 0, 0) + + write_rollout( + "2026/04/19/rollout-old.jsonl", + [ + { + "type" => "event_msg", + "payload" => { + "type" => "token_count", + "info" => { + "model_context_window" => 258_400, + "last_token_usage" => { "total_tokens" => 8_498 }, + "total_token_usage" => { "total_tokens" => 24_766 } + } + } + } + ], + mtime: started_at - 60 + ) + + write_rollout( + "2026/04/19/rollout-new.jsonl", + [ + { "type" => "event_msg", "payload" => { "type" => "token_count", "info" => nil } }, + { + "type" => "event_msg", + "payload" => { + "type" => "token_count", + "info" => { + "model_context_window" => 1_050_000, + "last_token_usage" => { + "input_tokens" => 18_000, + "cached_input_tokens" => 2_000, + "output_tokens" => 120, + "reasoning_output_tokens" => 30, + "total_tokens" => 20_150 + }, + "total_token_usage" => { + "input_tokens" => 42_000, + "cached_input_tokens" => 12_000, + "output_tokens" => 360, + "reasoning_output_tokens" => 60, + "total_tokens" => 54_420 + } + } + } + } + ], + mtime: started_at + 5 + ) + + snapshot = described_class.new(sessions_root: sessions_root, started_at: started_at).read + + expect(snapshot.model_context_window).to eq(1_050_000) + expect(snapshot.context_tokens).to eq(20_150) + expect(snapshot.last_token_usage.total_tokens).to eq(20_150) + expect(snapshot.last_token_usage.reasoning_output_tokens).to eq(30) + expect(snapshot.total_token_usage.total_tokens).to eq(54_420) + end + + it "reads updated existing rollout files when resuming a thread" do + started_at = Time.utc(2026, 4, 19, 10, 0, 0) + path = write_rollout( + "2026/04/19/rollout-resume.jsonl", + [ + { + "type" => "event_msg", + "payload" => { + "type" => "token_count", + "info" => { + "model_context_window" => 258_400, + "last_token_usage" => { "total_tokens" => 8_057 }, + "total_token_usage" => { "total_tokens" => 8_057 } + } + } + } + ], + mtime: started_at - 60 + ) + + File.write( + path, + [ + { + "type" => "event_msg", + "payload" => { + "type" => "token_count", + "info" => { + "model_context_window" => 258_400, + "last_token_usage" => { "total_tokens" => 20_145 }, + "total_token_usage" => { "total_tokens" => 28_198 } + } + } + } + ].map { |event| JSON.generate(event) }.join("\n") + "\n" + ) + File.utime(started_at + 5, started_at + 5, path) + + snapshot = described_class.new(sessions_root: sessions_root, started_at: started_at).read + + expect(snapshot.context_tokens).to eq(20_145) + expect(snapshot.total_token_usage.total_tokens).to eq(28_198) + end + + it "returns nil when no token_count snapshot is available" do + started_at = Time.utc(2026, 4, 19, 10, 0, 0) + write_rollout( + "2026/04/19/rollout-empty.jsonl", + [{ "type" => "event_msg", "payload" => { "type" => "token_count", "info" => nil } }], + mtime: started_at + 5 + ) + + snapshot = described_class.new(sessions_root: sessions_root, started_at: started_at).read + + expect(snapshot).to be_nil + end +end From 1ea7c422ee52ef10fa50e048d97d60d6b520de96 Mon Sep 17 00:00:00 2001 From: Anton Kopylov Date: Sun, 19 Apr 2026 22:01:04 +0300 Subject: [PATCH 2/4] ci(lint): Add RuboCop to the repo and workflow Add RuboCop as a development dependency, check it in a dedicated CI job, and define a small baseline config that matches the existing repo style. Move development-only gems into the Gemfile, require RubyGems MFA metadata, and apply the small code cleanups needed for bundle exec rubocop to pass alongside the full RSpec suite. --- .github/workflows/ci.yml | 11 ++++++ .rubocop.yml | 38 +++++++++++++++++++ Gemfile | 4 ++ codex-ruby.gemspec | 4 +- lib/codex_sdk/agent_thread.rb | 9 ++--- lib/codex_sdk/config_serializer.rb | 1 + lib/codex_sdk/events.rb | 4 +- lib/codex_sdk/exec.rb | 17 +++++---- spec/codex_sdk/agent_thread_spec.rb | 6 +-- spec/codex_sdk/config_serializer_spec.rb | 6 +-- spec/codex_sdk/events_spec.rb | 6 ++- spec/codex_sdk/exec_spec.rb | 29 +++++++------- .../rollout_context_snapshot_reader_spec.rb | 26 +++++++------ 13 files changed, 109 insertions(+), 52 deletions(-) create mode 100644 .rubocop.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f852572..dfd8406 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -7,6 +7,17 @@ on: branches: [main] jobs: + lint: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + - uses: ruby/setup-ruby@v1 + with: + ruby-version: "3.3" + bundler-cache: true + - run: bundle exec rubocop + test: runs-on: ubuntu-latest strategy: diff --git a/.rubocop.yml b/.rubocop.yml new file mode 100644 index 0000000..5b4d8e8 --- /dev/null +++ b/.rubocop.yml @@ -0,0 +1,38 @@ +AllCops: + NewCops: enable + SuggestExtensions: false + TargetRubyVersion: 3.2 + +Metrics/BlockLength: + Exclude: + - "spec/**/*" + +Metrics/AbcSize: + Enabled: false + +Metrics/ClassLength: + Enabled: false + +Metrics/CyclomaticComplexity: + Enabled: false + +Metrics/MethodLength: + Enabled: false + +Metrics/PerceivedComplexity: + Enabled: false + +Style/StringLiterals: + Enabled: false + +Style/ArgumentsForwarding: + Enabled: false + +Style/ConcatArrayLiterals: + Enabled: false + +Style/Documentation: + Enabled: false + +Style/StringLiteralsInInterpolation: + Enabled: false diff --git a/Gemfile b/Gemfile index be173b2..1e23602 100644 --- a/Gemfile +++ b/Gemfile @@ -3,3 +3,7 @@ source "https://rubygems.org" gemspec + +gem "rake", "~> 13.0" +gem "rspec", "~> 3.0" +gem "rubocop", "~> 1.72" diff --git a/codex-ruby.gemspec b/codex-ruby.gemspec index 7fbdd4d..5f19ec6 100644 --- a/codex-ruby.gemspec +++ b/codex-ruby.gemspec @@ -18,10 +18,8 @@ Gem::Specification.new do |spec| spec.metadata["homepage_uri"] = spec.homepage spec.metadata["source_code_uri"] = spec.homepage spec.metadata["changelog_uri"] = "#{spec.homepage}/blob/main/CHANGELOG.md" + spec.metadata["rubygems_mfa_required"] = "true" spec.files = Dir["lib/**/*.rb", "LICENSE.txt", "README.md", "CHANGELOG.md"] spec.require_paths = ["lib"] - - spec.add_development_dependency "rake", "~> 13.0" - spec.add_development_dependency "rspec", "~> 3.0" end diff --git a/lib/codex_sdk/agent_thread.rb b/lib/codex_sdk/agent_thread.rb index 451953b..cae0675 100644 --- a/lib/codex_sdk/agent_thread.rb +++ b/lib/codex_sdk/agent_thread.rb @@ -41,9 +41,7 @@ def run_streamed(input, turn_options: TurnOptions.new, &block) prompt = normalize_input(input) output_schema_path = nil - if turn_options.output_schema - output_schema_path = write_output_schema(turn_options.output_schema) - end + output_schema_path = write_output_schema(turn_options.output_schema) if turn_options.output_schema @exec = Exec.new( @options, @@ -77,9 +75,9 @@ def normalize_input(input) when String input when Array - input.filter_map { |entry| + input.filter_map do |entry| entry[:text] if entry[:type] == "text" - }.join("\n\n") + end.join("\n\n") else input.to_s end @@ -94,6 +92,7 @@ def write_output_schema(schema) def cleanup_output_schema(path) return unless path + dir = File.dirname(path) FileUtils.rm_rf(dir) rescue StandardError diff --git a/lib/codex_sdk/config_serializer.rb b/lib/codex_sdk/config_serializer.rb index 78d9513..6d3e50e 100644 --- a/lib/codex_sdk/config_serializer.rb +++ b/lib/codex_sdk/config_serializer.rb @@ -38,6 +38,7 @@ def to_toml_value(value) value.to_json when Integer, Float raise ArgumentError, "cannot serialize non-finite number" unless value.to_f.finite? + value.to_s when true, false value.to_s diff --git a/lib/codex_sdk/events.rb b/lib/codex_sdk/events.rb index 49969ad..356def6 100644 --- a/lib/codex_sdk/events.rb +++ b/lib/codex_sdk/events.rb @@ -19,9 +19,7 @@ def self.parse(data) ThreadStarted = Data.define(:thread_id) - TurnStarted = Data.define do - def initialize; super(); end - end + TurnStarted = Data.define TurnCompleted = Data.define(:usage) do def self.from_json(data) diff --git a/lib/codex_sdk/exec.rb b/lib/codex_sdk/exec.rb index d9fabad..52a344c 100644 --- a/lib/codex_sdk/exec.rb +++ b/lib/codex_sdk/exec.rb @@ -38,7 +38,11 @@ def run(prompt, resume_thread_id: nil, images: [], output_schema_path: nil, &blo @stdin.close # Read stderr in background thread - stderr_reader = ::Thread.new { @stderr.read rescue "" } + stderr_reader = ::Thread.new do + @stderr.read + rescue StandardError + "" + end # Read JSONL from stdout line by line @stdout.each_line do |line| @@ -130,9 +134,7 @@ def build_args(resume_thread_id: nil, images: [], output_schema_path: nil) args.concat(["--config", "sandbox_workspace_write.network_access=#{to.network_access}"]) end - if to.web_search - args.concat(["--config", "web_search=#{ConfigSerializer.to_toml_value(to.web_search)}"]) - end + args.concat(["--config", "web_search=#{ConfigSerializer.to_toml_value(to.web_search)}"]) if to.web_search if to.approval_policy args.concat(["--config", "approval_policy=#{ConfigSerializer.to_toml_value(to.approval_policy)}"]) @@ -161,13 +163,12 @@ def build_env def find_codex_path path = `which codex 2>/dev/null`.strip raise Error, "codex binary not found in PATH" if path.empty? + path end def codex_sessions_root(env) - if env["CODEX_HOME"] && !env["CODEX_HOME"].empty? - return File.join(env["CODEX_HOME"], "sessions") - end + return File.join(env["CODEX_HOME"], "sessions") if env["CODEX_HOME"] && !env["CODEX_HOME"].empty? return unless env["HOME"] && !env["HOME"].empty? @@ -189,8 +190,10 @@ def wait_for_exit(timeout) deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + timeout loop do return true unless @wait_thread&.alive? + remaining = deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC) return false if remaining <= 0 + sleep([0.1, remaining].min) end end diff --git a/spec/codex_sdk/agent_thread_spec.rb b/spec/codex_sdk/agent_thread_spec.rb index c074351..87f2623 100644 --- a/spec/codex_sdk/agent_thread_spec.rb +++ b/spec/codex_sdk/agent_thread_spec.rb @@ -105,7 +105,7 @@ def stub_exec_run(events) thread = described_class.new(options, thread_options: thread_options) expect(thread.id).to be_nil - thread.run_streamed("test") { |_| } + thread.run_streamed("test") { |_event| nil } expect(thread.id).to eq("thread_xyz") end @@ -122,7 +122,7 @@ def stub_exec_run(events) end thread = described_class.new(options, thread_options: thread_options) - thread.run_streamed("test") { |_| } + thread.run_streamed("test") { |_event| nil } expect(thread.context_snapshot).to eq(context_snapshot) end @@ -135,7 +135,7 @@ def stub_exec_run(events) allow(exec).to receive(:run) thread = described_class.new(options, thread_options: thread_options) - thread.run_streamed("test") { |_| } + thread.run_streamed("test") { |_event| nil } thread.interrupt expect(exec).to have_received(:interrupt) diff --git a/spec/codex_sdk/config_serializer_spec.rb b/spec/codex_sdk/config_serializer_spec.rb index 40742d7..3fdfbd5 100644 --- a/spec/codex_sdk/config_serializer_spec.rb +++ b/spec/codex_sdk/config_serializer_spec.rb @@ -65,9 +65,9 @@ input = { model_reasoning_effort: "high", web_search: "disabled" } flags = described_class.to_flags(input) expect(flags).to eq([ - "--config", 'model_reasoning_effort="high"', - "--config", 'web_search="disabled"' - ]) + "--config", 'model_reasoning_effort="high"', + "--config", 'web_search="disabled"' + ]) end it "returns empty array for empty hash" do diff --git a/spec/codex_sdk/events_spec.rb b/spec/codex_sdk/events_spec.rb index 2959dda..f667268 100644 --- a/spec/codex_sdk/events_spec.rb +++ b/spec/codex_sdk/events_spec.rb @@ -43,7 +43,8 @@ it "parses item.started" do data = { "type" => "item.started", - "item" => { "type" => "mcp_tool_call", "id" => "item_0", "server" => "test", "tool" => "foo", "status" => "in_progress" } + "item" => { "type" => "mcp_tool_call", "id" => "item_0", "server" => "test", "tool" => "foo", + "status" => "in_progress" } } event = described_class.parse(data) expect(event).to be_a(CodexSDK::Events::ItemStarted) @@ -54,7 +55,8 @@ it "parses item.updated" do data = { "type" => "item.updated", - "item" => { "type" => "command_execution", "id" => "item_1", "command" => "ls", "aggregated_output" => "partial", "status" => "in_progress" } + "item" => { "type" => "command_execution", "id" => "item_1", "command" => "ls", + "aggregated_output" => "partial", "status" => "in_progress" } } event = described_class.parse(data) expect(event).to be_a(CodexSDK::Events::ItemUpdated) diff --git a/spec/codex_sdk/exec_spec.rb b/spec/codex_sdk/exec_spec.rb index f72119f..7de9ef3 100644 --- a/spec/codex_sdk/exec_spec.rb +++ b/spec/codex_sdk/exec_spec.rb @@ -8,10 +8,10 @@ def mock_popen3(stdout_lines:, stderr: "", exit_code: 0) stdin = instance_double(IO, write: nil, close: nil, closed?: true) - stdout = StringIO.new(stdout_lines.join("\n") + "\n") + stdout = StringIO.new("#{stdout_lines.join("\n")}\n") stderr_io = StringIO.new(stderr) status = instance_double(Process::Status, success?: exit_code == 0, exitstatus: exit_code, termsig: nil) - wait_thread = double("wait_thread", value: status, alive?: false, pid: 12345) + wait_thread = double("wait_thread", value: status, alive?: false, pid: 12_345) allow(Open3).to receive(:popen3).and_return([stdin, stdout, stderr_io, wait_thread]) @@ -52,9 +52,9 @@ def mock_popen3(stdout_lines:, stderr: "", exit_code: 0) exec = described_class.new(options, thread_options: thread_options) - expect { - exec.run("hello") { |_| } - }.to raise_error(CodexSDK::ExecError, /exited with code 1/) + expect do + exec.run("hello") { |_event| nil } + end.to raise_error(CodexSDK::ExecError, /exited with code 1/) end it "raises ParseError for invalid JSON" do @@ -62,9 +62,9 @@ def mock_popen3(stdout_lines:, stderr: "", exit_code: 0) exec = described_class.new(options, thread_options: thread_options) - expect { - exec.run("hello") { |_| } - }.to raise_error(CodexSDK::ParseError, /Failed to parse/) + expect do + exec.run("hello") { |_event| nil } + end.to raise_error(CodexSDK::ParseError, /Failed to parse/) end it "skips empty lines" do @@ -88,7 +88,7 @@ def mock_popen3(stdout_lines:, stderr: "", exit_code: 0) described_class.new( options, thread_options: CodexSDK::ThreadOptions.new(dangerously_bypass_approvals_and_sandbox: true) - ).run("hello") { |_| } + ).run("hello") { |_event| nil } expect(Open3).to have_received(:popen3).with( anything, @@ -107,11 +107,12 @@ def mock_popen3(stdout_lines:, stderr: "", exit_code: 0) ) reader = instance_double(CodexSDK::RolloutContextSnapshotReader, read: snapshot) - mock_popen3(stdout_lines: ['{"type":"turn.completed","usage":{"input_tokens":10,"cached_input_tokens":0,"output_tokens":5}}']) + completed_json = '{"type":"turn.completed","usage":{"input_tokens":10,"cached_input_tokens":0,"output_tokens":5}}' + mock_popen3(stdout_lines: [completed_json]) allow(CodexSDK::RolloutContextSnapshotReader).to receive(:new).and_return(reader) exec = described_class.new(options, thread_options: thread_options) - exec.run("hello") { |_| } + exec.run("hello") { |_event| nil } expect(exec.context_snapshot).to eq(snapshot) end @@ -120,7 +121,7 @@ def mock_popen3(stdout_lines:, stderr: "", exit_code: 0) describe "#interrupt" do it "sends SIGTERM to the subprocess" do status = instance_double(Process::Status, success?: true, exitstatus: 0, termsig: nil) - wait_thread = double("wait_thread", value: status, alive?: true, pid: 12345) + wait_thread = double("wait_thread", value: status, alive?: true, pid: 12_345) stdin = instance_double(IO, write: nil, close: nil, closed?: true) stdout = StringIO.new("") @@ -133,12 +134,12 @@ def mock_popen3(stdout_lines:, stderr: "", exit_code: 0) exec = described_class.new(options, thread_options: thread_options) # Start in a background thread so we can interrupt - runner = ::Thread.new { exec.run("hello") { |_| } } + runner = Thread.new { exec.run("hello") { |_event| nil } } sleep(0.05) exec.interrupt - expect(Process).to have_received(:kill).with("TERM", 12345) + expect(Process).to have_received(:kill).with("TERM", 12_345) runner.join(1) end end diff --git a/spec/codex_sdk/rollout_context_snapshot_reader_spec.rb b/spec/codex_sdk/rollout_context_snapshot_reader_spec.rb index d228d15..1960d69 100644 --- a/spec/codex_sdk/rollout_context_snapshot_reader_spec.rb +++ b/spec/codex_sdk/rollout_context_snapshot_reader_spec.rb @@ -15,7 +15,7 @@ def write_rollout(relative_path, events, mtime:) path = File.join(sessions_root, relative_path) FileUtils.mkdir_p(File.dirname(path)) - File.write(path, events.map { |event| JSON.generate(event) }.join("\n") + "\n") + File.write(path, "#{events.map { |event| JSON.generate(event) }.join("\n")}\n") File.utime(mtime, mtime, path) path end @@ -103,19 +103,21 @@ def write_rollout(relative_path, events, mtime:) File.write( path, - [ - { - "type" => "event_msg", - "payload" => { - "type" => "token_count", - "info" => { - "model_context_window" => 258_400, - "last_token_usage" => { "total_tokens" => 20_145 }, - "total_token_usage" => { "total_tokens" => 28_198 } + "#{ + [ + { + "type" => "event_msg", + "payload" => { + "type" => "token_count", + "info" => { + "model_context_window" => 258_400, + "last_token_usage" => { "total_tokens" => 20_145 }, + "total_token_usage" => { "total_tokens" => 28_198 } + } } } - } - ].map { |event| JSON.generate(event) }.join("\n") + "\n" + ].map { |event| JSON.generate(event) }.join("\n") + }\n" ) File.utime(started_at + 5, started_at + 5, path) From 6c86e50a87c3c280ae13805993f4b6d6f3cc4898 Mon Sep 17 00:00:00 2001 From: Anton Kopylov Date: Sun, 19 Apr 2026 22:03:32 +0300 Subject: [PATCH 3/4] fix(lint): Avoid multiline chain indentation in rollout reader Replace the chained candidate_rollouts call with a local variable so RuboCop's multiline method call indentation cop passes consistently in CI and locally. --- lib/codex_sdk/rollout_context_snapshot_reader.rb | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/lib/codex_sdk/rollout_context_snapshot_reader.rb b/lib/codex_sdk/rollout_context_snapshot_reader.rb index b749d45..6a8ffac 100644 --- a/lib/codex_sdk/rollout_context_snapshot_reader.rb +++ b/lib/codex_sdk/rollout_context_snapshot_reader.rb @@ -23,9 +23,8 @@ def read def candidate_rollouts return [] unless Dir.exist?(@sessions_root) - Dir.glob(File.join(@sessions_root, "**", "rollout-*.jsonl")) - .select { |path| candidate_rollout?(path) } - .sort_by { |path| File.mtime(path) } + rollout_paths = Dir.glob(File.join(@sessions_root, "**", "rollout-*.jsonl")) + rollout_paths.select { |path| candidate_rollout?(path) }.sort_by { |path| File.mtime(path) } end def candidate_rollout?(path) From a5ac7a73e1fcf227dbdf355b7632bb2ffb964a4a Mon Sep 17 00:00:00 2001 From: Anton Kopylov Date: Sun, 19 Apr 2026 22:05:32 +0300 Subject: [PATCH 4/4] style(lint): Let RuboCop format rollout reader chain Restore the chained candidate_rollouts implementation and keep the exact formatting produced by bundle exec rubocop -A so CI and local lint use the same output. --- lib/codex_sdk/rollout_context_snapshot_reader.rb | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/lib/codex_sdk/rollout_context_snapshot_reader.rb b/lib/codex_sdk/rollout_context_snapshot_reader.rb index 6a8ffac..d699619 100644 --- a/lib/codex_sdk/rollout_context_snapshot_reader.rb +++ b/lib/codex_sdk/rollout_context_snapshot_reader.rb @@ -23,8 +23,9 @@ def read def candidate_rollouts return [] unless Dir.exist?(@sessions_root) - rollout_paths = Dir.glob(File.join(@sessions_root, "**", "rollout-*.jsonl")) - rollout_paths.select { |path| candidate_rollout?(path) }.sort_by { |path| File.mtime(path) } + Dir.glob(File.join(@sessions_root, "**", "rollout-*.jsonl")) + .select { |path| candidate_rollout?(path) } + .sort_by { |path| File.mtime(path) } end def candidate_rollout?(path)