ruby_llm-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.
Files changed (57) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +48 -0
  3. data/CONTRIBUTING.md +56 -0
  4. data/LICENSE.txt +21 -0
  5. data/README.md +146 -0
  6. data/SECURITY.md +30 -0
  7. data/docs/_advanced/rails-integration.md +199 -0
  8. data/docs/_advanced/security-and-deployment.md +204 -0
  9. data/docs/_core_features/approval-and-resume.md +62 -0
  10. data/docs/_core_features/input-validation.md +64 -0
  11. data/docs/_core_features/reconnects.md +42 -0
  12. data/docs/_core_features/streaming-runs.md +49 -0
  13. data/docs/_getting_started/configuration.md +57 -0
  14. data/docs/_getting_started/getting-started.md +52 -0
  15. data/docs/_getting_started/overview.md +189 -0
  16. data/docs/_getting_started/whats-new-in-0-1.md +41 -0
  17. data/docs/_reference/api-reference.md +26 -0
  18. data/docs/_reference/compatibility.md +130 -0
  19. data/docs/_reference/protocol-coverage.md +175 -0
  20. data/docs/_reference/release-history.md +22 -0
  21. data/docs/_reference/releasing.md +54 -0
  22. data/docs/_reference/third-party-notices.md +82 -0
  23. data/docs/_reference/upgrading.md +42 -0
  24. data/docs/index.md +53 -0
  25. data/lib/generators/ruby_llm/ag_ui/install_generator.rb +37 -0
  26. data/lib/generators/ruby_llm/ag_ui/templates/create_ruby_llm_ag_ui_runs.rb.tt +24 -0
  27. data/lib/generators/ruby_llm/ag_ui/templates/initializer.rb.tt +24 -0
  28. data/lib/ruby_llm/ag_ui/canonical.rb +40 -0
  29. data/lib/ruby_llm/ag_ui/configuration.rb +113 -0
  30. data/lib/ruby_llm/ag_ui/coverage.rb +163 -0
  31. data/lib/ruby_llm/ag_ui/emitter.rb +119 -0
  32. data/lib/ruby_llm/ag_ui/error.rb +69 -0
  33. data/lib/ruby_llm/ag_ui/error_sanitizer.rb +69 -0
  34. data/lib/ruby_llm/ag_ui/input.rb +299 -0
  35. data/lib/ruby_llm/ag_ui/interrupt.rb +207 -0
  36. data/lib/ruby_llm/ag_ui/mapper.rb +333 -0
  37. data/lib/ruby_llm/ag_ui/message_parser.rb +127 -0
  38. data/lib/ruby_llm/ag_ui/protocol.rb +130 -0
  39. data/lib/ruby_llm/ag_ui/rails/active_record_store.rb +119 -0
  40. data/lib/ruby_llm/ag_ui/rails/railtie.rb +29 -0
  41. data/lib/ruby_llm/ag_ui/rails/record.rb +51 -0
  42. data/lib/ruby_llm/ag_ui/rails/streaming.rb +166 -0
  43. data/lib/ruby_llm/ag_ui/reconnect.rb +86 -0
  44. data/lib/ruby_llm/ag_ui/redaction.rb +58 -0
  45. data/lib/ruby_llm/ag_ui/redemption.rb +62 -0
  46. data/lib/ruby_llm/ag_ui/reservation.rb +56 -0
  47. data/lib/ruby_llm/ag_ui/resume.rb +108 -0
  48. data/lib/ruby_llm/ag_ui/resume_entry.rb +49 -0
  49. data/lib/ruby_llm/ag_ui/run.rb +177 -0
  50. data/lib/ruby_llm/ag_ui/run_record.rb +229 -0
  51. data/lib/ruby_llm/ag_ui/sse_writer.rb +119 -0
  52. data/lib/ruby_llm/ag_ui/store/base.rb +123 -0
  53. data/lib/ruby_llm/ag_ui/store/memory_store.rb +123 -0
  54. data/lib/ruby_llm/ag_ui/validation.rb +63 -0
  55. data/lib/ruby_llm/ag_ui/version.rb +8 -0
  56. data/lib/ruby_llm/ag_ui.rb +65 -0
  57. metadata +223 -0
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: ab7bc5eeccd9381e134cd795b20b0815aed665ac3f4371d29503bed969811410
4
+ data.tar.gz: 5991ee6bec49c64e3dd5ac0f8f9ccd18abe7c5f9aab55cb5493b232cff088cbe
5
+ SHA512:
6
+ metadata.gz: 205524fd5c5cbcd214da1f98c2c2b3678e0961b417ee598803741b577a7c7400be2e51b8d78716905cb14b2fb386d3ee46a8a3e157947cbf06afe6eaf8d01669
7
+ data.tar.gz: 8680a5a03eacc9cfa8d3920db2be725d5f148befd24125a9b6875312307c4207528f69b527dc294f7e064a2c4f33d0c5bf3666318d3ea81f5927089f6408ae78
data/CHANGELOG.md ADDED
@@ -0,0 +1,48 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project are documented here. The format follows
4
+ [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and this project
5
+ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
6
+
7
+ ## [Unreleased]
8
+
9
+ ## [0.1.0] - 2026-10-18
10
+
11
+ ### Added
12
+
13
+ - Initial gem skeleton: gemspec, Zeitwerk loader, configuration, errors and the
14
+ `RubyLLM::AgUI` namespace.
15
+ - Inbound `RunAgentInput` parsing with size and schema bounds.
16
+ - RubyLLM 2.x to AG-UI 1.0 mapping for text, tool and lifecycle events, audited
17
+ against the pinned 1.0 schema (31 event types, see `docs/_reference/protocol-coverage.md`).
18
+ - Run reservation, approval interrupt/resume and the cancelled outcome.
19
+ - An SSE writer and an optional Rails adapter: a controller concern, a durable
20
+ ActiveRecord run store and an install generator.
21
+ - Reconnect: a re-sent input for a run that already ended gets `RUN_STARTED`, its
22
+ `STATE_SNAPSHOT` and the recorded terminal event instead of a second run.
23
+ - A runnable pure-Ruby Rack example (`examples/pure_ruby`) that works with no
24
+ provider, and a Rails 8 endpoint example (`examples/rails_8`).
25
+ - An opt-in live-provider spec (`RUBY_LLM_LIVE=1 bundle exec rspec spec/live`).
26
+ - Documentation: architecture, protocol coverage, compatibility, Rails, security,
27
+ licenses and upstream `ag-ui-protocol` issue drafts.
28
+ - Versioned GitHub Pages guides and an RDoc API reference.
29
+ - Tag-triggered RubyGems trusted publishing with dependency audits, isolated
30
+ package installation checks, and GitHub Release assets.
31
+
32
+ ### Fixed
33
+
34
+ - Register the install generator under `ruby_llm:ag_ui:install` so Rails can
35
+ resolve the documented command.
36
+ - Require patched CGI, DNS resolution, URI and zlib runtime gems so an isolated
37
+ install does not activate older vulnerable Ruby default libraries.
38
+ - A second turn on the same chat no longer leaks the previous run's message ids,
39
+ steps and text into its own stream. RubyLLM's callbacks are additive, so the
40
+ callbacks a finished run registered stay attached; a mapper is now final after
41
+ it closes its run.
42
+ - A refused request is answered with the status the error names (413 for a body
43
+ past the configured limit, not 400).
44
+ - The generated migration targets the host's Rails version instead of a
45
+ hardcoded `7.1`.
46
+ - The durable store writes the run record as JSON text the gem encodes, so it no
47
+ longer depends on the host's ActiveSupport JSON encoder (which breaks on
48
+ Rails 8.0 with `json` 3.x).
data/CONTRIBUTING.md ADDED
@@ -0,0 +1,56 @@
1
+ # Contributing
2
+
3
+ Thanks for helping. This is an unofficial community gem; small, focused changes
4
+ with tests are the easiest to accept.
5
+
6
+ ## Getting started
7
+
8
+ ```bash
9
+ bundle install
10
+ bundle exec rspec
11
+ bundle exec rubocop
12
+ ```
13
+
14
+ ## Ground rules
15
+
16
+ - **Do not fork the AG-UI schema.** Where `ag-ui-protocol` lacks a 1.0 shape,
17
+ emit a schema-valid event and open an upstream issue before a PR. The known
18
+ gaps (the JSON input parser, the cancelled outcome, subagent events, the
19
+ `RUN_STARTED` `protocolVersion`) are tracked in
20
+ [docs/_reference/protocol-coverage.md](docs/_reference/protocol-coverage.md), with issue drafts in
21
+ [docs/.internal/upstream/](docs/.internal/upstream).
22
+ - **Never load Rails from the core.** `require 'ruby_llm/ag_ui'` must not load
23
+ Rails, open a network connection, or read provider credentials. There is a
24
+ spec that asserts this.
25
+ - **Do not define or reopen top-level `AgUi`.** That namespace is owned by the
26
+ separate `ag-ui` gem. Inside `RubyLLM::AgUI`, refer to the protocol SDK as
27
+ `::AgUiProtocol`, and write `::Rails` for the framework.
28
+ - **Do not weaken trust boundaries.** No invented events, no raw exception text
29
+ or secrets in SSE or logs, and no approval that is not bound to the tool call
30
+ it approves.
31
+ - Follow the [Semantic Versioning](https://semver.org) policy and add a
32
+ CHANGELOG entry for user-visible changes.
33
+
34
+ ## Tests
35
+
36
+ Every behavior change needs a test, and lifecycle, identity and security changes
37
+ need their negative case too. The acceptance IDs (FR-01..FR-08) are referenced
38
+ in spec descriptions. Offline CI uses fakes; live provider tests are opt-in and
39
+ never required:
40
+
41
+ ```bash
42
+ RUBY_LLM_LIVE=1 OPENAI_API_KEY=... bundle exec rspec spec/live
43
+ ```
44
+
45
+ ## Commit style
46
+
47
+ Conventional Commits (`feat:`, `fix:`, `docs:`, `test:`, `chore:`, `refactor:`),
48
+ one coherent change per commit.
49
+
50
+ ## Releasing
51
+
52
+ Maintainer only. Match `lib/ruby_llm/ag_ui/version.rb` and the documentation
53
+ version, add the version to CHANGELOG, and run the package checks in
54
+ [Releasing](docs/_reference/releasing.md). Push the completed commit and its
55
+ matching `vX.Y.Z` tag. The `Release Gem` workflow verifies the tag, publishes
56
+ through RubyGems trusted publishing, and creates the GitHub Release.
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Andy Wang and contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,146 @@
1
+ # ruby_llm-ag_ui
2
+
3
+ [![CI](https://github.com/yorzi/ruby_llm-ag-ui/actions/workflows/ci.yml/badge.svg)](https://github.com/yorzi/ruby_llm-ag-ui/actions/workflows/ci.yml)
4
+
5
+ Run [RubyLLM](https://rubyllm.com) 2.x chats as [AG-UI](https://github.com/ag-ui-protocol/ag-ui) 1.0 agent runs.
6
+
7
+ `ruby_llm-ag_ui` turns one RubyLLM chat turn into one AG-UI run: it validates the
8
+ inbound `RunAgentInput`, maps RubyLLM streaming, tool and lifecycle behavior onto
9
+ schema-valid AG-UI events, turns `requires_approval` pauses into interrupt
10
+ outcomes with host-authorized resume, reports `Chat#cancel` as the cancelled
11
+ outcome, and writes the stream as Server-Sent Events. A pure-Ruby core comes
12
+ with an optional Rails adapter.
13
+
14
+ > **Unofficial community project.** This gem is not affiliated with, endorsed
15
+ > by, or maintained by the RubyLLM maintainers or the AG-UI protocol
16
+ > maintainers. `RubyLLM::` and "AG-UI" appear here because this gem extends
17
+ > those projects, not because it speaks for them.
18
+
19
+ ## Why
20
+
21
+ If your agent already runs on RubyLLM and you want an AG-UI client (CopilotKit,
22
+ `@ag-ui/client`, or another AG-UI consumer) in front of it, you currently have
23
+ to hand-map RubyLLM callbacks, approval pauses and cancellation onto the AG-UI
24
+ run lifecycle, and you have to parse and authorize `RunAgentInput` yourself:
25
+ the community Ruby SDK builds outbound events but has no inbound parser. The
26
+ usual results are wrong terminal events, duplicate runs on retry, approvals that
27
+ are not bound to the call they approve, and cancellations reported as success.
28
+ This gem removes that mapping work.
29
+
30
+ ## What it does and does not do
31
+
32
+ It owns the RubyLLM → AG-UI mapping, input parsing, event ordering, run identity
33
+ and terminal correctness. It is **not** an AG-UI core SDK, a schema fork, a chat
34
+ UI, a CopilotKit runtime clone, or a second agent framework. RubyLLM owns the
35
+ agent loop, tool execution, approval pauses and cancellation checkpoints. Your
36
+ application owns identity, authorization, which tools and model a run may use,
37
+ and persistence.
38
+
39
+ ## Status
40
+
41
+ Version `0.1.0` is the first release. The pure-Ruby core, run store, approval/resume, cancel
42
+ handling and the Rails adapter are implemented and covered by an offline test
43
+ suite. Compatibility claims for real AG-UI clients and live providers are
44
+ tracked in the [compatibility guide](https://rubyllm-ag-ui.wangyaodi.com/compatibility/) and are
45
+ **not** asserted until recorded there.
46
+
47
+ Read the [documentation site](https://rubyllm-ag-ui.wangyaodi.com/) for the
48
+ guides, protocol coverage, compatibility matrix and security model. Its source
49
+ is in [docs/](https://github.com/yorzi/ruby_llm-ag-ui/tree/main/docs).
50
+
51
+ ## Installation
52
+
53
+ ```ruby
54
+ # Gemfile
55
+ gem 'ruby_llm-ag_ui', '~> 0.1.0'
56
+ ```
57
+
58
+ ```bash
59
+ bundle install
60
+ ```
61
+
62
+ Requires Ruby >= 3.2, RubyLLM >= 2.1 and `ag-ui-protocol` ~> 0.2.0.
63
+ Runtime dependencies also select patched `cgi`, `resolv`, `uri` and `zlib` gems.
64
+
65
+ ## Quick start (pure Ruby)
66
+
67
+ The host authenticates, authorizes and builds the chat; the gem writes the
68
+ stream.
69
+
70
+ ```ruby
71
+ require 'ruby_llm'
72
+ require 'ruby_llm/ag_ui'
73
+
74
+ input = RubyLLM::AgUI::Input.parse(request_body) # bounds, validates, digests
75
+ chat = host_chat_for(actor, input.thread_id) # your RubyLLM::Chat
76
+
77
+ RubyLLM::AgUI::Run.new(
78
+ chat: chat,
79
+ input: input,
80
+ scope: "tenant:#{tenant.id}:actor:#{actor.id}", # from authenticated identity
81
+ store: run_store
82
+ ).stream(to: sse_io)
83
+ ```
84
+
85
+ `scope` is what the run is reserved under: it must be a non-empty String the host
86
+ builds from authenticated identity, never anything from the request body.
87
+
88
+ `examples/pure_ruby/app.rb` is a runnable Rack endpoint that streams a complete
89
+ run (a tool call, an approval pause and the run that answers it) with no
90
+ provider and no credentials (`rake example`, or
91
+ `rackup examples/pure_ruby/config.ru`). Set `RUBY_LLM_AG_UI_EXAMPLE_MODEL` to
92
+ drive a real `RubyLLM::Chat` through the same code.
93
+
94
+ ## Rails
95
+
96
+ The optional adapter provides a controller concern, a durable run store keyed
97
+ by the actor's scope, and reconnect snapshots. It loads only when
98
+ `Rails::Railtie` is already defined.
99
+
100
+ ```ruby
101
+ class AgentRunsController < ApplicationController
102
+ include RubyLLM::AgUI::Rails::Streaming
103
+
104
+ def create
105
+ stream_ag_ui_run { |input| current_user.chats.find_or_create_by!(ag_ui_thread_id: input.thread_id) }
106
+ end
107
+
108
+ def ag_ui_scope
109
+ "actor:#{current_user.id}"
110
+ end
111
+ end
112
+ ```
113
+
114
+ See the [Rails integration guide](https://rubyllm-ag-ui.wangyaodi.com/rails-integration/) and `examples/rails_8`.
115
+
116
+ ## Development
117
+
118
+ ```bash
119
+ bundle install
120
+ bundle exec rspec
121
+ bundle exec rubocop
122
+ rake example # prints a full AG-UI run as SSE
123
+ ```
124
+
125
+ The suite is offline: fakes instead of a provider, no network, no credentials.
126
+ The only test that talks to a model is opt-in:
127
+
128
+ ```bash
129
+ RUBY_LLM_LIVE=1 OPENAI_API_KEY=... bundle exec rspec spec/live
130
+ ```
131
+
132
+ ## Documentation
133
+
134
+ - [Architecture](https://rubyllm-ag-ui.wangyaodi.com/overview/) - the components, run, and invariants.
135
+ - [Protocol coverage](https://rubyllm-ag-ui.wangyaodi.com/protocol-coverage/) - all 31 AG-UI 1.0 event types
136
+ and what this gem does with each.
137
+ - [Compatibility](https://rubyllm-ag-ui.wangyaodi.com/compatibility/) - what this repository verifies.
138
+ - [Rails](https://rubyllm-ag-ui.wangyaodi.com/rails-integration/) - the controller concern, store, and transport.
139
+ - [Security](https://rubyllm-ag-ui.wangyaodi.com/security-and-deployment/) - the threat model and deployment checklist.
140
+
141
+ ## License
142
+
143
+ MIT. See [LICENSE.txt](LICENSE.txt). Read the [third-party notices](https://rubyllm-ag-ui.wangyaodi.com/third-party-notices/).
144
+
145
+ This is an unofficial community project and is not affiliated with the RubyLLM or
146
+ AG-UI maintainers. Report security issues privately. See [SECURITY.md](SECURITY.md).
data/SECURITY.md ADDED
@@ -0,0 +1,30 @@
1
+ # Security Policy
2
+
3
+ ## Supported versions
4
+
5
+ The latest released minor version receives security fixes.
6
+
7
+ ## Reporting a vulnerability
8
+
9
+ Do not open a public issue for a security problem. Use GitHub's private
10
+ vulnerability reporting on the repository
11
+ (<https://github.com/yorzi/ruby_llm-ag_ui/security/advisories/new>) or email the
12
+ maintainer at <andy@yorzi.me>.
13
+
14
+ Include, where possible:
15
+
16
+ - the affected version and Ruby/RubyLLM/`ag-ui-protocol` versions;
17
+ - a minimal reproduction, ideally without real credentials or model calls;
18
+ - whether untrusted input, an approval bypass, a duplicated side effect, or a
19
+ leak into SSE or logs is involved.
20
+
21
+ You will get an acknowledgement within 7 days. Credit is offered unless you
22
+ prefer to stay anonymous.
23
+
24
+ ## Security model
25
+
26
+ The threat model is documented in the [security and deployment guide](https://rubyllm-ag-ui.wangyaodi.com/security-and-deployment/). In
27
+ short: client-supplied `messages`, `state`, `tools`, `context` and
28
+ `forwardedProps` are untrusted and never grant host privileges or change host
29
+ policy; tool approvals and `resume` payloads are never permission grants; and
30
+ `RUN_ERROR` carries a sanitized message and code, never raw exception text.
@@ -0,0 +1,199 @@
1
+ ---
2
+ layout: default
3
+ title: "Rails Integration"
4
+ nav_order: 1
5
+ description: Serve AG-UI runs from Rails with the optional controller concern and durable ActiveRecord store.
6
+ ---
7
+
8
+ # {{ page.title }}
9
+
10
+ {{ page.description }}
11
+ {: .fs-6 .fw-300 }
12
+
13
+ After reading this guide, you will know:
14
+
15
+ * How to install the Rails adapter files.
16
+ * How to authenticate, select a chat, and stream a run.
17
+ * What the durable store and reconnect path provide.
18
+
19
+ The adapter is optional. It is what a Rails host gets for free: a controller
20
+ concern that serves the event stream, a durable run store backed by ActiveRecord,
21
+ a generator for both, and reconnect support.
22
+
23
+ Nothing under `lib/ruby_llm/ag_ui/rails/` loads unless `Rails::Railtie` is already
24
+ defined, so a pure-Ruby app pays nothing for it.
25
+
26
+ ## Install
27
+
28
+ ```bash
29
+ bundle add ruby_llm-ag_ui
30
+ bin/rails generate ruby_llm:ag_ui:install
31
+ bin/rails db:migrate
32
+ ```
33
+
34
+ The generator writes two files:
35
+
36
+ - `db/migrate/*_create_ruby_llm_ag_ui_runs.rb`: one row per run, with a unique
37
+ index on `(scope, thread_id, run_id)` and one on
38
+ `(scope, thread_id, status)`.
39
+ - `config/initializers/ruby_llm_ag_ui.rb`: points `config.run_store` at
40
+ `RubyLLM::AgUI::Rails::ActiveRecordStore` and lists the choices worth making.
41
+
42
+ The migration targets the host's own Rails version
43
+ (`ActiveRecord::Migration.current_version`), so it does not pin a schema version
44
+ you did not choose.
45
+
46
+ ## The controller
47
+
48
+ ```ruby
49
+ class AgentRunsController < ApplicationController
50
+ include RubyLLM::AgUI::Rails::Streaming
51
+
52
+ def create
53
+ stream_ag_ui_run { |input| chat_for(input) }
54
+ end
55
+ end
56
+ ```
57
+
58
+ `stream_ag_ui_run` parses the body, resolves the chat, runs, and writes SSE. The
59
+ block receives the parsed `Input` and must return the chat for it.
60
+
61
+ Two methods are yours:
62
+
63
+ ```ruby
64
+ # Required. Built from the authenticated request, never from the input.
65
+ def ag_ui_scope
66
+ "tenant:#{current_tenant.id}:actor:#{current_user.id}"
67
+ end
68
+
69
+ # Optional. The state to publish on a reconnect, or nil (the default).
70
+ def ag_ui_state
71
+ { 'orders' => current_user.visible_orders.map(&:as_json) }
72
+ end
73
+ ```
74
+
75
+ `ag_ui_scope` has no default on purpose: raising a `ConfigurationError` is better
76
+ than quietly reserving every run under one shared scope. It bounds every store
77
+ lookup, so two actors using the same `threadId` cannot reach each other's runs.
78
+
79
+ `ag_ui_state` is only published when a completed run is served to a client that
80
+ reconnects. It is not replayed as deltas, and the gem never computes it; your
81
+ app knows what the client is looking at.
82
+
83
+ ## What the client gets, and when
84
+
85
+ | Request | Response |
86
+ |---|---|
87
+ | Valid input, `Accept: text/event-stream` | `200`, SSE, one `data:` line per AG-UI event |
88
+ | `Accept` that cannot read SSE | `406` before any event |
89
+ | Empty, non-JSON, or schema-invalid body | `400` |
90
+ | Body past `config.max_input_bytes` | `413` |
91
+ | `threadId` the actor does not own, when the block raises `ActiveRecord::RecordNotFound` | `404` |
92
+ | Same `(scope, threadId, runId)` with different input | `409` |
93
+ | A live run that already has a stream | `409` |
94
+ | The same identity and input for a run that ended | `200`, `RUN_STARTED` + `STATE_SNAPSHOT` + the recorded terminal event |
95
+ | Client disconnects mid-run | no error; the record in the store is the outcome |
96
+
97
+ Refusals are decided before the response commits, so a client that cannot be
98
+ served gets a status code instead of a stream that stops mid-event. A disconnect
99
+ is not a run outcome: the run keeps its own record, and a reconnect server can
100
+ serve it.
101
+
102
+ ## The store
103
+
104
+ `Rails::ActiveRecordStore` implements the same contract as `MemoryStore` and is
105
+ what makes run identity survive a worker:
106
+
107
+ - `reserve` inserts; a `RecordNotUnique` means another worker won, and the
108
+ existing row is attached or refused by digest.
109
+ - `redeem_interrupt` runs under `row.with_lock`, so exactly one resume records a
110
+ decision and the tool runs once.
111
+ - `find_interrupt` is scoped by `(scope, thread_id, interrupt_id)`, so an
112
+ interrupt id is not a global key.
113
+
114
+ `scope`, `thread_id` and `run_id` are strings; the record itself is stored as
115
+ JSON **text** in `payload`, because a record is immutable and has no partial
116
+ update to model. The gem does the encoding, so the stored shape is exactly
117
+ `RunRecord#to_storage` on every database and Rails version, rather than whatever
118
+ the host's ActiveSupport JSON encoder produces.
119
+
120
+ Use `MemoryStore` only for a single process (development, tests, one Puma worker
121
+ with no restarts). It is per-process, and a retry after a restart is a new run.
122
+
123
+ ## Resume, from the client's side
124
+
125
+ A pause is a `RUN_FINISHED` with an interrupt outcome:
126
+
127
+ ```json
128
+ {"type":"RUN_FINISHED","threadId":"thread-1","runId":"run-1",
129
+ "outcome":{"type":"interrupt","interrupts":[
130
+ {"id":"9f2c...","reason":"tool_call_confirmation",
131
+ "message":"Approve the cancel_order tool call?",
132
+ "toolCallId":"call_1","expiresAt":"2026-10-18T12:15:00Z"}]}}
133
+ ```
134
+
135
+ The client answers it on a **new run** for the same thread:
136
+
137
+ ```json
138
+ {"threadId":"thread-1","runId":"run-2",
139
+ "messages":[{"id":"m1","role":"user","content":"Cancel order 42."}],
140
+ "resume":[{"interruptId":"9f2c...","status":"resolved"}]}
141
+ ```
142
+
143
+ `status: "cancelled"` records a denial instead. Either way the interrupt must
144
+ still be pending, unexpired, and bound to the same tool call arguments the host
145
+ chat holds, or the run is refused before it is reserved.
146
+
147
+ The approval is a request, not a permission: re-authorize before the tool runs.
148
+
149
+ ## Transport
150
+
151
+ `ActionController::Live` holds a thread for the life of the run and is the
152
+ simplest thing that streams correctly. Before production, know its two costs:
153
+
154
+ - **Threads.** A server with `N` workers and `T` threads can serve `T` concurrent
155
+ streams. Long model calls make that a real limit.
156
+ - **Proxies.** `X-Accel-Buffering: no` is set for nginx, but any intermediary that
157
+ buffers will turn the stream into one response at the end. `curl -N` is the
158
+ quickest way to see whether yours does.
159
+
160
+ If you need to survive deploys, or to serve one run to several clients, run the
161
+ work elsewhere and have the controller read from your own broker. The gem gives
162
+ you the parts, not the relay: any object with `#write` is a stream, the run record
163
+ is durable, and `RubyLLM::AgUI::Reconnect` builds the events for a run that has
164
+ already ended. Two live streams for one run are refused rather than merged,
165
+ because a silent second stream duplicates the work.
166
+
167
+ ## Testing a host app
168
+
169
+ The adapter's own specs live in `spec/rails_helper.rb`-based files and are not
170
+ auto-required. In your app, two kinds of test are usually enough:
171
+
172
+ 1. A controller test that the refusals happen (no auth, wrong `Accept`, a
173
+ `threadId` the actor does not own) and that a valid request yields an event
174
+ stream.
175
+ 2. A store test that the same identity twice does not execute twice, and that a
176
+ resume replayed twice does not run the tool twice.
177
+
178
+ You do not need a real model for either: pass a chat double that plays
179
+ `before_tool_call`, `after_message`, `complete`, `waiting?`,
180
+ `awaiting_approval?`, `pending_approvals`, `approve` and `deny`, which is exactly
181
+ what `spec/support/fake_chat.rb` in this repository does.
182
+
183
+ ## Troubleshooting
184
+
185
+ | Symptom | Likely cause |
186
+ |---|---|
187
+ | The whole response arrives at once | A buffering proxy, or `curl` without `-N` |
188
+ | `406` for every request | The client did not send `Accept: text/event-stream` |
189
+ | `ConfigurationError` about `ag_ui_scope` | The controller does not define it |
190
+ | `409` on a request the client thinks is new | The same `(scope, threadId, runId)` was used with different input |
191
+ | `409` on a second tab of the same run | A live run has one stream; serve the second client from your own relay |
192
+ | Tool results are `[redacted]` | Redaction is the default; configure `config.redact` |
193
+ | A run succeeds but the client shows nothing | The client was not sent the interrupt/resume flow it expected, or it re-sent a `runId` whose run had already ended |
194
+
195
+ ## Next Steps
196
+
197
+ * [Approval and Resume]({% link _core_features/approval-and-resume.md %}) - answer a tool approval pause.
198
+ * [Reconnects and Run Identity]({% link _core_features/reconnects.md %}) - understand durable run records.
199
+ * [Security and Deployment]({% link _advanced/security-and-deployment.md %}) - review host authorization duties.