summaryrefslogtreecommitdiffhomepage
path: root/lib/dispatch/adapter/tester/step.rb
blob: bfd5fce6f5f65c656d5e9cb7245574584504ab40 (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
# frozen_string_literal: true

module Dispatch
  module Adapter
    module Tester
      # Immutable value object representing a single step in a playbook.
      # Validated at parse time so errors surface early.
      class Step
        VALID_TYPES = %w[message tool_calls].freeze

        attr_reader :step_id, :type, :content, :tool_calls

        def initialize(data)
          validate_and_assign!(data)
          freeze
        end

        def message?
          @type == "message"
        end

        def tool_calls?
          @type == "tool_calls"
        end

        def to_s
          "Step ##{@step_id} (#{@type})"
        end

        private

        def validate_and_assign!(data)
          validate_hash!(data)
          @step_id = extract_step_id!(data)
          @type = extract_type!(data)
          @content = data["content"]

          if tool_calls?
            @tool_calls = extract_tool_calls!(data)
          else
            @tool_calls = []
            validate_message_has_content!
          end
        end

        def validate_hash!(data)
          return if data.is_a?(Hash)

          raise InvalidPlaybookError, "Each step must be a JSON object, got #{data.class}"
        end

        def extract_step_id!(data)
          step_id = data["step"]
          raise InvalidPlaybookError, "Step is missing required field 'step'" if step_id.nil?
          unless step_id.is_a?(Integer)
            raise InvalidPlaybookError, "Step 'step' field must be an integer, got #{step_id.inspect}"
          end

          step_id
        end

        def extract_type!(data)
          type = data["type"]
          raise InvalidPlaybookError, "Step ##{@step_id} is missing required field 'type'" if type.nil?
          unless VALID_TYPES.include?(type)
            raise InvalidPlaybookError,
                  "Step ##{@step_id} has invalid type #{type.inspect}. " \
                  "Must be one of: #{VALID_TYPES.join(", ")}"
          end
          type
        end

        def extract_tool_calls!(data)
          raw = data["tool_calls"]
          if raw.nil? || !raw.is_a?(Array) || raw.empty?
            raise InvalidPlaybookError,
                  "Step ##{@step_id} (tool_calls) requires a non-empty 'tool_calls' array"
          end

          raw.map.with_index do |tc, idx|
            validate_tool_call!(tc, idx)
          end
        end

        def validate_tool_call!(tc, idx)
          unless tc.is_a?(Hash)
            raise InvalidPlaybookError,
                  "Step ##{@step_id}, tool_call[#{idx}] must be a JSON object"
          end

          %w[id name arguments].each do |field|
            if tc[field].nil?
              raise InvalidPlaybookError,
                    "Step ##{@step_id}, tool_call[#{idx}] is missing required field '#{field}'"
            end
          end

          unless tc["id"].is_a?(String)
            raise InvalidPlaybookError,
                  "Step ##{@step_id}, tool_call[#{idx}] 'id' must be a string"
          end

          unless tc["name"].is_a?(String)
            raise InvalidPlaybookError,
                  "Step ##{@step_id}, tool_call[#{idx}] 'name' must be a string"
          end

          unless tc["arguments"].is_a?(Hash)
            raise InvalidPlaybookError,
                  "Step ##{@step_id}, tool_call[#{idx}] 'arguments' must be a JSON object"
          end

          tc
        end

        def validate_message_has_content!
          return if @content.is_a?(String) && [email protected]?

          raise InvalidPlaybookError,
                "Step ##{@step_id} (message) requires a non-empty 'content' string"
        end
      end
    end
  end
end