brute 1.0.0 → 2.0.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 (85) hide show
  1. checksums.yaml +4 -4
  2. data/lib/brute/agent.rb +72 -6
  3. data/lib/brute/events/handler.rb +69 -0
  4. data/lib/brute/events/prefixed_terminal_output.rb +72 -0
  5. data/lib/brute/events/terminal_output_handler.rb +68 -0
  6. data/lib/brute/middleware/001_otel_span.rb +77 -0
  7. data/lib/brute/middleware/003_tool_result_loop.rb +103 -0
  8. data/lib/brute/middleware/004_summarize.rb +139 -0
  9. data/lib/brute/middleware/005_tracing.rb +86 -0
  10. data/lib/brute/middleware/010_max_iterations.rb +73 -0
  11. data/lib/brute/middleware/015_otel_token_usage.rb +42 -0
  12. data/lib/brute/middleware/020_system_prompt.rb +128 -0
  13. data/lib/brute/middleware/040_compaction_check.rb +155 -0
  14. data/lib/brute/middleware/060_questions.rb +41 -0
  15. data/lib/brute/middleware/070_tool_call.rb +247 -0
  16. data/lib/brute/middleware/073_otel_tool_call.rb +49 -0
  17. data/lib/brute/middleware/075_otel_tool_results.rb +46 -0
  18. data/lib/brute/middleware/100_llm_call.rb +62 -0
  19. data/lib/brute/middleware/event_handler.rb +25 -0
  20. data/lib/brute/middleware/user_queue.rb +35 -0
  21. data/lib/brute/pipeline.rb +44 -107
  22. data/lib/brute/prompts/skills.rb +2 -2
  23. data/lib/brute/prompts.rb +23 -23
  24. data/lib/brute/providers/shell.rb +6 -19
  25. data/lib/brute/providers/shell_response.rb +22 -30
  26. data/lib/brute/session.rb +52 -0
  27. data/lib/brute/store/snapshot_store.rb +21 -37
  28. data/lib/brute/sub_agent.rb +106 -0
  29. data/lib/brute/system_prompt.rb +1 -83
  30. data/lib/brute/tool.rb +107 -0
  31. data/lib/brute/tools/delegate.rb +61 -70
  32. data/lib/brute/tools/fs_patch.rb +9 -7
  33. data/lib/brute/tools/fs_read.rb +233 -20
  34. data/lib/brute/tools/fs_remove.rb +8 -9
  35. data/lib/brute/tools/fs_search.rb +98 -16
  36. data/lib/brute/tools/fs_undo.rb +8 -8
  37. data/lib/brute/tools/fs_write.rb +7 -5
  38. data/lib/brute/tools/net_fetch.rb +8 -8
  39. data/lib/brute/tools/question.rb +36 -24
  40. data/lib/brute/tools/shell.rb +74 -16
  41. data/lib/brute/tools/todo_read.rb +8 -8
  42. data/lib/brute/tools/todo_write.rb +25 -18
  43. data/lib/brute/tools.rb +8 -12
  44. data/lib/brute/truncation.rb +219 -0
  45. data/lib/brute/version.rb +1 -1
  46. data/lib/brute.rb +82 -45
  47. metadata +59 -46
  48. data/lib/brute/loop/agent_stream.rb +0 -118
  49. data/lib/brute/loop/agent_turn.rb +0 -520
  50. data/lib/brute/loop/compactor.rb +0 -107
  51. data/lib/brute/loop/doom_loop.rb +0 -86
  52. data/lib/brute/loop/step.rb +0 -332
  53. data/lib/brute/loop/tool_call_step.rb +0 -90
  54. data/lib/brute/middleware/base.rb +0 -27
  55. data/lib/brute/middleware/compaction_check.rb +0 -106
  56. data/lib/brute/middleware/doom_loop_detection.rb +0 -136
  57. data/lib/brute/middleware/llm_call.rb +0 -128
  58. data/lib/brute/middleware/message_tracking.rb +0 -339
  59. data/lib/brute/middleware/otel/span.rb +0 -105
  60. data/lib/brute/middleware/otel/token_usage.rb +0 -68
  61. data/lib/brute/middleware/otel/tool_calls.rb +0 -68
  62. data/lib/brute/middleware/otel/tool_results.rb +0 -65
  63. data/lib/brute/middleware/otel.rb +0 -34
  64. data/lib/brute/middleware/reasoning_normalizer.rb +0 -192
  65. data/lib/brute/middleware/retry.rb +0 -157
  66. data/lib/brute/middleware/session_persistence.rb +0 -72
  67. data/lib/brute/middleware/token_tracking.rb +0 -124
  68. data/lib/brute/middleware/tool_error_tracking.rb +0 -179
  69. data/lib/brute/middleware/tool_use_guard.rb +0 -133
  70. data/lib/brute/middleware/tracing.rb +0 -124
  71. data/lib/brute/middleware.rb +0 -18
  72. data/lib/brute/orchestrator/turn.rb +0 -105
  73. data/lib/brute/patches/anthropic_tool_role.rb +0 -35
  74. data/lib/brute/patches/buffer_nil_guard.rb +0 -26
  75. data/lib/brute/providers/models_dev.rb +0 -111
  76. data/lib/brute/providers/ollama.rb +0 -135
  77. data/lib/brute/providers/opencode_go.rb +0 -43
  78. data/lib/brute/providers/opencode_zen.rb +0 -87
  79. data/lib/brute/providers.rb +0 -62
  80. data/lib/brute/queue/base_queue.rb +0 -222
  81. data/lib/brute/queue/parallel_queue.rb +0 -66
  82. data/lib/brute/queue/sequential_queue.rb +0 -63
  83. data/lib/brute/store/message_store.rb +0 -362
  84. data/lib/brute/store/session.rb +0 -106
  85. /data/lib/brute/{diff.rb → utils/diff.rb} +0 -0
