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.
- checksums.yaml +7 -0
- data/LICENSE +21 -0
- data/data/ag_ui.json +3890 -0
- data/data/generate-ag-ui-schema.py +50 -0
- data/lib/ag_ui/a2ui/catalog.rb +106 -0
- data/lib/ag_ui/a2ui/recovery.rb +216 -0
- data/lib/ag_ui/a2ui/validate.rb +616 -0
- data/lib/ag_ui/event_bridge.rb +174 -0
- data/lib/ag_ui/messages.rb +159 -0
- data/lib/ag_ui/middleware/a2ui.rb +442 -0
- data/lib/ag_ui/middleware/forwarded_props.rb +38 -0
- data/lib/ag_ui/middleware/system_prompt.rb +90 -0
- data/lib/ag_ui/middleware/tool_router.rb +251 -0
- data/lib/ag_ui/protocol/json_schema/definition.rb +233 -0
- data/lib/ag_ui/protocol/json_schema/validation_error.rb +122 -0
- data/lib/ag_ui/protocol/json_schema.rb +270 -0
- data/lib/ag_ui/run_input.rb +149 -0
- data/lib/ag_ui/run_loop.rb +285 -0
- data/lib/ag_ui/run_store.rb +180 -0
- data/lib/ag_ui/server/info.rb +85 -0
- data/lib/ag_ui/server/middleware/sse_stream.rb +160 -0
- data/lib/ag_ui/server/sse/event_encoder.rb +69 -0
- data/lib/ag_ui/server/sse/stream.rb +235 -0
- data/lib/ag_ui/server/triage.rb +208 -0
- data/lib/ag_ui/server.rb +316 -0
- data/lib/ag_ui/terminals/ruby_llm.rb +558 -0
- data/lib/ag_ui/version.rb +5 -0
- data/lib/ag_ui.rb +32 -0
- metadata +242 -0
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "bundler/setup"
|
|
4
|
+
require "ag_ui"
|
|
5
|
+
require "protocol/http/body/writable"
|
|
6
|
+
|
|
7
|
+
module AgUi
|
|
8
|
+
class Server
|
|
9
|
+
module SSE
|
|
10
|
+
# Async-native SSE body built on ::Protocol::HTTP::Body::Writable,
|
|
11
|
+
# ported from a2a's Server::SSE::Stream with the AG-UI vocabulary.
|
|
12
|
+
#
|
|
13
|
+
# Falcon's protocol-rack passes Readable subclasses through untouched,
|
|
14
|
+
# giving true async streaming with backpressure. write() pushes frames,
|
|
15
|
+
# read() pops them (the HTTP server does this), close_write signals EOF.
|
|
16
|
+
#
|
|
17
|
+
# One typed emitter per AG-UI event, generated from the schema bundle:
|
|
18
|
+
#
|
|
19
|
+
# stream = AgUi::Server::SSE::Stream.new(thread_id: "t1", run_id: "r1")
|
|
20
|
+
# stream.run_started
|
|
21
|
+
# stream.text_message_start(message_id: "m1", role: "assistant")
|
|
22
|
+
# stream.text_message_content(message_id: "m1", delta: "Hello")
|
|
23
|
+
# stream.text_message_end(message_id: "m1")
|
|
24
|
+
# stream.run_finished
|
|
25
|
+
# stream.finish
|
|
26
|
+
#
|
|
27
|
+
# threadId / runId are injected automatically wherever the event schema
|
|
28
|
+
# has those properties (RUN_STARTED, RUN_FINISHED, ...); kwargs override.
|
|
29
|
+
# Every event is schema-validated before hitting the wire (validate:
|
|
30
|
+
# false to skip) — a wire-contract bug should raise, not stream.
|
|
31
|
+
class Stream < ::Protocol::HTTP::Body::Writable
|
|
32
|
+
# Response headers for the /run SSE response. Matches the Node
|
|
33
|
+
# runtime (Cache-Control: no-cache, keep-alive) plus
|
|
34
|
+
# x-accel-buffering to defeat proxy buffering.
|
|
35
|
+
SSE_HEADERS = {
|
|
36
|
+
"content-type" => "text/event-stream",
|
|
37
|
+
"cache-control" => "no-cache",
|
|
38
|
+
"x-accel-buffering" => "no",
|
|
39
|
+
"connection" => "keep-alive",
|
|
40
|
+
}.freeze
|
|
41
|
+
|
|
42
|
+
attr_reader :thread_id, :run_id
|
|
43
|
+
|
|
44
|
+
# on_event: optional tap receiving every wire payload — the run
|
|
45
|
+
# store records through it for /connect replay.
|
|
46
|
+
def initialize(thread_id:, run_id:, validate: true, on_event: nil, **options)
|
|
47
|
+
@thread_id = thread_id
|
|
48
|
+
@run_id = run_id
|
|
49
|
+
@validate = validate
|
|
50
|
+
@on_event = on_event
|
|
51
|
+
@encoder = EventEncoder.new
|
|
52
|
+
super(**options)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# Emit a pre-built event payload (Hash with camelCase keys).
|
|
56
|
+
def event(payload)
|
|
57
|
+
@on_event&.call(payload)
|
|
58
|
+
write(@encoder.encode(payload))
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# Signal end-of-stream; the reader receives nil and closes the
|
|
62
|
+
# SSE connection.
|
|
63
|
+
def finish
|
|
64
|
+
close_write
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# A fresh mutable copy — upstream middleware mutates response
|
|
68
|
+
# headers in place.
|
|
69
|
+
def self.headers
|
|
70
|
+
SSE_HEADERS.dup
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# --- Typed event emitters -------------------------------------------
|
|
74
|
+
#
|
|
75
|
+
# One method per concrete event definition in the schema bundle,
|
|
76
|
+
# named after the wire type: RUN_STARTED -> #run_started,
|
|
77
|
+
# TEXT_MESSAGE_CONTENT -> #text_message_content, ...
|
|
78
|
+
AgUi::Protocol::JsonSchema.event_types.each do |wire_type, definition_name|
|
|
79
|
+
definition_class = AgUi::Protocol::JsonSchema[definition_name]
|
|
80
|
+
injects_thread = definition_class.schema_properties.include?("threadId")
|
|
81
|
+
injects_run = definition_class.schema_properties.include?("runId")
|
|
82
|
+
|
|
83
|
+
# Pydantic serializes non-null field defaults onto the wire
|
|
84
|
+
# (e.g. TEXT_MESSAGE_START carries role: "assistant" when
|
|
85
|
+
# omitted); null defaults are dropped by exclude_none. Mirror
|
|
86
|
+
# that: pre-fill non-null defaults, let kwargs override. Fields
|
|
87
|
+
# whose schema is a const (e.g. REASONING_MESSAGE_START's
|
|
88
|
+
# role: "reasoning") can only hold that value — fill them too,
|
|
89
|
+
# matching the reference SDKs' constructor defaults.
|
|
90
|
+
property_defaults = AgUi::Protocol::JsonSchema.raw_schema
|
|
91
|
+
.dig("definitions", definition_name, "properties")
|
|
92
|
+
.each_with_object({}) do |(camel, prop), defaults|
|
|
93
|
+
value = prop["default"].nil? ? prop["const"] : prop["default"]
|
|
94
|
+
unless value.nil?
|
|
95
|
+
defaults[camel] = value
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
define_method(wire_type.downcase) do |**kwargs|
|
|
100
|
+
# "type" inserted first so it leads the wire JSON, like the
|
|
101
|
+
# reference SDKs — keeps oracle byte-diffs quiet.
|
|
102
|
+
defaults = { "type" => wire_type }.merge(property_defaults)
|
|
103
|
+
if injects_thread
|
|
104
|
+
defaults["threadId"] = @thread_id
|
|
105
|
+
end
|
|
106
|
+
if injects_run
|
|
107
|
+
defaults["runId"] = @run_id
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
definition = definition_class.new(defaults.merge(kwargs))
|
|
111
|
+
if @validate
|
|
112
|
+
definition.valid!
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
event(definition.to_h)
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
__END__
|
|
124
|
+
|
|
125
|
+
describe "AgUi::Server::SSE::Stream" do
|
|
126
|
+
read_frames = ->(stream) do
|
|
127
|
+
frames = []
|
|
128
|
+
while (chunk = stream.read)
|
|
129
|
+
frames << JSON.parse(chunk.sub(/\Adata: /, "").strip)
|
|
130
|
+
end
|
|
131
|
+
frames
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
it "emits the phase-1 minimum viable event sequence" do
|
|
135
|
+
stream = AgUi::Server::SSE::Stream.new(thread_id: "t1", run_id: "r1")
|
|
136
|
+
|
|
137
|
+
stream.run_started
|
|
138
|
+
stream.text_message_start(message_id: "m1")
|
|
139
|
+
stream.text_message_content(message_id: "m1", delta: "Hello")
|
|
140
|
+
stream.text_message_end(message_id: "m1")
|
|
141
|
+
stream.run_finished
|
|
142
|
+
stream.finish
|
|
143
|
+
|
|
144
|
+
frames = read_frames.(stream)
|
|
145
|
+
frames.map { |f| f["type"] }.should == %w[
|
|
146
|
+
RUN_STARTED TEXT_MESSAGE_START TEXT_MESSAGE_CONTENT TEXT_MESSAGE_END RUN_FINISHED
|
|
147
|
+
]
|
|
148
|
+
frames.first.should == { "type" => "RUN_STARTED", "threadId" => "t1", "runId" => "r1" }
|
|
149
|
+
frames[1]["role"].should == "assistant"
|
|
150
|
+
frames[2]["delta"].should == "Hello"
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
it "frames every event as data:-only SSE" do
|
|
154
|
+
stream = AgUi::Server::SSE::Stream.new(thread_id: "t1", run_id: "r1")
|
|
155
|
+
stream.run_started
|
|
156
|
+
stream.finish
|
|
157
|
+
|
|
158
|
+
chunk = stream.read
|
|
159
|
+
chunk.should.start_with("data: ")
|
|
160
|
+
chunk.should.end_with("\n\n")
|
|
161
|
+
chunk.lines.length.should == 2
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
it "injects thread/run ids only where the schema has them" do
|
|
165
|
+
stream = AgUi::Server::SSE::Stream.new(thread_id: "t1", run_id: "r1")
|
|
166
|
+
stream.tool_call_start(tool_call_id: "tc1", tool_call_name: "navigate")
|
|
167
|
+
stream.finish
|
|
168
|
+
|
|
169
|
+
frame = read_frames.(stream).first
|
|
170
|
+
frame.should == {
|
|
171
|
+
"type" => "TOOL_CALL_START", "toolCallId" => "tc1", "toolCallName" => "navigate",
|
|
172
|
+
}
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
it "allows kwargs to override injected ids" do
|
|
176
|
+
stream = AgUi::Server::SSE::Stream.new(thread_id: "t1", run_id: "r1")
|
|
177
|
+
stream.run_finished(run_id: "override", result: { "ok" => true })
|
|
178
|
+
stream.finish
|
|
179
|
+
|
|
180
|
+
frame = read_frames.(stream).first
|
|
181
|
+
frame["runId"].should == "override"
|
|
182
|
+
frame["threadId"].should == "t1"
|
|
183
|
+
frame["result"].should == { "ok" => true }
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
it "emits activity snapshots in the flat wire shape" do
|
|
187
|
+
stream = AgUi::Server::SSE::Stream.new(thread_id: "t1", run_id: "r1")
|
|
188
|
+
stream.activity_snapshot(
|
|
189
|
+
message_id: "a2ui-surface-tc1",
|
|
190
|
+
activity_type: "a2ui-surface",
|
|
191
|
+
content: { "a2ui_operations" => [] },
|
|
192
|
+
replace: true,
|
|
193
|
+
)
|
|
194
|
+
stream.finish
|
|
195
|
+
|
|
196
|
+
frame = read_frames.(stream).first
|
|
197
|
+
frame["messageId"].should == "a2ui-surface-tc1"
|
|
198
|
+
frame["activityType"].should == "a2ui-surface"
|
|
199
|
+
frame.key?("activity").should == false
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
it "raises ValidationError on schema-invalid events before writing" do
|
|
203
|
+
stream = AgUi::Server::SSE::Stream.new(thread_id: "t1", run_id: "r1")
|
|
204
|
+
|
|
205
|
+
begin
|
|
206
|
+
stream.text_message_content(message_id: "m1") # missing delta
|
|
207
|
+
raise "expected ValidationError"
|
|
208
|
+
rescue AgUi::Protocol::JsonSchema::ValidationError => e
|
|
209
|
+
e.message.should.include?("delta")
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
stream.finish
|
|
213
|
+
stream.read.should.be.nil
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
it "skips validation when validate: false" do
|
|
217
|
+
stream = AgUi::Server::SSE::Stream.new(thread_id: "t1", run_id: "r1", validate: false)
|
|
218
|
+
stream.text_message_content(message_id: "m1")
|
|
219
|
+
stream.finish
|
|
220
|
+
|
|
221
|
+
read_frames.(stream).first["type"].should == "TEXT_MESSAGE_CONTENT"
|
|
222
|
+
end
|
|
223
|
+
|
|
224
|
+
it "provides the SSE response headers as a fresh mutable copy" do
|
|
225
|
+
headers = AgUi::Server::SSE::Stream.headers
|
|
226
|
+
headers["content-type"].should == "text/event-stream"
|
|
227
|
+
headers["cache-control"].should == "no-cache"
|
|
228
|
+
headers.frozen?.should == false
|
|
229
|
+
end
|
|
230
|
+
|
|
231
|
+
it "is a ::Protocol::HTTP::Body::Readable" do
|
|
232
|
+
stream = AgUi::Server::SSE::Stream.new(thread_id: "t1", run_id: "r1")
|
|
233
|
+
stream.is_a?(::Protocol::HTTP::Body::Readable).should == true
|
|
234
|
+
end
|
|
235
|
+
end
|
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "bundler/setup"
|
|
4
|
+
require "ag_ui"
|
|
5
|
+
|
|
6
|
+
module AgUi
|
|
7
|
+
class Server
|
|
8
|
+
# Resolves the incoming request to an AG-UI operation, mirroring the
|
|
9
|
+
# Node runtime's suffix-matching fetch-router (doc 09 §2):
|
|
10
|
+
#
|
|
11
|
+
# GET …/info → "info"
|
|
12
|
+
# POST …/agent/:agentId/run → "run" (body = RunAgentInput)
|
|
13
|
+
# POST …/agent/:agentId/connect → "connect" (body tolerated, not required)
|
|
14
|
+
# POST …/agent/:agentId/stop/:tid → "stop"
|
|
15
|
+
# POST <mount root> → "run" (bare AG-UI endpoint —
|
|
16
|
+
# what @ag-ui/client's HttpAgent
|
|
17
|
+
# POSTs when given a plain URL)
|
|
18
|
+
# OPTIONS * → 204 (permissive CORS preflight)
|
|
19
|
+
#
|
|
20
|
+
# Sets env["ag_ui.operation"], env["ag_ui.agent_id"] and, for runs,
|
|
21
|
+
# env["ag_ui.input"] (parsed RunInput). Short-circuits 400 on a bad
|
|
22
|
+
# run body and 404 on unknown routes — downstream only ever sees a
|
|
23
|
+
# resolved operation.
|
|
24
|
+
#
|
|
25
|
+
# Works both mounted (Rails/Rack map set SCRIPT_NAME, PATH_INFO is
|
|
26
|
+
# relative) and standalone: matching is on trailing path segments.
|
|
27
|
+
class Triage
|
|
28
|
+
AGENT_ROUTE = %r{/agent/(?<agent_id>[^/]+)/(?<action>run|connect)\z}
|
|
29
|
+
STOP_ROUTE = %r{/agent/(?<agent_id>[^/]+)/stop/(?<thread_id>[^/]+)\z}
|
|
30
|
+
INFO_ROUTE = %r{(\A|/)info\z}
|
|
31
|
+
|
|
32
|
+
def initialize(app)
|
|
33
|
+
@app = app
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def call(env)
|
|
37
|
+
method = env["REQUEST_METHOD"]
|
|
38
|
+
path = env["PATH_INFO"].to_s.chomp("/")
|
|
39
|
+
|
|
40
|
+
if method == "OPTIONS"
|
|
41
|
+
preflight
|
|
42
|
+
elsif method == "GET" && path.match?(INFO_ROUTE)
|
|
43
|
+
@app.call(env.merge("ag_ui.operation" => "info"))
|
|
44
|
+
elsif method == "POST" && (m = path.match(STOP_ROUTE))
|
|
45
|
+
@app.call(
|
|
46
|
+
env.merge(
|
|
47
|
+
"ag_ui.operation" => "stop",
|
|
48
|
+
"ag_ui.agent_id" => m[:agent_id],
|
|
49
|
+
"ag_ui.thread_id" => m[:thread_id],
|
|
50
|
+
),
|
|
51
|
+
)
|
|
52
|
+
elsif method == "POST" && (m = path.match(AGENT_ROUTE))
|
|
53
|
+
dispatch_agent(env, m)
|
|
54
|
+
elsif method == "POST" && path.empty?
|
|
55
|
+
dispatch_bare_run(env)
|
|
56
|
+
else
|
|
57
|
+
not_found
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
private
|
|
62
|
+
|
|
63
|
+
def dispatch_bare_run(env)
|
|
64
|
+
input = parse_input(env, required: true)
|
|
65
|
+
|
|
66
|
+
@app.call(
|
|
67
|
+
env.merge(
|
|
68
|
+
"ag_ui.operation" => "run",
|
|
69
|
+
"ag_ui.agent_id" => "default",
|
|
70
|
+
"ag_ui.input" => input,
|
|
71
|
+
),
|
|
72
|
+
)
|
|
73
|
+
rescue RunInput::InvalidError => e
|
|
74
|
+
bad_request(e.message)
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def dispatch_agent(env, match)
|
|
78
|
+
input = parse_input(env, required: match[:action] == "run")
|
|
79
|
+
|
|
80
|
+
@app.call(
|
|
81
|
+
env.merge(
|
|
82
|
+
"ag_ui.operation" => match[:action],
|
|
83
|
+
"ag_ui.agent_id" => match[:agent_id],
|
|
84
|
+
"ag_ui.input" => input,
|
|
85
|
+
),
|
|
86
|
+
)
|
|
87
|
+
rescue RunInput::InvalidError => e
|
|
88
|
+
bad_request(e.message)
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# /run requires a valid RunAgentInput; /connect bodies are only
|
|
92
|
+
# parsed opportunistically (the stub ignores them).
|
|
93
|
+
def parse_input(env, required:)
|
|
94
|
+
body = env["rack.input"]&.read.to_s
|
|
95
|
+
|
|
96
|
+
if required
|
|
97
|
+
RunInput.parse(body)
|
|
98
|
+
else
|
|
99
|
+
begin
|
|
100
|
+
RunInput.parse(body)
|
|
101
|
+
rescue RunInput::InvalidError
|
|
102
|
+
nil
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def preflight
|
|
108
|
+
[204, {
|
|
109
|
+
"access-control-allow-origin" => "*",
|
|
110
|
+
"access-control-allow-methods" => "GET, POST, OPTIONS",
|
|
111
|
+
"access-control-allow-headers" => "*",
|
|
112
|
+
}, []]
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def bad_request(details)
|
|
116
|
+
[400, { "content-type" => "application/json" },
|
|
117
|
+
[JSON.generate({ "error" => "Invalid request body", "details" => details })]]
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def not_found
|
|
121
|
+
[404, { "content-type" => "application/json" },
|
|
122
|
+
[JSON.generate({ "error" => "Not found" })]]
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
__END__
|
|
129
|
+
|
|
130
|
+
describe "AgUi::Server::Triage" do
|
|
131
|
+
minimal_input = JSON.generate({
|
|
132
|
+
"threadId" => "t1", "runId" => "r1", "state" => nil,
|
|
133
|
+
"messages" => [], "tools" => [], "context" => [], "forwardedProps" => nil,
|
|
134
|
+
})
|
|
135
|
+
|
|
136
|
+
seen = nil
|
|
137
|
+
app = ->(env) { seen = env; [200, {}, ["ok"]] }
|
|
138
|
+
triage = AgUi::Server::Triage.new(app)
|
|
139
|
+
|
|
140
|
+
request = ->(method, path, body: "") do
|
|
141
|
+
seen = nil
|
|
142
|
+
triage.call({
|
|
143
|
+
"REQUEST_METHOD" => method,
|
|
144
|
+
"PATH_INFO" => path,
|
|
145
|
+
"rack.input" => StringIO.new(body),
|
|
146
|
+
})
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
it "resolves GET /info" do
|
|
150
|
+
request.("GET", "/info")
|
|
151
|
+
seen["ag_ui.operation"].should == "info"
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
it "resolves /info under a mount prefix" do
|
|
155
|
+
request.("GET", "/api/copilotkit/info")
|
|
156
|
+
seen["ag_ui.operation"].should == "info"
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
it "resolves POST /agent/:id/run with a parsed input" do
|
|
160
|
+
request.("POST", "/agent/default/run", body: minimal_input)
|
|
161
|
+
seen["ag_ui.operation"].should == "run"
|
|
162
|
+
seen["ag_ui.agent_id"].should == "default"
|
|
163
|
+
seen["ag_ui.input"].thread_id.should == "t1"
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
it "resolves POST /agent/:id/stop/:threadId" do
|
|
167
|
+
request.("POST", "/agent/default/stop/t99")
|
|
168
|
+
seen["ag_ui.operation"].should == "stop"
|
|
169
|
+
seen["ag_ui.thread_id"].should == "t99"
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
it "resolves POST /agent/:id/connect without requiring a valid body" do
|
|
173
|
+
request.("POST", "/agent/default/connect", body: "")
|
|
174
|
+
seen["ag_ui.operation"].should == "connect"
|
|
175
|
+
seen["ag_ui.input"].should.be.nil
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
it "returns 400 with details for a bad run body" do
|
|
179
|
+
status, _headers, body = request.("POST", "/agent/default/run", body: "{}")
|
|
180
|
+
seen.should.be.nil
|
|
181
|
+
status.should == 400
|
|
182
|
+
parsed = JSON.parse(body.first)
|
|
183
|
+
parsed["error"].should == "Invalid request body"
|
|
184
|
+
parsed["details"].should.include?("threadId")
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
it "resolves a bare POST to the mount root as a run (HttpAgent shape)" do
|
|
188
|
+
request.("POST", "/", body: minimal_input)
|
|
189
|
+
seen["ag_ui.operation"].should == "run"
|
|
190
|
+
seen["ag_ui.agent_id"].should == "default"
|
|
191
|
+
seen["ag_ui.input"].thread_id.should == "t1"
|
|
192
|
+
|
|
193
|
+
status, = request.("POST", "/", body: "{}")
|
|
194
|
+
status.should == 400
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
it "returns 404 for unknown routes and wrong methods" do
|
|
198
|
+
request.("GET", "/agent/default/run")[0].should == 404
|
|
199
|
+
request.("POST", "/nope")[0].should == 404
|
|
200
|
+
seen.should.be.nil
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
it "answers OPTIONS preflight with 204" do
|
|
204
|
+
status, headers, _body = request.("OPTIONS", "/agent/default/run")
|
|
205
|
+
status.should == 204
|
|
206
|
+
headers["access-control-allow-origin"].should == "*"
|
|
207
|
+
end
|
|
208
|
+
end
|