anima-core 0.2.1 → 0.3.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 9517757f2c4fdb8b19d204a154d3badd3c3b8fb456dffaf03236b2d7c065378d
4
- data.tar.gz: a0d26e0b27fd1d2df0c46c3a4bbad822ac3517d1d02b41a6bdd49195de64e71f
3
+ metadata.gz: e3b4a76568897bb6c9d465151e61f6dd889c42c33b71dcf0c019ae561143b2e4
4
+ data.tar.gz: 44acf306c750f33b3bb006c7be209ec80924521f61a89d2e863179672f33751b
5
5
  SHA512:
6
- metadata.gz: d150e5f97a3a2055c66cfaa773c7bd1dd33354b869fe38d6fa25b2b47352c76cec0c222f44aa4eb7e7e65b61098b7531e61522a01e6dd1d722ee9224c7759aea
7
- data.tar.gz: 0f9ef56323a4598ed8dc3dca79e6ec9cf1e63ea9dff8751612cd32691aa0968e629144b4f7c47150cd9c9861f3d74e323c852433e1aa066732bb10fc8db235fc
6
+ metadata.gz: bb66a71439efafa605eb7e6124269e82a0183c3d133d7e63658a1bdbe1c4f6adc9a4bf3303fb4bc724337319434eded5de40e9134c456ecbb3bb4508500b9370
7
+ data.tar.gz: b06ae76628e9abd1a76832703bc1769f24ea206c39c86b5fc1ef3bcfe9a59cfe87db222042aa7ea277714d18c3fb9c7c460769974fd75edd3b1f00923560e756
data/CHANGELOG.md CHANGED
@@ -1,5 +1,20 @@
1
1
  ## [Unreleased]
2
2
 
