ag-ui 0.3.0 → 1.0.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.
@@ -6,49 +6,58 @@ require "ag_ui"
6
6
  module AgUi
7
7
  # The seam between brute's turn pipeline and the AG-UI SSE stream.
8
8
  #
9
- # Brute middleware and terminal procs push `{type:, data:}` events into
10
- # env[:events]; an EventBridge is that sink, translating each event into
11
- # the matching typed SSE emitter as it arrives — the browser sees deltas
12
- # mid-turn, not after.
9
+ # Brute middleware and terminal procs emit AG-UI events through the env; a
10
+ # bridge subscribes to that vocabulary on the agent's hooks and translates
11
+ # each one into the matching typed SSE emitter as it arrives — the browser
12
+ # sees deltas mid-turn, not after.
13
13
  #
14
- # pipeline.start(messages, events: AgUi::EventBridge.new(stream))
14
+ # agent = Brute.agent.use(...).run(terminal)
15
+ # AgUi::EventBridge.new(stream).subscribe(agent)
16
+ # agent.start(messages)
15
17
  #
16
- # Unknown event types (brute's own :log, :tool_result telemetry, etc.)
17
- # are ignored only the AG-UI vocabulary reaches the wire.
18
+ # Brute's own lifecycle events (:llm_start, :tool_end, the middleware and
19
+ # trace sets) are simply never subscribed to here, so only the AG-UI
20
+ # vocabulary reaches the wire.
18
21
  class EventBridge
19
22
  TRANSLATIONS = {
20
- text_message_start: :translate_text_start,
21
- text_message_content: :translate_text_content,
22
- text_message_end: :translate_text_end,
23
- tool_call_start: :translate_tool_call_start,
24
- tool_call_args: :translate_tool_call_args,
25
- tool_call_end: :translate_tool_call_end,
26
- tool_call_result: :translate_tool_call_result,
27
- state_snapshot: :translate_state_snapshot,
28
- state_delta: :translate_state_delta,
29
- messages_snapshot: :translate_messages_snapshot,
30
- activity_snapshot: :translate_activity_snapshot,
31
- reasoning_start: :translate_reasoning_start,
32
- reasoning_message_start: :translate_reasoning_message_start,
23
+ text_message_start: :translate_text_start,
24
+ text_message_content: :translate_text_content,
25
+ text_message_end: :translate_text_end,
26
+ tool_call_start: :translate_tool_call_start,
27
+ tool_call_args: :translate_tool_call_args,
28
+ tool_call_end: :translate_tool_call_end,
29
+ tool_call_result: :translate_tool_call_result,
30
+ state_snapshot: :translate_state_snapshot,
31
+ state_delta: :translate_state_delta,
32
+ messages_snapshot: :translate_messages_snapshot,
33
+ activity_snapshot: :translate_activity_snapshot,
34
+ reasoning_start: :translate_reasoning_start,
35
+ reasoning_message_start: :translate_reasoning_message_start,
33
36
  reasoning_message_content: :translate_reasoning_message_content,
34
- reasoning_message_end: :translate_reasoning_message_end,
35
- reasoning_end: :translate_reasoning_end,
36
- step_started: :translate_step_started,
37
- step_finished: :translate_step_finished,
38
- custom: :translate_custom,
39
- raw: :translate_raw,
37
+ reasoning_message_end: :translate_reasoning_message_end,
38
+ reasoning_end: :translate_reasoning_end,
39
+ step_started: :translate_step_started,
40
+ step_finished: :translate_step_finished,
41
+ custom: :translate_custom,
42
+ raw: :translate_raw,
40
43
  }.freeze
41
44
 
42
45
  def initialize(stream)
43
46
  @stream = stream
44
47
  end
45
48
 
46
- def <<(event)
47
- handler = TRANSLATIONS[event[:type]]
48
- if handler
49
- send(handler, event[:data] || {})
49
+ # Registers the whole vocabulary above on a hooks registry — or on
50
+ # anything else answering `on`, which a Brute pipeline builder does.
51
+ # Answers what it was given, so it composes in one line.
52
+ #
53
+ # Every subscriber takes (env, data, trace): brute hands the env first and
54
+ # appends the trace last, and a translation wants neither — the data is
55
+ # the whole of an AG-UI event.
56
+ def subscribe(hooks)
57
+ TRANSLATIONS.each do |event, handler|
58
+ hooks.on(event) { |_env, data, _trace| send(handler, data || {}) }
50
59
  end
