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.
@@ -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
@@ -0,0 +1,366 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bundler/setup"
4
+ require "securerandom"
5
+ require "hana"
6
+ require "ag_ui"
7
+
8
+ module AgUi
9
+ module Middleware
10
+ # Shared state (CoAgents) — the Ruby side of AG-UI's STATE_SNAPSHOT /
11
+ # STATE_DELTA channel (doc 05). Bidirectional state sync between the
12
+ # frontend's `agent.state` and the running agent.
13
+ #
14
+ # Way in:
15
+ # - seeds env[:state] from RunAgentInput.state (the state the frontend
16
+ # last held, pushed via agent.setState) so downstream middleware,
17
+ # tools, and the app can READ what the UI currently shows
18
+ # - injects two tools the model calls to WRITE state:
19
+ # AGUISendStateSnapshot({ snapshot }) — replace the whole state
20
+ # AGUISendStateDelta({ delta }) — RFC 6902 JSON Patch ops
21
+ #
22
+ # Way out, per state-tool call the model made:
23
+ # - snapshot: env[:state] = snapshot; emits STATE_SNAPSHOT
24
+ # - delta: patches env[:state] (Hana); emits STATE_DELTA
25
+ # - both append a {"status":"ok"} :tool message and emit a
26
+ # TOOL_CALL_RESULT — the same shape a server tool produces — then let
27
+ # the run CONTINUE (unlike a browser/client tool) so the model can act
28
+ # on the new state or confirm to the user. State tools are server-side.
29
+ #
30
+ # The TOOL_CALL_START/ARGS/END chrome is left to ToolRouter (it advertises
31
+ # nothing about these being "client" vs "server" — it just streams the
32
+ # call), exactly as A2ui leaves its render_a2ui call to ToolRouter. Compose
33
+ # State OUTSIDE ToolRouter:
34
+ #
35
+ # use Loop::ToolResult
36
+ # use State, state: input.state
37
+ # use ToolRouter, tools: input.tools
38
+ #
39
+ # The frontend applies the same snapshot/patch to its own store; env[:state]
40
+ # is kept coherent so the agent's own later reads (and further deltas) see
41
+ # the current value.
42
+ class State < Brute::Middleware::Base
43
+ SNAPSHOT_TOOL = "AGUISendStateSnapshot"
44
+ DELTA_TOOL = "AGUISendStateDelta"
45
+ TOOL_NAMES = [SNAPSHOT_TOOL, DELTA_TOOL].freeze
46
+
47
+ SNAPSHOT_DEFINITION = {
48
+ "name" => SNAPSHOT_TOOL,
49
+ "description" =>
50
+ "Replace the shared application state with a new snapshot; the " \
51
+ "frontend re-renders from it. Send the COMPLETE next state object, " \
52
+ "not a diff. Prefer AGUISendStateDelta for small targeted changes.",
53
+ "parameters" => {
54
+ "type" => "object",
55
+ "properties" => {
56
+ "snapshot" => {
57
+ "type" => "object",
58
+ "description" => "The complete new application state.",
59
+ },
60
+ },
61
+ "required" => %w[snapshot],
62
+ },
63
+ }.freeze
64
+
65
+ DELTA_DEFINITION = {
66
+ "name" => DELTA_TOOL,
67
+ "description" =>
68
+ "Apply a JSON Patch (RFC 6902) to the shared application state — an " \
69
+ "array of {op, path, value} operations. Use for small, targeted " \
70
+ "changes. Paths are JSON Pointers, e.g. " \
71
+ "\"/documentEditor/activeTabId\". ops: add, replace, remove, move, " \
72
+ "copy, test.",
73
+ "parameters" => {
74
+ "type" => "object",
75
+ "properties" => {
76
+ "delta" => {
77
+ "type" => "array",
78
+ "description" =>
79
+ "JSON Patch operations, e.g. " \
80
+ "[{\"op\":\"replace\",\"path\":\"/theme\",\"value\":\"dark\"}].",
81
+ "items" => { "type" => "object" },
82
+ },
83
+ },
84
+ "required" => %w[delta],
85
+ },
86
+ }.freeze
87
+
88
+ def initialize(app, state: nil)
89
+ @app = app
90
+ @initial_state = normalize(state)
91
+ end
92
+
93
+ def call(env)
94
+ seed_state(env)
95
+ advertise(env)
96
+
97
+ before = env[:messages].length
98
+ @app.call(env)
99
+
100
+ # Only this iteration's assistant message — seeded history can carry
101
+ # old state-tool turns that must not re-emit.
102
+ appended = env[:messages][before..] || []
103
+ assistant = appended.reverse.find { |m| m.respond_to?(:tool_call?) && m.tool_call? }
104
+ if assistant
105
+ apply_state_calls(env, assistant)
106
+ end
107
+
108
+ env
109
+ end
110
+
111
+ private
112
+
113
+ def apply_state_calls(env, assistant)
114
+ state_calls = assistant.tool_calls.select { |tc| TOOL_NAMES.include?(tc.name) }
115
+ unless state_calls.empty?
116
+ state_calls.each { |tool_call| handle(env, tool_call) }
117
+
118
+ # State tools don't end the run — they're server-side, not browser
119
+ # tools — so let Loop::ToolResult continue (last message is now
120
+ # :tool). Only override the exit for a PURE state turn; a real
121
+ # client tool mixed in still ends the run so the browser runs it.
122
+ if assistant.tool_calls.all? { |tc| TOOL_NAMES.include?(tc.name) }
123
+ env[:should_exit] = false
124
+ end
125
+ end
126
+ end
127
+
128
+ def seed_state(env)
129
+ unless env.key?(:state)
130
+ env[:state] = @initial_state
131
+ end
132
+ end
133
+
134
+ # Idempotent — the turn loop re-enters every iteration.
135
+ def advertise(env)
136
+ env[:tools] ||= []
137
+ names = env[:tools].map { |t| t.is_a?(Hash) ? t["name"] : nil }
138
+ [SNAPSHOT_DEFINITION, DELTA_DEFINITION].each do |definition|
139
+ unless names.include?(definition["name"])
140
+ env[:tools] << definition
141
+ end
142
+ end
143
+ end
144
+
145
+ def handle(env, tool_call)
146
+ case tool_call.name
147
+ when SNAPSHOT_TOOL then apply_snapshot(env, tool_call)
148
+ when DELTA_TOOL then apply_delta(env, tool_call)
149
+ end
150
+ end
151
+
152
+ def apply_snapshot(env, tool_call)
153
+ snapshot = tool_call.arguments["snapshot"]
154
+ if snapshot.is_a?(Hash)
155
+ env[:state] = snapshot
156
+ env.emit(:state_snapshot, { snapshot: snapshot })
157
+ ack(env, tool_call)
158
+ else
159
+ ack(env, tool_call, error: "snapshot must be an object")
160
+ end
161
+ end
162
+
163
+ def apply_delta(env, tool_call)
164
+ delta = tool_call.arguments["delta"]
165
+ if delta.is_a?(Array)
166
+ patch_state(env, tool_call, delta)
167
+ else
168
+ ack(env, tool_call, error: "delta must be an array of JSON Patch operations")
169
+ end
170
+ end
171
+
172
+ # Keep our copy coherent for later reads/deltas; the frontend applies
173
+ # the same patch to its own store. A bad patch is reported back to the
174
+ # model as a tool error rather than raising the run.
175
+ def patch_state(env, tool_call, delta)
176
+ patched = Hana::Patch.new(delta).apply(deep_dup(env[:state] || {}))
177
+ rescue StandardError => e
178
+ ack(env, tool_call, error: "invalid JSON Patch: #{e.message}")
179
+ else
180
+ env[:state] = patched
181
+ env.emit(:state_delta, { delta: delta })
182
+ ack(env, tool_call)
183
+ end
184
+
185
+ # Append the tool result (so the assistant tool-call has its matching
186
+ # :tool message and Loop::ToolResult continues) and emit TOOL_CALL_RESULT
187
+ # on the wire — the exact shape server tools produce.
188
+ def ack(env, tool_call, error: nil)
189
+ if error
190
+ content = { "status" => "error", "error" => error }
191
+ else
192
+ content = { "status" => "ok" }
193
+ end
194
+ json = JSON.generate(content)
195
+ env[:messages].tool(json, tool_call_id: tool_call.id)
196
+ env.emit(
197
+ :tool_call_result,
198
+ {
199
+ message_id: SecureRandom.uuid,
200
+ tool_call_id: tool_call.id,
201
+ content: json,
202
+ },
203
+ )
204
+ end
205
+
206
+ def normalize(state)
207
+ if state.nil?
208
+ nil
209
+ elsif state.respond_to?(:to_h)
210
+ state.to_h
211
+ else
212
+ state
213
+ end
214
+ end
215
+
216
+ def deep_dup(obj)
217
+ case obj
218
+ when Hash then obj.each_with_object({}) { |(k, v), h| h[k] = deep_dup(v) }
219
+ when Array then obj.map { |v| deep_dup(v) }
220
+ else obj
221
+ end
222
+ end
223
+ end
224
+ end
225
+ end
226
+
227
+ __END__
228
+
229
+ describe "AgUi::Middleware::State" do
230
+
231
+ # Brute 6 replaced the env[:events] sink with the hooks registry, so a test
232
+ # that wants to see what a middleware emitted subscribes instead of reading
233
+ # an array off the env. This records the vocabulary State speaks, in
234
+ # emission order.
235
+ STATE_EVENTS = %i[state_snapshot state_delta tool_call_result].freeze
236
+
237
+ def recording(env, into)
238
+ hooks = Brute::Hooks::Registry.new
239
+ STATE_EVENTS.each do |event|
240
+ hooks.on(event) { |_env, data, _trace| into << { type: event, data: data } }
241
+ end
242
+ Brute::Hooks::Trace.new(env, hooks: hooks)
243
+ end
244
+ snapshot_call = ->(args) do
245
+ Brute::Message.new(
246
+ role: :assistant, content: nil,
247
+ tool_calls: [{ id: "tc1", name: "AGUISendStateSnapshot", arguments: args }],
248
+ )
249
+ end
250
+
251
+ delta_call = ->(args) do
252
+ Brute::Message.new(
253
+ role: :assistant, content: nil,
254
+ tool_calls: [{ id: "tc1", name: "AGUISendStateDelta", arguments: args }],
255
+ )
256
+ end
257
+
258
+ it "advertises both state tools idempotently and seeds env[:state] from input" do
259
+ seen = nil
260
+ mw = AgUi::Middleware::State.new(->(env) { seen = env }, state: { "theme" => "light" })
261
+ env = { messages: Brute.log, tools: [{ "name" => "navigate" }] }
262
+ mw.call(recording(env, []))
263
+ mw.call(recording(env, [])) # second loop iteration — no dupes
264
+
265
+ seen[:tools].map { |t| t["name"] }.should ==
266
+ %w[navigate AGUISendStateSnapshot AGUISendStateDelta]
267
+ seen[:state].should == { "theme" => "light" }
268
+ end
269
+
270
+ it "seeds from a Definition-like state via to_h" do
271
+ definition = Object.new
272
+ def definition.to_h = { "count" => 1 }
273
+ seen = nil
274
+ AgUi::Middleware::State.new(->(env) { seen = env }, state: definition)
275
+ .call(recording({ messages: Brute.log, tools: [] }, []))
276
+ seen[:state].should == { "count" => 1 }
277
+ end
278
+
279
+ it "AGUISendStateSnapshot: sets state, emits STATE_SNAPSHOT + result, continues the run" do
280
+ terminal = ->(env) { env[:messages] << snapshot_call.("snapshot" => { "count" => 3 }) }
281
+ env = { messages: Brute.log, tools: [], should_exit: true }
282
+ events = []
283
+ AgUi::Middleware::State.new(terminal).call(recording(env, events))
284
+
285
+ env[:state].should == { "count" => 3 }
286
+ events.map { |e| e[:type] }.should == %i[state_snapshot tool_call_result]
287
+ events[0][:data][:snapshot].should == { "count" => 3 }
288
+ env[:messages].last.role.should == :tool
289
+ env[:messages].last.tool_call_id.should == "tc1"
290
+ env[:messages].last.content.should == "{\"status\":\"ok\"}"
291
+ env[:should_exit].should == false
292
+ end
293
+
294
+ it "AGUISendStateDelta: patches state (Hana), emits STATE_DELTA with the raw ops" do
295
+ delta = [{ "op" => "replace", "path" => "/theme", "value" => "dark" }]
296
+ terminal = ->(env) { env[:messages] << delta_call.("delta" => delta) }
297
+ env = { messages: Brute.log, tools: [], state: { "theme" => "light" }, should_exit: true }
298
+ events = []
299
+ AgUi::Middleware::State.new(terminal).call(recording(env, events))
300
+
301
+ env[:state].should == { "theme" => "dark" }
302
+ events.map { |e| e[:type] }.should == %i[state_delta tool_call_result]
303
+ events[0][:data][:delta].should == delta
304
+ env[:should_exit].should == false
305
+ end
306
+
307
+ it "adds a nested key via delta against seeded state" do
308
+ delta = [{ "op" => "add", "path" => "/documentEditor", "value" => { "activeTabId" => "doc-2" } }]
309
+ terminal = ->(env) { env[:messages] << delta_call.("delta" => delta) }
310
+ env = { messages: Brute.log, tools: [], should_exit: true }
311
+ events = []
312
+ AgUi::Middleware::State.new(terminal, state: { "documentEditor" => { "activeTabId" => "doc-1" } })
313
+ .call(recording(env, events))
314
+
315
+ env[:state].should == { "documentEditor" => { "activeTabId" => "doc-2" } }
316
+ events[0][:type].should == :state_delta
317
+ end
318
+
319
+ it "reports a bad patch back to the model as a tool error, no STATE_DELTA emitted" do
320
+ delta = [{ "op" => "replace", "path" => "/missing/deep", "value" => 1 }]
321
+ terminal = ->(env) { env[:messages] << delta_call.("delta" => delta) }
322
+ env = { messages: Brute.log, tools: [], state: {}, should_exit: true }
323
+ events = []
324
+ AgUi::Middleware::State.new(terminal).call(recording(env, events))
325
+
326
+ events.map { |e| e[:type] }.should == %i[tool_call_result]
327
+ JSON.parse(events[0][:data][:content])["status"].should == "error"
328
+ env[:messages].last.role.should == :tool
329
+ end
330
+
331
+ it "rejects a non-array delta and a non-object snapshot as tool errors" do
332
+ [delta_call.("delta" => "nope"), snapshot_call.("snapshot" => "nope")].each do |msg|
333
+ terminal = ->(env) { env[:messages] << msg }
334
+ env = { messages: Brute.log, tools: [] }
335
+ events = []
336
+ AgUi::Middleware::State.new(terminal).call(recording(env, events))
337
+ JSON.parse(events.last[:data][:content])["status"].should == "error"
338
+ end
339
+ end
340
+
341
+ it "leaves should_exit set when a real client tool is mixed into the turn" do
342
+ terminal = ->(env) do
343
+ env[:messages] << Brute::Message.new(
344
+ role: :assistant, content: nil,
345
+ tool_calls: [
346
+ { id: "tc1", name: "AGUISendStateSnapshot", arguments: { "snapshot" => { "a" => 1 } } },
347
+ { id: "tc2", name: "navigate", arguments: { "path" => "/x" } },
348
+ ],
349
+ )
350
+ end
351
+ env = { messages: Brute.log, tools: [], should_exit: true }
352
+ events = []
353
+ AgUi::Middleware::State.new(terminal).call(recording(env, events))
354
+
355
+ events.any? { |e| e[:type] == :state_snapshot }.should == true
356
+ env[:should_exit].should == true # browser still needs to run navigate
357
+ end
358
+
359
+ it "does nothing on a plain text turn" do
360
+ env = { messages: Brute.log, tools: [] }
361
+ events = []
362
+ AgUi::Middleware::State.new(->(e) { e[:messages].assistant("hi") }).call(recording(env, events))
363
+ events.should == []
364
+ env.key?(:should_exit).should == false
365
+ end
366
+ end
@@ -11,7 +11,7 @@ module AgUi
11
11
  #
