brute 0.4.1 → 1.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.
- checksums.yaml +4 -4
- data/lib/brute/agent.rb +14 -0
- data/lib/brute/diff.rb +18 -28
- data/lib/brute/loop/agent_stream.rb +118 -0
- data/lib/brute/loop/agent_turn.rb +520 -0
- data/lib/brute/{compactor.rb → loop/compactor.rb} +2 -0
- data/lib/brute/{doom_loop.rb → loop/doom_loop.rb} +2 -0
- data/lib/brute/loop/step.rb +332 -0
- data/lib/brute/loop/tool_call_step.rb +90 -0
- data/lib/brute/middleware/compaction_check.rb +60 -146
- data/lib/brute/middleware/doom_loop_detection.rb +95 -92
- data/lib/brute/middleware/llm_call.rb +78 -80
- data/lib/brute/middleware/message_tracking.rb +115 -162
- data/lib/brute/middleware/otel/span.rb +25 -106
- data/lib/brute/middleware/otel/token_usage.rb +29 -84
- data/lib/brute/middleware/otel/tool_calls.rb +23 -107
- data/lib/brute/middleware/otel/tool_results.rb +22 -86
- data/lib/brute/middleware/reasoning_normalizer.rb +78 -103
- data/lib/brute/middleware/retry.rb +95 -76
- data/lib/brute/middleware/session_persistence.rb +38 -37
- data/lib/brute/middleware/token_tracking.rb +64 -63
- data/lib/brute/middleware/tool_error_tracking.rb +108 -82
- data/lib/brute/middleware/tool_use_guard.rb +57 -90
- data/lib/brute/middleware/tracing.rb +53 -63
- data/lib/brute/middleware.rb +18 -0
- data/lib/brute/orchestrator/turn.rb +105 -0
- data/lib/brute/pipeline.rb +77 -133
- data/lib/brute/prompts/build_switch.rb +21 -25
- data/lib/brute/prompts/environment.rb +31 -35
- data/lib/brute/prompts/identity.rb +22 -29
- data/lib/brute/prompts/instructions.rb +15 -18
- data/lib/brute/prompts/max_steps.rb +18 -25
- data/lib/brute/prompts/plan_reminder.rb +18 -26
- data/lib/brute/prompts/skills.rb +8 -30
- data/lib/brute/prompts.rb +28 -0
- data/lib/brute/providers/ollama.rb +135 -0
- data/lib/brute/providers/shell.rb +2 -2
- data/lib/brute/providers/shell_response.rb +2 -2
- data/lib/brute/providers.rb +62 -0
- data/lib/brute/queue/base_queue.rb +222 -0
- data/lib/brute/{file_mutation_queue.rb → queue/file_mutation_queue.rb} +28 -26
- data/lib/brute/queue/parallel_queue.rb +66 -0
- data/lib/brute/queue/sequential_queue.rb +63 -0
- data/lib/brute/store/message_store.rb +362 -0
- data/lib/brute/store/session.rb +106 -0
- data/lib/brute/{snapshot_store.rb → store/snapshot_store.rb} +2 -0
- data/lib/brute/{todo_store.rb → store/todo_store.rb} +2 -0
- data/lib/brute/system_prompt.rb +81 -194
- data/lib/brute/tools/delegate.rb +46 -116
- data/lib/brute/tools/fs_patch.rb +36 -37
- data/lib/brute/tools/fs_remove.rb +2 -2
- data/lib/brute/tools/fs_undo.rb +2 -2
- data/lib/brute/tools/fs_write.rb +29 -41
- data/lib/brute/tools/todo_read.rb +1 -1
- data/lib/brute/tools/todo_write.rb +1 -1
- data/lib/brute/tools.rb +31 -0
- data/lib/brute/version.rb +1 -1
- data/lib/brute.rb +40 -204
- metadata +31 -20
- data/lib/brute/agent_stream.rb +0 -181
- data/lib/brute/hooks.rb +0 -84
- data/lib/brute/message_store.rb +0 -463
- data/lib/brute/orchestrator.rb +0 -550
- data/lib/brute/session.rb +0 -161
data/lib/brute/message_store.rb
DELETED
|
@@ -1,463 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
require "json"
|
|
4
|
-
require "fileutils"
|
|
5
|
-
require "securerandom"
|
|
6
|
-
|
|
7
|
-
module Brute
|
|
8
|
-
# Stores session messages as individual JSON files in the OpenCode
|
|
9
|
-
# {info, parts} format. Each session gets a directory; each message
|
|
10
|
-
# is a numbered JSON file inside it.
|
|
11
|
-
#
|
|
12
|
-
# Storage layout:
|
|
13
|
-
#
|
|
14
|
-
# ~/.brute/sessions/{session-id}/
|
|
15
|
-
# session.meta.json
|
|
16
|
-
# msg_0001.json
|
|
17
|
-
# msg_0002.json
|
|
18
|
-
# ...
|
|
19
|
-
#
|
|
20
|
-
# Message format matches OpenCode's MessageV2.WithParts:
|
|
21
|
-
#
|
|
22
|
-
# { info: { id:, sessionID:, role:, time:, ... },
|
|
23
|
-
# parts: [{ id:, type:, ... }, ...] }
|
|
24
|
-
#
|
|
25
|
-
class MessageStore
|
|
26
|
-
attr_reader :session_id, :dir
|
|
27
|
-
|
|
28
|
-
def initialize(session_id:, dir: nil)
|
|
29
|
-
@session_id = session_id
|
|
30
|
-
@dir = dir || File.join(Dir.home, ".brute", "sessions", session_id)
|
|
31
|
-
@messages = {} # id => { info:, parts: }
|
|
32
|
-
@seq = 0
|
|
33
|
-
@part_seq = 0
|
|
34
|
-
@mutex = Mutex.new
|
|
35
|
-
load_existing
|
|
36
|
-
end
|
|
37
|
-
|
|
38
|
-
# ── Append messages ──────────────────────────────────────────────
|
|
39
|
-
|
|
40
|
-
# Record a user message.
|
|
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
|
-
# Record the start of an assistant message. Returns the message ID.
|
|
60
|
-
# Call complete_assistant later to fill in tokens/timing.
|
|
61
|
-
def append_assistant(message_id: nil, parent_id: nil, model_id: nil, provider_id: nil)
|
|
62
|
-
id = message_id || next_message_id
|
|
63
|
-
msg = {
|
|
64
|
-
info: {
|
|
65
|
-
id: id,
|
|
66
|
-
sessionID: @session_id,
|
|
67
|
-
role: "assistant",
|
|
68
|
-
parentID: parent_id,
|
|
69
|
-
time: { created: now_ms },
|
|
70
|
-
modelID: model_id,
|
|
71
|
-
providerID: provider_id,
|
|
72
|
-
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
|
|
73
|
-
cost: 0.0,
|
|
74
|
-
},
|
|
75
|
-
parts: [],
|
|
76
|
-
}
|
|
77
|
-
save_message(id, msg)
|
|
78
|
-
id
|
|
79
|
-
end
|
|
80
|
-
|
|
81
|
-
# ── Parts ────────────────────────────────────────────────────────
|
|
82
|
-
|
|
83
|
-
# Add a text part to an existing message.
|
|
84
|
-
def add_text_part(message_id:, text:)
|
|
85
|
-
@mutex.synchronize do
|
|
86
|
-
msg = @messages[message_id]
|
|
87
|
-
return unless msg
|
|
88
|
-
|
|
89
|
-
part = { id: next_part_id, sessionID: @session_id, messageID: message_id,
|
|
90
|
-
type: "text", text: text }
|
|
91
|
-
msg[:parts] << part
|
|
92
|
-
persist(message_id)
|
|
93
|
-
part[:id]
|
|
94
|
-
end
|
|
95
|
-
end
|
|
96
|
-
|
|
97
|
-
# Add a tool part in "running" state. Returns the part ID.
|
|
98
|
-
def add_tool_part(message_id:, tool:, call_id:, input:)
|
|
99
|
-
@mutex.synchronize do
|
|
100
|
-
msg = @messages[message_id]
|
|
101
|
-
return unless msg
|
|
102
|
-
|
|
103
|
-
part = {
|
|
104
|
-
id: next_part_id, sessionID: @session_id, messageID: message_id,
|
|
105
|
-
type: "tool", callID: call_id, tool: tool,
|
|
106
|
-
state: {
|
|
107
|
-
status: "running",
|
|
108
|
-
input: input,
|
|
109
|
-
time: { start: now_ms },
|
|
110
|
-
},
|
|
111
|
-
}
|
|
112
|
-
msg[:parts] << part
|
|
113
|
-
persist(message_id)
|
|
114
|
-
part[:id]
|
|
115
|
-
end
|
|
116
|
-
end
|
|
117
|
-
|
|
118
|
-
# Mark a tool part as completed with output.
|
|
119
|
-
def complete_tool_part(message_id:, call_id:, output:)
|
|
120
|
-
@mutex.synchronize do
|
|
121
|
-
msg = @messages[message_id]
|
|
122
|
-
return unless msg
|
|
123
|
-
|
|
124
|
-
part = msg[:parts].find { |p| p[:type] == "tool" && p[:callID] == call_id }
|
|
125
|
-
return unless part
|
|
126
|
-
|
|
127
|
-
part[:state][:status] = "completed"
|
|
128
|
-
part[:state][:output] = output
|
|
129
|
-
part[:state][:time][:end] = now_ms
|
|
130
|
-
persist(message_id)
|
|
131
|
-
end
|
|
132
|
-
end
|
|
133
|
-
|
|
134
|
-
# Mark a tool part as errored.
|
|
135
|
-
def error_tool_part(message_id:, call_id:, error:)
|
|
136
|
-
@mutex.synchronize do
|
|
137
|
-
msg = @messages[message_id]
|
|
138
|
-
return unless msg
|
|
139
|
-
|
|
140
|
-
part = msg[:parts].find { |p| p[:type] == "tool" && p[:callID] == call_id }
|
|
141
|
-
return unless part
|
|
142
|
-
|
|
143
|
-
part[:state][:status] = "error"
|
|
144
|
-
part[:state][:error] = error.to_s
|
|
145
|
-
part[:state][:time][:end] = now_ms
|
|
146
|
-
persist(message_id)
|
|
147
|
-
end
|
|
148
|
-
end
|
|
149
|
-
|
|
150
|
-
# Add a step-finish part to an assistant message.
|
|
151
|
-
def add_step_finish(message_id:, tokens: nil)
|
|
152
|
-
@mutex.synchronize do
|
|
153
|
-
msg = @messages[message_id]
|
|
154
|
-
return unless msg
|
|
155
|
-
|
|
156
|
-
part = {
|
|
157
|
-
id: next_part_id, sessionID: @session_id, messageID: message_id,
|
|
158
|
-
type: "step-finish",
|
|
159
|
-
reason: "stop",
|
|
160
|
-
tokens: tokens || { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
|
|
161
|
-
}
|
|
162
|
-
msg[:parts] << part
|
|
163
|
-
persist(message_id)
|
|
164
|
-
end
|
|
165
|
-
end
|
|
166
|
-
|
|
167
|
-
# ── Complete / update ────────────────────────────────────────────
|
|
168
|
-
|
|
169
|
-
# Finalize an assistant message with token counts and completion time.
|
|
170
|
-
def complete_assistant(message_id:, tokens: nil)
|
|
171
|
-
@mutex.synchronize do
|
|
172
|
-
msg = @messages[message_id]
|
|
173
|
-
return unless msg
|
|
174
|
-
|
|
175
|
-
msg[:info][:time][:completed] = now_ms
|
|
176
|
-
if tokens
|
|
177
|
-
msg[:info][:tokens] = {
|
|
178
|
-
input: tokens[:input] || tokens[:total_input] || 0,
|
|
179
|
-
output: tokens[:output] || tokens[:total_output] || 0,
|
|
180
|
-
reasoning: tokens[:reasoning] || tokens[:total_reasoning] || 0,
|
|
181
|
-
cache: tokens[:cache] || { read: 0, write: 0 },
|
|
182
|
-
}
|
|
183
|
-
end
|
|
184
|
-
persist(message_id)
|
|
185
|
-
end
|
|
186
|
-
end
|
|
187
|
-
|
|
188
|
-
# ── Queries ──────────────────────────────────────────────────────
|
|
189
|
-
|
|
190
|
-
# All messages in order.
|
|
191
|
-
def messages
|
|
192
|
-
@mutex.synchronize { @messages.values }
|
|
193
|
-
end
|
|
194
|
-
|
|
195
|
-
# Single message by ID.
|
|
196
|
-
def message(id)
|
|
197
|
-
@mutex.synchronize { @messages[id] }
|
|
198
|
-
end
|
|
199
|
-
|
|
200
|
-
# Number of stored messages.
|
|
201
|
-
def count
|
|
202
|
-
@mutex.synchronize { @messages.size }
|
|
203
|
-
end
|
|
204
|
-
|
|
205
|
-
private
|
|
206
|
-
|
|
207
|
-
# ── ID generation ────────────────────────────────────────────────
|
|
208
|
-
|
|
209
|
-
def next_message_id
|
|
210
|
-
@seq += 1
|
|
211
|
-
format("msg_%04d", @seq)
|
|
212
|
-
end
|
|
213
|
-
|
|
214
|
-
def next_part_id
|
|
215
|
-
@part_seq += 1
|
|
216
|
-
format("prt_%04d", @part_seq)
|
|
217
|
-
end
|
|
218
|
-
|
|
219
|
-
def now_ms
|
|
220
|
-
(Time.now.to_f * 1000).to_i
|
|
221
|
-
end
|
|
222
|
-
|
|
223
|
-
# ── Persistence ──────────────────────────────────────────────────
|
|
224
|
-
|
|
225
|
-
def save_message(id, msg)
|
|
226
|
-
@mutex.synchronize do
|
|
227
|
-
@messages[id] = msg
|
|
228
|
-
persist(id)
|
|
229
|
-
end
|
|
230
|
-
end
|
|
231
|
-
|
|
232
|
-
def persist(id)
|
|
233
|
-
FileUtils.mkdir_p(@dir)
|
|
234
|
-
msg = @messages[id]
|
|
235
|
-
return unless msg
|
|
236
|
-
|
|
237
|
-
path = File.join(@dir, "#{id}.json")
|
|
238
|
-
File.write(path, JSON.pretty_generate(msg))
|
|
239
|
-
end
|
|
240
|
-
|
|
241
|
-
# Load any existing message files from disk on init.
|
|
242
|
-
def load_existing
|
|
243
|
-
return unless File.directory?(@dir)
|
|
244
|
-
|
|
245
|
-
Dir.glob(File.join(@dir, "msg_*.json")).sort.each do |path|
|
|
246
|
-
data = JSON.parse(File.read(path), symbolize_names: true)
|
|
247
|
-
id = data.dig(:info, :id)
|
|
248
|
-
next unless id
|
|
249
|
-
|
|
250
|
-
@messages[id] = data
|
|
251
|
-
|
|
252
|
-
# Track sequence numbers so new IDs don't collide
|
|
253
|
-
if (m = id.match(/\Amsg_(\d+)\z/))
|
|
254
|
-
n = m[1].to_i
|
|
255
|
-
@seq = n if n > @seq
|
|
256
|
-
end
|
|
257
|
-
|
|
258
|
-
# Track part sequences too
|
|
259
|
-
(data[:parts] || []).each do |part|
|
|
260
|
-
pid = part[:id]
|
|
261
|
-
if pid.is_a?(String) && (m = pid.match(/\Aprt_(\d+)\z/))
|
|
262
|
-
n = m[1].to_i
|
|
263
|
-
@part_seq = n if n > @part_seq
|
|
264
|
-
end
|
|
265
|
-
end
|
|
266
|
-
end
|
|
267
|
-
end
|
|
268
|
-
end
|
|
269
|
-
end
|
|
270
|
-
|
|
271
|
-
if __FILE__ == $0
|
|
272
|
-
require_relative "../../spec/spec_helper"
|
|
273
|
-
|
|
274
|
-
require "tmpdir"
|
|
275
|
-
|
|
276
|
-
RSpec.describe Brute::MessageStore do
|
|
277
|
-
let(:tmpdir) { Dir.mktmpdir("brute_test_") }
|
|
278
|
-
let(:session_id) { "test-session-123" }
|
|
279
|
-
let(:store) { described_class.new(session_id: session_id, dir: tmpdir) }
|
|
280
|
-
|
|
281
|
-
after { FileUtils.rm_rf(tmpdir) }
|
|
282
|
-
|
|
283
|
-
describe "#append_user" do
|
|
284
|
-
it "creates a user message with text part" do
|
|
285
|
-
id = store.append_user(text: "Hello")
|
|
286
|
-
|
|
287
|
-
msg = store.message(id)
|
|
288
|
-
expect(msg[:info][:role]).to eq("user")
|
|
289
|
-
expect(msg[:info][:sessionID]).to eq(session_id)
|
|
290
|
-
expect(msg[:parts].size).to eq(1)
|
|
291
|
-
expect(msg[:parts][0][:type]).to eq("text")
|
|
292
|
-
expect(msg[:parts][0][:text]).to eq("Hello")
|
|
293
|
-
end
|
|
294
|
-
|
|
295
|
-
it "generates sequential message IDs" do
|
|
296
|
-
id1 = store.append_user(text: "First")
|
|
297
|
-
id2 = store.append_user(text: "Second")
|
|
298
|
-
|
|
299
|
-
expect(id1).to eq("msg_0001")
|
|
300
|
-
expect(id2).to eq("msg_0002")
|
|
301
|
-
end
|
|
302
|
-
|
|
303
|
-
it "persists to disk as JSON" do
|
|
304
|
-
id = store.append_user(text: "Persisted")
|
|
305
|
-
|
|
306
|
-
path = File.join(tmpdir, "#{id}.json")
|
|
307
|
-
expect(File.exist?(path)).to be true
|
|
308
|
-
|
|
309
|
-
data = JSON.parse(File.read(path), symbolize_names: true)
|
|
310
|
-
expect(data[:info][:role]).to eq("user")
|
|
311
|
-
expect(data[:parts][0][:text]).to eq("Persisted")
|
|
312
|
-
end
|
|
313
|
-
end
|
|
314
|
-
|
|
315
|
-
describe "#append_assistant" do
|
|
316
|
-
it "creates an assistant message" do
|
|
317
|
-
user_id = store.append_user(text: "Hi")
|
|
318
|
-
asst_id = store.append_assistant(parent_id: user_id, model_id: "claude", provider_id: "anthropic")
|
|
319
|
-
|
|
320
|
-
msg = store.message(asst_id)
|
|
321
|
-
expect(msg[:info][:role]).to eq("assistant")
|
|
322
|
-
expect(msg[:info][:parentID]).to eq(user_id)
|
|
323
|
-
expect(msg[:info][:modelID]).to eq("claude")
|
|
324
|
-
expect(msg[:info][:providerID]).to eq("anthropic")
|
|
325
|
-
expect(msg[:info][:tokens]).to include(input: 0, output: 0)
|
|
326
|
-
expect(msg[:parts]).to be_empty
|
|
327
|
-
end
|
|
328
|
-
end
|
|
329
|
-
|
|
330
|
-
describe "#add_text_part" do
|
|
331
|
-
it "appends a text part to an existing message" do
|
|
332
|
-
asst_id = store.append_assistant
|
|
333
|
-
|
|
334
|
-
store.add_text_part(message_id: asst_id, text: "Here is my response")
|
|
335
|
-
|
|
336
|
-
msg = store.message(asst_id)
|
|
337
|
-
expect(msg[:parts].size).to eq(1)
|
|
338
|
-
expect(msg[:parts][0][:type]).to eq("text")
|
|
339
|
-
expect(msg[:parts][0][:text]).to eq("Here is my response")
|
|
340
|
-
end
|
|
341
|
-
end
|
|
342
|
-
|
|
343
|
-
describe "#add_tool_part / #complete_tool_part / #error_tool_part" do
|
|
344
|
-
it "tracks tool lifecycle: running → completed" do
|
|
345
|
-
asst_id = store.append_assistant
|
|
346
|
-
|
|
347
|
-
store.add_tool_part(
|
|
348
|
-
message_id: asst_id,
|
|
349
|
-
tool: "read",
|
|
350
|
-
call_id: "call_001",
|
|
351
|
-
input: { file_path: "/tmp/test.rb" },
|
|
352
|
-
)
|
|
353
|
-
|
|
354
|
-
msg = store.message(asst_id)
|
|
355
|
-
tool_part = msg[:parts].find { |p| p[:type] == "tool" }
|
|
356
|
-
expect(tool_part[:tool]).to eq("read")
|
|
357
|
-
expect(tool_part[:state][:status]).to eq("running")
|
|
358
|
-
|
|
359
|
-
store.complete_tool_part(
|
|
360
|
-
message_id: asst_id,
|
|
361
|
-
call_id: "call_001",
|
|
362
|
-
output: "file contents here",
|
|
363
|
-
)
|
|
364
|
-
|
|
365
|
-
msg = store.message(asst_id)
|
|
366
|
-
tool_part = msg[:parts].find { |p| p[:type] == "tool" }
|
|
367
|
-
expect(tool_part[:state][:status]).to eq("completed")
|
|
368
|
-
expect(tool_part[:state][:output]).to eq("file contents here")
|
|
369
|
-
expect(tool_part[:state][:time][:end]).to be_a(Integer)
|
|
370
|
-
end
|
|
371
|
-
|
|
372
|
-
it "tracks tool lifecycle: running → error" do
|
|
373
|
-
asst_id = store.append_assistant
|
|
374
|
-
|
|
375
|
-
store.add_tool_part(
|
|
376
|
-
message_id: asst_id,
|
|
377
|
-
tool: "shell",
|
|
378
|
-
call_id: "call_002",
|
|
379
|
-
input: { command: "rm -rf /" },
|
|
380
|
-
)
|
|
381
|
-
|
|
382
|
-
store.error_tool_part(
|
|
383
|
-
message_id: asst_id,
|
|
384
|
-
call_id: "call_002",
|
|
385
|
-
error: "permission denied",
|
|
386
|
-
)
|
|
387
|
-
|
|
388
|
-
msg = store.message(asst_id)
|
|
389
|
-
tool_part = msg[:parts].find { |p| p[:type] == "tool" }
|
|
390
|
-
expect(tool_part[:state][:status]).to eq("error")
|
|
391
|
-
expect(tool_part[:state][:error]).to eq("permission denied")
|
|
392
|
-
end
|
|
393
|
-
end
|
|
394
|
-
|
|
395
|
-
describe "#complete_assistant" do
|
|
396
|
-
it "sets completion time and token counts" do
|
|
397
|
-
asst_id = store.append_assistant
|
|
398
|
-
|
|
399
|
-
store.complete_assistant(
|
|
400
|
-
message_id: asst_id,
|
|
401
|
-
tokens: { input: 100, output: 50, reasoning: 10, cache: { read: 20, write: 5 } },
|
|
402
|
-
)
|
|
403
|
-
|
|
404
|
-
msg = store.message(asst_id)
|
|
405
|
-
expect(msg[:info][:time][:completed]).to be_a(Integer)
|
|
406
|
-
expect(msg[:info][:tokens][:input]).to eq(100)
|
|
407
|
-
expect(msg[:info][:tokens][:output]).to eq(50)
|
|
408
|
-
expect(msg[:info][:tokens][:reasoning]).to eq(10)
|
|
409
|
-
end
|
|
410
|
-
end
|
|
411
|
-
|
|
412
|
-
describe "#messages" do
|
|
413
|
-
it "returns all messages in order" do
|
|
414
|
-
store.append_user(text: "Q1")
|
|
415
|
-
store.append_assistant
|
|
416
|
-
store.append_user(text: "Q2")
|
|
417
|
-
|
|
418
|
-
msgs = store.messages
|
|
419
|
-
expect(msgs.size).to eq(3)
|
|
420
|
-
expect(msgs[0][:info][:role]).to eq("user")
|
|
421
|
-
expect(msgs[1][:info][:role]).to eq("assistant")
|
|
422
|
-
expect(msgs[2][:info][:role]).to eq("user")
|
|
423
|
-
end
|
|
424
|
-
end
|
|
425
|
-
|
|
426
|
-
describe "#count" do
|
|
427
|
-
it "returns the number of stored messages" do
|
|
428
|
-
expect(store.count).to eq(0)
|
|
429
|
-
|
|
430
|
-
store.append_user(text: "Q1")
|
|
431
|
-
expect(store.count).to eq(1)
|
|
432
|
-
|
|
433
|
-
store.append_assistant
|
|
434
|
-
expect(store.count).to eq(2)
|
|
435
|
-
end
|
|
436
|
-
end
|
|
437
|
-
|
|
438
|
-
describe "loading from disk" do
|
|
439
|
-
it "restores messages from existing files" do
|
|
440
|
-
store.append_user(text: "Persisted Q")
|
|
441
|
-
asst_id = store.append_assistant(model_id: "claude")
|
|
442
|
-
store.add_text_part(message_id: asst_id, text: "Persisted A")
|
|
443
|
-
|
|
444
|
-
# Create a new store from the same directory
|
|
445
|
-
store2 = described_class.new(session_id: session_id, dir: tmpdir)
|
|
446
|
-
|
|
447
|
-
expect(store2.count).to eq(2)
|
|
448
|
-
expect(store2.messages[0][:parts][0][:text]).to eq("Persisted Q")
|
|
449
|
-
expect(store2.messages[1][:parts][0][:text]).to eq("Persisted A")
|
|
450
|
-
end
|
|
451
|
-
|
|
452
|
-
it "continues sequence numbering from loaded messages" do
|
|
453
|
-
store.append_user(text: "Q1")
|
|
454
|
-
store.append_user(text: "Q2")
|
|
455
|
-
|
|
456
|
-
store2 = described_class.new(session_id: session_id, dir: tmpdir)
|
|
457
|
-
id = store2.append_user(text: "Q3")
|
|
458
|
-
|
|
459
|
-
expect(id).to eq("msg_0003")
|
|
460
|
-
end
|
|
461
|
-
end
|
|
462
|
-
end
|
|
463
|
-
end
|