@@ -1,222 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require "bundler/setup"
4
- require "brute"
5
-
6
- require "async"
7
- require "async/queue"
8
- require "async/barrier"
9
- require "async/semaphore"
10
-
11
- module Brute
12
- module Queue
13
- # A queue that dequeues Step objects and runs them, honoring cancellation.
14
- #
15
- # Composes four async primitives:
16
- # - An inbox (Async::Queue) that holds pending steps
17
- # - A barrier (Async::Barrier) that tracks every task the queue spawns
18
- # - A semaphore (Async::Semaphore) parented to the barrier, limiting concurrency
19
- # - Workers — long-lived tasks that dequeue from the inbox and run steps
20
- #
21
- # The barrier-semaphore composition via parent: means every task the
22
- # semaphore spawns is also tracked by the barrier. One call site
23
- # (semaphore.async), two guarantees (scoped lifetime + bounded concurrency).
24
- #
25
- class BaseQueue
26
- attr_reader :steps
27
-
28
- def initialize(concurrency:, worker_count:, parent: Async::Task.current)
29
- @steps = []
30
- @inbox = Async::Queue.new
31
- @barrier = Async::Barrier.new(parent: parent)
32
- @semaphore = Async::Semaphore.new(concurrency, parent: @barrier)
33
- @worker_count = worker_count
34
- @started = false
35
- end
36
-
37
- def <<(step)
38
- @steps << step
39
- @inbox.push(step)
40
- self
41
- end
42
-
43
- def first = @steps.first
44
- def last = @steps.last
45
-
46
- def start
47
- return self if @started
48
- @started = true
49
-
50
- @worker_count.times do
51
- @barrier.async do
52
- while (step = @inbox.dequeue)
53
- @semaphore.async do |task|
54
- step.call(task)
55
- end
56
- end
57
- end
58
- end
59
- self
60
- end
61
-
62
- # Graceful: stop accepting, wait for running work to finish.
63
- def drain
64
- @inbox.close
65
- @barrier.wait
66
- end
67
-
68
- # Hard: close inbox, cancel pending steps, cancel running work.
69
- def cancel
70
- @inbox.close
71
- @steps.each do |step|
72
- step.cancel if step.state == :pending
73
- end
74
- @barrier.cancel
75
- end
76
- end
77
- end
78
- end
79
-
80
- test do
81
- class CountStep < Brute::Loop::Step
82
- def perform(task)
83
- @attributes[:counter] << @attributes[:value]
84
- @attributes[:value]
85
- end
86
- end
87
-
88
- class SleepStep < Brute::Loop::Step
89
- def perform(task)
90
- sleep(@attributes[:duration])
91
- "slept"
92
- end
93
- end
94
-
95
- # -- enqueue --
96
-
97
- it "appends steps to the steps list" do
98
- Sync do
99
- q = Brute::Queue::BaseQueue.new(concurrency: 1, worker_count: 1)
100
- q << CountStep.new(counter: [], value: 1)
101
- q.steps.size.should == 1
102
- end
103
- end
104
-
105
- it "returns self from <<" do
106
- Sync do
107
- q = Brute::Queue::BaseQueue.new(concurrency: 1, worker_count: 1)
108
- (q << CountStep.new(counter: [], value: 1)).should.be.identical_to q
109
- end
110
- end
111
-
112
- # -- first / last --
113
-
114
- it "returns the first step" do
115
- Sync do
116
- q = Brute::Queue::BaseQueue.new(concurrency: 1, worker_count: 1)
117
- s1 = CountStep.new(counter: [], value: 1)
118
- s2 = CountStep.new(counter: [], value: 2)
119
- q << s1 << s2
120
- q.first.should.be.identical_to s1
121
- end
122
- end
123
-
124
- it "returns the last step" do
125
- Sync do
126
- q = Brute::Queue::BaseQueue.new(concurrency: 1, worker_count: 1)
127
- s1 = CountStep.new(counter: [], value: 1)
128
- s2 = CountStep.new(counter: [], value: 2)
129
- q << s1 << s2
130
- q.last.should.be.identical_to s2
131
- end
132
- end
133
-
134
- # -- start + drain --
135
-
136
- it "runs steps to completion on drain" do
137
- Sync do
138
- results = []
139
- q = Brute::Queue::BaseQueue.new(concurrency: 1, worker_count: 1)
140
- q << CountStep.new(counter: results, value: "a")
141
- q << CountStep.new(counter: results, value: "b")
142
- q.start
143
- q.drain
144
- results.should == ["a", "b"]
145
- end
146
- end
147
-
148
- it "completes all steps" do
149
- Sync do
150
- q = Brute::Queue::BaseQueue.new(concurrency: 1, worker_count: 1)
151
- q << CountStep.new(counter: [], value: 1)
152
- q << CountStep.new(counter: [], value: 2)
153
- q.start
154
- q.drain
155
- q.steps.all? { |s| s.state == :completed }.should.be.true
156
- end
157
- end
158
-
159
- it "captures results on each step" do
160
- Sync do
161
- q = Brute::Queue::BaseQueue.new(concurrency: 1, worker_count: 1)
162
- q << CountStep.new(counter: [], value: 42)
163
- q.start
164
- q.drain
165
- q.first.result.should == 42
166
- end
167
- end
168
-
169
- it "is idempotent on start" do
170
- Sync do
171
- q = Brute::Queue::BaseQueue.new(concurrency: 1, worker_count: 1)
172
- q << CountStep.new(counter: [], value: 1)
173
- q.start
174
- q.start # should not double-spawn workers
175
- q.drain
176
- q.steps.size.should == 1
177
- end
178
- end
179
-
180
- # -- cancel --
181
-
182
- it "marks pending steps as cancelled" do
183
- Sync do
184
- q = Brute::Queue::BaseQueue.new(concurrency: 1, worker_count: 1)
185
- s1 = SleepStep.new(duration: 10)
186
- s2 = SleepStep.new(duration: 10)
187
- s3 = SleepStep.new(duration: 10)
188
- q << s1 << s2 << s3
189
- q.start
190
- sleep 0.01 # let worker pick up s1
191
- q.cancel
192
- # s2 and s3 should be cancelled (they were pending)
193
- [s2, s3].all? { |s| s.state == :cancelled }.should.be.true
194
- end
195
- end
196
-
197
- # -- concurrent execution --
198
-
199
- it "runs multiple steps concurrently" do
200
- Sync do
201
- started = []
202
- done = []
203
-
204
- steps = 3.times.map do |i|
205
- Class.new(Brute::Loop::Step) do
206
- define_method(:perform) do |task|
207
- started << i
208
- sleep 0.05
209
- done << i
210
- i
211
- end
212
- end.new
213
- end
214
-
215
- q = Brute::Queue::BaseQueue.new(concurrency: 3, worker_count: 3)
216
- steps.each { |s| q << s }
217
- q.start
218
- q.drain
219
- done.size.should == 3
220
- end
221
- end
222
- end
@@ -1,66 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require "bundler/setup"
4
- require "brute"
5
-
6
- module Brute
7
- module Queue
8
- # A queue that processes steps concurrently up to a limit.
9
- # Workers match concurrency slots.
10
- class ParallelQueue < BaseQueue
11
- def initialize(concurrency: 4, parent: Async::Task.current)
12
- super(concurrency: concurrency, worker_count: concurrency, parent: parent)
13
- end
14
- end
15
- end
16
- end
17
-
18
- test do
19
- it "runs steps concurrently" do
20
- Sync do
21
- concurrent = 0
22
- max_concurrent = 0
23
-
24
- steps = 4.times.map do
25
- Class.new(Brute::Loop::Step) do
26
- define_method(:perform) do |task|
27
- concurrent += 1
28
- max_concurrent = [max_concurrent, concurrent].max
29
- sleep 0.05
30
- concurrent -= 1
31
- end
32
- end.new
33
- end
34
-
35
- q = Brute::Queue::ParallelQueue.new(concurrency: 4)
36
- steps.each { |s| q << s }
37
- q.start
38
- q.drain
39
- (max_concurrent > 1).should.be.true
40
- end
41
- end
42
-
43
- it "limits concurrency to the specified amount" do
44
- Sync do
45
- concurrent = 0
46
- max_concurrent = 0
47
-
48
- steps = 8.times.map do
49
- Class.new(Brute::Loop::Step) do
50
- define_method(:perform) do |task|
51
- concurrent += 1
52
- max_concurrent = [max_concurrent, concurrent].max
53
- sleep 0.05
54
- concurrent -= 1
55
- end
56
- end.new
57
- end
58
-
59
- q = Brute::Queue::ParallelQueue.new(concurrency: 2)
60
- steps.each { |s| q << s }
61
- q.start
62
- q.drain
63
- (max_concurrent <= 2).should.be.true
64
- end
65
- end
66
- end
@@ -1,63 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require "bundler/setup"
4
- require "brute"
5
-
6
- module Brute
7
- module Queue
8
- # A queue that processes steps one at a time, in order.
9
- # One worker, one concurrency slot.
10
- class SequentialQueue < BaseQueue
11
- def initialize(parent: Async::Task.current)
12
- super(concurrency: 1, worker_count: 1, parent: parent)
13
- end
14
- end
15
- end
16
- end
17
-
18
- test do
19
- class OrderStep < Brute::Loop::Step
20
- def perform(task)
21
- @attributes[:log] << @attributes[:value]
22
- sleep(@attributes[:delay]) if @attributes[:delay]
23
- @attributes[:value]
24
- end
25
- end
26
-
27
- it "processes steps in order" do
28
- Sync do
29
- log = []
30
- q = Brute::Queue::SequentialQueue.new
31
- q << OrderStep.new(log: log, value: "a", delay: 0.01)
32
- q << OrderStep.new(log: log, value: "b", delay: 0.01)
33
- q << OrderStep.new(log: log, value: "c", delay: 0.01)
34
- q.start
35
- q.drain
36
- log.should == ["a", "b", "c"]
37
- end
38
- end
39
-
40
- it "runs only one step at a time" do
41
- Sync do
42
- concurrent = 0
43
- max_concurrent = 0
44
-
45
- steps = 3.times.map do
46
- Class.new(Brute::Loop::Step) do
47
- define_method(:perform) do |task|
48
- concurrent += 1
49
- max_concurrent = [max_concurrent, concurrent].max
50
- sleep 0.02
51
- concurrent -= 1
52
- end
53
- end.new
54
- end
55
-
56
- q = Brute::Queue::SequentialQueue.new
57
- steps.each { |s| q << s }
58
- q.start
59
- q.drain
60
- max_concurrent.should == 1
61
- end
62
- end
63
- end
@@ -1,362 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require "bundler/setup"
4
- require "brute"
5
- require "json"
6
- require "fileutils"
7
- require "securerandom"
8
-
9
- module Brute
10
- module Store
11
- # Stores session messages as individual JSON files in the OpenCode
12
- # {info, parts} format. Each session gets a directory; each message
13
- # is a numbered JSON file inside it.
14
- #
15
- # Storage layout:
16
- #
17
- # ~/.brute/sessions/{session-id}/
18
- # session.meta.json
19
- # msg_0001.json
20
- # msg_0002.json
21
- # ...
22
- #
23
- # Message format matches OpenCode's MessageV2.WithParts:
24
- #
25
- # { info: { id:, sessionID:, role:, time:, ... },
26
- # parts: [{ id:, type:, ... }, ...] }
27
- #
28
- class MessageStore
29
- attr_reader :session_id, :dir
30
-
31
- def initialize(session_id:, dir: nil)
32
- @session_id = session_id
33
- @dir = dir || File.join(Dir.home, ".brute", "sessions", session_id)
34
- @messages = {} # id => { info:, parts: }
35
- @seq = 0
36
- @part_seq = 0
37
- @mutex = Mutex.new
38
- load_existing
39
- end
40
-
41
- def append_user(text:, message_id: nil)
42
- id = message_id || next_message_id
43
- msg = {
44
- info: {
45
- id: id,
46
- sessionID: @session_id,
47
- role: "user",
48
- time: { created: now_ms },
49
- },
50
- parts: [
51
- { id: next_part_id, sessionID: @session_id, messageID: id,
52
- type: "text", text: text },
53
- ],
54
- }
55
- save_message(id, msg)
56
- id
57
- end
58
-
59
- def append_assistant(message_id: nil, parent_id: nil, model_id: nil, provider_id: nil)
60
- id = message_id || next_message_id
61
- msg = {
62
- info: {
63
- id: id,
64
- sessionID: @session_id,
65
- role: "assistant",
66
- parentID: parent_id,
67
- time: { created: now_ms },
68
- modelID: model_id,
69
- providerID: provider_id,
70
- tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
71
- cost: 0.0,
72
- },
73
- parts: [],
74
- }
75
- save_message(id, msg)
76
- id
77
- end
78
-
79
- def add_text_part(message_id:, text:)
80
- @mutex.synchronize do
81
- msg = @messages[message_id]
82
- return unless msg
83
-
84
- part = { id: next_part_id, sessionID: @session_id, messageID: message_id,
85
- type: "text", text: text }
86
- msg[:parts] << part
87
- persist(message_id)
88
- part[:id]
89
- end
90
- end
91
-
92
- def add_tool_part(message_id:, tool:, call_id:, input:)
93
- @mutex.synchronize do
94
- msg = @messages[message_id]
95
- return unless msg
96
-
97
- part = {
98
- id: next_part_id, sessionID: @session_id, messageID: message_id,
99
- type: "tool", callID: call_id, tool: tool,
100
- state: {
101
- status: "running",
102
- input: input,
103
- time: { start: now_ms },
104
- },
105
- }
106
- msg[:parts] << part
107
- persist(message_id)
108
- part[:id]
109
- end
110
- end
111
-
112
- def complete_tool_part(message_id:, call_id:, output:)
113
- @mutex.synchronize do
114
- msg = @messages[message_id]
115
- return unless msg
116
-
117
- part = msg[:parts].find { |p| p[:type] == "tool" && p[:callID] == call_id }
118
- return unless part
119
-
120
- part[:state][:status] = "completed"
121
- part[:state][:output] = output
122
- part[:state][:time][:end] = now_ms
123
- persist(message_id)
124
- end
125
- end
126
-
127
- def error_tool_part(message_id:, call_id:, error:)
128
- @mutex.synchronize do
129
- msg = @messages[message_id]
130
- return unless msg
131
-
132
- part = msg[:parts].find { |p| p[:type] == "tool" && p[:callID] == call_id }
133
- return unless part
134
-
135
- part[:state][:status] = "error"
136
- part[:state][:error] = error.to_s
137
- part[:state][:time][:end] = now_ms
138
- persist(message_id)
139
- end
140
- end
141
-
142
- def add_step_finish(message_id:, tokens: nil)
143
- @mutex.synchronize do
144
- msg = @messages[message_id]
145
- return unless msg
146
-
147
- part = {
148
- id: next_part_id, sessionID: @session_id, messageID: message_id,
149
- type: "step-finish",
150
- reason: "stop",
151
- tokens: tokens || { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
152
- }
153
- msg[:parts] << part
154
- persist(message_id)
155
- end
156
- end
157
-
158
- # Finalize an assistant message with token counts and completion time.
159
- def complete_assistant(message_id:, tokens: nil)
160
- @mutex.synchronize do
161
- msg = @messages[message_id]
162
- return unless msg
163
-
164
- msg[:info][:time][:completed] = now_ms
165
- if tokens
166
- msg[:info][:tokens] = {
167
- input: tokens[:input] || tokens[:total_input] || 0,
168
- output: tokens[:output] || tokens[:total_output] || 0,
169
- reasoning: tokens[:reasoning] || tokens[:total_reasoning] || 0,
170
- cache: tokens[:cache] || { read: 0, write: 0 },
171
- }
172
- end
173
- persist(message_id)
174
- end
175
- end
176
-
177
- def messages
178
- @mutex.synchronize { @messages.values }
179
- end
180
-
181
- def message(id)
182
- @mutex.synchronize { @messages[id] }
183
- end
184
-
185
- def count
186
- @mutex.synchronize { @messages.size }
187
- end
188
-
189
- private
190
-
191
- def next_message_id
192
- @seq += 1
193
- format("msg_%04d", @seq)
194
- end
195
-
196
- def next_part_id
197
- @part_seq += 1
198
- format("prt_%04d", @part_seq)
199
- end
200
-
201
- def now_ms
202
- (Time.now.to_f * 1000).to_i
203
- end
204
-
205
- def save_message(id, msg)
206
- @mutex.synchronize do
207
- @messages[id] = msg
208
- persist(id)
209
- end
210
- end
211
-
212
- def persist(id)
213
- FileUtils.mkdir_p(@dir)
214
- msg = @messages[id]
215
- return unless msg
216
-
217
- path = File.join(@dir, "#{id}.json")
218
- File.write(path, JSON.pretty_generate(msg))
219
- end
220
-
221
- def load_existing
222
- return unless File.directory?(@dir)
223
-
224
- Dir.glob(File.join(@dir, "msg_*.json")).sort.each do |path|
225
- data = JSON.parse(File.read(path), symbolize_names: true)
226
- id = data.dig(:info, :id)
227
- next unless id
228
-
229
- @messages[id] = data
230
-
231
- # Track sequence numbers so new IDs don't collide
232
- if (m = id.match(/\Amsg_(\d+)\z/))
233
- n = m[1].to_i
234
- @seq = n if n > @seq
235
- end
236
-
237
- # Track part sequences too
238
- (data[:parts] || []).each do |part|
239
- pid = part[:id]
240
- if pid.is_a?(String) && (m = pid.match(/\Aprt_(\d+)\z/))
241
- n = m[1].to_i
242
- @part_seq = n if n > @part_seq
243
- end
244
- end
245
- end
246
- end
247
- end
248
- end
249
- end
250
-
251
- test do
252
- require "tmpdir"
253
-
254
- def with_store
255
- dir = Dir.mktmpdir("brute_test_")
256
- store = Brute::Store::MessageStore.new(session_id: "test-session-123", dir: dir)
257
- yield store, dir
258
- ensure
259
- FileUtils.rm_rf(dir)
260
- end
261
-
262
- it "creates a user message with text part" do
263
- with_store do |store, _|
264
- id = store.append_user(text: "Hello")
265
- store.message(id)[:info][:role].should == "user"
266
- end
267
- end
268
-
269
- it "stores user message text" do
270
- with_store do |store, _|
271
- id = store.append_user(text: "Hello")
272
- store.message(id)[:parts][0][:text].should == "Hello"
273
- end
274
- end
275
-
276
- it "generates sequential message IDs" do
277
- with_store do |store, _|
278
- id1 = store.append_user(text: "First")
279
- id2 = store.append_user(text: "Second")
280
- id1.should == "msg_0001"
281
- end
282
- end
283
-
284
- it "creates an assistant message" do
285
- with_store do |store, _|
286
- uid = store.append_user(text: "Hi")
287
- aid = store.append_assistant(parent_id: uid, model_id: "claude", provider_id: "anthropic")
288
- store.message(aid)[:info][:role].should == "assistant"
289
- end
290
- end
291
-
292
- it "appends a text part to an existing message" do
293
- with_store do |store, _|
294
- aid = store.append_assistant
295
- store.add_text_part(message_id: aid, text: "Here is my response")
296
- store.message(aid)[:parts][0][:text].should == "Here is my response"
297
- end
298
- end
299
-
300
- it "tracks tool lifecycle running to completed" do
301
- with_store do |store, _|
302
- aid = store.append_assistant
303
- store.add_tool_part(message_id: aid, tool: "read", call_id: "call_001", input: {})
304
- store.message(aid)[:parts].find { |p| p[:type] == "tool" }[:state][:status].should == "running"
305
- store.complete_tool_part(message_id: aid, call_id: "call_001", output: "done")
306
- store.message(aid)[:parts].find { |p| p[:type] == "tool" }[:state][:status].should == "completed"
307
- end
308
- end
309
-
310
- it "tracks tool lifecycle running to error" do
311
- with_store do |store, _|
312
- aid = store.append_assistant
313
- store.add_tool_part(message_id: aid, tool: "shell", call_id: "call_002", input: {})
314
- store.error_tool_part(message_id: aid, call_id: "call_002", error: "denied")
315
- store.message(aid)[:parts].find { |p| p[:type] == "tool" }[:state][:status].should == "error"
316
- end
317
- end
318
-
319
- it "sets token counts on complete_assistant" do
320
- with_store do |store, _|
321
- aid = store.append_assistant
322
- store.complete_assistant(message_id: aid, tokens: { input: 100, output: 50, reasoning: 10, cache: { read: 0, write: 0 } })
323
- store.message(aid)[:info][:tokens][:input].should == 100
324
- end
325
- end
326
-
327
- it "returns all messages in order" do
328
- with_store do |store, _|
329
- store.append_user(text: "Q1")
330
- store.append_assistant
331
- store.append_user(text: "Q2")
332
- store.messages.size.should == 3
333
- end
334
- end
335
-
336
- it "returns count of stored messages" do
337
- with_store do |store, _|
338
- store.count.should == 0
339
- store.append_user(text: "Q1")
340
- store.count.should == 1
341
- end
342
- end
343
-
344
- it "restores messages from disk" do
345
- with_store do |store, dir|
346
- store.append_user(text: "Persisted Q")
347
- aid = store.append_assistant(model_id: "claude")
348
- store.add_text_part(message_id: aid, text: "Persisted A")
349
- store2 = Brute::Store::MessageStore.new(session_id: "test-session-123", dir: dir)
350
- store2.count.should == 2
351
- end
352
- end
353
-
354
- it "continues sequence numbering from loaded messages" do
355
- with_store do |store, dir|
356
- store.append_user(text: "Q1")
357
- store.append_user(text: "Q2")
358
- store2 = Brute::Store::MessageStore.new(session_id: "test-session-123", dir: dir)
359
- store2.append_user(text: "Q3").should == "msg_0003"
360
- end
361
- end
362
- end