12
12
  # Skips entirely when the history already carries a system message
13
13
  # (the client can send its own via system/developer roles).
14
- class SystemPrompt
14
+ class SystemPrompt < Brute::Middleware::Base
15
15
  def initialize(app, prompt: nil, context: nil)
16
16
  @app = app
17
17
  @prompt = prompt
@@ -26,7 +26,7 @@ module AgUi
26
26
  # RUN_FINISHED and the browser executes it (multi-run model)
27
27
  # - mixed turns: server tools still execute, but any client call
28
28
  # ends the run
29
- class ToolRouter
29
+ class ToolRouter < Brute::Middleware::Base
30
30
  def initialize(app, tools: nil, server_tools: nil)
31
31
  @app = app
32
32
  @client_tools = tools || []
@@ -72,9 +72,9 @@ module AgUi
72
72
 
73
73
  def server_definition(tool)
74
74
  {
75
- "name" => tool[:name].to_s,
75
+ "name" => tool[:name].to_s,
76
76
  "description" => tool[:description].to_s,
77
- "parameters" => tool[:parameters] || { "type" => "object" },
77
+ "parameters" => tool[:parameters] || { "type" => "object" },
78
78
  }
79
79
  end
80
80
 
@@ -82,7 +82,7 @@ module AgUi
82
82
  client_called = false
