-
-
Notifications
You must be signed in to change notification settings - Fork 25
Expand file tree
/
Copy pathlazy_image_test.cpp
More file actions
319 lines (283 loc) · 12.3 KB
/
Copy pathlazy_image_test.cpp
File metadata and controls
319 lines (283 loc) · 12.3 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
// lazy_image_test — loading a thread must not decode its images, and must
// still be byte-for-byte correct the moment anything asks for them.
//
// Images are the bulk of a big thread on disk (one real 29 MB thread here is
// 17 MB of image payload), and decoding them at load time is pure waste: the
// render path never reads image bytes, only their sizes. So ImageContent
// defers materialisation to the first bytes() call.
//
// That optimisation is only worth anything if it is invisible. These tests
// pin the properties that make it invisible:
//
// 1. a saved → loaded image yields the ORIGINAL bytes (blob and legacy
// inline-base64 forms alike),
// 2. loading alone does NOT materialise (the whole point),
// 3. copies of an unmaterialised image still resolve correctly,
// 4. re-saving an unmaterialised image preserves the payload — the
// dangerous case, since a naive writer would serialise empty bytes and
// silently destroy every image in the thread on the next autosave,
// 5. a missing blob degrades to empty rather than throwing, matching what
// the old eager loader did.
#include "agtest.hpp"
#include <agentty/domain/conversation.hpp>
#include <agentty/io/persistence.hpp>
#include <agentty/util/base64.hpp>
#include <nlohmann/json.hpp>
#include <atomic>
#include <cstdlib>
#include <filesystem>
#include <fstream>
#include <string>
#include <thread>
#include <vector>
namespace fs = std::filesystem;
using namespace agentty;
namespace {
// Deterministic pseudo-binary payload — includes bytes that are not valid
// UTF-8, so a path that stringifies rather than treats it as bytes breaks.
std::string payload(std::size_t n, unsigned seed = 1) {
std::string s;
s.reserve(n);
unsigned x = seed * 2654435761u + 1;
for (std::size_t i = 0; i < n; ++i) {
x = x * 1664525u + 1013904223u;
s.push_back(static_cast<char>(x >> 24));
}
return s;
}
Thread thread_with_image(const std::string& bytes) {
Thread t;
t.id = ThreadId{"lazyimg" + std::to_string(bytes.size())};
Message m;
m.role = Role::User;
m.text = "look at this";
m.images.push_back(ImageContent{"image/png", bytes});
t.messages.push_back(std::move(m));
return t;
}
} // namespace
TEST_CASE("lazy image: save → load round-trips the exact bytes") {
const std::string original = payload(64u * 1024u);
Thread t = thread_with_image(original);
persistence::save_thread(t);
persistence::flush_pending_saves();
auto loaded = persistence::load_thread_by_id(t.id);
REQUIRE(loaded.has_value());
REQUIRE(loaded->messages.size() == 1);
REQUIRE(loaded->messages[0].images.size() == 1);
const auto& img = loaded->messages[0].images[0];
CHECK(img.media_type == "image/png");
CHECK(img.bytes() == original);
}
TEST_CASE("lazy image: loading does NOT materialise the bytes") {
const std::string original = payload(32u * 1024u, 7);
Thread t = thread_with_image(original);
persistence::save_thread(t);
persistence::flush_pending_saves();
auto loaded = persistence::load_thread_by_id(t.id);
REQUIRE(loaded.has_value());
const auto& img = loaded->messages[0].images[0];
// THE point of the change: nothing was decoded yet.
CHECK(!img.materialised());
CHECK(!img.source().blob.empty());
// Asking is what pays for it — and it's correct when we do.
CHECK(img.bytes() == original);
CHECK(img.materialised());
// Idempotent: a second call returns the same bytes, not a re-read.
CHECK(img.bytes() == original);
}
TEST_CASE("lazy image: a copy of an unmaterialised image still resolves") {
const std::string original = payload(16u * 1024u, 3);
Thread t = thread_with_image(original);
persistence::save_thread(t);
persistence::flush_pending_saves();
auto loaded = persistence::load_thread_by_id(t.id);
REQUIRE(loaded.has_value());
// Threads are copied around freely (checkpoints, forks, the view cache).
// A copy taken BEFORE materialisation must carry the source with it.
ImageContent copy = loaded->messages[0].images[0];
CHECK(!copy.materialised());
CHECK(copy.bytes() == original);
}
TEST_CASE("lazy image: re-saving an unmaterialised image keeps the payload") {
// The dangerous case. Every turn autosaves the thread; if the writer
// serialised an unmaterialised image by reading its (empty) bytes, the
// first save after a switch would wipe every image in the thread.
const std::string original = payload(48u * 1024u, 11);
Thread t = thread_with_image(original);
persistence::save_thread(t);
persistence::flush_pending_saves();
auto loaded = persistence::load_thread_by_id(t.id);
REQUIRE(loaded.has_value());
CHECK(!loaded->messages[0].images[0].materialised());
// Save it straight back WITHOUT ever touching bytes().
persistence::save_thread(*loaded);
persistence::flush_pending_saves();
auto again = persistence::load_thread_by_id(t.id);
REQUIRE(again.has_value());
REQUIRE(again->messages[0].images.size() == 1);
CHECK_MESSAGE(again->messages[0].images[0].bytes() == original,
"re-saving a never-materialised image must preserve it");
}
TEST_CASE("lazy image: legacy inline base64 still loads") {
// Threads written before the blob store carry `data` (base64) inline.
// They must keep working, lazily, with no migration.
const std::string original = payload(4096, 5);
Thread t;
t.id = ThreadId{"lazyimg_legacy"};
Message m;
m.role = Role::User;
m.text = "legacy";
t.messages.push_back(std::move(m));
// Seed a LEGACY whole-document file directly. save_thread() now
// migrates to the log format and retires the .json, so it can no
// longer be used to manufacture the old shape this test is about.
const auto seed_path = persistence::threads_dir() / (t.id.value + ".json");
{
auto j = persistence::thread_meta_to_json(t);
nlohmann::json arr = nlohmann::json::array();
for (const auto& mm : t.messages)
arr.push_back(persistence::message_to_json(mm));
j["messages"] = std::move(arr);
persistence::write_json_atomic(seed_path, j.dump(2));
}
// Hand-write the legacy shape into the saved file.
const auto path = persistence::threads_dir() / (t.id.value + ".json");
std::string doc;
{
std::ifstream in(path, std::ios::binary);
doc.assign(std::istreambuf_iterator<char>(in),
std::istreambuf_iterator<char>());
}
const std::string inject =
"\"images\":[{\"data\":\"" + util::base64_encode(original)
+ "\",\"media_type\":\"image/png\"}],";
const auto at = doc.find("\"role\"");
REQUIRE(at != std::string::npos);
doc.insert(at, inject);
{
std::ofstream out(path, std::ios::binary | std::ios::trunc);
out << doc;
}
auto loaded = persistence::load_thread_file(path);
REQUIRE(loaded.has_value());
REQUIRE(loaded->messages[0].images.size() == 1);
const auto& img = loaded->messages[0].images[0];
CHECK(!img.materialised()); // still lazy, just from base64
CHECK(img.bytes() == original);
}
TEST_CASE("lazy image: a legacy inline image migrates to a blob on save") {
// Lazy loading alone doesn't shrink a legacy thread: the 17 MB of base64
// is still tokenized by the JSON parser on every single load. Saving
// migrates it to the blob store ONCE, after which the thread file is
// small forever. The bytes must survive that migration untouched.
const std::string original = payload(8192, 13);
Thread t;
t.id = ThreadId{"lazyimg_migrate"};
Message m;
m.role = Role::User;
m.text = "legacy";
t.messages.push_back(std::move(m));
// Seed a LEGACY whole-document file directly (see the note above).
const auto path = persistence::threads_dir() / (t.id.value + ".json");
{
auto j = persistence::thread_meta_to_json(t);
nlohmann::json arr = nlohmann::json::array();
for (const auto& mm : t.messages)
arr.push_back(persistence::message_to_json(mm));
j["messages"] = std::move(arr);
persistence::write_json_atomic(path, j.dump(2));
}
std::string doc;
{
std::ifstream in(path, std::ios::binary);
doc.assign(std::istreambuf_iterator<char>(in),
std::istreambuf_iterator<char>());
}
const std::string b64 = util::base64_encode(original);
const std::string inject =
"\"images\":[{\"data\":\"" + b64 + "\",\"media_type\":\"image/png\"}],";
const auto at = doc.find("\"role\"");
REQUIRE(at != std::string::npos);
doc.insert(at, inject);
{
std::ofstream out(path, std::ios::binary | std::ios::trunc);
out << doc;
}
const auto legacy_size = fs::file_size(path);
// Load (lazy) then save straight back — the autosave every turn does.
auto legacy = persistence::load_thread_file(path);
REQUIRE(legacy.has_value());
CHECK(!legacy->messages[0].images[0].materialised());
persistence::save_thread(*legacy);
persistence::flush_pending_saves();
// The save rewrote the thread in the LOG format, so the legacy
// document is gone and the payload now lives in the blob store.
const auto log_path = persistence::threads_dir() / (t.id.value + ".jsonl");
REQUIRE(fs::exists(log_path));
CHECK_FALSE(fs::exists(path));
std::string after;
{
std::ifstream in(log_path, std::ios::binary);
after.assign(std::istreambuf_iterator<char>(in),
std::istreambuf_iterator<char>());
}
CHECK_MESSAGE(after.find(b64) == std::string::npos,
"the inline base64 must be replaced by a blob reference");
CHECK_MESSAGE(fs::file_size(log_path) < legacy_size,
"and the thread file must shrink by the payload size");
// And the image still reads back byte-for-byte.
auto migrated = persistence::load_thread_by_id(t.id);
REQUIRE(migrated.has_value());
REQUIRE(migrated->messages[0].images.size() == 1);
CHECK(migrated->messages[0].images[0].bytes() == original);
}
TEST_CASE("lazy image: a missing blob degrades to empty, never throws") {
// Matches the eager loader's old behaviour for a corrupt/absent payload:
// empty bytes, which every wire path already skips.
auto img = ImageContent::lazy(
"image/png", ImageContent::Source{.blob = "definitely-not-a-real-blob",
.b64 = {}});
CHECK(img.bytes().empty());
}
TEST_CASE("lazy image: many threads reading one unmaterialised image") {
// bytes() is const and runs on worker threads: the provider transports
// build the wire body off the UI thread, and the persistence writer
// saves on its own. It used to write two `mutable` fields unguarded, so
// two readers of the same unresolved payload raced on a std::string.
// Now it resolves once under std::call_once. Under the tsan preset this
// is the regression check; in a normal build it checks every reader
// sees the exact bytes.
const std::string original = payload(256u * 1024u, 11);
Thread t = thread_with_image(original);
persistence::save_thread(t);
persistence::flush_pending_saves();
auto loaded = persistence::load_thread_by_id(t.id);
REQUIRE(loaded.has_value());
// One object shared by reference, AND copies of it: both are real
// shapes (a const Thread& handed around, a Thread copied per turn).
const ImageContent& shared = loaded->messages[0].images[0];
REQUIRE(!shared.materialised());
std::vector<ImageContent> copies(4, shared);
std::atomic<int> wrong{0};
{
std::vector<std::jthread> ts;
for (int i = 0; i < 8; ++i)
ts.emplace_back([&, i] {
const auto& b = (i % 2) ? shared.bytes() : copies[i / 2].bytes();
if (b != original) ++wrong;
});
}
CHECK(wrong.load() == 0);
CHECK(shared.materialised());
}
TEST_CASE("lazy image: set_bytes on one copy leaves the others alone") {
// Copies share one cell for cheap copying; replacing the payload must
// not change what the other copies see.
const std::string original = payload(4u * 1024u, 5);
ImageContent a{"image/png", original};
ImageContent b = a;
a.set_bytes("replaced");
CHECK(a.bytes() == "replaced");
CHECK(b.bytes() == original);
}