anima-core 0.2.0 → 0.2.1

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.
@@ -1,13 +1,24 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module TUI
4
- # Thread-safe in-memory store for chat messages displayed in the TUI.
4
+ # Thread-safe in-memory store for chat entries displayed in the TUI.
5
5
  # Replaces {Events::Subscribers::MessageCollector} in the WebSocket-based
6
6
  # TUI, with no dependency on Rails or the Events module.
7
7
  #
8
- # Accepts Action Cable event payloads and extracts displayable messages.
8
+ # Accepts Action Cable event payloads and stores typed entries:
9
+ # - `{type: :rendered, data:, event_type:}` for events with structured decorator output
10
+ # - `{type: :message, role:, content:}` for user/agent messages (fallback)
11
+ # - `{type: :tool_counter, calls:, responses:}` for tool activity
12
+ #
13
+ # Structured data takes priority when available. Events with nil
14
+ # rendered content fall back to existing behavior: tool events aggregate
15
+ # into counters, messages store role and content.
16
+ #
17
+ # Tool counters aggregate per agent turn: a new counter starts when a
18
+ # tool_call arrives after a message entry. Consecutive tool events
19
+ # increment the same counter until the next message breaks the chain.
9
20
  class MessageStore
10
- DISPLAYABLE_TYPES = %w[user_message agent_message].freeze
21
+ MESSAGE_TYPES = %w[user_message agent_message].freeze
11
22
 
12
23
  ROLE_MAP = {
13
24
  "user_message" => "user",
@@ -15,35 +26,97 @@ module TUI
15
26
  }.freeze
16
27
 
17
28
  def initialize
18
- @messages = []
29
+ @entries = []
19
30
  @mutex = Mutex.new
20
31
  end
21
32
 
22
- # @return [Array<Hash>] thread-safe copy of collected messages
33
+ # @return [Array<Hash>] thread-safe copy of stored entries
23
34
  def messages
24
- @mutex.synchronize { @messages.dup }
35
+ @mutex.synchronize { @entries.dup }
25
36
  end
26
37
 
27
38
  # Processes a raw event payload from the WebSocket channel.
28
- # Only user_message and agent_message events are stored.
39
+ # Uses structured decorator data when available; falls back to
40
+ # role/content extraction for messages and tool counter aggregation.
29
41
  #
30
- # @param event_data [Hash] Action Cable event payload with "type" and "content"
31
- # @return [Boolean] true if the message was stored
42
+ # @param event_data [Hash] Action Cable event payload with "type", "content",
43
+ # and optionally "rendered" (hash of mode => lines)
44
+ # @return [Boolean] true if the event type was recognized and handled
32
45
  def process_event(event_data)
33
- type = event_data["type"]
34
- return false unless DISPLAYABLE_TYPES.include?(type)
46
+ rendered = extract_rendered(event_data)
47
+
48
+ if rendered
49
+ record_rendered(rendered, event_type: event_data["type"])
50
+ else
51
+ case event_data["type"]
52
+ when "tool_call" then record_tool_call
53
+ when "tool_response" then record_tool_response
54
+ when *MESSAGE_TYPES then record_message(event_data)
55
+ else false
56
+ end
57
+ end
58
+ end
59
+
60
+ # Removes all entries. Called on view mode change and session switch
61
+ # to prepare for re-decorated viewport events from the server.
62
+ # @return [void]
63
+ def clear
64
+ @mutex.synchronize { @entries = [] }
65
+ end
66
+
67
+ private
68
+
69
+ # Extracts the first non-nil structured data hash from the rendered payload.
70
+ # The "rendered" hash is keyed by view mode — the server includes only the
71
+ # session's current mode, so there is always at most one entry.
72
+ # (e.g. {"basic" => {"role" => "user", ...}} or {"basic" => nil} for hidden events)
73
+ #
74
+ # @return [Hash, nil] structured event data, or nil if not present
75
+ def extract_rendered(event_data)
76
+ event_data.dig("rendered")&.values&.compact&.first
77
+ end
78
+
79
+ def record_rendered(data, event_type: nil)
80
+ @mutex.synchronize do
81
+ @entries << {type: :rendered, data: data, event_type: event_type}
82
+ end
83
+ true
84
+ end
85
+
86
+ def record_tool_call
87
+ @mutex.synchronize do
88
+ current = current_tool_counter
89
+ if current
90
+ current[:calls] += 1
91
+ else
92
+ @entries << {type: :tool_counter, calls: 1, responses: 0}
93
+ end
94
+ end
95
+ true
96
+ end
35
97
 
98
+ def record_tool_response
99
+ @mutex.synchronize do
100
+ current = current_tool_counter
101
+ current[:responses] += 1 if current
102
+ end
103
+ true
104
+ end
105
+
106
+ def record_message(event_data)
36
107
  content = event_data["content"]
37
108
  return false if content.nil?
38
109
 
39
110
  @mutex.synchronize do
40
- @messages << {role: ROLE_MAP.fetch(type), content: content}
111
+ @entries << {type: :message, role: ROLE_MAP.fetch(event_data["type"]), content: content}
41
112
  end
42
113
  true
43
114
  end
44
115
 
45
- def clear
46
- @mutex.synchronize { @messages = [] }
116
+ # @return [Hash, nil] the last entry if it is a tool counter
117
+ def current_tool_counter
118
+ last = @entries.last
119
+ last if last&.dig(:type) == :tool_counter
47
120
  end
48
121
  end
49
122
  end