83
83
 
84
84
  tool_calls.each do |tool_call|
85
- emit_call(env[:events], tool_call)
85
+ emit_call(env, tool_call)
86
86
 
87
87
  server = @server_tools[tool_call.name]
88
88
  if server
@@ -97,19 +97,16 @@ module AgUi
97
97
  end
98
98
  end
99
99
 
100
- def emit_call(events, tool_call)
101
- events << {
102
- type: :tool_call_start,
103
- data: { tool_call_id: tool_call.id, tool_call_name: tool_call.name },
104
- }
105
- events << {
106
- type: :tool_call_args,
107
- data: { tool_call_id: tool_call.id, delta: JSON.generate(tool_call.arguments) },
108
- }
109
- events << {
110
- type: :tool_call_end,
111
- data: { tool_call_id: tool_call.id },
112
- }
100
+ def emit_call(env, tool_call)
101
+ env.emit(
102
+ :tool_call_start,
103
+ { tool_call_id: tool_call.id, tool_call_name: tool_call.name },
104
+ )
105
+ env.emit(
106
+ :tool_call_args,
107
+ { tool_call_id: tool_call.id, delta: JSON.generate(tool_call.arguments) },
108
+ )
109
+ env.emit(:tool_call_end, { tool_call_id: tool_call.id })
113
110
  end
