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,180 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "bundler/setup"
|
|
4
|
+
require "async"
|
|
5
|
+
require "async/queue"
|
|
6
|
+
require "ag_ui"
|
|
7
|
+
|
|
8
|
+
module AgUi
|
|
9
|
+
# Per-thread run bookkeeping backing /connect (replay + live attach) and
|
|
10
|
+
# /stop (cancel the run's Async task) — the Ruby counterpart of the Node
|
|
11
|
+
# runtime's InMemoryAgentRunner thread store.
|
|
12
|
+
#
|
|
13
|
+
# The interface is duck-typed; hosts can supply anything implementing
|
|
14
|
+
# begin_run / record / finish_run / attach_task / stop /
|
|
15
|
+
# open_subscription (e.g. a redis-backed store) via
|
|
16
|
+
# `AgUi.agent(store: ...)`.
|
|
17
|
+
module RunStore
|
|
18
|
+
# Single-process store. Fiber/thread-safe: state mutations are locked,
|
|
19
|
+
# and live fanout uses Async::Queue so connect streams receive events
|
|
20
|
+
# as they are recorded.
|
|
21
|
+
class InMemory
|
|
22
|
+
FINISHED = :finished
|
|
23
|
+
|
|
24
|
+
def initialize
|
|
25
|
+
@threads = Hash.new do |hash, key|
|
|
26
|
+
hash[key] = { historic: [], live: nil, task: nil, subscribers: [] }
|
|
27
|
+
end
|
|
28
|
+
@mutex = Mutex.new
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def begin_run(thread_id, run_id)
|
|
32
|
+
@mutex.synchronize do
|
|
33
|
+
@threads[thread_id][:live] = { run_id: run_id, events: [] }
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def record(thread_id, payload)
|
|
38
|
+
subscribers = @mutex.synchronize do
|
|
39
|
+
thread = @threads[thread_id]
|
|
40
|
+
if thread[:live]
|
|
41
|
+
thread[:live][:events] << payload
|
|
42
|
+
thread[:subscribers].dup
|
|
43
|
+
else
|
|
44
|
+
[]
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
subscribers.each { |queue| queue.enqueue(payload) }
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def finish_run(thread_id)
|
|
51
|
+
subscribers = @mutex.synchronize do
|
|
52
|
+
thread = @threads[thread_id]
|
|
53
|
+
if thread[:live]
|
|
54
|
+
thread[:historic] << thread[:live]
|
|
55
|
+
thread[:live] = nil
|
|
56
|
+
end
|
|
57
|
+
thread[:task] = nil
|
|
58
|
+
drained = thread[:subscribers]
|
|
59
|
+
thread[:subscribers] = []
|
|
60
|
+
drained
|
|
61
|
+
end
|
|
62
|
+
subscribers.each { |queue| queue.enqueue(FINISHED) }
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# Only meaningful while the run is live — a late attach (the builder
|
|
66
|
+
# calls on_task after Async returns, which for an already-completed
|
|
67
|
+
# run is after finish_run) must not resurrect a stopped handle.
|
|
68
|
+
def attach_task(thread_id, task)
|
|
69
|
+
@mutex.synchronize do
|
|
70
|
+
if @threads[thread_id][:live]
|
|
71
|
+
@threads[thread_id][:task] = task
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# Cancel the thread's in-flight run. The stream still closes cleanly
|
|
77
|
+
# (StreamBuilder's ensure) — matching the Node runtime, which ends
|
|
78
|
+
# an aborted run with a plain RUN_FINISHED-then-close.
|
|
79
|
+
def stop(thread_id)
|
|
80
|
+
task = @mutex.synchronize { @threads[thread_id][:task] }
|
|
81
|
+
if task
|
|
82
|
+
task.stop
|
|
83
|
+
true
|
|
84
|
+
else
|
|
85
|
+
false
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
# Atomic snapshot + live subscription: the returned events are
|
|
90
|
+
# everything recorded so far (historic runs + the live run's events),
|
|
91
|
+
# and when a run is in flight, queue receives each subsequent event
|
|
92
|
+
# and finally FINISHED. Atomicity means no gap and no duplicates
|
|
93
|
+
# between snapshot and subscription.
|
|
94
|
+
def open_subscription(thread_id)
|
|
95
|
+
@mutex.synchronize do
|
|
96
|
+
thread = @threads[thread_id]
|
|
97
|
+
events = thread[:historic].flat_map { |run| run[:events] }
|
|
98
|
+
queue = nil
|
|
99
|
+
if thread[:live]
|
|
100
|
+
events += thread[:live][:events]
|
|
101
|
+
queue = Async::Queue.new
|
|
102
|
+
thread[:subscribers] << queue
|
|
103
|
+
end
|
|
104
|
+
{ events: events, queue: queue }
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
__END__
|
|
112
|
+
|
|
113
|
+
describe "AgUi::RunStore::InMemory" do
|
|
114
|
+
it "replays historic runs in order for a thread" do
|
|
115
|
+
store = AgUi::RunStore::InMemory.new
|
|
116
|
+
store.begin_run("t1", "r1")
|
|
117
|
+
store.record("t1", { "type" => "RUN_STARTED" })
|
|
118
|
+
store.record("t1", { "type" => "RUN_FINISHED" })
|
|
119
|
+
store.finish_run("t1")
|
|
120
|
+
store.begin_run("t1", "r2")
|
|
121
|
+
store.record("t1", { "type" => "RUN_STARTED" })
|
|
122
|
+
store.finish_run("t1")
|
|
123
|
+
|
|
124
|
+
subscription = store.open_subscription("t1")
|
|
125
|
+
subscription[:events].map { |e| e["type"] }.should == %w[RUN_STARTED RUN_FINISHED RUN_STARTED]
|
|
126
|
+
subscription[:queue].should.be.nil
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
it "returns an empty snapshot for unknown threads" do
|
|
130
|
+
subscription = AgUi::RunStore::InMemory.new.open_subscription("nope")
|
|
131
|
+
subscription[:events].should == []
|
|
132
|
+
subscription[:queue].should.be.nil
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
it "fans live events out to subscribers, with FINISHED at run end" do
|
|
136
|
+
store = AgUi::RunStore::InMemory.new
|
|
137
|
+
store.begin_run("t1", "r1")
|
|
138
|
+
store.record("t1", { "type" => "RUN_STARTED" })
|
|
139
|
+
|
|
140
|
+
received = []
|
|
141
|
+
Async do |task|
|
|
142
|
+
subscription = store.open_subscription("t1")
|
|
143
|
+
subscription[:events].length.should == 1
|
|
144
|
+
queue = subscription[:queue]
|
|
145
|
+
|
|
146
|
+
reader = task.async do
|
|
147
|
+
loop do
|
|
148
|
+
item = queue.dequeue
|
|
149
|
+
if item == AgUi::RunStore::InMemory::FINISHED
|
|
150
|
+
break
|
|
151
|
+
end
|
|
152
|
+
received << item
|
|
153
|
+
end
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
store.record("t1", { "type" => "TEXT_MESSAGE_START" })
|
|
157
|
+
store.finish_run("t1")
|
|
158
|
+
reader.wait
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
received.map { |e| e["type"] }.should == ["TEXT_MESSAGE_START"]
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
it "stops the attached task exactly while a run is live" do
|
|
165
|
+
store = AgUi::RunStore::InMemory.new
|
|
166
|
+
stopped = false
|
|
167
|
+
fake_task = Object.new
|
|
168
|
+
fake_task.define_singleton_method(:stop) { stopped = true }
|
|
169
|
+
|
|
170
|
+
store.stop("t1").should == false
|
|
171
|
+
|
|
172
|
+
store.begin_run("t1", "r1")
|
|
173
|
+
store.attach_task("t1", fake_task)
|
|
174
|
+
store.stop("t1").should == true
|
|
175
|
+
stopped.should == true
|
|
176
|
+
|
|
177
|
+
store.finish_run("t1")
|
|
178
|
+
store.stop("t1").should == false
|
|
179
|
+
end
|
|
180
|
+
end
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "bundler/setup"
|
|
4
|
+
require "ag_ui"
|
|
5
|
+
|
|
6
|
+
module AgUi
|
|
7
|
+
class Server
|
|
8
|
+
# Builds the GET /info payload — the envelope the CopilotKit client
|
|
9
|
+
# fetches on mount to discover agents and feature-detect capabilities.
|
|
10
|
+
# Shape extracted verbatim from @copilotkit/runtime's
|
|
11
|
+
# get-runtime-info.mjs (docs/09-ground-truth-host-app.md §1).
|
|
12
|
+
#
|
|
13
|
+
# The client feature-detects A2UI from the TOP-LEVEL `a2uiEnabled`
|
|
14
|
+
# flag; when enabled, the optional `a2ui: {enabled: true}` detail
|
|
15
|
+
# object rides along.
|
|
16
|
+
module Info
|
|
17
|
+
# The runtime version the client was built against; advertise the
|
|
18
|
+
# same so version-gated client behaviour matches the Node sidecar.
|
|
19
|
+
VERSION_PARITY = "1.62.2"
|
|
20
|
+
|
|
21
|
+
class << self
|
|
22
|
+
def payload(agent_id: "default", description: nil, a2ui_enabled: false, overrides: {})
|
|
23
|
+
agent = { "name" => agent_id, "className" => "BuiltInAgent" }
|
|
24
|
+
unless description.nil?
|
|
25
|
+
agent["description"] = description
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
base = {
|
|
29
|
+
"version" => VERSION_PARITY,
|
|
30
|
+
"agents" => { agent_id => agent },
|
|
31
|
+
"audioFileTranscriptionEnabled" => false,
|
|
32
|
+
"mode" => "sse",
|
|
33
|
+
"threadEndpoints" => {
|
|
34
|
+
"list" => false,
|
|
35
|
+
"inspect" => false,
|
|
36
|
+
"mutations" => false,
|
|
37
|
+
"realtimeMetadata" => false,
|
|
38
|
+
},
|
|
39
|
+
"a2uiEnabled" => a2ui_enabled,
|
|
40
|
+
"openGenerativeUIEnabled" => false,
|
|
41
|
+
"telemetryDisabled" => true,
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
if a2ui_enabled
|
|
45
|
+
base["a2ui"] = { "enabled" => true }
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
base.merge(overrides)
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
__END__
|
|
56
|
+
|
|
57
|
+
describe "AgUi::Server::Info" do
|
|
58
|
+
it "matches the Node runtime envelope for the default agent" do
|
|
59
|
+
payload = AgUi::Server::Info.payload
|
|
60
|
+
|
|
61
|
+
payload["version"].should == "1.62.2"
|
|
62
|
+
payload["agents"].should == { "default" => { "name" => "default", "className" => "BuiltInAgent" } }
|
|
63
|
+
payload["mode"].should == "sse"
|
|
64
|
+
payload["a2uiEnabled"].should == false
|
|
65
|
+
payload.key?("a2ui").should == false
|
|
66
|
+
payload["threadEndpoints"].should == {
|
|
67
|
+
"list" => false, "inspect" => false, "mutations" => false, "realtimeMetadata" => false,
|
|
68
|
+
}
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
it "advertises a2ui via the top-level flag plus detail object" do
|
|
72
|
+
payload = AgUi::Server::Info.payload(a2ui_enabled: true)
|
|
73
|
+
payload["a2uiEnabled"].should == true
|
|
74
|
+
payload["a2ui"].should == { "enabled" => true }
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
it "includes agent description when given and applies overrides" do
|
|
78
|
+
payload = AgUi::Server::Info.payload(
|
|
79
|
+
agent_id: "host-app", description: "Studio assistant",
|
|
80
|
+
overrides: { "telemetryDisabled" => false },
|
|
81
|
+
)
|
|
82
|
+
payload["agents"]["host-app"]["description"].should == "Studio assistant"
|
|
83
|
+
payload["telemetryDisabled"].should == false
|
|
84
|
+
end
|
|
85
|
+
end
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "bundler/setup"
|
|
4
|
+
require "ag_ui"
|
|
5
|
+
require "async"
|
|
6
|
+
|
|
7
|
+
module AgUi
|
|
8
|
+
class Server
|
|
9
|
+
module Middleware
|
|
10
|
+
# Sets up an SSE stream builder on `env["ag_ui.stream"]`, ported
|
|
11
|
+
# from a2a's Server::Middleware::SSEStream.
|
|
12
|
+
#
|
|
13
|
+
# The `open` block runs inside an Async fiber and the stream is
|
|
14
|
+
# automatically finished when the block exits (even on exception).
|
|
15
|
+
# If the handler never calls `open`, the builder is removed from env
|
|
16
|
+
# so the response layer doesn't mistake it for a real stream.
|
|
17
|
+
#
|
|
18
|
+
# Usage (inside the /run handler):
|
|
19
|
+
#
|
|
20
|
+
# env["ag_ui.stream"].open(thread_id:, run_id:) do |s|
|
|
21
|
+
# s.run_started
|
|
22
|
+
# s.text_message_content(message_id: "m1", delta: "Hi")
|
|
23
|
+
# s.run_finished
|
|
24
|
+
# end
|
|
25
|
+
#
|
|
26
|
+
class SSEStream
|
|
27
|
+
def initialize(app)
|
|
28
|
+
@app = app
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def call(env)
|
|
32
|
+
builder = StreamBuilder.new(env)
|
|
33
|
+
env["ag_ui.stream"] = builder
|
|
34
|
+
|
|
35
|
+
result = @app.call(env)
|
|
36
|
+
|
|
37
|
+
# If open was never called, clear the builder so the response
|
|
38
|
+
# layer doesn't mistake it for a real stream.
|
|
39
|
+
if env["ag_ui.stream"].equal?(builder)
|
|
40
|
+
env.delete("ag_ui.stream")
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
result
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# Factory that creates the SSE stream and runs the caller's block
|
|
48
|
+
# inside Async with automatic finish on exit.
|
|
49
|
+
#
|
|
50
|
+
# Created by SSEStream middleware — not intended for direct use.
|
|
51
|
+
class StreamBuilder
|
|
52
|
+
def initialize(env)
|
|
53
|
+
@env = env
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# Create and open the SSE stream for the current run.
|
|
57
|
+
#
|
|
58
|
+
# The block runs inside an Async fiber; the stream is finished
|
|
59
|
+
# when the block exits, even if an exception is raised. The run
|
|
60
|
+
# handler owns terminal-event semantics (RUN_FINISHED / RUN_ERROR)
|
|
61
|
+
# — this layer only guarantees the connection closes.
|
|
62
|
+
#
|
|
63
|
+
# on_event / on_finish / on_task are the run-store taps (set by
|
|
64
|
+
# the Server's recording wrapper): every payload, end-of-run, and
|
|
65
|
+
# the Async task handle for /stop cancellation.
|
|
66
|
+
def open(thread_id:, run_id:, validate: true,
|
|
67
|
+
on_event: nil, on_finish: nil, on_task: nil, &block)
|
|
68
|
+
stream = SSE::Stream.new(
|
|
69
|
+
thread_id: thread_id,
|
|
70
|
+
run_id: run_id,
|
|
71
|
+
validate: validate,
|
|
72
|
+
on_event: on_event,
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
@env["ag_ui.stream"] = stream
|
|
76
|
+
|
|
77
|
+
task = Async do
|
|
78
|
+
block.call(stream)
|
|
79
|
+
ensure
|
|
80
|
+
stream.finish
|
|
81
|
+
on_finish&.call
|
|
82
|
+
end
|
|
83
|
+
on_task&.call(task)
|
|
84
|
+
|
|
85
|
+
nil
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
__END__
|
|
93
|
+
|
|
94
|
+
describe "AgUi::Server::Middleware::SSEStream" do
|
|
95
|
+
it "sets a StreamBuilder on env and clears it when open is never called" do
|
|
96
|
+
seen = nil
|
|
97
|
+
mw = AgUi::Server::Middleware::SSEStream.new(->(env) { seen = env["ag_ui.stream"]; :ok })
|
|
98
|
+
|
|
99
|
+
env = {}
|
|
100
|
+
result = mw.call(env)
|
|
101
|
+
|
|
102
|
+
seen.should.be.kind_of(AgUi::Server::Middleware::StreamBuilder)
|
|
103
|
+
env.key?("ag_ui.stream").should == false
|
|
104
|
+
result.should == :ok
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
it "preserves the opened stream on env" do
|
|
108
|
+
mw = AgUi::Server::Middleware::SSEStream.new(->(env) do
|
|
109
|
+
env["ag_ui.stream"].open(thread_id: "t1", run_id: "r1") { |s| s.run_started }
|
|
110
|
+
end)
|
|
111
|
+
|
|
112
|
+
env = {}
|
|
113
|
+
mw.call(env)
|
|
114
|
+
|
|
115
|
+
env["ag_ui.stream"].should.be.kind_of(AgUi::Server::SSE::Stream)
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
describe "AgUi::Server::Middleware::StreamBuilder" do
|
|
120
|
+
it "passes thread_id and run_id through to the stream" do
|
|
121
|
+
env = {}
|
|
122
|
+
builder = AgUi::Server::Middleware::StreamBuilder.new(env)
|
|
123
|
+
|
|
124
|
+
builder.open(thread_id: "t1", run_id: "r1") do |s|
|
|
125
|
+
s.thread_id.should == "t1"
|
|
126
|
+
s.run_id.should == "r1"
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
it "auto-finishes the stream when the block completes" do
|
|
131
|
+
env = {}
|
|
132
|
+
builder = AgUi::Server::Middleware::StreamBuilder.new(env)
|
|
133
|
+
|
|
134
|
+
builder.open(thread_id: "t1", run_id: "r1") { |s| s.run_started }
|
|
135
|
+
|
|
136
|
+
stream = env["ag_ui.stream"]
|
|
137
|
+
stream.read.should.include?("RUN_STARTED")
|
|
138
|
+
stream.read.should.be.nil
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
it "auto-finishes even when the block raises" do
|
|
142
|
+
env = {}
|
|
143
|
+
builder = AgUi::Server::Middleware::StreamBuilder.new(env)
|
|
144
|
+
|
|
145
|
+
builder.open(thread_id: "t1", run_id: "r1") do |s|
|
|
146
|
+
s.run_started
|
|
147
|
+
raise "boom"
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
stream = env["ag_ui.stream"]
|
|
151
|
+
stream.read.should.include?("RUN_STARTED")
|
|
152
|
+
stream.read.should.be.nil
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
it "returns nil" do
|
|
156
|
+
env = {}
|
|
157
|
+
builder = AgUi::Server::Middleware::StreamBuilder.new(env)
|
|
158
|
+
builder.open(thread_id: "t1", run_id: "r1") { |_s| }.should.be.nil
|
|
159
|
+
end
|
|
160
|
+
end
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module AgUi
|
|
6
|
+
class Server
|
|
7
|
+
module SSE
|
|
8
|
+
# AG-UI SSE framing, ported from the reference encoder
|
|
9
|
+
# (ag-ui sdks/python/ag_ui/encoder/encoder.py, cross-checked against
|
|
10
|
+
# @ag-ui/encoder dist): one event per frame, `data: <json>\n\n`, no
|
|
11
|
+
# `event:`/`id:` lines, no heartbeats. Keys are camelCase and nil
|
|
12
|
+
# fields are omitted at every depth (pydantic exclude_none).
|
|
13
|
+
class EventEncoder
|
|
14
|
+
CONTENT_TYPE = "text/event-stream"
|
|
15
|
+
|
|
16
|
+
def content_type = CONTENT_TYPE
|
|
17
|
+
|
|
18
|
+
# event: a Hash with camelCase string/symbol keys, e.g.
|
|
19
|
+
# { type: "TEXT_MESSAGE_CONTENT", messageId: "m1", delta: "Hi" }
|
|
20
|
+
def encode(event)
|
|
21
|
+
"data: #{JSON.generate(strip_nils(event))}\n\n"
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
private
|
|
25
|
+
|
|
26
|
+
def strip_nils(value)
|
|
27
|
+
case value
|
|
28
|
+
when Hash
|
|
29
|
+
value.each_with_object({}) do |(k, v), out|
|
|
30
|
+
unless v.nil?
|
|
31
|
+
out[k] = strip_nils(v)
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
when Array
|
|
35
|
+
value.map { |v| strip_nils(v) }
|
|
36
|
+
else
|
|
37
|
+
value
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
__END__
|
|
46
|
+
|
|
47
|
+
describe "ag_ui/server/sse/event_encoder" do
|
|
48
|
+
encoder = AgUi::Server::SSE::EventEncoder.new
|
|
49
|
+
|
|
50
|
+
it "frames an event as a single data line with a blank-line terminator" do
|
|
51
|
+
frame = encoder.encode({ type: "TEXT_MESSAGE_CONTENT", messageId: "m1", delta: "Hi" })
|
|
52
|
+
frame.should == "data: {\"type\":\"TEXT_MESSAGE_CONTENT\",\"messageId\":\"m1\",\"delta\":\"Hi\"}\n\n"
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
it "omits nil fields at every depth" do
|
|
56
|
+
frame = encoder.encode({ type: "RUN_FINISHED", threadId: "t", runId: "r",
|
|
57
|
+
result: nil, nested: { keep: 1, drop: nil } })
|
|
58
|
+
frame.should == "data: {\"type\":\"RUN_FINISHED\",\"threadId\":\"t\",\"runId\":\"r\",\"nested\":{\"keep\":1}}\n\n"
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
it "preserves nils inside arrays and keeps empty strings/false" do
|
|
62
|
+
frame = encoder.encode({ type: "CUSTOM", name: "x", value: ["a", nil], flag: false, s: "" })
|
|
63
|
+
frame.should == "data: {\"type\":\"CUSTOM\",\"name\":\"x\",\"value\":[\"a\",null],\"flag\":false,\"s\":\"\"}\n\n"
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
it "advertises the SSE content type" do
|
|
67
|
+
encoder.content_type.should == "text/event-stream"
|
|
68
|
+
end
|
|
69
|
+
end
|