ag-ui 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,270 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bundler/setup"
4
+ require "ag_ui"
5
+
6
+ module AgUi
7
+ module Protocol
8
+ # Schema-validated AG-UI protocol objects, powered by json_schemer.
9
+ # Same mechanism as a2a's Protocol::JsonSchema, over the schema bundle
10
+ # generated from the reference Python SDK's pydantic models
11
+ # (data/generate-ag-ui-schema.py -> data/ag_ui.json).
12
+ #
13
+ # AgUi::Protocol::JsonSchema["TextMessageContentEvent"]
14
+ # #=> Class < Definition with .schema, .valid?, reader methods
15
+ #
16
+ # AgUi::Protocol::JsonSchema.list_definitions
17
+ # #=> ["ActivityDeltaEvent", "ActivityMessage", ...]
18
+ #
19
+ module JsonSchema
20
+ DATA_PATH = File.expand_path("../../../data/ag_ui.json", __dir__).freeze
21
+
22
+ @definition_classes = {}
23
+ @schemer = nil
24
+ @raw_schema = nil
25
+
26
+ class << self
27
+ # Look up a definition by model class name.
28
+ #
29
+ # AgUi::Protocol::JsonSchema["RunAgentInput"]
30
+ # #=> Class < Definition
31
+ #
32
+ def [](name)
33
+ @definition_classes[name] ||= begin
34
+ definitions = raw_schema.fetch("definitions", {})
35
+
36
+ unless definitions.key?(name)
37
+ raise "No AG-UI definition found for #{name.inspect}!" \
38
+ "\nAvailable: #{list_definitions.join(", ")}"
39
+ end
40
+
41
+ ref_schema = schemer.ref("#/definitions/#{name}")
42
+ build_definition_class(ref_schema, name, definitions[name])
43
+ end
44
+ end
45
+
46
+ # All available definition names, sorted.
47
+ def list_definitions
48
+ raw_schema.fetch("definitions", {}).keys.sort
49
+ end
50
+
51
+ # Definition names for concrete events (have a const "type"),
52
+ # mapped from wire event type to definition name:
53
+ #
54
+ # { "TEXT_MESSAGE_CONTENT" => "TextMessageContentEvent", ... }
55
+ #
56
+ def event_types
57
+ @event_types ||= raw_schema.fetch("definitions", {}).each_with_object({}) do |(name, defn), map|
58
+ const = defn.dig("properties", "type", "const")
59
+ if const
60
+ map[const] = name
61
+ end
62
+ end.freeze
63
+ end
64
+
65
+ # The JSONSchemer instance for the full AG-UI schema bundle.
66
+ def schemer
67
+ @schemer ||= JSONSchemer.schema(raw_schema)
68
+ end
69
+
70
+ # The parsed schema hash (all $refs are already internal —
71
+ # the generator emits #/definitions/... pointers).
72
+ def raw_schema
73
+ @raw_schema ||= JSON.parse(File.read(DATA_PATH))
74
+ end
75
+
76
+ # Reset all cached state (useful for tests).
77
+ def reset!
78
+ @definition_classes.clear
79
+ @schemer = nil
80
+ @raw_schema = nil
81
+ @event_types = nil
82
+ end
83
+
84
+ private
85
+
86
+ # Build a Definition subclass for a specific AG-UI type.
87
+ def build_definition_class(schema_instance, definition_name, raw_definition)
88
+ properties = raw_definition.fetch("properties", {})
89
+ camel_keys = properties.keys
90
+ snake_to_camel = build_snake_to_camel(camel_keys)
91
+ prop_refs = build_property_refs(properties)
92
+
93
+ reader_pairs = camel_keys.map { |ck| [camel_to_snake(ck).to_sym, ck] }
94
+
95
+ Class.new(Definition) do
96
+ @schema = schema_instance
97
+ @definition_name = definition_name
98
+ @schema_properties = camel_keys
99
+ @snake_to_camel = snake_to_camel
100
+ @property_refs = prop_refs
101
+
102
+ class << self
103
+ def schema = @schema
104
+ def definition_name = @definition_name
105
+ def schema_properties = @schema_properties
106
+ def snake_to_camel_map = @snake_to_camel
107
+ def property_refs = @property_refs
108
+ end
109
+
110
+ reader_pairs.each do |snake_sym, camel_key|
111
+ define_method(snake_sym) { @data[camel_key] }
112
+ end
113
+ end
114
+ end
115
+
116
+ # Inspect each property's schema for $ref pointers and build a map
117
+ # of { camelKey => [:kind, "DefinitionName"] } so Definition can
118
+ # auto-wrap nested Hashes. Pydantic wraps optional properties in
119
+ # anyOf [<schema>, {type: null}], so detection looks through the
120
+ # first non-null anyOf variant.
121
+ #
122
+ # :object — direct $ref
123
+ # :array — items.$ref
124
+ # :map — additionalProperties.$ref
125
+ def build_property_refs(properties)
126
+ definitions = raw_schema.fetch("definitions", {})
127
+ refs = {}
128
+
129
+ properties.each do |camel_key, prop_schema|
130
+ schema = unwrap_optional(prop_schema)
131
+
132
+ kind, ref =
133
+ if (r = schema["$ref"])
134
+ [:object, r]
135
+ elsif schema["type"] == "array" && (r = schema.dig("items", "$ref"))
136
+ [:array, r]
137
+ elsif schema["type"] == "object" && (r = schema.dig("additionalProperties", "$ref"))
138
+ [:map, r]
139
+ end
140
+
141
+ if ref
142
+ name = ref_name_for(ref)
143
+ if name && definitions.dig(name, "properties")
144
+ refs[camel_key] = [kind, name]
145
+ end
146
+ end
147
+ end
148
+
149
+ refs
150
+ end
151
+
152
+ # Pydantic optional: anyOf [<schema>, {"type": "null"}] — return
153
+ # the non-null variant (only when the anyOf is that exact shape).
154
+ def unwrap_optional(prop_schema)
155
+ variants = prop_schema["anyOf"]
156
+ if variants
157
+ non_null = variants.reject { |s| s == { "type" => "null" } }
158
+ non_null.length == 1 ? non_null.first : prop_schema
159
+ else
160
+ prop_schema
161
+ end
162
+ end
163
+
164
+ def ref_name_for(ref)
165
+ if ref.start_with?("#/definitions/")
166
+ ref.sub("#/definitions/", "")
167
+ end
168
+ end
169
+
170
+ def build_snake_to_camel(camel_keys)
171
+ map = {}
172
+ camel_keys.each do |camel|
173
+ snake = camel_to_snake(camel)
174
+ map[snake] = camel
175
+ map[camel] = camel
176
+ end
177
+ map
178
+ end
179
+
180
+ def camel_to_snake(str)
181
+ str.gsub(/([A-Z])/) { "_#{$1.downcase}" }
182
+ .delete_prefix("_")
183
+ end
184
+ end
185
+ end
186
+ end
187
+ end
188
+
189
+ __END__
190
+
191
+ describe "AgUi::Protocol::JsonSchema" do
192
+ schema = AgUi::Protocol::JsonSchema
193
+
194
+ it "loads the raw schema with definitions" do
195
+ schema.raw_schema["definitions"].should.be.kind_of(Hash)
196
+ end
197
+
198
+ it "contains only internal $refs" do
199
+ external = []
200
+ walk = ->(obj) do
201
+ case obj
202
+ when Hash
203
+ obj.each do |k, v|
204
+ if k == "$ref" && v.is_a?(String) && !v.start_with?("#")
205
+ external << v
206
+ else
207
+ walk.(v)
208
+ end
209
+ end
210
+ when Array
211
+ obj.each { |v| walk.(v) }
212
+ end
213
+ end
214
+ walk.(schema.raw_schema["definitions"])
215
+ external.should == []
216
+ end
217
+
218
+ it "lists definitions sorted, covering the core vocabulary" do
219
+ defs = schema.list_definitions
220
+ defs.should == defs.sort
221
+ %w[RunAgentInput RunStartedEvent RunFinishedEvent TextMessageContentEvent
222
+ ToolCallStartEvent ActivitySnapshotEvent AssistantMessage ToolMessage
223
+ AgentCapabilities].each { |d| defs.should.include?(d) }
224
+ end
225
+
226
+ it "maps wire event types to definition names" do
227
+ schema.event_types["TEXT_MESSAGE_CONTENT"].should == "TextMessageContentEvent"
228
+ schema.event_types["RUN_STARTED"].should == "RunStartedEvent"
229
+ schema.event_types["ACTIVITY_SNAPSHOT"].should == "ActivitySnapshotEvent"
230
+ end
231
+
232
+ it "returns cached Definition subclasses" do
233
+ a = schema["RunStartedEvent"]
234
+ b = schema["RunStartedEvent"]
235
+ (a < AgUi::Protocol::JsonSchema::Definition).should == true
236
+ a.object_id.should == b.object_id
237
+ end
238
+
239
+ it "builds definitions that serialize snake_case input to camelCase wire form" do
240
+ event = schema["TextMessageContentEvent"].new(
241
+ type: "TEXT_MESSAGE_CONTENT", message_id: "m1", delta: "Hi"
242
+ )
243
+ event.valid?.should == true
244
+ event.to_h.should == { "type" => "TEXT_MESSAGE_CONTENT", "messageId" => "m1", "delta" => "Hi" }
245
+ end
246
+
247
+ it "rejects invalid events" do
248
+ event = schema["TextMessageContentEvent"].new(type: "TEXT_MESSAGE_CONTENT", message_id: "m1")
249
+ event.valid?.should == false
250
+ end
251
+
252
+ it "detects pydantic-optional anyOf refs as :object property refs" do
253
+ schema["RunStartedEvent"].property_refs["input"].should == [:object, "RunAgentInput"]
254
+ end
255
+
256
+ it "validates RunAgentInput built via Definition" do
257
+ input = schema["RunAgentInput"].new(
258
+ thread_id: "t1", run_id: "r1", state: {}, messages: [],
259
+ tools: [], context: [], forwarded_props: {}
260
+ )
261
+ input.valid?.should == true
262
+ input.to_h["threadId"].should == "t1"
263
+ end
264
+
265
+ it "validates a raw wire hash with explicit nulls via schemer (inbound path)" do
266
+ raw = { "threadId" => "t1", "runId" => "r1", "state" => nil, "messages" => [],
267
+ "tools" => [], "context" => [], "forwardedProps" => nil, "extra" => 1 }
268
+ schema.schemer.ref("#/definitions/RunAgentInput").valid?(raw).should == true
269
+ end
270
+ end
@@ -0,0 +1,149 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bundler/setup"
4
+ require "ag_ui"
5
+
6
+ module AgUi
7
+ # Parses and validates the POST body of /agent/:id/run — the
8
+ # RunAgentInput envelope (threadId, runId, messages, tools, context,
9
+ # forwardedProps, state, resume?).
10
+ #
11
+ # Validation runs against the RAW parsed hash so explicit nulls
12
+ # (state: null, forwardedProps: null) survive — Definition#to_h
13
+ # compacts nils away, which would fail required-field checks.
14
+ # The returned Definition gives snake_case readers and pattern
15
+ # matching over the same data.
16
+ #
17
+ # input = AgUi::RunInput.parse(request_body)
18
+ # input.thread_id #=> "t1"
19
+ # input.messages #=> [Definition(UserMessage), ...]
20
+ #
21
+ module RunInput
22
+ # Raised on malformed JSON or schema-invalid input. The route layer
23
+ # renders this as 400 {"error": "Invalid request body", "details": ...}.
24
+ class InvalidError < StandardError; end
25
+
26
+ SCHEMA_REF = "#/definitions/RunAgentInput"
27
+
28
+ class << self
29
+ def parse(body)
30
+ begin
31
+ raw = JSON.parse(body)
32
+ rescue JSON::ParserError => e
33
+ raise InvalidError, "malformed JSON: #{e.message}"
34
+ end
35
+
36
+ unless raw.is_a?(Hash)
37
+ raise InvalidError, "expected a JSON object, got #{raw.class}"
38
+ end
39
+
40
+ errors = schema.validate(raw).to_a
41
+ unless errors.empty?
42
+ validation_error = Protocol::JsonSchema::ValidationError.new(
43
+ errors,
44
+ definition_name: "RunAgentInput",
45
+ data: raw,
46
+ )
47
+ raise InvalidError, validation_error.message
48
+ end
49
+
50
+ Protocol::JsonSchema["RunAgentInput"].new(raw)
51
+ end
52
+
53
+ def schema
54
+ @schema ||= Protocol::JsonSchema.schemer.ref(SCHEMA_REF)
55
+ end
56
+ end
57
+ end
58
+ end
59
+
60
+ __END__
61
+
62
+ describe "AgUi::RunInput" do
63
+ minimal = {
64
+ "threadId" => "t1", "runId" => "r1", "state" => nil,
65
+ "messages" => [], "tools" => [], "context" => [], "forwardedProps" => nil,
66
+ }
67
+
68
+ it "parses a minimal valid body with explicit nulls" do
69
+ input = AgUi::RunInput.parse(JSON.generate(minimal))
70
+ input.thread_id.should == "t1"
71
+ input.run_id.should == "r1"
72
+ input.messages.should == []
73
+ end
74
+
75
+ it "parses the full envelope: messages, tools, context" do
76
+ body = minimal.merge(
77
+ "messages" => [
78
+ { "id" => "u1", "role" => "user", "content" => "hi" },
79
+ { "id" => "a1", "role" => "assistant",
80
+ "toolCalls" => [{ "id" => "tc1", "type" => "function",
81
+ "function" => { "name" => "navigate", "arguments" => "{}" } }] },
82
+ { "id" => "tr1", "role" => "tool", "content" => "{}", "toolCallId" => "tc1" },
83
+ ],
84
+ "tools" => [{ "name" => "navigate", "description" => "Go to a page",
85
+ "parameters" => { "type" => "object" } }],
86
+ "context" => [{ "description" => "currentPath", "value" => "/data" }],
87
+ )
88
+
89
+ input = AgUi::RunInput.parse(JSON.generate(body))
90
+ input.messages.length.should == 3
91
+ input.tools.first.name.should == "navigate"
92
+ input.context.first.value.should == "/data"
93
+
94
+ # Messages are a discriminated union — they stay raw camelCase hashes.
95
+ input.messages.last["toolCallId"].should == "tc1"
96
+
97
+ case input
98
+ in { thread_id: String => tid }
99
+ tid.should == "t1"
100
+ end
101
+ end
102
+
103
+ it "accepts multimodal user message content parts" do
104
+ body = minimal.merge(
105
+ "messages" => [
106
+ { "id" => "u1", "role" => "user", "content" => [
107
+ { "type" => "text", "text" => "what is this?" },
108
+ { "type" => "image",
109
+ "source" => { "type" => "data", "value" => "aGk=", "mimeType" => "image/png" } },
110
+ ] },
111
+ ],
112
+ )
113
+
114
+ input = AgUi::RunInput.parse(JSON.generate(body))
115
+ input.messages.first["content"].length.should == 2
116
+ end
117
+
118
+ it "tolerates unknown extra fields (extra=allow)" do
119
+ input = AgUi::RunInput.parse(JSON.generate(minimal.merge("lastSeenEventId" => "5")))
120
+ input.thread_id.should == "t1"
121
+ end
122
+
123
+ it "rejects malformed JSON" do
124
+ begin
125
+ AgUi::RunInput.parse("{nope")
126
+ raise "expected InvalidError"
127
+ rescue AgUi::RunInput::InvalidError => e
128
+ e.message.should.include?("malformed JSON")
129
+ end
130
+ end
131
+
132
+ it "rejects non-object bodies" do
133
+ begin
134
+ AgUi::RunInput.parse("[1,2]")
135
+ raise "expected InvalidError"
136
+ rescue AgUi::RunInput::InvalidError => e
137
+ e.message.should.include?("expected a JSON object")
138
+ end
139
+ end
140
+
141
+ it "rejects schema-invalid bodies with readable details" do
142
+ begin
143
+ AgUi::RunInput.parse(JSON.generate({ "threadId" => "t1" }))
144
+ raise "expected InvalidError"
145
+ rescue AgUi::RunInput::InvalidError => e
146
+ e.message.should.include?("runId")
147
+ end
148
+ end
149
+ end
@@ -0,0 +1,285 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bundler/setup"
4
+ require "console"
5
+ require "ag_ui"
6
+
7
+ module AgUi
8
+ # The run loop: drives one AG-UI run through a brute turn pipeline.
9
+ #
10
+ # The terminal block is the LLM call (ruby_llm or anything else) — it
11
+ # receives brute's env ({messages:, events:, ...}), streams deltas into
12
+ # env[:events] (translated live to SSE by EventBridge) and appends the
13
+ # assistant message to env[:messages]. The gem stays LLM-agnostic.
14
+ #
15
+ # run_loop = AgUi::RunLoop.new(system_prompt: PROMPT) do |env|
16
+ # env[:events] << { type: :text_message_start, data: { message_id: id } }
17
+ # ... stream provider chunks ...
18
+ # env[:events] << { type: :text_message_end, data: { message_id: id } }
19
+ # env[:messages].assistant(full_text)
20
+ # end
21
+ #
22
+ # app = AgUi.agent(agent_id: "default", &run_loop)
23
+ #
24
+ # Lifecycle per run (the AG-UI contract): RUN_STARTED first; then the
25
+ # pipeline streams; then RUN_FINISHED — or RUN_ERROR if the pipeline
26
+ # raised. Schema-validation failures are wire-contract bugs and re-raise
27
+ # after RUN_ERROR so they fail loudly in dev.
28
+ class RunLoop
29
+ # a2ui: nil/false = off; an AgUi::A2ui::Catalog = on with that catalog;
30
+ # true = on degraded (tool injected, no component schema).
31
+ # server_tools: [{name:, description:, parameters:, handler:}] execute
32
+ # inline and the turn loops (Loop::ToolResult) until the model answers
33
+ # in text, a client tool defers to the browser, or max_iterations hits.
34
+ def initialize(system_prompt: nil, validate: true, middleware: [], a2ui: nil,
35
+ server_tools: [], max_iterations: 10, &terminal)
36
+ unless terminal
37
+ raise ArgumentError, "RunLoop requires a terminal block (the LLM call)"
38
+ end
39
+
40
+ @system_prompt = system_prompt
41
+ @validate = validate
42
+ @middleware = middleware
43
+ @a2ui = a2ui
44
+ @server_tools = server_tools
45
+ @max_iterations = max_iterations
46
+ @terminal = terminal
47
+ end
48
+
49
+ def a2ui_enabled?
50
+ !(@a2ui.nil? || @a2ui == false)
51
+ end
52
+
53
+ # AgUi.agent takes a block; RunLoop quacks like one.
54
+ def to_proc
55
+ run_loop = self
56
+ proc { |rack_env| run_loop.handle(rack_env) }
57
+ end
58
+
59
+ def handle(rack_env)
60
+ input = rack_env["ag_ui.input"]
61
+
62
+ rack_env["ag_ui.stream"].open(
63
+ thread_id: input.thread_id,
64
+ run_id: input.run_id,
65
+ validate: @validate,
66
+ ) do |stream|
67
+ stream.run_started
68
+ run(stream, input)
69
+ end
70
+ end
71
+
72
+ private
73
+
74
+ def run(stream, input)
75
+ pipeline(input).start(
76
+ Messages.to_brute(input.messages),
77
+ events: EventBridge.new(stream),
78
+ )
79
+ stream.run_finished
80
+ rescue Protocol::JsonSchema::ValidationError => e
81
+ Console.error(self, "wire-contract bug: #{e.message}", e)
82
+ stream.run_error(message: "Internal error", code: "validation")
83
+ raise
84
+ rescue => e
85
+ Console.error(self, "run failed: #{e.class}: #{e.message}", e)
86
+ stream.run_error(message: e.message, code: e.class.name)
87
+ end
88
+
89
+ def pipeline(input)
90
+ agent = Brute.agent
91
+ agent.use(
92
+ AgUi::Middleware::SystemPrompt,
93
+ prompt: @system_prompt,
94
+ context: input.context,
95
+ )
96
+ agent.use(AgUi::Middleware::ForwardedProps, props: input.forwarded_props)
97
+ agent.use(Brute::Middleware::Loop::ToolResult)
98
+ agent.use(Brute::Middleware::MaxIterations, max_iterations: @max_iterations)
99
+ if a2ui_enabled?
100
+ catalog = @a2ui.is_a?(AgUi::A2ui::Catalog) ? @a2ui : nil
101
+ agent.use(AgUi::Middleware::A2ui, catalog: catalog)
102
+ end
103
+ agent.use(AgUi::Middleware::ToolRouter, tools: input.tools, server_tools: @server_tools)
104
+ @middleware.each do |(klass, options)|
105
+ agent.use(klass, **(options || {}))
106
+ end
107
+ agent.run(@terminal)
108
+ end
109
+ end
110
+ end
111
+
112
+ __END__
113
+
114
+ describe "AgUi::RunLoop" do
115
+ minimal_input = JSON.generate({
116
+ "threadId" => "t1", "runId" => "r1", "state" => nil,
117
+ "messages" => [{ "id" => "u1", "role" => "user", "content" => "hi" }],
118
+ "tools" => [], "context" => [{ "description" => "currentPath", "value" => "/data" }],
119
+ "forwardedProps" => nil,
120
+ })
121
+
122
+ request = ->(app, body) do
123
+ app.call({
124
+ "REQUEST_METHOD" => "POST",
125
+ "PATH_INFO" => "/agent/default/run",
126
+ "rack.input" => StringIO.new(body),
127
+ })
128
+ end
129
+
130
+ read_frames = ->(stream) do
131
+ frames = []
132
+ while (chunk = stream.read)
133
+ frames << JSON.parse(chunk.sub(/\Adata: /, "").strip)
134
+ end
135
+ frames
136
+ end
137
+
138
+ it "streams a full run through a brute pipeline" do
139
+ seen_env = nil
140
+ run_loop = AgUi::RunLoop.new(system_prompt: "Be terse.") do |env|
141
+ seen_env = env
142
+ env[:events] << { type: :text_message_start, data: { message_id: "m1" } }
143
+ env[:events] << { type: :text_message_content, data: { message_id: "m1", delta: "Hello" } }
144
+ env[:events] << { type: :text_message_end, data: { message_id: "m1" } }
145
+ env[:messages].assistant("Hello")
146
+ end
147
+
148
+ app = AgUi.agent(agent_id: "default", &run_loop)
149
+ _status, _headers, body = request.(app, minimal_input)
150
+
151
+ frames = read_frames.(body)
152
+ frames.map { |f| f["type"] }.should == %w[
153
+ RUN_STARTED TEXT_MESSAGE_START TEXT_MESSAGE_CONTENT TEXT_MESSAGE_END RUN_FINISHED
154
+ ]
155
+
156
+ # The pipeline saw: system prompt (with context addendum) + history.
157
+ seen_env[:messages].map(&:role).should == [:system, :user, :assistant]
158
+ seen_env[:messages].first.content.should.include?("Be terse.")
159
+ seen_env[:messages].first.content.should.include?("currentPath: /data")
160
+ end
161
+
162
+ it "ends the run with RUN_ERROR when the terminal raises" do
163
+ run_loop = AgUi::RunLoop.new { |_env| raise "provider exploded" }
164
+ app = AgUi.agent(agent_id: "default", &run_loop)
165
+
166
+ _status, _headers, body = request.(app, minimal_input)
167
+ frames = read_frames.(body)
168
+
169
+ frames.map { |f| f["type"] }.should == %w[RUN_STARTED RUN_ERROR]
170
+ frames.last["message"].should == "provider exploded"
171
+ frames.last["code"].should == "RuntimeError"
172
+ end
173
+
174
+ it "supports extra brute middleware" do
175
+ marker = Class.new do
176
+ def initialize(app, note:)
177
+ @app = app
178
+ @note = note
179
+ end
180
+
181
+ def call(env)
182
+ env[:metadata][:note] = @note
183
+ @app.call(env)
184
+ end
185
+ end
186
+
187
+ seen = nil
188
+ run_loop = AgUi::RunLoop.new(middleware: [[marker, { note: "hi" }]]) do |env|
189
+ seen = env[:metadata][:note]
190
+ env[:messages].assistant("ok")
191
+ end
192
+
193
+ request.(AgUi.agent(&run_loop), minimal_input)
194
+ seen.should == "hi"
195
+ end
196
+
197
+ it "requires a terminal block" do
198
+ lambda { AgUi::RunLoop.new }.should.raise(ArgumentError)
199
+ end
200
+
201
+ it "executes server tools inline and loops the turn to completion" do
202
+ weather_tool = {
203
+ name: "get_weather",
204
+ description: "Weather for a city",
205
+ parameters: { "type" => "object" },
206
+ handler: ->(args) { { "city" => args["city"], "temp" => 21 } },
207
+ }
208
+
209
+ iterations = 0
210
+ terminal = ->(env) do
211
+ iterations += 1
212
+ if iterations == 1
213
+ env[:messages] << Brute::Message.new(
214
+ role: :assistant, content: nil,
215
+ tool_calls: [{ id: "tc1", name: "get_weather", arguments: { "city" => "Lisbon" } }],
216
+ )
217
+ else
218
+ # The model sees its own call + the executed result.
219
+ env[:messages].last.role.should == :tool
220
+ env[:messages].last.content.should == "{\"city\":\"Lisbon\",\"temp\":21}"
221
+ env[:events] << { type: :text_message_start, data: { message_id: "m2" } }
222
+ env[:events] << { type: :text_message_content, data: { message_id: "m2", delta: "21C in Lisbon" } }
223
+ env[:events] << { type: :text_message_end, data: { message_id: "m2" } }
224
+ env[:messages].assistant("21C in Lisbon")
225
+ end
226
+ end
227
+
228
+ run_loop = AgUi::RunLoop.new(server_tools: [weather_tool], &terminal)
229
+ app = AgUi.agent(agent_id: "default", &run_loop)
230
+
231
+ _status, _headers, stream = request.(app, minimal_input)
232
+ frames = read_frames.(stream)
233
+
234
+ iterations.should == 2
235
+ frames.map { |f| f["type"] }.should == %w[
236
+ RUN_STARTED
237
+ TOOL_CALL_START TOOL_CALL_ARGS TOOL_CALL_END TOOL_CALL_RESULT
238
+ TEXT_MESSAGE_START TEXT_MESSAGE_CONTENT TEXT_MESSAGE_END
239
+ RUN_FINISHED
240
+ ]
241
+ frames[4]["content"].should == "{\"city\":\"Lisbon\",\"temp\":21}"
242
+ end
243
+
244
+ it "streams the full A2UI sequence when the model renders a surface" do
245
+ catalog = AgUi::A2ui::Catalog.new(
246
+ catalog_id: "host://ai-catalog",
247
+ components: { "Card" => {} },
248
+ )
249
+
250
+ terminal = ->(env) do
251
+ env[:tools].map { |t| t["name"] }.should.include?("render_a2ui")
252
+ env[:messages].first.content.should.include?("A2UI Component Schema")
253
+
254
+ env[:messages] << Brute::Message.new(
255
+ role: :assistant, content: nil,
256
+ tool_calls: [{ id: "tc9", name: "render_a2ui", arguments: {
257
+ "surfaceId" => "s1",
258
+ "components" => [{ "id" => "root", "component" => "Card" }],
259
+ } }],
260
+ )
261
+ end
262
+
263
+ run_loop = AgUi::RunLoop.new(a2ui: catalog, &terminal)
264
+ app = AgUi.agent(agent_id: "default", &run_loop)
265
+
266
+ _status, _headers, stream = request.(app, minimal_input)
267
+ frames = read_frames.(stream)
268
+
269
+ frames.map { |f| f["type"] }.should == %w[
270
+ RUN_STARTED TOOL_CALL_START TOOL_CALL_ARGS TOOL_CALL_END
271
+ ACTIVITY_SNAPSHOT TOOL_CALL_RESULT RUN_FINISHED
272
+ ]
273
+
274
+ snapshot = frames[4]
275
+ snapshot["messageId"].should == "a2ui-surface-tc9"
276
+ snapshot["activityType"].should == "a2ui-surface"
277
+ snapshot["replace"].should == true
278
+ snapshot["content"]["a2ui_operations"][0]["createSurface"]["catalogId"]
279
+ .should == "host://ai-catalog"
280
+
281
+ result = frames[5]
282
+ result["toolCallId"].should == "tc9"
283
+ result["content"].should == "{\"status\":\"rendered\"}"
284
+ end
285
+ end