114
111
 
115
112
  def execute_server_tool(env, tool_call, tool)
@@ -120,16 +117,20 @@ module AgUi
120
117
  result = { "error" => e.message }
121
118
  end
122
119
 
123
- content = result.is_a?(String) ? result : JSON.generate(result)
120
+ if result.is_a?(String)
121
+ content = result
122
+ else
123
+ content = JSON.generate(result)
124
+ end
124
125
  env[:messages].tool(content, tool_call_id: tool_call.id)
125
- env[:events] << {
126
- type: :tool_call_result,
127
- data: {
128
- message_id: SecureRandom.uuid,
126
+ env.emit(
127
+ :tool_call_result,
128
+ {
129
+ message_id: SecureRandom.uuid,
129
130
  tool_call_id: tool_call.id,
130
- content: content,
131
+ content: content,
131
132
  },
132
- }
133
+ )
133
134
  end
134
135
  end
135
136
  end
@@ -138,6 +139,20 @@ end
138
139
  __END__
139
140
 
140
141
  describe "AgUi::Middleware::ToolRouter" do
142
+ # Brute 6 replaced the env[:events] sink with the hooks registry, so a test
143
+ # that wants to see what a middleware emitted subscribes instead of reading
144
+ # an array off the env. This records the AG-UI tool vocabulary in emission
145
+ # order, which keeps the assertions below the shape they always had.
146
+ TOOL_EVENTS = %i[tool_call_start tool_call_args tool_call_end tool_call_result].freeze
147
+
148
+ def recording(env, into)
149
+ hooks = Brute::Hooks::Registry.new
150
+ TOOL_EVENTS.each do |event|
151
+ hooks.on(event) { |_env, data, _trace| into << { type: event, data: data } }
152
+ end
153
+ Brute::Hooks::Trace.new(env, hooks: hooks)
154
+ end
155
+
141
156
  it "advertises client and server tools idempotently across iterations" do
142
157
  seen = nil
143
158
  server_tool = { name: "get_time", description: "Now", handler: -> (_args) { "12:00" } }
@@ -147,9 +162,9 @@ describe "AgUi::Middleware::ToolRouter" do
147
162
  server_tools: [server_tool],
148
163
  )