51
- self
60
+ hooks
52
61
  end
53
62
 
54
63
  private
@@ -71,8 +80,8 @@ module AgUi
71
80
 
72
81
  def translate_tool_call_start(data)
73
82
  @stream.tool_call_start(
74
- tool_call_id: data[:tool_call_id],
75
- tool_call_name: data[:tool_call_name],
83
+ tool_call_id: data[:tool_call_id],
84
+ tool_call_name: data[:tool_call_name],
76
85
  parent_message_id: data[:parent_message_id],
77
86
  )
78
87
  end
@@ -89,9 +98,9 @@ module AgUi
89
98
 
90
99
  def translate_tool_call_result(data)
91
100
  @stream.tool_call_result(
92
- message_id: data[:message_id],
101
+ message_id: data[:message_id],
93
102
  tool_call_id: data[:tool_call_id],
94
- content: data[:content],
103
+ content: data[:content],
95
104
  )
96
105
  end
97
106
 
@@ -131,10 +140,10 @@ module AgUi
131
140
 
132
141
  def translate_activity_snapshot(data)
133
142
  @stream.activity_snapshot(
134
- message_id: data[:message_id],
143
+ message_id: data[:message_id],
135
144
  activity_type: data[:activity_type],
136
- content: data[:content],
137
- replace: data.fetch(:replace, true),
145
+ content: data[:content],
146
+ replace: data.fetch(:replace, true),
138
147
  )
139
148
  end
140
149
 
@@ -165,6 +174,15 @@ end
165
174
  __END__
166
175
 
167
176
  describe "AgUi::EventBridge" do
177
+ # A bridge is driven by emitting on a registry it has subscribed to, so the
178
+ # tests below build the pair and push events through the same path a turn
179
+ # would.
180
+ def bridged(stream)
181
+ hooks = Brute::Hooks::Registry.new
182
+ AgUi::EventBridge.new(stream).subscribe(hooks)
183
+ Brute::Hooks::Trace.new({}, hooks: hooks)
184
+ end
185
+
168
186
  read_frames = ->(stream) do
169
187
  frames = []
170
188
  while (chunk = stream.read)
@@ -175,12 +193,12 @@ describe "AgUi::EventBridge" do
175
193
 
176
194
  it "translates text events into SSE frames as they arrive" do
177
195
  stream = AgUi::Server::SSE::Stream.new(thread_id: "t1", run_id: "r1")
178
- bridge = AgUi::EventBridge.new(stream)
196
+ env = bridged(stream)
179
197
 
180
- bridge << { type: :text_message_start, data: { message_id: "m1" } }
181
- bridge << { type: :text_message_content, data: { message_id: "m1", delta: "Hel" } }
182
- bridge << { type: :text_message_content, data: { message_id: "m1", delta: "lo" } }
183
- bridge << { type: :text_message_end, data: { message_id: "m1" } }
198
+ env.emit(:text_message_start, { message_id: "m1" })
199
+ env.emit(:text_message_content, { message_id: "m1", delta: "Hel" })
200
+ env.emit(:text_message_content, { message_id: "m1", delta: "lo" })
201
+ env.emit(:text_message_end, { message_id: "m1" })
184
202
  stream.finish
185
203
 
186
204
  frames = read_frames.(stream)
@@ -192,34 +210,42 @@ describe "AgUi::EventBridge" do
192
210
 
193
211
  it "drops empty deltas (protocol rule)" do
194
212
  stream = AgUi::Server::SSE::Stream.new(thread_id: "t1", run_id: "r1")
195
- bridge = AgUi::EventBridge.new(stream)
213
+ env = bridged(stream)
196
214
 
197
- bridge << { type: :text_message_content, data: { message_id: "m1", delta: "" } }
198
- bridge << { type: :text_message_content, data: { message_id: "m1", delta: nil } }
215
+ env.emit(:text_message_content, { message_id: "m1", delta: "" })
216
+ env.emit(:text_message_content, { message_id: "m1", delta: nil })
199
217
  stream.finish
200
218
 
201
219
  read_frames.(stream).should == []
202
220
  end
203
221
 
