# frozen_string_literal: true RSpec.describe Dispatch::Adapter::Claude::RequestBuilder::CacheControl do # Build a minimal user message hash def user_msg(content) { role: "user", content: content } end # Build a minimal assistant message hash def asst_msg(content) { role: "assistant", content: content } end # Build a minimal text block hash (symbol keys) def text_block(text) { type: "text", text: text } end # Build a minimal tool hash def tool_hash(name) { name: name, description: "A tool", input_schema: { "type" => "object" } } end # Build a system block hash def sys_block(text) { "type" => "text", "text" => text } end # Count cache_control markers across the whole params hash def count_markers(params) count = 0 Array(params[:tools]).each { |b| count += 1 if has_cc?(b) } Array(params[:system]).each { |b| count += 1 if has_cc?(b) } Array(params[:messages]).each do |msg| next unless msg[:content].is_a?(Array) msg[:content].each { |b| count += 1 if has_cc?(b) } end count end def has_cc?(block) return false unless block.is_a?(Hash) block.key?(:cache_control) || block.key?("cache_control") end def get_cc(block) block[:cache_control] || block["cache_control"] end # ── resolve_cache_control ───────────────────────────────────────────────── describe ".resolve_cache_control" do let(:api_url) { "https://api.anthropic.com" } let(:proxy_url) { "https://my.proxy.example.com" } it "returns nil for :none" do expect(described_class.resolve_cache_control(:none, api_url)).to be_nil end it "defaults to :short when nil is given" do cc = described_class.resolve_cache_control(nil, api_url) expect(cc).to eq({ "type" => "ephemeral", "ttl" => "5m" }) end it "returns ttl: '5m' for :short on api.anthropic.com" do cc = described_class.resolve_cache_control(:short, api_url) expect(cc["ttl"]).to eq("5m") end it "returns ttl: '1h' for :long on api.anthropic.com" do cc = described_class.resolve_cache_control(:long, api_url) expect(cc["ttl"]).to eq("1h") end it "returns ttl: '5m' for :long on a non-Anthropic base URL" do cc = described_class.resolve_cache_control(:long, proxy_url) expect(cc["ttl"]).to eq("5m") end it "returns type: 'ephemeral' in all non-nil cases" do %i[short long].each do |r| cc = described_class.resolve_cache_control(r, api_url) expect(cc["type"]).to eq("ephemeral") end end end # ── auto-placement: no-op on :none ──────────────────────────────────────── describe "with cache_retention: :none" do it "places no markers" do params = { tools: [tool_hash("bash")], system: [sys_block("You are helpful.")], messages: [user_msg("hello")] } described_class.apply(params, cache_retention: :none) expect(count_markers(params)).to eq(0) end end # ── auto-placement: typical 4-position case ─────────────────────────────── describe "marker placement" do let(:base_params) do { tools: [tool_hash("bash"), tool_hash("find")], system: [sys_block("Block 1"), sys_block("Block 2")], messages: [ user_msg([text_block("user turn 1")]), asst_msg("assistant reply"), user_msg([text_block("user turn 2")]), asst_msg("assistant reply 2"), user_msg([text_block("user turn 3")]) # last user ] } end it "places at most 4 markers total" do described_class.apply(base_params) expect(count_markers(base_params)).to be <= 4 end it "places a marker on the last tool" do described_class.apply(base_params) last_tool = base_params[:tools].last expect(has_cc?(last_tool)).to be true end it "places a marker on the last system block" do described_class.apply(base_params) last_sys = base_params[:system].last expect(has_cc?(last_sys)).to be true end it "places a marker on the last-user-message's last text block" do described_class.apply(base_params) last_user_msg = base_params[:messages].select { |m| m[:role] == "user" }.last blocks = last_user_msg[:content] expect(blocks.any? { |b| has_cc?(b) }).to be true end it "places a marker on the penultimate-user-message's last text block" do described_class.apply(base_params) user_msgs = base_params[:messages].select { |m| m[:role] == "user" } penultimate = user_msgs[-2] blocks = penultimate[:content] expect(blocks.any? { |b| has_cc?(b) }).to be true end it "does not mark assistant messages" do described_class.apply(base_params) asst_messages = base_params[:messages].select { |m| m[:role] == "assistant" } asst_messages.each do |msg| content = msg[:content] content.each { |b| expect(has_cc?(b)).to be false } if content.is_a?(Array) end end end # ── string content promotion ────────────────────────────────────────────── describe "string content promotion" do it "converts a user message with String content to [{type:text,text:…}] and marks it" do params = { tools: [], system: [], messages: [user_msg("hello there")] } described_class.apply(params) content = params[:messages].first[:content] expect(content).to be_an(Array) expect(content.first[:type]).to eq("text") expect(has_cc?(content.first)).to be true end end # ── caller-placed markers short-circuit ─────────────────────────────────── describe "caller-placed markers" do it "does NOT auto-place markers when any message block already has cache_control" do params = { tools: [tool_hash("bash")], system: [sys_block("system")], messages: [ user_msg([ { type: "text", text: "hi", cache_control: { "type" => "ephemeral" } } ]) ] } # Record initial state — system and tools are not yet marked described_class.apply(params) # The auto-placement should not touch system or tool since caller placed a marker expect(has_cc?(params[:tools].last)).to be false expect(has_cc?(params[:system].last)).to be false end end # ── enforce_limit: never exceed 4 breakpoints ──────────────────────────── describe "enforce_limit" do it "strips excess markers beyond 4 when more are placed externally" do # Build a params with 6 markers already placed params = { tools: [ { name: "t1", input_schema: {}, cache_control: { "type" => "ephemeral" } }, { name: "t2", input_schema: {}, cache_control: { "type" => "ephemeral" } } ], system: [ { "type" => "text", "text" => "s1", "cache_control" => { "type" => "ephemeral" } }, { "type" => "text", "text" => "s2", "cache_control" => { "type" => "ephemeral" } } ], messages: [ user_msg([ { type: "text", text: "u1", cache_control: { "type" => "ephemeral" } }, { type: "text", text: "u2", cache_control: { "type" => "ephemeral" } } ]) ] } described_class.apply(params, cache_retention: :none) # skip auto-placement expect(count_markers(params)).to be <= 4 end end # ── TTL ordering normalization ───────────────────────────────────────────── describe "TTL ordering" do it "downgrades a later '1h' block to plain ephemeral when an earlier '5m' block exists" do params = { tools: [{ name: "t1", input_schema: {}, cache_control: { "type" => "ephemeral", "ttl" => "5m" } }], system: [{ "type" => "text", "text" => "sys", "cache_control" => { "type" => "ephemeral", "ttl" => "1h" } }], messages: [] } described_class.apply(params, cache_retention: :none) sys_cc = get_cc(params[:system].last) expect(sys_cc["ttl"]).to be_nil expect(sys_cc[:ttl]).to be_nil end it "preserves '1h' on system when tools are not '5m' first" do params = { tools: [{ name: "t1", input_schema: {}, cache_control: { "type" => "ephemeral", "ttl" => "1h" } }], system: [{ "type" => "text", "text" => "sys", "cache_control" => { "type" => "ephemeral", "ttl" => "1h" } }], messages: [] } described_class.apply(params, cache_retention: :none) sys_cc = get_cc(params[:system].last) expect(sys_cc["ttl"]).to eq("1h") end it "does not mutate an 'ephemeral' block without ttl" do params = { tools: [], system: [{ "type" => "text", "text" => "sys", "cache_control" => { "type" => "ephemeral" } }], messages: [] } described_class.apply(params, cache_retention: :none) sys_cc = get_cc(params[:system].last) expect(sys_cc["type"]).to eq("ephemeral") end end # ── no tools/system ──────────────────────────────────────────────────────── describe "minimal params (no tools, no system)" do it "still marks the last user message when only messages are present" do params = { messages: [user_msg([text_block("hi")])] } described_class.apply(params) blocks = params[:messages].last[:content] expect(blocks.any? { |b| has_cc?(b) }).to be true end it "does not raise when params has only messages with no blocks" do params = { messages: [user_msg([])] } expect { described_class.apply(params) }.not_to raise_error end end # ── idempotency on :none after markers exist ─────────────────────────────── describe "when called multiple times" do it "does not add duplicate markers on a second call with :short" do params = { tools: [tool_hash("bash")], messages: [user_msg([text_block("hi")])] } described_class.apply(params, cache_retention: :short) first_count = count_markers(params) # The second call: the first user message now has a marker, so caller_placed_markers? # returns true — auto-placement skipped. Counts should be stable. described_class.apply(params, cache_retention: :short) second_count = count_markers(params) expect(second_count).to be <= 4 expect(second_count).to eq(first_count) end end end