149
164
 
150
- env = { messages: Brute.log, events: [] }
151
- mw.call(env)
152
- mw.call(env) # second loop iteration
165
+ env = { messages: Brute.log }
166
+ mw.call(recording(env, []))
167
+ mw.call(recording(env, [])) # second loop iteration
153
168
 
154
169
  seen.map { |t| t["name"] }.should == %w[navigate get_time]
155
170
  seen.last["parameters"].should == { "type" => "object" }
@@ -169,17 +184,18 @@ describe "AgUi::Middleware::ToolRouter" do
169
184
  )
170
185
  end
171
186
 
172
- env = { messages: Brute.log, events: [] }
173
- AgUi::Middleware::ToolRouter.new(terminal, server_tools: [server_tool]).call(env)
187
+ env = { messages: Brute.log }
188
+ events = []
189
+ AgUi::Middleware::ToolRouter.new(terminal, server_tools: [server_tool]).call(recording(env, events))
174
190
 
175
191
  env[:messages].last.role.should == :tool
176
192
  env[:messages].last.tool_call_id.should == "tc1"
177
193
  env[:messages].last.content.should == "{\"found\":42}"
178
194
 
179
- env[:events].map { |e| e[:type] }.should == %i[
195
+ events.map { |e| e[:type] }.should == %i[
180
196
  tool_call_start tool_call_args tool_call_end tool_call_result
181
197
  ]
182
- env[:events].last[:data][:content].should == "{\"found\":42}"
198
+ events.last[:data][:content].should == "{\"found\":42}"
183
199
  env.key?(:should_exit).should == false
184
200
  end
185
201
 
@@ -192,8 +208,8 @@ describe "AgUi::Middleware::ToolRouter" do
192
208
  )