204
- it "ignores brute telemetry and unknown event types, returning self" do
222
+ it "leaves brute's own lifecycle events and unknown types off the wire" do
205
223
  stream = AgUi::Server::SSE::Stream.new(thread_id: "t1", run_id: "r1")
206
- bridge = AgUi::EventBridge.new(stream)
224
+ env = bridged(stream)
207
225
 
208
- result = bridge << { type: :log, data: { note: "internal" } }
209
- bridge << { type: :whatever }
226
+ env.emit(Brute::Hooks::LLM_START_EVENT)
227
+ env.emit(:whatever, { note: "internal" })
210
228
  stream.finish
211
229
 
212
- result.should.equal?(bridge)
213
230
  read_frames.(stream).should == []
214
231
  end
215
232
 
233
+ it "answers what it subscribed, so it composes in one line" do
234
+ hooks = Brute::Hooks::Registry.new
235
+ stream = AgUi::Server::SSE::Stream.new(thread_id: "t1", run_id: "r1")
236
+
237
+ AgUi::EventBridge.new(stream).subscribe(hooks).should.equal?(hooks)
238
+ end
239
+
216
240
  it "translates shared-state events (snapshot + JSON-Patch delta)" do
217
241
  stream = AgUi::Server::SSE::Stream.new(thread_id: "t1", run_id: "r1")
218
- bridge = AgUi::EventBridge.new(stream)
242
+ env = bridged(stream)
219
243
 
220
- bridge << { type: :state_snapshot, data: { snapshot: { "theme" => "dark" } } }
221
- bridge << { type: :state_delta,
222
- data: { delta: [{ "op" => "replace", "path" => "/theme", "value" => "light" }] } }
244
+ env.emit(:state_snapshot, { snapshot: { "theme" => "dark" } })
245
+ env.emit(
246
+ :state_delta,
247
+ { delta: [{ "op" => "replace", "path" => "/theme", "value" => "light" }] },
248
+ )
223
249
  stream.finish
224
250
 
225
251
  frames = read_frames.(stream)
@@ -230,13 +256,14 @@ describe "AgUi::EventBridge" do
230
256
 
231
257
  it "translates CUSTOM (e.g. the PredictState convention) and STEP markers" do
232
258
  stream = AgUi::Server::SSE::Stream.new(thread_id: "t1", run_id: "r1")
233
- bridge = AgUi::EventBridge.new(stream)
234
-
235
- bridge << { type: :step_started, data: { step_name: "plan" } }
236
- bridge << { type: :custom,
237
- data: { name: "PredictState",
238
- value: [{ "state_key" => "document", "tool" => "write" }] } }
239
- bridge << { type: :step_finished, data: { step_name: "plan" } }
259
+ env = bridged(stream)
260
+
261
+ env.emit(:step_started, { step_name: "plan" })
262
+ env.emit(
263
+ :custom,
264
+ { name: "PredictState", value: [{ "state_key" => "document", "tool" => "write" }] },
265
+ )
266
+ env.emit(:step_finished, { step_name: "plan" })
240
267
  stream.finish
241
268
 
242
269
  frames = read_frames.(stream)
@@ -12,26 +12,24 @@ module AgUi
12
12
  module Messages
13
13
  class << self
14
14
  def to_brute(wire_messages)
15
- log = Brute.log
16
-
17
- wire_messages.each do |message|
18
- case message["role"]
19
- in "user"
20
- log.user(text_content(message["content"]))
21
- in "assistant"
22
- log << assistant_message(message)
23
- in "tool"
24
- log.tool(message["content"].to_s, tool_call_id: message["toolCallId"])
25
- in "system" | "developer"
26
- log.system(message["content"].to_s)
27
- in "activity" | "reasoning"
28
- # Not part of the LLM conversation: activities render client-side
29
- # (phase 4) and reasoning is provider-managed (phase 5).
30
- nil
15
+ Brute.log.tap do |log|
16
+ wire_messages.each do |message|
17
+ case message["role"]
18
+ in "user"
19
+ log.user(text_content(message["content"]))
20
+ in "assistant"
21
+ log << assistant_message(message)
22
+ in "tool"
23
+ log.tool(message["content"].to_s, tool_call_id: message["toolCallId"])
24
+ in "system" | "developer"
25
+ log.system(message["content"].to_s)
26
+ in "activity" | "reasoning"
27
+ # Not part of the LLM conversation: activities render client-side
28
+ # (phase 4) and reasoning is provider-managed (phase 5).
29
+ nil
30
+ end
31
31
  end
