ag-ui 0.2.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,42 +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
- activity_snapshot: :translate_activity_snapshot,
28
- reasoning_start: :translate_reasoning_start,
29
- 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,
30
36
  reasoning_message_content: :translate_reasoning_message_content,
31
- reasoning_message_end: :translate_reasoning_message_end,
32
- reasoning_end: :translate_reasoning_end,
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,
33
43
  }.freeze
34
44
 
35
45
  def initialize(stream)
36
46
  @stream = stream
37
47
  end
38
48
 
39
- def <<(event)
40
- handler = TRANSLATIONS[event[:type]]
41
- if handler
42
- 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 || {}) }
43
59
  end
44
- self
60
+ hooks
45
61
  end
46
62
 
47
63
  private
@@ -64,8 +80,8 @@ module AgUi
64
80
 
65
81
  def translate_tool_call_start(data)
66
82
  @stream.tool_call_start(
67
- tool_call_id: data[:tool_call_id],
68
- tool_call_name: data[:tool_call_name],
83
+ tool_call_id: data[:tool_call_id],
84
+ tool_call_name: data[:tool_call_name],
69
85
  parent_message_id: data[:parent_message_id],
70
86
  )
71
87
  end
@@ -82,18 +98,52 @@ module AgUi
82
98
 
83
99
  def translate_tool_call_result(data)
84
100
  @stream.tool_call_result(
85
- message_id: data[:message_id],
101
+ message_id: data[:message_id],
86
102
  tool_call_id: data[:tool_call_id],
87
- content: data[:content],
103
+ content: data[:content],
88
104
  )
89
105
  end
90
106
 
107
+ # Shared state (CoAgents). snapshot replaces the whole state; delta is a
108
+ # JSON Patch (RFC 6902) op array the client applies to its own store.
109
+ def translate_state_snapshot(data)
110
+ @stream.state_snapshot(snapshot: data[:snapshot])
111
+ end
112
+
113
+ def translate_state_delta(data)
114
+ @stream.state_delta(delta: data[:delta])
115
+ end
116
+
117
+ def translate_messages_snapshot(data)
118
+ @stream.messages_snapshot(messages: data[:messages])
119
+ end
120
+
121
+ # Structured progress markers (paired start/finish by step name).
122
+ def translate_step_started(data)
123
+ @stream.step_started(step_name: data[:step_name])
124
+ end
125
+
126
+ def translate_step_finished(data)
127
+ @stream.step_finished(step_name: data[:step_name])
128
+ end
129
+
130
+ # Escape hatches: CUSTOM carries app/agent-defined events (e.g. the
131
+ # "PredictState" convention for predictive state updates); RAW passes a
132
+ # framework event straight through.
133
+ def translate_custom(data)
134
+ @stream.custom(name: data[:name], value: data[:value])
135
+ end
136
+
137
+ def translate_raw(data)
138
+ @stream.raw(event: data[:event], source: data[:source])
139
+ end
140
+
91
141
  def translate_activity_snapshot(data)
92
142
  @stream.activity_snapshot(
93
- message_id: data[:message_id],
143
+ message_id: data[:message_id],
94
144
  activity_type: data[:activity_type],
95
- content: data[:content],
96
- replace: data.fetch(:replace, true),
145
+ content: data[:content],
146
+ replace: data.fetch(:replace, true),
97
147
  )
98
148
  end
99
149
 
@@ -124,6 +174,15 @@ end
124
174
  __END__
125
175
 
126
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
+
127
186
  read_frames = ->(stream) do
128
187
  frames = []
129
188
  while (chunk = stream.read)
@@ -134,12 +193,12 @@ describe "AgUi::EventBridge" do
134
193
 
135
194
  it "translates text events into SSE frames as they arrive" do
136
195
  stream = AgUi::Server::SSE::Stream.new(thread_id: "t1", run_id: "r1")
137
- bridge = AgUi::EventBridge.new(stream)
196
+ env = bridged(stream)
138
197
 
139
- bridge << { type: :text_message_start, data: { message_id: "m1" } }
140
- bridge << { type: :text_message_content, data: { message_id: "m1", delta: "Hel" } }
141
- bridge << { type: :text_message_content, data: { message_id: "m1", delta: "lo" } }
142
- 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" })
143
202
  stream.finish
144
203
 
145
204
  frames = read_frames.(stream)
@@ -151,24 +210,65 @@ describe "AgUi::EventBridge" do
151
210
 
152
211
  it "drops empty deltas (protocol rule)" do
153
212
  stream = AgUi::Server::SSE::Stream.new(thread_id: "t1", run_id: "r1")
154
- bridge = AgUi::EventBridge.new(stream)
213
+ env = bridged(stream)
155
214
 
156
- bridge << { type: :text_message_content, data: { message_id: "m1", delta: "" } }
157
- 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 })
158
217
  stream.finish
159
218
 
160
219
  read_frames.(stream).should == []
161
220
  end
162
221
 
163
- 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
164
223
  stream = AgUi::Server::SSE::Stream.new(thread_id: "t1", run_id: "r1")
165
- bridge = AgUi::EventBridge.new(stream)
224
+ env = bridged(stream)
166
225
 
167
- result = bridge << { type: :log, data: { note: "internal" } }
168
- bridge << { type: :whatever }
226
+ env.emit(Brute::Hooks::LLM_START_EVENT)
227
+ env.emit(:whatever, { note: "internal" })
169
228
  stream.finish
170
229
 
171
- result.should.equal?(bridge)
172
230
  read_frames.(stream).should == []
173
231
  end
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
+
240
+ it "translates shared-state events (snapshot + JSON-Patch delta)" do
241
+ stream = AgUi::Server::SSE::Stream.new(thread_id: "t1", run_id: "r1")
242
+ env = bridged(stream)
243
+
244
+ env.emit(:state_snapshot, { snapshot: { "theme" => "dark" } })
245
+ env.emit(
246
+ :state_delta,
247
+ { delta: [{ "op" => "replace", "path" => "/theme", "value" => "light" }] },
248
+ )
249
+ stream.finish
250
+
251
+ frames = read_frames.(stream)
252
+ frames.map { |f| f["type"] }.should == %w[STATE_SNAPSHOT STATE_DELTA]
253
+ frames[0]["snapshot"].should == { "theme" => "dark" }
254
+ frames[1]["delta"].first["path"].should == "/theme"
255
+ end
256
+
257
+ it "translates CUSTOM (e.g. the PredictState convention) and STEP markers" do
258
+ stream = AgUi::Server::SSE::Stream.new(thread_id: "t1", run_id: "r1")
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" })
267
+ stream.finish
268
+
269
+ frames = read_frames.(stream)
270
+ frames.map { |f| f["type"] }.should == %w[STEP_STARTED CUSTOM STEP_FINISHED]
271
+ frames[0]["stepName"].should == "plan"
272
+ frames[1]["name"].should == "PredictState"
273
+ end
174
274
  end
@@ -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