193
209
  end
194
210
 
195
- env = { messages: Brute.log, events: [] }
196
- AgUi::Middleware::ToolRouter.new(terminal, server_tools: [server_tool]).call(env)
211
+ env = { messages: Brute.log }
212
+ AgUi::Middleware::ToolRouter.new(terminal, server_tools: [server_tool]).call(recording(env, []))
197
213
 
198
214
  env[:messages].last.content.should == "{\"error\":\"kaput\"}"
199
215
  env.key?(:should_exit).should == false
@@ -211,11 +227,12 @@ describe "AgUi::Middleware::ToolRouter" do
211
227
  )
212
228
  end
213
229
 
214
- env = { messages: Brute.log, events: [] }
215
- AgUi::Middleware::ToolRouter.new(terminal, server_tools: [server_tool]).call(env)
230
+ env = { messages: Brute.log }
231
+ events = []
232
+ AgUi::Middleware::ToolRouter.new(terminal, server_tools: [server_tool]).call(recording(env, events))
216
233
 
217
234
  env[:should_exit].should == true
218
- env[:events].count { |e| e[:type] == :tool_call_result }.should == 1
235
+ events.count { |e| e[:type] == :tool_call_result }.should == 1
219
236
  end
220
237
 
221
238
  it "emits TOOL_CALL events and exits when the turn ends on client tool calls" do
