summaryrefslogtreecommitdiffhomepage
path: root/lib/dispatch/adapter/tester/playbook.rb
blob: bf18bf0ec30bc216fba1694e5d4c5d2493800d08 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
# frozen_string_literal: true

require "json"

module Dispatch
  module Adapter
    module Tester
      # A deterministic, scriptable adapter for integration testing.
      #
      # Playbook is a drop-in replacement for Dispatch::Adapter::Copilot.
      # Instead of calling a real LLM API, it replays a pre-defined sequence
      # of steps from a JSON script. Each call to #chat consumes the next step
      # and returns a Dispatch::Adapter::Response built from it.
      #
      # Usage:
      #   steps_json = '[{"step":1,"type":"message","content":"Hello"}]'
      #   adapter = Dispatch::Adapter::Tester::Playbook.new(steps_json: steps_json)
      #   response = adapter.chat(messages, system: "...", tools: [...])
      #   response.content  # => "Hello"
      #
      class Playbook < Dispatch::Adapter::Base
        MODEL_NAME = "tester-playbook"
        PROVIDER_NAME = "Tester Playbook"
        MAX_CONTEXT_TOKENS = 1_000_000

        attr_reader :steps, :current_index, :call_log

        # @param steps_json [String] JSON string containing an array of step objects
        # @param model [String] model name to report (default: "tester-playbook")
        # @param max_tokens [Integer] reported max tokens (unused, for interface compat)
        # @param kwargs [Hash] absorbs any extra keyword arguments for drop-in compat
        #   (e.g. min_request_interval, rate_limit, etc.)
        def initialize(steps_json: "[]", model: MODEL_NAME, max_tokens: 200_000, **_kwargs)
          super()
          @model = model
          @max_tokens = max_tokens
          @steps = parse_steps(steps_json)
          @current_index = 0
          @call_log = []
          @mutex = Mutex.new

          validate_step_ids_unique!
        end

        # Consume the next step and return a Response.
        #
        # Accepts the same signature as Dispatch::Adapter::Base#chat.
        # The messages, system, tools, stream, max_tokens, and thinking
        # parameters are recorded in the call_log for assertion purposes
        # but do not affect the response (which is entirely driven by the script).
        #
        # @return [Dispatch::Adapter::Response]
        def chat(messages, system: nil, tools: [], stream: false, max_tokens: nil, thinking: nil, &_block)
          @mutex.synchronize do
            if @current_index >= @steps.length
              raise PlaybookExhaustedError.new(
                total_steps: @steps.length,
                calls_made: @current_index + 1
              )
            end

            step = @steps[@current_index]
            @current_index += 1

            @call_log << {
              step: step,
              messages: messages,
              system: system,
              tools: tools,
              stream: stream,
              max_tokens: max_tokens,
              thinking: thinking
            }

            build_response_from_step(step)
          end
        end

        def model_name
          @model
        end

        def provider_name
          PROVIDER_NAME
        end

        def max_context_tokens
          MAX_CONTEXT_TOKENS
        end

        def list_models
          [
            Dispatch::Adapter::ModelInfo.new(
              id: MODEL_NAME,
              name: "Tester Playbook",
              max_context_tokens: MAX_CONTEXT_TOKENS,
              supports_vision: false,
              supports_tool_use: true,
              supports_streaming: false
            )
          ]
        end

        # --- Test helper methods ---

        # Check if all steps have been consumed.
        # @return [Boolean]
        def finished?
          @current_index >= @steps.length
        end

        # Returns the number of remaining unconsumed steps.
        # @return [Integer]
        def remaining_steps
          @steps.length - @current_index
        end

        # Raises UnconsumedStepsError if there are steps left.
        # Call this at the end of a test to ensure the full script was exercised.
        def verify_all_consumed!
          return if finished?

          remaining_ids = @steps[@current_index..].map(&:step_id)
          raise UnconsumedStepsError.new(
            total_steps: @steps.length,
            consumed: @current_index,
            remaining_step_ids: remaining_ids
          )
        end

        # Resets the playbook to the beginning.
        # Useful if you need to replay the same script.
        def reset!
          @mutex.synchronize do
            @current_index = 0
            @call_log.clear
          end
        end

        private

        def parse_steps(steps_json)
          raw = if steps_json.is_a?(String)
                  parsed = JSON.parse(steps_json)
                  unless parsed.is_a?(Array)
                    raise InvalidPlaybookError, "Playbook JSON must be an array, got #{parsed.class}"
                  end

                  parsed
                elsif steps_json.is_a?(Array)
                  steps_json
                else
                  raise InvalidPlaybookError,
                        "steps_json must be a JSON string or Array, got #{steps_json.class}"
                end

          raw.map { |data| Step.new(data) }
        rescue JSON::ParserError => e
          raise InvalidPlaybookError, "Failed to parse playbook JSON: #{e.message}"
        end

        def validate_step_ids_unique!
          ids = @steps.map(&:step_id)
          duplicates = ids.group_by(&:itself).select { |_, v| v.size > 1 }.keys
          return if duplicates.empty?

          raise InvalidPlaybookError,
                "Duplicate step IDs found: #{duplicates.inspect}. Each step must have a unique 'step' value."
        end

        def build_response_from_step(step)
          tool_calls = step.tool_calls.map do |tc|
            Dispatch::Adapter::ToolUseBlock.new(
              id: tc["id"],
              name: tc["name"],
              arguments: tc["arguments"]
            )
          end

          stop_reason = tool_calls.any? ? :tool_use : :end_turn

          Dispatch::Adapter::Response.new(
            content: step.content,
            tool_calls: tool_calls,
            model: @model,
            stop_reason: stop_reason,
            usage: Dispatch::Adapter::Usage.new(
              input_tokens: 0,
              output_tokens: 0
            )
          )
        end
      end
    end
  end
end