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/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/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.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..cae0675 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. @@ -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, @@ -61,6 +59,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 @@ -76,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 @@ -93,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 71842f3..52a344c 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) @@ -35,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| @@ -64,6 +71,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 @@ -122,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)}"]) @@ -153,15 +163,37 @@ 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) + return File.join(env["CODEX_HOME"], "sessions") if env["CODEX_HOME"] && !env["CODEX_HOME"].empty? + + 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 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/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..d699619 --- /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..87f2623 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 @@ -94,19 +105,37 @@ 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 + + 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") { |_event| nil } + + 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) 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 553cc46..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, @@ -98,12 +98,30 @@ 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) + + 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") { |_event| nil } + + expect(exec.context_snapshot).to eq(snapshot) + end end 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("") @@ -116,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 new file mode 100644 index 0000000..1960d69 --- /dev/null +++ b/spec/codex_sdk/rollout_context_snapshot_reader_spec.rb @@ -0,0 +1,142 @@ +# 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