@@ -229,23 +246,25 @@ describe "AgUi::Middleware::ToolRouter" do
229
246
  )
230
247
  end
231
248
 
232
- env = { messages: Brute.log, events: [] }
233
- AgUi::Middleware::ToolRouter.new(terminal).call(env)
249
+ env = { messages: Brute.log }
250
+ events = []
251
+ AgUi::Middleware::ToolRouter.new(terminal).call(recording(env, events))
234
252
 
235
- env[:events].map { |e| e[:type] }.should == %i[
253
+ events.map { |e| e[:type] }.should == %i[
236
254
  tool_call_start tool_call_args tool_call_end
237
255
  tool_call_start tool_call_args tool_call_end
238
256
  ]
239
- env[:events][0][:data].should == { tool_call_id: "tc1", tool_call_name: "navigate" }
240
- env[:events][1][:data][:delta].should == "{\"path\":\"/data\"}"
257
+ events[0][:data].should == { tool_call_id: "tc1", tool_call_name: "navigate" }
258
+ events[1][:data][:delta].should == "{\"path\":\"/data\"}"
241
259
  env[:should_exit].should == true
242
260
  end
243
261
 
244
262
  it "does nothing on the way out for plain text turns" do
245
- env = { messages: Brute.log, events: [] }
246
- AgUi::Middleware::ToolRouter.new(->(e) { e[:messages].assistant("hi") }).call(env)
263
+ env = { messages: Brute.log }
264
+ events = []
265
+ AgUi::Middleware::ToolRouter.new(->(e) { e[:messages].assistant("hi") }).call(recording(env, events))
247
266
 
248
- env[:events].should == []
267
+ events.should == []
249
268
  env.key?(:should_exit).should == false
250
269
  end
251
270
  end
@@ -33,12 +33,12 @@ module AgUi
33
33
  camel = snake[k] || k
34
34
 
35
35
  if props.include?(camel)
36
- @data[camel] = if value.is_a?(Definition)
37
- value.to_h
36
+ if value.is_a?(Definition)
37
+ @data[camel] = value.to_h
38
38
  elsif (ref_info = refs[camel])
39
- wrap_ref(value, ref_info)
39
+ @data[camel] = wrap_ref(value, ref_info)
40
40
  else
41
- value
41
+ @data[camel] = value
42
42
  end
43
43
  end
44
44
  end
@@ -91,7 +91,7 @@ module AgUi
91
91
  raise ValidationError.new(
92
92
  errors,
93
93
  definition_name: self.class.definition_name,
94
- data: to_h,
94
+ data: to_h,
95
95
  )
96
96
  end
97
97
  end
@@ -129,14 +129,18 @@ module AgUi
129
129
  properties.each do |camel_key, prop_schema|
130
130
  schema = unwrap_optional(prop_schema)
131
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
132
+ kind = nil
133
+ ref = nil
134
+ if (r = schema["$ref"])
135
+ kind = :object
136
+ ref = r
137
+ elsif schema["type"] == "array" && (r = schema.dig("items", "$ref"))
138
+ kind = :array
139
+ ref = r
140
+ elsif schema["type"] == "object" && (r = schema.dig("additionalProperties", "$ref"))
141
+ kind = :map
142
+ ref = r
143
+ end
140
144
 
141
145
  if ref
142
146
  name = ref_name_for(ref)
@@ -168,13 +172,13 @@ module AgUi
168
172
  end
169
173
 
170
174
  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
175
+ {}.tap do |map|
176
+ camel_keys.each do |camel|
177
+ snake = camel_to_snake(camel)
178
+ map[snake] = camel
179
+ map[camel] = camel
180
+ end
176
181
  end
177
- map
178
182
  end
179
183
 
180
184
  def camel_to_snake(str)
@@ -42,7 +42,7 @@ module AgUi
42
42
  validation_error = Protocol::JsonSchema::ValidationError.new(
43
43
  errors,
44
44
  definition_name: "RunAgentInput",
45
- data: raw,
45
+ data: raw,
46
46
  )
47
47
  raise InvalidError, validation_error.message
48
48
  end