3
+ ### Added
4
+ - Real-time event broadcasting via `Event::Broadcasting` concern — `after_create_commit` and `after_update_commit` callbacks broadcast decorated payloads with database ID and action type to the session's ActionCable stream (#91)
5
+ - TUI `MessageStore` ID-indexed updates — events with `action: "update"` replace existing entries in-place (O(1) lookup) without changing display order
6
+ - `CountEventTokensJob` triggers broadcast — uses `update!` so token count updates push to connected clients in real time
7
+ - Connection status constants in `CableClient` — replaces magic strings with named constants for protocol message types
8
+
9
+ ### Changed
10
+ - Connection status indicator simplified — emoji-only `🟢` for normal state, descriptive text only for abnormal states (#80)
11
+ - `STATUS_STYLES` structure simplified from `{label, fg, bg}` to `{label, color}` (#80)
12
+ - `ActionCableBridge` removed — broadcasting moved from EventBus subscriber to AR callbacks, eliminating the timing gap where events were broadcast before persistence
13
+ - `SessionChannel` history includes event IDs for client-side correlation
14
+
15
+ ### Fixed
16
+ - TUI showed empty chat on reconnect — message store was cleared _after_ history arrived because `confirm_subscription` comes after `transmit` in Action Cable protocol; now clears on "subscribing" before history (#82)
17
+
3
18
  ## [0.2.1] - 2026-03-13
4
19
 
5
20
  ### Added
data/README.md CHANGED
@@ -130,37 +130,13 @@ Updates: `gem update anima-core` — next launch runs pending migrations automat
130
130
 
131
131
  ### Authentication Setup
132
132
 
133
- Anima uses your Claude Pro/Max subscription for API access. You need a setup-token from Claude Code CLI.
133
+ Anima uses your Claude Pro/Max subscription for API access. You need a setup-token from [Claude Code CLI](https://docs.anthropic.com/en/docs/claude-code).
134
134
 
135
- **1. Get a setup-token:**
135
+ 1. Run `claude setup-token` in a terminal to get your token
136
+ 2. In the TUI, press `Ctrl+a → a` to open the token setup popup
137
+ 3. Paste the token and press Enter — Anima validates it against the Anthropic API and saves it to encrypted credentials
136
138
 
137
- ```bash
138
- claude setup-token
139
- ```
140
-
141
- This requires [Claude Code CLI](https://docs.anthropic.com/en/docs/claude-code) installed and a Claude Pro or Max subscription.
142
-
143
- **2. Store the token in Anima credentials:**
144
-
145
- ```bash
146
- cd $(gem contents anima-core | head -1 | xargs dirname | xargs dirname)
147
- bin/rails credentials:edit
148
- ```
149
-
150
- Add your token:
151
-
152
- ```yaml
153
- anthropic:
154
- subscription_token: sk-ant-oat01-YOUR_TOKEN_HERE
155
- ```
156
-
157
- **3. Verify the token works:**
158
-
159
- ```bash
160
- bin/rails runner "Providers::Anthropic.validate!"
161
- ```
162
-
163
- If the token expires or is revoked, repeat steps 1-2 with a new token.
139
+ The popup also activates automatically when Anima detects a missing or invalid token. If the token expires, repeat the process with a new one.
164
140
 
165
141
  ### Three Layers (mirroring biology)
166
142
 
@@ -186,7 +162,7 @@ Five event types form the agent's nervous system:
186
162
 
187
163
  Events flow through two channels:
188
164
  1. **In-process** — Rails Structured Event Reporter (local subscribers like Persister)
189
- 2. **Over the wire** — Action Cable WebSocket (the ActionCableBridge subscriber forwards events to connected TUI clients)
165
+ 2. **Over the wire** — Action Cable WebSocket (`Event::Broadcasting` callbacks push to connected TUI clients)
190
166
 
191
167
  Events fire, subscribers react, state updates, the cortex (LLM) reads the resulting desire landscape. The system prompt is assembled separately for each LLM call — it is not an event.
192
168
 
@@ -14,19 +14,25 @@ class SessionChannel < ApplicationCable::Channel
14
14
  MAX_LIST_LIMIT = 50
15
15
 
16
16
  # Subscribes the client to the session-specific stream.
17
- # Rejects the subscription if no valid session_id is provided.
18
- # Transmits the current view_mode and chat history to the subscribing client.
17
+ # When a valid session_id is provided, subscribes to that session.
18
+ # When omitted or zero, resolves to the most recent session (creating
19
+ # one if none exist) — this is the CQRS-compliant path where the
20
+ # server owns session resolution instead of a REST endpoint.
19
21
  #
20
- # @param params [Hash] must include :session_id (positive integer)
22
+ # Always transmits a session_changed signal so the client learns
23
+ # the authoritative session ID, followed by view_mode and history.
24
+ #
25
+ # @param params [Hash] optional :session_id (positive integer)
21
26
  def subscribed
22
- @current_session_id = params[:session_id].to_i
23
- if @current_session_id > 0
24
- stream_from stream_name
25
- transmit_view_mode
26
- transmit_history
27
- else
28
- reject
29
- end
27
+ @current_session_id = resolve_session_id
28
+ stream_from stream_name
29
+
30
+ session = Session.find_by(id: @current_session_id)
31
+ return unless session
32
+
33
+ transmit_session_changed(session)
34
+ transmit_view_mode(session)
35
+ transmit_history(session)
30
36
  end
31
37
 
32
38
  # Receives messages from clients and broadcasts them to all session subscribers.
@@ -37,14 +43,43 @@ class SessionChannel < ApplicationCable::Channel
37
43
  end
38
44
 
39
45
  # Processes user input: persists the message and enqueues LLM processing.
46
+ # When the session is actively processing an agent request, the message
47
+ # is queued as "pending" and picked up after the current loop completes.
40
48
  #
41
49
  # @param data [Hash] must include "content" with the user's message text
42
50
  def speak(data)
43
51
  content = data["content"].to_s.strip
44
- return if content.empty? || !Session.exists?(@current_session_id)
52
+ return if content.empty?
53
+
54
+ session = Session.find_by(id: @current_session_id)
55
+ return unless session
45
56
 
46
- Events::Bus.emit(Events::UserMessage.new(content: content, session_id: @current_session_id))
47
- AgentRequestJob.perform_later(@current_session_id)
57
+ if session.processing?
58
+ Events::Bus.emit(Events::UserMessage.new(content: content, session_id: @current_session_id, status: Event::PENDING_STATUS))
59
+ else
60
+ Events::Bus.emit(Events::UserMessage.new(content: content, session_id: @current_session_id))
61
+ AgentRequestJob.perform_later(@current_session_id)
62
+ end
63
+ end
64
+
65
+ # Recalls the most recent pending message for editing. Deletes the
66
+ # pending event and broadcasts the recall so all clients remove it.
67
+ #
68
+ # @param data [Hash] must include "event_id" (positive integer)
69
+ def recall_pending(data)
70
+ event_id = data["event_id"].to_i
71
+ return if event_id <= 0
72
+
73
+ event = Event.find_by(
74
+ id: event_id,
75
+ session_id: @current_session_id,
76
+ event_type: "user_message",
77
+ status: Event::PENDING_STATUS
78
+ )
79
+ return unless event
80
+
81
+ event.destroy!
82
+ ActionCable.server.broadcast(stream_name, {"action" => "user_message_recalled", "event_id" => event_id})
48
83
  end
49
84
 
50
85
  # Returns recent sessions with metadata for session picker UI.
@@ -86,6 +121,23 @@ class SessionChannel < ApplicationCable::Channel
86
121
  transmit_error("Session not found")
87
122
  end
88
123
 
124
+ # Validates and saves an Anthropic subscription token to encrypted credentials.
125
+ # Format-validated and API-validated before storage. The token never enters the
126
+ # LLM context window — it flows directly from WebSocket to encrypted credentials.
127
+ #
128
+ # @param data [Hash] must include "token" (Anthropic subscription token string)
129
+ def save_token(data)
130
+ token = data["token"].to_s.strip
131
+
132
+ Providers::Anthropic.validate_token_format!(token)
133
+ Providers::Anthropic.validate_token_api!(token)
134
+ write_anthropic_token(token)
135
+
136
+ transmit({"action" => "token_saved"})
137
+ rescue Providers::Anthropic::TokenFormatError, Providers::Anthropic::AuthenticationError => error
138
+ transmit({"action" => "token_error", "message" => error.message})
139
+ end
140
+
89
141
  # Changes the session's view mode and re-broadcasts the viewport.
90
142
  # All clients on the session receive the mode change and fresh history.
91
143
  #
@@ -109,6 +161,33 @@ class SessionChannel < ApplicationCable::Channel
109
161
  "session_#{@current_session_id}"
110
162
  end
111
163
 
164
+ # Resolves the session to subscribe to. Uses the client-provided ID
165
+ # when valid, otherwise falls back to the most recent session or
166
+ # creates a new one.
167
+ #
168
+ # @return [Integer] resolved session ID
169
+ def resolve_session_id
170
+ id = params[:session_id].to_i
171
+ return id if id > 0
172
+
173
+ (Session.recent(1).first || Session.create!).id
174
+ end
175
+
176
+ # Transmits session metadata as a session_changed signal.
177
+ # Used on initial subscription and after session switches so the
178
+ # client can handle both paths with a single code path.
179
+ #
180
+ # @param session [Session] the session to announce
181
+ # @return [void]
182
+ def transmit_session_changed(session)
183
+ transmit({
184
+ "action" => "session_changed",
185
+ "session_id" => session.id,
186
+ "message_count" => session.events.llm_messages.count,
187
+ "view_mode" => session.view_mode
188
+ })
189
+ end
190
+
112
191
  # Switches the channel to a different session: stops current stream,
113
192
  # updates the session reference, starts the new stream, and sends
114
193
  # a session_changed signal followed by chat history.
@@ -116,23 +195,18 @@ class SessionChannel < ApplicationCable::Channel
116
195
  stop_all_streams
117
196
  @current_session_id = new_id
118
197
  stream_from stream_name
198
+
119
199
  session = Session.find(new_id)
120
- transmit({
121
- "action" => "session_changed",
122
- "session_id" => new_id,
123
- "message_count" => session.events.llm_messages.count,
124
- "view_mode" => session.view_mode
125
- })
126
- transmit_history
200
+ transmit_session_changed(session)
201
+ transmit_history(session)
127
202
  end
128
203
 
129
204
  # Transmits the current view_mode so the TUI initializes correctly.
130
205
  # Sends `{action: "view_mode", view_mode: <mode>}` to the subscribing client.
206
+ #
207
+ # @param session [Session] the session whose view_mode to transmit
131
208
  # @return [void]
132
- def transmit_view_mode
133
- session = Session.find_by(id: @current_session_id)
134
- return unless session
135
-
209
+ def transmit_view_mode(session)
136
210
  transmit({"action" => "view_mode", "view_mode" => session.view_mode})
137
211
  end
138
212
 
@@ -142,10 +216,9 @@ class SessionChannel < ApplicationCable::Channel
142
216
  # the transmitted payload. Tool events are included so the TUI can
143
217
  # reconstruct tool call counters on reconnect.
144
218
  # In debug mode, prepends the assembled system prompt as a special block.
145
- def transmit_history
146
- session = Session.find_by(id: @current_session_id)
147
- return unless session
148
-
219
+ #
220
+ # @param session [Session] the session whose history to transmit
221
+ def transmit_history(session)
149
222
  transmit_system_prompt(session) if session.view_mode == "debug"
150
223
 
151
224
  session.viewport_events.each do |event|
@@ -166,8 +239,16 @@ class SessionChannel < ApplicationCable::Channel
166
239
  end
167
240
  end
168
241
 
242
+ # Decorates an event for transmission to clients. Merges the event's
243
+ # database ID and structured decorator output into the payload.
244
+ # Used by {#transmit_history} and {#broadcast_viewport} for historical
245
+ # and viewport re-broadcast — live broadcasts use {Event::Broadcasting}.
246
+ #
247
+ # @param event [Event] persisted event record
248
+ # @param mode [String] view mode for decoration (default: "basic")
249
+ # @return [Hash] payload with "id" and optional "rendered" key
169
250
  def decorate_event_payload(event, mode = "basic")
170
- payload = event.payload
251
+ payload = event.payload.merge("id" => event.id)
171
252
  decorator = EventDecorator.for(event)
172
253
  return payload unless decorator
173
254
 
@@ -212,6 +293,27 @@ class SessionChannel < ApplicationCable::Channel
212
293
  }
213
294
  end
214
295
 
296
+ # Merges the Anthropic subscription token into encrypted credentials,
297
+ # preserving existing keys (e.g. secret_key_base).
298
+ #
299
+ # @param token [String] validated Anthropic subscription token
300
+ # @return [void]
301
+ def write_anthropic_token(token)
302
+ creds = Rails.application.credentials
303
+ existing = begin
304
+ YAML.safe_load(creds.read) || {}
305
+ rescue ActiveSupport::EncryptedFile::MissingContentError
306
+ {}
307
+ end
308
+ existing["anthropic"] ||= {}
309
+ existing["anthropic"]["subscription_token"] = token
310
+ creds.write(existing.to_yaml)
311
+ # Rails memoizes the decrypted config in @config. Without clearing it,
312
+ # subsequent credential reads return stale data. No public API exists
313
+ # for cache invalidation as of Rails 8.1.
314
+ creds.instance_variable_set(:@config, nil)
315
+ end
316
+
215
317
  def transmit_error(message)
216
318
  transmit({"action" => "error", "message" => message})
217
319
  end
@@ -3,22 +3,33 @@
3
3
  # Decorates user_message events for display in the TUI.
4
4
  # Basic mode returns role and content. Verbose mode adds a timestamp.
5
5
  # Debug mode adds token count (exact when counted, estimated when not).
6
+ # Pending messages include `status: "pending"` so the TUI renders them
7
+ # with a visual indicator (dimmed, clock icon).
6
8
  class UserMessageDecorator < EventDecorator
7
9
  # @return [Hash] structured user message data
8
- # `{role: :user, content: String}`
10
+ # `{role: :user, content: String}` or with `status: "pending"` when queued
9
11
  def render_basic
10
- {role: :user, content: content}
12
+ base = {role: :user, content: content}
13
+ base[:status] = "pending" if pending?
14
+ base
11
15
  end
12
16
 
13
17
  # @return [Hash] structured user message with nanosecond timestamp
14
- # `{role: :user, content: String, timestamp: Integer|nil}`
15
18
  def render_verbose
16
- {role: :user, content: content, timestamp: timestamp}
19
+ base = {role: :user, content: content, timestamp: timestamp}
20
+ base[:status] = "pending" if pending?
21
+ base
17
22
  end
18
23
 
19
24
  # @return [Hash] verbose output plus token count for debugging
20
- # `{role: :user, content: String, timestamp: Integer|nil, tokens: Integer, estimated: Boolean}`
21
25
  def render_debug
22
26
  render_verbose.merge(token_info)
23
27
  end
28
+
29
+ private
30
+
31
+ # @return [Boolean] true when this message is queued but not yet sent to LLM
32
+ def pending?
33
+ payload["status"] == Event::PENDING_STATUS
34
+ end
24
35
  end
@@ -26,23 +26,52 @@ class AgentRequestJob < ApplicationJob
26
26
 
27
27
  discard_on ActiveRecord::RecordNotFound
28
28
  discard_on Providers::Anthropic::AuthenticationError do |job, error|
29
+ session_id = job.arguments.first
30
+ # Persistent system message for the event log
29
31
  Events::Bus.emit(Events::SystemMessage.new(
30
32
  content: "Authentication failed: #{error.message}",
31
- session_id: job.arguments.first
33
+ session_id: session_id
32
34
  ))
35
+ # Transient signal to trigger TUI token setup popup (not persisted)
36
+ ActionCable.server.broadcast(
37
+ "session_#{session_id}",
38
+ {"action" => "authentication_required", "message" => error.message}
39
+ )
33
40
  end
34
41
 
35
42
  # @param session_id [Integer] ID of the session to process
36
43
  def perform(session_id)
37
44
  session = Session.find(session_id)
45
+
46
+ # Atomic: only one job processes a session at a time. If another job
47
+ # is already running, this one exits — the running job will pick up
48
+ # any pending messages after its current loop completes.
49
+ return unless claim_processing(session_id)
50
+
38
51
  agent_loop = AgentLoop.new(session: session)
39
- agent_loop.run
52
+ loop do
53
+ agent_loop.run
54
+ promoted = session.promote_pending_messages!
55
+ break if promoted == 0
56
+ end
40
57
  ensure
58
+ release_processing(session_id)
41
59
  agent_loop&.finalize
42
60
  end
43
61
 
44
62
  private
45
63
 
64
+ # Sets the session's processing flag atomically. Returns true if this
65
+ # job claimed the lock, false if another job already holds it.
66
+ def claim_processing(session_id)
67
+ Session.where(id: session_id, processing: false).update_all(processing: true) == 1
68
+ end
69
+
70
+ # Clears the processing flag so the session can accept new jobs.
71
+ def release_processing(session_id)
72
+ Session.where(id: session_id).update_all(processing: false)
73
+ end
74
+
46
75
  # Emits a system message before each retry so the user sees
47
76
  # "retrying..." instead of nothing.
48
77
  def retry_job(options = {})
@@ -12,7 +12,7 @@ class CountEventTokensJob < ApplicationJob
12
12
  # @param event_id [Integer] the Event record to count tokens for
13
13
  def perform(event_id)
14
14
  event = Event.find(event_id)
15
- return if event.token_count > 0
15
+ return if already_counted?(event)
16
16
 
17
17
  provider = Providers::Anthropic.new
18
18
  messages = [{role: event.api_role, content: event.payload["content"].to_s}]
@@ -22,7 +22,18 @@ class CountEventTokensJob < ApplicationJob
22
22
  messages: messages
23
23
  )
24
24
 
25
- # Atomic update: only write if still uncounted (avoids race with parallel jobs).
26
- Event.where(id: event.id, token_count: 0).update_all(token_count: token_count)
25
+ # Guard against parallel jobs: reload and re-check before writing.
26
+ # Uses update! (not update_all) so {Event::Broadcasting} after_update_commit
27
+ # broadcasts the updated token count to connected clients.
28
+ event.reload
29
+ return if already_counted?(event)
30
+
31
+ event.update!(token_count: token_count)
32
+ end
33
+
34
+ private
35
+
36
+ def already_counted?(event)
37
+ event.token_count > 0
27
38
  end
28
39
  end
@@ -0,0 +1,63 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Broadcasts Event records to connected WebSocket clients via ActionCable.
4
+ # Follows the Turbo Streams pattern: events are broadcast on both create
5
+ # and update, with an action type so clients can distinguish append from
6
+ # replace operations.
7
+ #
8
+ # Each broadcast includes the Event's database ID, enabling clients to
9
+ # maintain an ID-indexed store for efficient in-place updates (e.g. when
10
+ # token counts arrive asynchronously from {CountEventTokensJob}).
11
+ #
12
+ # @example Create broadcast payload
13
+ # {
14
+ # "type" => "user_message", "content" => "hello", ...,
15
+ # "id" => 42, "action" => "create",
16
+ # "rendered" => { "basic" => { "role" => "user", "content" => "hello" } }
17
+ # }
18
+ #
19
+ # @example Update broadcast payload (e.g. token count arrives)
20
+ # {
21
+ # "type" => "user_message", "content" => "hello", ...,
22
+ # "id" => 42, "action" => "update",
23
+ # "rendered" => { "debug" => { "role" => "user", "content" => "hello", "tokens" => 15 } }
24
+ # }
25
+ module Event::Broadcasting
26
+ extend ActiveSupport::Concern
27
+
28
+ ACTION_CREATE = "create"
29
+ ACTION_UPDATE = "update"
30
+
31
+ included do
32
+ after_create_commit :broadcast_create
33
+ after_update_commit :broadcast_update
34
+ end
35
+
36
+ private
37
+
38
+ def broadcast_create
39
+ broadcast_event(action: ACTION_CREATE)
40
+ end
41
+
42
+ def broadcast_update
43
+ broadcast_event(action: ACTION_UPDATE)
44
+ end
45
+
46
+ # Decorates the event for the session's current view mode and broadcasts
47
+ # the payload to the session's ActionCable stream.
48
+ #
49
+ # @param action [String] ACTION_CREATE or ACTION_UPDATE — tells clients how to handle the event
50
+ def broadcast_event(action:)
51
+ return unless session_id
52
+
53
+ mode = Session.where(id: session_id).pick(:view_mode) || "basic"
54
+ decorator = EventDecorator.for(self)
55
+ broadcast_payload = payload.merge("id" => id, "action" => action)
56
+
57
+ if decorator
58
+ broadcast_payload["rendered"] = {mode => decorator.render(mode)}
59
+ end
60
+
61
+ ActionCable.server.broadcast("session_#{session_id}", broadcast_payload)
62
+ end
63
+ end
data/app/models/event.rb CHANGED
@@ -16,9 +16,12 @@
16
16
  # @!attribute tool_use_id
17
17
  # @return [String, nil] Anthropic-assigned ID correlating tool_call and tool_response
18
18
  class Event < ApplicationRecord
19
+ include Event::Broadcasting
20
+
19
21
  TYPES = %w[system_message user_message agent_message tool_call tool_response].freeze
20
22
  LLM_TYPES = %w[user_message agent_message].freeze
21
23
  CONTEXT_TYPES = %w[user_message agent_message tool_call tool_response].freeze
24
+ PENDING_STATUS = "pending"
22
25
 
23
26
  ROLE_MAP = {"user_message" => "user", "agent_message" => "assistant"}.freeze
24
27
 
@@ -43,6 +46,17 @@ class Event < ApplicationRecord
43
46
  # @return [ActiveRecord::Relation]
44
47
  scope :context_events, -> { where(event_type: CONTEXT_TYPES) }
45
48
 
49
+ # @!method self.pending
50
+ # User messages queued during active agent processing, not yet sent to LLM.
51
+ # @return [ActiveRecord::Relation]
52
+ scope :pending, -> { where(status: PENDING_STATUS) }
53
+
54
+ # @!method self.deliverable
55
+ # Events eligible for LLM context (excludes pending messages).
56
+ # NULL status means delivered/processed — the only excluded value is "pending".
57
+ # @return [ActiveRecord::Relation]
58
+ scope :deliverable, -> { where(status: nil) }
59
+
46
60
  # Maps event_type to the Anthropic Messages API role.
47
61
  # @return [String] "user" or "assistant"
48
62
  def api_role
@@ -59,6 +73,11 @@ class Event < ApplicationRecord
59
73
  event_type.in?(CONTEXT_TYPES)
60
74
  end
61
75
 
76
+ # @return [Boolean] true if this is a pending message not yet sent to the LLM
77
+ def pending?
78
+ status == PENDING_STATUS
79
+ end
80
+
62
81
  # Heuristic token estimate: ~4 bytes per token for English prose.
63
82
  # Tool events are estimated from the full payload JSON since tool_input
64
83
  # and tool metadata contribute to token count. Messages use content only.
@@ -28,12 +28,17 @@ class Session < ApplicationRecord
28
28
  # is exhausted. Events are full-size or excluded entirely.
29
29
  #
30
30
  # @param token_budget [Integer] maximum tokens to include (positive)
31
+ # @param include_pending [Boolean] whether to include pending messages (true for
32
+ # display, false for LLM context assembly)
31
33
  # @return [Array<Event>] chronologically ordered
32
- def viewport_events(token_budget: DEFAULT_TOKEN_BUDGET)
34
+ def viewport_events(token_budget: DEFAULT_TOKEN_BUDGET, include_pending: true)
35
+ scope = events.context_events
36
+ scope = scope.deliverable unless include_pending
37
+
33
38
  selected = []
34
39
  remaining = token_budget
35
40
 
36
- events.context_events.reorder(id: :desc).each do |event|
41
+ scope.reorder(id: :desc).each do |event|
37
42
  cost = (event.token_count > 0) ? event.token_count : estimate_tokens(event)
38
43
  break if cost > remaining && selected.any?
39
44
 
@@ -58,11 +63,26 @@ class Session < ApplicationRecord
58
63
  # Anthropic's wire format. Consecutive tool_call events are grouped
59
64
  # into a single assistant message; consecutive tool_response events
60
65
  # are grouped into a single user message with tool_result blocks.
66
+ # Pending messages are excluded — they haven't been delivered yet.
61
67
  #
62
68
  # @param token_budget [Integer] maximum tokens to include (positive)
63
69
  # @return [Array<Hash>] Anthropic Messages API format
64
70
  def messages_for_llm(token_budget: DEFAULT_TOKEN_BUDGET)
65
- assemble_messages(viewport_events(token_budget: token_budget))
71
+ assemble_messages(viewport_events(token_budget: token_budget, include_pending: false))
72
+ end
73
+
74
+ # Promotes all pending user messages to delivered status so they
75
+ # appear in the next LLM context. Triggers broadcast_update for
76
+ # each event so connected clients refresh the pending indicator.
77
+ #
78
+ # @return [Integer] number of promoted messages
79
+ def promote_pending_messages!
80
+ promoted = 0
81
+ events.where(event_type: "user_message", status: Event::PENDING_STATUS).find_each do |event|
82
+ event.update!(status: nil, payload: event.payload.except("status"))
83
+ promoted += 1
84
+ end
85
+ promoted
66
86
  end
67
87
 
68
88
  private
@@ -7,5 +7,4 @@ Rails.application.config.after_initialize do
7
7
  # Global persister handles events from all sessions (brain server, background jobs).
8
8
  # Skipped in test — specs manage their own persisters for isolation.
9
9
  Events::Bus.subscribe(Events::Subscribers::Persister.new) unless Rails.env.test?
10
- Events::Bus.subscribe(Events::Subscribers::ActionCableBridge.instance)
11
10
  end
data/config/routes.rb CHANGED
@@ -3,10 +3,4 @@
3
3
  Rails.application.routes.draw do
4
4
  mount ActionCable.server => "/cable"
5
5
  get "up", to: "rails/health#show", as: :rails_health_check
6
-
7
- namespace :api do
8
- resources :sessions, only: [:create] do
9
- get :current, on: :collection
10
- end
11
- end
12
6
  end
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ class AddStatusToEvents < ActiveRecord::Migration[8.1]
4
+ def change
5
+ add_column :events, :status, :string
6
+ add_index :events, [:session_id, :status], name: "index_events_on_session_id_and_status"
7
+ end
8
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ class AddProcessingToSessions < ActiveRecord::Migration[8.1]
4
+ def change
5
+ add_column :sessions, :processing, :boolean, default: false, null: false
6
+ end
7
+ end
data/lib/agent_loop.rb CHANGED
@@ -87,11 +87,14 @@ class AgentLoop
87
87
  private
88
88
 
89
89
  # Builds the default tool registry with all available tools.
90
- # @return [Tools::Registry] registry with Bash and WebGet tools
90
+ # @return [Tools::Registry] registry with all available tools
91
91
  def build_tool_registry
92
92
  registry = Tools::Registry.new(context: {shell_session: @shell_session})
93
- registry.register(Tools::WebGet)
94
93
  registry.register(Tools::Bash)
94
+ registry.register(Tools::Read)
95
+ registry.register(Tools::Write)
96
+ registry.register(Tools::Edit)
97
+ registry.register(Tools::WebGet)
95
98
  registry
96
99
  end
97
100
  end