32
32
  end
33
-
34
- log
35
33
  end
36
34
 
37
35
  private
@@ -39,15 +37,15 @@ module AgUi
39
37
  def assistant_message(message)
40
38
  tool_calls = message["toolCalls"]&.map do |tc|
41
39
  Brute::ToolCall.new(
42
- id: tc["id"],
43
- name: tc.dig("function", "name"),
40
+ id: tc["id"],
41
+ name: tc.dig("function", "name"),
44
42
  arguments: parse_arguments(tc.dig("function", "arguments")),
45
43
  )
46
44
  end
47
45
 
48
46
  Brute::Message.new(
49
- role: :assistant,
50
- content: message["content"],
47
+ role: :assistant,
48
+ content: message["content"],
51
49
  tool_calls: tool_calls,
52
50
  )
53
51
  end
@@ -29,37 +29,37 @@ module AgUi
29
29
  #
30
30
  # Sits OUTSIDE ToolRouter in the pipeline:
31
31
  # use SystemPrompt; use A2ui, catalog: catalog; use ToolRouter
32
- class A2ui
32
+ class A2ui < Brute::Middleware::Base
33
33
  TOOL_NAME = "render_a2ui"
34
34
 
35
35
  TOOL_DEFINITION = {
36
- "name" => TOOL_NAME,
36
+ "name" => TOOL_NAME,
37
37
  "description" =>
38
- "Render a dynamic A2UI v0.9 surface with structured parameters. " \
38
+ "Render a dynamic A2UI v0.9 surface with structured parameters. " \
39
39
  "Follow the A2UI render tool usage guide provided in context.",
40
- "parameters" => {
41
- "type" => "object",
40
+ "parameters" => {
41
+ "type" => "object",
42
42
  "properties" => {
43
- "surfaceId" => {
44
- "type" => "string",
43
+ "surfaceId" => {
44
+ "type" => "string",
45
45
  "description" => "Unique surface identifier.",
46
46
  },
47
47
  "components" => {
48
- "type" => "array",
48
+ "type" => "array",
49
49
  "description" =>
50
- "A2UI v0.9 component array (flat format). The root component " \
50
+ "A2UI v0.9 component array (flat format). The root component " \
51
51
  "must have id \"root\".",
52
- "items" => { "type" => "object" },
52
+ "items" => { "type" => "object" },
53
53
  },
54
- "data" => {
55
- "type" => "object",
54
+ "data" => {
55
+ "type" => "object",
56
56
  "description" =>
57
- "Initial data model for the surface. Written to the root path. " \
57
+ "Initial data model for the surface. Written to the root path. " \
58
58
  "Use for pre-filling form values (e.g. {\"form\": {\"name\": \"Alice\"}}) " \
59
59
  "or providing data for components bound to data model paths.",
60
60
  },
61
61
  },
62
- "required" => %w[surfaceId components],
62
+ "required" => %w[surfaceId components],
63
63
  },
64
64
  }.freeze
65
65
 
@@ -123,7 +123,7 @@ module AgUi
123
123
  if catalog_components? && !metadata(env)[:a2ui_schema_injected]
124
124
  env[:messages].unshift(
125
125
  Brute::Message.new(
126
- role: :system,
126
+ role: :system,
127
127
  content: "#{SCHEMA_CONTEXT_PREAMBLE}\n#{JSON.generate(@catalog.components)}",
128
128
  ),
129
129
  )
@@ -151,9 +151,9 @@ module AgUi
151
151
  surface_id = args["surfaceId"].to_s
152
152
 
153
153
  validation = ::AgUi::A2ui.validate_components(
154
- components: args["components"],
155
- data: args["data"].is_a?(Hash) ? args["data"] : {},
156
- catalog: catalog_components? ? { "components" => @catalog.components } : nil,
154
+ components: args["components"],
155
+ data: args["data"].is_a?(Hash) ? args["data"] : {},
156
+ catalog: catalog_components? ? { "components" => @catalog.components } : nil,
157
157
  validate_bindings: false,
158
158
  )
159
159
 
@@ -161,8 +161,8 @@ module AgUi
161
161
  render_failure(env, tool_call, validation["errors"])
162
162
  else
163
163
  ops = operations(args, surface_id, emitted_surfaces(env))
164
- env[:events] << activity(tool_call, { "a2ui_operations" => ops })
165
- env[:events] << result(tool_call, { "status" => "rendered" })
164
+ emit_activity(env, tool_call, { "a2ui_operations" => ops })
165
+ emit_result(env, tool_call, { "status" => "rendered" })
166
166
  end
167
167
  end
168
168
 
@@ -171,18 +171,19 @@ module AgUi
171
171
  metadata(env)[:a2ui_attempts] = attempt
172
172
  final = attempt >= MAX_ATTEMPTS
173
173
 
174
- env[:events] << activity(
174
+ emit_activity(
175
+ env,
175
176
  tool_call,
176
177
  {
177
- "status" => final ? "failed" : "retrying",
178
- "attempt" => attempt,
178
+ "status" => final ? "failed" : "retrying",
179
+ "attempt" => attempt,
179
180
  "maxAttempts" => MAX_ATTEMPTS,
180
- "errors" => errors,
181
+ "errors" => errors,
181
182
  },
182
183
  )
183
184
 
184
185
  content = { "status" => "failed", "errors" => errors }
185
- env[:events] << result(tool_call, content)
186
+ emit_result(env, tool_call, content)
186
187
  env[:messages].tool(JSON.generate(content), tool_call_id: tool_call.id)
187
188
 
188
189
  unless final
@@ -197,55 +198,55 @@ module AgUi
197
198
  # createSurface once per surface within the turn; updateComponents
198
199
  # always; updateDataModel when initial data was given.
199
200
  def operations(args, surface_id, emitted_surfaces)
200
- ops = []
201
+ [].tap do |ops|
202
+ if emitted_surfaces.add?(surface_id)
203
+ ops << {
204
+ "version" => "v0.9",
205
+ "createSurface" => { "surfaceId" => surface_id, "catalogId" => catalog_id },
206
+ }
207
+ end
201
208
 
202
- if emitted_surfaces.add?(surface_id)
203
209
  ops << {
204
- "version" => "v0.9",
205
- "createSurface" => { "surfaceId" => surface_id, "catalogId" => catalog_id },
210
+ "version" => "v0.9",
211
+ "updateComponents" => { "surfaceId" => surface_id, "components" => args["components"] },
206
212
  }
207
- end
208
-
209
- ops << {
210
- "version" => "v0.9",
211
- "updateComponents" => { "surfaceId" => surface_id, "components" => args["components"] },
212
- }
213
213
 
214
- if args["data"].is_a?(Hash) && !args["data"].empty?
215
- ops << {
216
- "version" => "v0.9",
217
- "updateDataModel" => { "surfaceId" => surface_id, "path" => "/", "value" => args["data"] },
218
- }
214
+ if args["data"].is_a?(Hash) && !args["data"].empty?
215
+ ops << {
216
+ "version" => "v0.9",
217
+ "updateDataModel" => { "surfaceId" => surface_id, "path" => "/", "value" => args["data"] },
218
+ }
219
+ end
219
220
  end
220
-
221
- ops
222
221
  end
223
222
 
224
223
  def catalog_id
225
224
  @default_catalog_id || BASIC_CATALOG_ID
226
225
  end
227
226
 
228
- def activity(tool_call, content)
229
- {
230
- type: :activity_snapshot,
231
- data: {
232
- message_id: "a2ui-surface-#{tool_call.id}",
227
+ # One activity per tool call id, replacing rather than appending, so a
228
+ # surface re-rendered mid-turn updates in place on the client.
229
+ def emit_activity(env, tool_call, content)
230
+ env.emit(
231
+ :activity_snapshot,
232
+ {
233
+ message_id: "a2ui-surface-#{tool_call.id}",
233
234
  activity_type: "a2ui-surface",
234
- content: content,
235
- replace: true,
235
+ content: content,
236
+ replace: true,
236
237
  },
237
- }
238
+ )
238
239
  end
239
240
 
240
- def result(tool_call, content)
241
- {
242
- type: :tool_call_result,
243
- data: {
244
- message_id: SecureRandom.uuid,
241
+ def emit_result(env, tool_call, content)
242
+ env.emit(
243
+ :tool_call_result,
244
+ {
245
+ message_id: SecureRandom.uuid,
245
246
  tool_call_id: tool_call.id,
246
- content: JSON.generate(content),
247
+ content: JSON.generate(content),
247
248
  },
248
- }
249
+ )
249
250
  end
250
251
  end
251
252
  end
@@ -254,6 +255,20 @@ end
254
255
  __END__
255
256
 
256
257
  describe "AgUi::Middleware::A2ui" do
258
+
259
+ # Brute 6 replaced the events sink with the hooks registry, so a test
260
+ # that wants to see what a middleware emitted subscribes instead of reading
261
+ # an array off the env. This records the vocabulary A2ui speaks, in emission
262
+ # order, and accumulates across calls the way the old array did.
263
+ A2UI_EVENTS = %i[activity_snapshot tool_call_result].freeze
264
+
265
+ def recording(env, into)
266
+ hooks = Brute::Hooks::Registry.new
267
+ A2UI_EVENTS.each do |event|
268
+ hooks.on(event) { |_env, data, _trace| into << { type: event, data: data } }
269
+ end
270
+ Brute::Hooks::Trace.new(env, hooks: hooks)
271
+ end
257
272
  catalog = AgUi::A2ui::Catalog.new(
258
273
  catalog_id: "host://ai-catalog",
259
274
  components: { "Card" => { "description" => "A card", "props" => {} } },
@@ -292,12 +307,14 @@ describe "AgUi::Middleware::A2ui" do
292
307
  )
293
308
  end
294
309
 
295
- env = { messages: Brute.log, events: [], tools: [] }
296
- AgUi::Middleware::A2ui.new(terminal, catalog: id_only).call(env)
310
+ env = { messages: Brute.log, tools: [] }
311
+
312
+ events = []
313
+ AgUi::Middleware::A2ui.new(terminal, catalog: id_only).call(recording(env, events))
297
314
 
298
315
  seen[:messages].first.role.should == :assistant # no schema system message
299
- env[:events][0][:data][:content].key?("a2ui_operations").should == true
300
- env[:events][0][:data][:content]["a2ui_operations"][0]["createSurface"]["catalogId"]
316
+ events[0][:data][:content].key?("a2ui_operations").should == true
317
+ events[0][:data][:content]["a2ui_operations"][0]["createSurface"]["catalogId"]
301
318
  .should == "app://cat"
302
319
  end
303
320
 
@@ -319,10 +336,12 @@ describe "AgUi::Middleware::A2ui" do
319
336
  )
320
337
  end
321
338
 
322
- env = { messages: Brute.log, events: [], tools: [] }
323
- AgUi::Middleware::A2ui.new(terminal, catalog: catalog).call(env)
339
+ env = { messages: Brute.log, tools: [] }
324
340
 
325
- activity = env[:events][0]
341
+ events = []
342
+ AgUi::Middleware::A2ui.new(terminal, catalog: catalog).call(recording(env, events))
343
+
344
+ activity = events[0]
326
345
  activity[:type].should == :activity_snapshot
327
346
  activity[:data][:message_id].should == "a2ui-surface-tc1"
328
347
  activity[:data][:activity_type].should == "a2ui-surface"
@@ -333,7 +352,7 @@ describe "AgUi::Middleware::A2ui" do
333
352
  ops[1]["updateComponents"]["components"].first["id"].should == "root"
334
353
  ops[2]["updateDataModel"].should == { "surfaceId" => "s1", "path" => "/", "value" => { "title" => "hi" } }
335
354
 
336
- result = env[:events][1]
355
+ result = events[1]
337
356
  result[:type].should == :tool_call_result
338
357
  result[:data][:tool_call_id].should == "tc1"
339
358
  result[:data][:content].should == "{\"status\":\"rendered\"}"
@@ -346,10 +365,12 @@ describe "AgUi::Middleware::A2ui" do
346
365
  env[:messages] << render_call.("surfaceId" => "s1", "components" => minimal_components)
347
366
  end
348
367
 
349
- env = { messages: Brute.log, events: [], tools: [] }
350
- AgUi::Middleware::A2ui.new(terminal).call(env)
368
+ env = { messages: Brute.log, tools: [] }
369
+
370
+ events = []
371
+ AgUi::Middleware::A2ui.new(terminal).call(recording(env, events))
351
372
 
352
- ops = env[:events][0][:data][:content]["a2ui_operations"]
373
+ ops = events[0][:data][:content]["a2ui_operations"]
353
374
  ops[0]["createSurface"]["catalogId"].should ==
354
375
  "https://a2ui.org/specification/v0_9/basic_catalog.json"
355
376
  end
@@ -367,10 +388,12 @@ describe "AgUi::Middleware::A2ui" do
367
388
  )
368
389
  end
369
390
 
370
- env = { messages: Brute.log, events: [], tools: [] }
371
- AgUi::Middleware::A2ui.new(terminal, catalog: catalog).call(env)
391
+ env = { messages: Brute.log, tools: [] }
392
+
393
+ events = []
394
+ AgUi::Middleware::A2ui.new(terminal, catalog: catalog).call(recording(env, events))
372
395
 
373
- activities = env[:events].select { |e| e[:type] == :activity_snapshot }
396
+ activities = events.select { |e| e[:type] == :activity_snapshot }
374
397
  first_ops = activities[0][:data][:content]["a2ui_operations"]
375
398
  second_ops = activities[1][:data][:content]["a2ui_operations"]
376
399
 
@@ -389,12 +412,14 @@ describe "AgUi::Middleware::A2ui" do
389
412
  )
390
413
  end
391
414
 
392
- env = { messages: Brute.log, events: [], tools: [], should_exit: true }
393
- AgUi::Middleware::A2ui.new(terminal, catalog: catalog).call(env)
415
+ env = { messages: Brute.log, tools: [], should_exit: true }
394
416
 
395
- env[:events].length.should == 2
396
- env[:events][0][:data][:content]["status"].should == "retrying"
397
- JSON.parse(env[:events][1][:data][:content])["status"].should == "failed"
417
+ events = []
418
+ AgUi::Middleware::A2ui.new(terminal, catalog: catalog).call(recording(env, events))
419
+
420
+ events.length.should == 2
421
+ events[0][:data][:content]["status"].should == "retrying"
422
+ JSON.parse(events[1][:data][:content])["status"].should == "failed"
398
423
  env[:messages].last.role.should == :tool
399
424
  env[:messages].last.tool_call_id.should == "tc2"
400
425
  env[:should_exit].should == false
@@ -417,11 +442,13 @@ describe "AgUi::Middleware::A2ui" do
417
442
  env[:messages] << invalid.("tc#{calls}")
418
443
  end
419
444
 
420
- env = { messages: Brute.log, events: [], tools: [], metadata: {} }
445
+ env = { messages: Brute.log, tools: [], metadata: {} }
446
+
447
+ events = []
421
448
  mw = AgUi::Middleware::A2ui.new(terminal, catalog: catalog)
422
449
 
423
- mw.call(env)
424
- first = env[:events][0][:data][:content]
450
+ mw.call(recording(env, events))
451
+ first = events[0][:data][:content]
425
452
  first["status"].should == "retrying"
426
453
  first["attempt"].should == 1
427
454
  codes = first["errors"].map { |e| e["code"] }
@@ -430,11 +457,11 @@ describe "AgUi::Middleware::A2ui" do
430
457
  env[:should_exit].should == false
431
458
 
432
459
  env[:should_exit] = true
433
- mw.call(env)
460
+ mw.call(recording(env, events))
434
461
  env[:should_exit] = true
435
- mw.call(env)
462
+ mw.call(recording(env, events))
436
463
 
437
- statuses = env[:events].select { |e| e[:type] == :activity_snapshot }
464
+ statuses = events.select { |e| e[:type] == :activity_snapshot }
438
465
  .map { |e| e[:data][:content]["status"] }
439
466
  statuses.should == %w[retrying retrying failed]
440
467
  env[:should_exit].should == true
@@ -9,7 +9,7 @@ module AgUi
9
9
  # middleware and the terminal can honor client-driven knobs — most
10
10
  # importantly `toolChoice` ({type: "function", function: {name}}),
11
11
  # which the suggestions engine uses to force `copilotkitSuggest`.
12
- class ForwardedProps
12
+ class ForwardedProps < Brute::Middleware::Base
13
13
  def initialize(app, props: nil)
14
14
  @app = app
15
15
  @props = props