mirrors-sdk 0.1.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 +7 -0
- data/Gemfile +7 -0
- data/README.md +273 -0
- data/Rakefile +11 -0
- data/lib/mirrors_sdk/agent_context.rb +51 -0
- data/lib/mirrors_sdk/capture.rb +236 -0
- data/lib/mirrors_sdk/circuit.rb +51 -0
- data/lib/mirrors_sdk/client.rb +261 -0
- data/lib/mirrors_sdk/configuration.rb +74 -0
- data/lib/mirrors_sdk/context.rb +281 -0
- data/lib/mirrors_sdk/delivery.rb +74 -0
- data/lib/mirrors_sdk/executor.rb +226 -0
- data/lib/mirrors_sdk/models.rb +104 -0
- data/lib/mirrors_sdk/snapshot.rb +129 -0
- data/lib/mirrors_sdk/transport.rb +223 -0
- data/lib/mirrors_sdk/version.rb +5 -0
- data/lib/mirrors_sdk/wire.rb +238 -0
- data/lib/mirrors_sdk/worker.rb +114 -0
- data/lib/mirrors_sdk.rb +13 -0
- data/mirrors-sdk.gemspec +23 -0
- data/skills/install-mirrors-ruby-sdk/SKILL.md +152 -0
- data/test/capture_test.rb +303 -0
- data/test/configuration_test.rb +64 -0
- data/test/context_test.rb +295 -0
- data/test/executor_test.rb +93 -0
- data/test/fixtures/canonical-events.json +38 -0
- data/test/integration_test.rb +154 -0
- data/test/lifecycle_test.rb +128 -0
- data/test/snapshot_test.rb +88 -0
- data/test/support/runtime_server.rb +101 -0
- data/test/test_helper.rb +30 -0
- data/test/transport_test.rb +182 -0
- data/test/wire_test.rb +128 -0
- metadata +72 -0
|
@@ -0,0 +1,303 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "test_helper"
|
|
4
|
+
require "timeout"
|
|
5
|
+
|
|
6
|
+
class CaptureTest < Minitest::Test
|
|
7
|
+
Settings = Struct.new(:agent_id, :timeout_ms)
|
|
8
|
+
|
|
9
|
+
class ScriptedTransport
|
|
10
|
+
attr_reader :calls
|
|
11
|
+
|
|
12
|
+
def initialize(&handler)
|
|
13
|
+
@calls, @handler, @lock = [], handler, Mutex.new
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def request(endpoint, body, deadline:, cancel: nil, final_recovery: false)
|
|
17
|
+
@lock.synchronize { @calls << [endpoint, body, deadline, final_recovery] }
|
|
18
|
+
request = JSON.parse(body)
|
|
19
|
+
value = @handler ? @handler.call(request, cancel) : CaptureTest.response(request)
|
|
20
|
+
raise MirrorsSDK::TransportError.new("transport", permanent: true) unless yield(value)
|
|
21
|
+
value
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def self.response(request)
|
|
26
|
+
result = {
|
|
27
|
+
"protocol" => 3, "agentId" => request["agentId"], "traceId" => request["trace"]["id"],
|
|
28
|
+
"requestId" => request["requestId"], "ordinal" => request["ordinal"], "final" => request["final"],
|
|
29
|
+
"captureOnly" => true, "decisionId" => "receipt", "expiresAt" => "2026-01-01T00:00:00.000Z",
|
|
30
|
+
"mode" => "off", "role" => "system", "context" => nil,
|
|
31
|
+
"ingestion" => request["final"] ? { "status" => "queued", "jobId" => "job" } : nil
|
|
32
|
+
}
|
|
33
|
+
result["terminal"] = request["terminal"] if request.key?("terminal")
|
|
34
|
+
result
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def input(id = "run", final: false, text: "original", **fields)
|
|
38
|
+
value = {
|
|
39
|
+
"trace" => { "id" => id, "messages" => [{ "role" => "user", "content" => text }] }.merge(fields.transform_keys(&:to_s)),
|
|
40
|
+
"final" => final, "captureOnly" => true
|
|
41
|
+
}
|
|
42
|
+
value["terminal"] = "completed" if final
|
|
43
|
+
value
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def runtime(transport = ScriptedTransport.new)
|
|
47
|
+
MirrorsSDK::CaptureRuntime.new(transport, Settings.new("agent", nil))
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def now
|
|
51
|
+
Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def send_capture(runtime, value, timeout: 2, cancel: nil)
|
|
55
|
+
runtime.send(value, deadline: now + timeout, cancel: cancel)
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def wait_for(&condition)
|
|
59
|
+
Timeout.timeout(2) { sleep(0.001) until condition.call }
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def test_owned_identity_and_advancing_ordinals
|
|
63
|
+
transport = ScriptedTransport.new
|
|
64
|
+
capture = runtime(transport)
|
|
65
|
+
%w[first second].each { |text| assert_equal "accepted", send_capture(capture, input(text: text)).status }
|
|
66
|
+
requests = transport.calls.map { |call| JSON.parse(call[1]) }
|
|
67
|
+
assert_equal [1, 2], requests.map { |request| request["ordinal"] }
|
|
68
|
+
refute_equal requests[0]["requestId"], requests[1]["requestId"]
|
|
69
|
+
assert requests.all? { |request| request["captureOnly"] && request["agentId"] == "agent" }
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def test_uncertain_write_reconciles_exact_bytes_before_changed_evidence
|
|
73
|
+
count = 0
|
|
74
|
+
transport = ScriptedTransport.new do |request, _|
|
|
75
|
+
count += 1
|
|
76
|
+
raise MirrorsSDK::TransportError.new("transport") if count == 1
|
|
77
|
+
self.class.response(request)
|
|
78
|
+
end
|
|
79
|
+
capture = runtime(transport)
|
|
80
|
+
first = input(text: "one")
|
|
81
|
+
assert_equal "unconfirmed", send_capture(capture, first).status
|
|
82
|
+
first["trace"]["messages"][0]["content"] = "mutated"
|
|
83
|
+
assert_equal "accepted", send_capture(capture, input(text: "two")).status
|
|
84
|
+
assert_equal transport.calls[0][1], transport.calls[1][1]
|
|
85
|
+
assert_equal "one", JSON.parse(transport.calls[1][1])["trace"]["messages"][0]["content"]
|
|
86
|
+
assert_equal 2, JSON.parse(transport.calls[2][1])["ordinal"]
|
|
87
|
+
assert_equal transport.calls[1][2], transport.calls[2][2]
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def test_failed_reconciliation_never_sends_new_evidence
|
|
91
|
+
transport = ScriptedTransport.new { raise MirrorsSDK::TransportError.new("transport") }
|
|
92
|
+
capture = runtime(transport)
|
|
93
|
+
send_capture(capture, input(text: "one"))
|
|
94
|
+
send_capture(capture, input(text: "two"))
|
|
95
|
+
assert_equal 2, transport.calls.length
|
|
96
|
+
assert_equal transport.calls[0][1], transport.calls[1][1]
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def test_final_retries_once_and_retains_immutable_receipt
|
|
100
|
+
count = 0
|
|
101
|
+
transport = ScriptedTransport.new do |request, _|
|
|
102
|
+
count += 1
|
|
103
|
+
raise MirrorsSDK::TransportError.new("transport") if count == 1
|
|
104
|
+
self.class.response(request)
|
|
105
|
+
end
|
|
106
|
+
capture = runtime(transport)
|
|
107
|
+
receipt = send_capture(capture, input(final: true))
|
|
108
|
+
assert_equal "accepted", receipt.status
|
|
109
|
+
assert_equal transport.calls[0], transport.calls[1]
|
|
110
|
+
assert_equal({ "status" => "queued", "jobId" => "job" }, receipt.ingestion)
|
|
111
|
+
assert_same receipt, send_capture(capture, input(final: true))
|
|
112
|
+
assert_equal "terminal_conflict", send_capture(capture, input(final: true, text: "different")).reason
|
|
113
|
+
assert_equal 2, transport.calls.length
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def test_permanent_failure_does_not_retry_and_releases_final_capacity
|
|
117
|
+
transport = ScriptedTransport.new { raise MirrorsSDK::TransportError.new("transport", permanent: true) }
|
|
118
|
+
capture = runtime(transport)
|
|
119
|
+
140.times do |index|
|
|
120
|
+
assert_equal "unconfirmed", send_capture(capture, input("final-#{index}", final: true)).status
|
|
121
|
+
end
|
|
122
|
+
assert_equal 140, transport.calls.length
|
|
123
|
+
assert_equal({ active: 0, terminal: 0, recent: 128 }, capture.diagnostics)
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def test_metadata_is_fixed_but_final_user_and_latency_may_change
|
|
127
|
+
transport = ScriptedTransport.new
|
|
128
|
+
capture = runtime(transport)
|
|
129
|
+
assert_equal "accepted", send_capture(capture, input(model: "a", user: { "id" => "first" })).status
|
|
130
|
+
assert_equal "invalid_evidence", send_capture(capture, input(model: "b")).reason
|
|
131
|
+
result = send_capture(capture, input(final: true, model: "a", user: { "id" => "second" }, turnLatencyMs: 0))
|
|
132
|
+
assert_equal "accepted", result.status
|
|
133
|
+
assert_equal 2, transport.calls.length
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
def test_metadata_key_order_has_no_semantic_effect
|
|
137
|
+
capture = runtime
|
|
138
|
+
flags = { "ended" => "unknown", "history" => "truncated", "original_content" => "partial" }
|
|
139
|
+
assert_equal "accepted", send_capture(capture, input(completeness: flags)).status
|
|
140
|
+
assert_equal "accepted", send_capture(capture, input(completeness: flags.to_a.reverse.to_h)).status
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
def test_prefix_capacity_is_independent_from_finals
|
|
144
|
+
capture = runtime
|
|
145
|
+
64.times { |i| assert_equal "accepted", send_capture(capture, input("run-#{i}")).status }
|
|
146
|
+
assert_equal "capacity", send_capture(capture, input("overflow")).reason
|
|
147
|
+
assert_equal "accepted", send_capture(capture, input("final-only", final: true)).status
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
def test_invalid_metadata_final_releases_prefix_state
|
|
151
|
+
capture = runtime
|
|
152
|
+
send_capture(capture, input(model: "original"))
|
|
153
|
+
assert_equal "invalid_evidence", send_capture(capture, input(final: true, model: "changed")).reason
|
|
154
|
+
assert_equal 0, capture.diagnostics[:active]
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
def test_same_trace_serializes_without_blocking_other_traces
|
|
158
|
+
gate = Queue.new
|
|
159
|
+
transport = ScriptedTransport.new do |request, _|
|
|
160
|
+
gate.pop if request["trace"]["id"] == "run" && request["ordinal"] == 1
|
|
161
|
+
self.class.response(request)
|
|
162
|
+
end
|
|
163
|
+
capture = runtime(transport)
|
|
164
|
+
first = Thread.new { send_capture(capture, input) }
|
|
165
|
+
wait_for { transport.calls.length == 1 }
|
|
166
|
+
second = Thread.new { send_capture(capture, input(text: "second")) }
|
|
167
|
+
assert_equal "accepted", send_capture(capture, input("other")).status
|
|
168
|
+
assert_equal 2, transport.calls.length
|
|
169
|
+
gate << true
|
|
170
|
+
assert_equal "accepted", first.value.status
|
|
171
|
+
assert_equal "accepted", second.value.status
|
|
172
|
+
assert_equal 2, JSON.parse(transport.calls.last[1])["ordinal"]
|
|
173
|
+
ensure
|
|
174
|
+
gate << true if gate
|
|
175
|
+
[first, second].compact.each { |thread| thread.join(2) }
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
def test_waiting_for_trace_respects_call_deadline
|
|
179
|
+
gate = Queue.new
|
|
180
|
+
transport = ScriptedTransport.new do |request, _|
|
|
181
|
+
gate.pop
|
|
182
|
+
self.class.response(request)
|
|
183
|
+
end
|
|
184
|
+
capture = runtime(transport)
|
|
185
|
+
first = Thread.new { send_capture(capture, input) }
|
|
186
|
+
wait_for { transport.calls.length == 1 }
|
|
187
|
+
assert_equal "timeout", send_capture(capture, input(text: "queued"), timeout: 0.02).reason
|
|
188
|
+
assert_equal 1, transport.calls.length
|
|
189
|
+
ensure
|
|
190
|
+
gate << true if gate
|
|
191
|
+
first.join(2) if first
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
def test_release_cancels_prefix_but_preserves_admitted_final
|
|
195
|
+
transport = ScriptedTransport.new do |_request, cancel|
|
|
196
|
+
sleep(0.001) until cancel.canceled?
|
|
197
|
+
raise MirrorsSDK::TransportError.new("canceled")
|
|
198
|
+
end
|
|
199
|
+
capture = runtime(transport)
|
|
200
|
+
prefix = Thread.new { send_capture(capture, input) }
|
|
201
|
+
wait_for { transport.calls.length == 1 }
|
|
202
|
+
capture.release("run")
|
|
203
|
+
assert_equal "unconfirmed", prefix.value.status
|
|
204
|
+
capture.clear
|
|
205
|
+
assert_equal "closed", send_capture(capture, input("next")).reason
|
|
206
|
+
ensure
|
|
207
|
+
capture.clear if capture
|
|
208
|
+
prefix.join(2) if prefix
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
def test_final_seals_before_waiting_and_release_keeps_upload
|
|
212
|
+
gate = Queue.new
|
|
213
|
+
transport = ScriptedTransport.new do |request, _|
|
|
214
|
+
gate.pop
|
|
215
|
+
self.class.response(request)
|
|
216
|
+
end
|
|
217
|
+
capture = runtime(transport)
|
|
218
|
+
prefix = Thread.new { send_capture(capture, input) }
|
|
219
|
+
wait_for { transport.calls.length == 1 }
|
|
220
|
+
final = Thread.new { send_capture(capture, input(final: true)) }
|
|
221
|
+
wait_for { capture.diagnostics[:terminal] == 1 }
|
|
222
|
+
assert_equal "terminal_conflict", send_capture(capture, input(text: "late")).reason
|
|
223
|
+
capture.release("run")
|
|
224
|
+
gate << true
|
|
225
|
+
assert_equal "accepted", prefix.value.status
|
|
226
|
+
wait_for { transport.calls.length == 2 }
|
|
227
|
+
gate << true
|
|
228
|
+
assert_equal "accepted", final.value.status
|
|
229
|
+
ensure
|
|
230
|
+
2.times { gate << true } if gate
|
|
231
|
+
[prefix, final].compact.each { |thread| thread.join(2) }
|
|
232
|
+
end
|
|
233
|
+
|
|
234
|
+
def test_final_recovery_applies_to_uncertain_prefix_and_new_final
|
|
235
|
+
count = 0
|
|
236
|
+
transport = ScriptedTransport.new do |request, _|
|
|
237
|
+
count += 1
|
|
238
|
+
raise MirrorsSDK::TransportError.new("transport") if count == 1
|
|
239
|
+
self.class.response(request)
|
|
240
|
+
end
|
|
241
|
+
capture = runtime(transport)
|
|
242
|
+
send_capture(capture, input)
|
|
243
|
+
assert_equal "accepted", send_capture(capture, input(final: true)).status
|
|
244
|
+
assert_equal [false, true, true], transport.calls.map(&:last)
|
|
245
|
+
assert_equal transport.calls[0][1], transport.calls[1][1]
|
|
246
|
+
assert_equal 2, JSON.parse(transport.calls.last[1])["ordinal"]
|
|
247
|
+
end
|
|
248
|
+
|
|
249
|
+
def test_host_sequence_supersedes_unsent_older_snapshot
|
|
250
|
+
transport = ScriptedTransport.new
|
|
251
|
+
capture = runtime(transport)
|
|
252
|
+
newer = capture.send(input(text: "newer"), deadline: now + 2, sequence: 2)
|
|
253
|
+
older = capture.send(input(text: "older"), deadline: now + 2, sequence: 1)
|
|
254
|
+
assert_equal "accepted", newer.status
|
|
255
|
+
assert_equal "superseded", older.reason
|
|
256
|
+
assert_equal 1, transport.calls.length
|
|
257
|
+
final = capture.send(input(final: true), deadline: now + 2, sequence: 3)
|
|
258
|
+
assert_equal "accepted", final.status
|
|
259
|
+
assert_equal 2, JSON.parse(transport.calls.last[1])["ordinal"]
|
|
260
|
+
end
|
|
261
|
+
|
|
262
|
+
def test_caller_cancellation_does_not_poison_execution_state
|
|
263
|
+
transport = ScriptedTransport.new
|
|
264
|
+
capture = runtime(transport)
|
|
265
|
+
cancellation = MirrorsSDK::Cancellation.new
|
|
266
|
+
cancellation.cancel("canceled")
|
|
267
|
+
assert_equal "unconfirmed", send_capture(capture, input, cancel: cancellation).status
|
|
268
|
+
assert_empty transport.calls
|
|
269
|
+
assert_equal "accepted", send_capture(capture, input(text: "retry")).status
|
|
270
|
+
end
|
|
271
|
+
|
|
272
|
+
def test_terminal_capacity_is_bounded_and_rejected_final_releases_prefix
|
|
273
|
+
gate = Queue.new
|
|
274
|
+
transport = ScriptedTransport.new do |request, _|
|
|
275
|
+
gate.pop if request["final"]
|
|
276
|
+
self.class.response(request)
|
|
277
|
+
end
|
|
278
|
+
capture = runtime(transport)
|
|
279
|
+
send_capture(capture, input("prefix"))
|
|
280
|
+
finals = 16.times.map do |index|
|
|
281
|
+
Thread.new { send_capture(capture, input("final-#{index}", final: true)) }
|
|
282
|
+
end
|
|
283
|
+
wait_for { capture.diagnostics[:terminal] == 16 }
|
|
284
|
+
assert_equal "capacity", send_capture(capture, input("prefix", final: true)).reason
|
|
285
|
+
assert_equal 0, capture.diagnostics[:active]
|
|
286
|
+
assert_equal "terminal_conflict", send_capture(capture, input("final-0", final: true, text: "changed")).reason
|
|
287
|
+
assert_equal 16, capture.diagnostics[:terminal]
|
|
288
|
+
16.times { gate << true }
|
|
289
|
+
assert finals.all? { |thread| thread.value.status == "accepted" }
|
|
290
|
+
ensure
|
|
291
|
+
16.times { gate << true } if gate
|
|
292
|
+
finals&.each { |thread| thread.join(2) }
|
|
293
|
+
end
|
|
294
|
+
|
|
295
|
+
def test_capture_ordinal_stops_at_contract_limit
|
|
296
|
+
transport = ScriptedTransport.new
|
|
297
|
+
capture = runtime(transport)
|
|
298
|
+
send_capture(capture, input)
|
|
299
|
+
capture.instance_variable_get(:@active)["run"].ordinal = 10_000
|
|
300
|
+
assert_equal "capacity", send_capture(capture, input(text: "next")).reason
|
|
301
|
+
assert_equal 1, transport.calls.length
|
|
302
|
+
end
|
|
303
|
+
end
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "test_helper"
|
|
4
|
+
require "mirrors_sdk/configuration"
|
|
5
|
+
|
|
6
|
+
class ConfigurationTest < Minitest::Test
|
|
7
|
+
def config(**options)
|
|
8
|
+
MirrorsSDK::Config.new(**{ api_url: "https://example.invalid", api_key: "fixture-key", agent_id: "agent" }.merge(options))
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def test_copies_and_freezes_settings_and_hides_credentials
|
|
12
|
+
key = +"secret-fixture"
|
|
13
|
+
value = config(api_key: key)
|
|
14
|
+
key.replace("modified")
|
|
15
|
+
assert_equal "secret-fixture", value.api_key
|
|
16
|
+
assert value.frozen?
|
|
17
|
+
assert value.api_key.frozen?
|
|
18
|
+
refute_includes value.inspect, "secret-fixture"
|
|
19
|
+
assert_equal 1000, value.heartbeat_interval_ms
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def test_only_https_and_literal_loopback_http_are_allowed
|
|
23
|
+
["https://example.invalid/api", "http://localhost:8000", "http://127.0.0.1:8000", "http://[::1]:8000"].each do |url|
|
|
24
|
+
assert_equal url, config(api_url: url).api_url
|
|
25
|
+
end
|
|
26
|
+
["http://example.invalid", "http://127.1", "https://user:pass@example.invalid", "https://example.invalid?x",
|
|
27
|
+
"https://example.invalid#x", "https://example.invalid\\x", "https://example.invalid:99999", "https://example.invalid/\n"].each do |url|
|
|
28
|
+
assert_raises(ArgumentError) { config(api_url: url) }
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def test_invalid_numeric_options_flags_and_credentials_are_rejected
|
|
33
|
+
[{ timeout_ms: 0 }, { timeout_ms: 30_001 }, { timeout_ms: 1.0 }, { failure_threshold: 0 },
|
|
34
|
+
{ cooldown_ms: 300_001 }, { enabled: 1 }, { heartbeat_interval_ms: 19 }, { mode_lease_ms: 3001 },
|
|
35
|
+
{ heartbeat_interval_ms: 200, mode_lease_ms: 200 }, { api_key: "key\n" }, { api_key: "" },
|
|
36
|
+
{ agent_id: "bad/id" }].each do |options|
|
|
37
|
+
assert_raises(ArgumentError) { config(**options) }
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def test_custom_options_cannot_invoke_comparison_or_string_hooks
|
|
42
|
+
hostile = Object.new
|
|
43
|
+
def hostile.==(*)
|
|
44
|
+
raise "comparison hook"
|
|
45
|
+
end
|
|
46
|
+
def hostile.instance_of?(*)
|
|
47
|
+
raise "type hook"
|
|
48
|
+
end
|
|
49
|
+
def hostile.nil?
|
|
50
|
+
raise "nil hook"
|
|
51
|
+
end
|
|
52
|
+
[:enabled, :timeout_ms, :api_url, :api_key, :agent_id].each do |option|
|
|
53
|
+
assert_raises(ArgumentError) { config(**{ option => hostile }) }
|
|
54
|
+
end
|
|
55
|
+
key = +"fixture-key"
|
|
56
|
+
def key.bytesize
|
|
57
|
+
raise "string hook"
|
|
58
|
+
end
|
|
59
|
+
def key.match?(*)
|
|
60
|
+
raise "string hook"
|
|
61
|
+
end
|
|
62
|
+
assert_equal "fixture-key", config(api_key: key).api_key
|
|
63
|
+
end
|
|
64
|
+
end
|
|
@@ -0,0 +1,295 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "test_helper"
|
|
4
|
+
require "time"
|
|
5
|
+
|
|
6
|
+
class ContextTest < Minitest::Test
|
|
7
|
+
class ManualExecutor
|
|
8
|
+
attr_reader :jobs
|
|
9
|
+
|
|
10
|
+
def initialize
|
|
11
|
+
@jobs = []
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def submit(&block)
|
|
15
|
+
future = MirrorsSDK::Future.new
|
|
16
|
+
@jobs << [future, block]
|
|
17
|
+
future
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
class ClockedHost < MirrorsSDK::ContextHost
|
|
22
|
+
attr_accessor :monotonic_offset, :wall_offset
|
|
23
|
+
|
|
24
|
+
def initialize(*args)
|
|
25
|
+
super
|
|
26
|
+
@monotonic_offset = @wall_offset = 0
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
private
|
|
30
|
+
|
|
31
|
+
def now
|
|
32
|
+
super + @monotonic_offset
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def wall
|
|
36
|
+
super + @wall_offset
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def setup
|
|
41
|
+
@config = MirrorsSDK::Config.new(api_url: "https://example.invalid", api_key: "test", agent_id: "agent")
|
|
42
|
+
@executor = ManualExecutor.new
|
|
43
|
+
@host = ClockedHost.new(@config, Object.new, @executor)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def teardown
|
|
47
|
+
@host.stop
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def lease(mode = "active", version: "v1", duration: 3000, offset: 0)
|
|
51
|
+
issued = Time.now.utc + offset / 1000.0
|
|
52
|
+
{ "protocol" => 1, "agentId" => "agent", "mode" => mode, "version" => version,
|
|
53
|
+
"issuedAt" => issued.iso8601(3), "expiresAt" => (issued + duration / 1000.0).iso8601(3) }
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def stamp(offset = 0)
|
|
57
|
+
(Time.now.utc + offset / 1000.0).iso8601(3)
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def activate
|
|
61
|
+
@host.observe_mode(lease, monotonic)
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def start(id = "execution", timeout_ms: 1000)
|
|
65
|
+
@host.start(trace(id), timeout_ms: timeout_ms, started: monotonic)
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def accept(wait, changes = {})
|
|
69
|
+
value = { "protocol" => 1, "agentId" => "agent", "traceId" => wait.trace_id,
|
|
70
|
+
"requestId" => wait.identifier, "decisionId" => "decision", "modeVersion" => "v1",
|
|
71
|
+
"issuedAt" => stamp, "expiresAt" => stamp(4000), "mode" => "active",
|
|
72
|
+
"role" => "system", "context" => "Check the account.", "reason" => "matched" }.merge(changes)
|
|
73
|
+
wait.job.resolve(value)
|
|
74
|
+
value
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def test_unknown_off_and_expired_modes_do_not_schedule_requests
|
|
78
|
+
assert_equal "mode_unknown", @host.get(trace).reason
|
|
79
|
+
@host.observe_mode(lease("off"), monotonic)
|
|
80
|
+
assert_equal "off", @host.get(trace).reason
|
|
81
|
+
@host.observe_mode(lease("active", duration: 50), monotonic - 0.1)
|
|
82
|
+
assert_equal "mode_expired", @host.get(trace).reason
|
|
83
|
+
assert_empty @executor.jobs
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def test_active_result_is_bound_to_request_and_releases_capacity_at_return
|
|
87
|
+
activate
|
|
88
|
+
wait = start
|
|
89
|
+
value = accept(wait)
|
|
90
|
+
assert_equal 1, @host.diagnostics[:pending_context]
|
|
91
|
+
result = @host.await(wait)
|
|
92
|
+
assert_equal "accepted", result.delivery
|
|
93
|
+
assert_equal "Check the account.", result.context
|
|
94
|
+
assert_equal value["requestId"], result.freshness.request_id
|
|
95
|
+
assert_equal value["expiresAt"], result.freshness.expires_at
|
|
96
|
+
assert_equal 0, @host.diagnostics[:pending_context]
|
|
97
|
+
assert_operator result.elapsed_ms, :>=, 0
|
|
98
|
+
assert_equal "superseded", @host.await(wait).reason
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def test_ready_decision_is_rechecked_when_the_caller_resumes
|
|
102
|
+
activate
|
|
103
|
+
wait = start
|
|
104
|
+
accept(wait)
|
|
105
|
+
@host.monotonic_offset = 1.01
|
|
106
|
+
result = @host.await(wait)
|
|
107
|
+
assert_equal "timeout", result.reason
|
|
108
|
+
assert_empty result.context
|
|
109
|
+
assert_operator result.elapsed_ms, :>=, 1000
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def test_context_lifetime_cannot_gain_time_from_clock_offset
|
|
113
|
+
activate
|
|
114
|
+
wait = start
|
|
115
|
+
@host.monotonic_offset = 0.02
|
|
116
|
+
accept(wait, "issuedAt" => stamp(100), "expiresAt" => stamp(101))
|
|
117
|
+
assert_equal "expired", @host.await(wait).reason
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def test_mode_expiry_checks_wall_clock_and_request_start_monotonic_lifetime
|
|
121
|
+
@host.observe_mode(lease(duration: 50, offset: 100), monotonic)
|
|
122
|
+
@host.wall_offset = -10
|
|
123
|
+
@host.monotonic_offset = 0.06
|
|
124
|
+
assert_equal "mode_expired", @host.get(trace).reason
|
|
125
|
+
|
|
126
|
+
@host.monotonic_offset = @host.wall_offset = 0
|
|
127
|
+
activate
|
|
128
|
+
@host.wall_offset = 10
|
|
129
|
+
result = @host.get(trace)
|
|
130
|
+
assert_equal "mode_expired", result.reason
|
|
131
|
+
assert_equal "shadow", result.mode
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
def test_lease_configuration_and_invalid_request_anchors_cannot_extend_mode
|
|
135
|
+
activate
|
|
136
|
+
[Float::NAN, Float::INFINITY, true, monotonic + 1].each do |invalid|
|
|
137
|
+
@host.observe_mode(lease("off", version: "v2"), invalid)
|
|
138
|
+
assert_equal "active", @host.diagnostics[:mode]
|
|
139
|
+
end
|
|
140
|
+
@host.monotonic_offset = 3.1
|
|
141
|
+
assert_equal "mode_expired", @host.get(trace).reason
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
def test_shadow_returns_immediately_but_retains_bounded_background_work
|
|
145
|
+
@host.observe_mode(lease("shadow"), monotonic)
|
|
146
|
+
wait = start
|
|
147
|
+
result = @host.await(wait)
|
|
148
|
+
assert_equal "shadow", result.reason
|
|
149
|
+
assert_empty result.context
|
|
150
|
+
assert_equal 1, @executor.jobs.length
|
|
151
|
+
assert_equal 1, @host.diagnostics[:pending_context]
|
|
152
|
+
accept(wait)
|
|
153
|
+
assert_equal 0, @host.diagnostics[:pending_context]
|
|
154
|
+
assert_empty @host.await(wait).context
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
def test_context_work_carries_shadow_only_and_full_copied_evidence
|
|
158
|
+
@host.observe_mode(lease("shadow"), monotonic)
|
|
159
|
+
transport = Object.new
|
|
160
|
+
requests = []
|
|
161
|
+
transport.define_singleton_method(:request) do |endpoint, body, **_options, &_validator|
|
|
162
|
+
requests << [endpoint, JSON.parse(body)]
|
|
163
|
+
{}
|
|
164
|
+
end
|
|
165
|
+
@host = ClockedHost.new(@config, transport, @executor)
|
|
166
|
+
@host.observe_mode(lease("shadow"), monotonic)
|
|
167
|
+
original = trace
|
|
168
|
+
wait = @host.start(original, started: monotonic)
|
|
169
|
+
original["messages"][0]["content"] = "mutated"
|
|
170
|
+
@executor.jobs.last[1].call(MirrorsSDK::Cancellation.new)
|
|
171
|
+
assert_equal "retrieve-context", requests[0][0]
|
|
172
|
+
assert_equal true, requests[0][1]["shadowOnly"]
|
|
173
|
+
assert_equal "Hello", requests[0][1]["trace"]["messages"][0]["content"]
|
|
174
|
+
assert_equal wait.identifier, requests[0][1]["requestId"]
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
def test_new_retrieval_and_changed_capture_invalidate_older_decisions
|
|
178
|
+
activate
|
|
179
|
+
first = start
|
|
180
|
+
second = start
|
|
181
|
+
assert_equal "superseded", @host.await(first).reason
|
|
182
|
+
@host.observe("execution", MirrorsSDK::Wire.encode(trace))
|
|
183
|
+
refute second.job.done?
|
|
184
|
+
changed = trace
|
|
185
|
+
changed["messages"] << { "role" => "assistant", "content" => "Done" }
|
|
186
|
+
@host.observe("execution", MirrorsSDK::Wire.encode(changed))
|
|
187
|
+
assert_equal "superseded", @host.await(second).reason
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
def test_release_disable_shutdown_and_mode_change_invalidate_ready_results
|
|
191
|
+
actions = [
|
|
192
|
+
["execution_closed", -> { @host.release("execution") }],
|
|
193
|
+
["disabled", -> { @host.set_enabled(false) }],
|
|
194
|
+
["closed", -> { @host.stop }],
|
|
195
|
+
["mode_changed", -> { @host.observe_mode(lease("active", version: "v2"), monotonic) }]
|
|
196
|
+
]
|
|
197
|
+
actions.each do |reason, action|
|
|
198
|
+
@host = ClockedHost.new(@config, Object.new, @executor)
|
|
199
|
+
activate
|
|
200
|
+
wait = start
|
|
201
|
+
accept(wait)
|
|
202
|
+
action.call
|
|
203
|
+
assert_equal reason, @host.await(wait).reason
|
|
204
|
+
assert_equal 0, @host.diagnostics[:pending_context]
|
|
205
|
+
end
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
def test_retrieval_capacity_includes_accepted_unconsumed_results
|
|
209
|
+
activate
|
|
210
|
+
waits = 16.times.map { |index| start("execution-#{index}") }
|
|
211
|
+
waits.each { |wait| accept(wait) }
|
|
212
|
+
assert_equal "capacity", @host.get(trace("overflow")).reason
|
|
213
|
+
assert_equal 16, @executor.jobs.length
|
|
214
|
+
@host.await(waits.first)
|
|
215
|
+
start("replacement")
|
|
216
|
+
assert_equal 16, @host.diagnostics[:pending_context]
|
|
217
|
+
assert_equal 17, @executor.jobs.length
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
def test_total_deadline_includes_time_before_preparation
|
|
221
|
+
activate
|
|
222
|
+
wait = @host.start(trace, timeout_ms: 20, started: monotonic - 0.03)
|
|
223
|
+
assert_equal "timeout", @host.await(wait).reason
|
|
224
|
+
assert_empty @executor.jobs
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
def test_wait_timeout_and_cancellation_cancel_transport_without_reusing_late_results
|
|
228
|
+
activate
|
|
229
|
+
wait = start(timeout_ms: 20)
|
|
230
|
+
assert_equal "timeout", @host.await(wait).reason
|
|
231
|
+
assert wait.job.token.canceled?
|
|
232
|
+
refute wait.job.resolve({})
|
|
233
|
+
|
|
234
|
+
cancel = MirrorsSDK::Cancellation.new
|
|
235
|
+
wait = start
|
|
236
|
+
thread = Thread.new { sleep 0.01; cancel.cancel }
|
|
237
|
+
result = @host.await(wait, cancel: cancel)
|
|
238
|
+
thread.join
|
|
239
|
+
assert_equal "canceled", result.delivery
|
|
240
|
+
assert_equal "canceled", result.reason
|
|
241
|
+
assert wait.job.token.canceled?
|
|
242
|
+
assert_equal 0, @host.diagnostics[:pending_context]
|
|
243
|
+
end
|
|
244
|
+
|
|
245
|
+
def test_already_canceled_calls_do_not_schedule_work
|
|
246
|
+
activate
|
|
247
|
+
cancel = MirrorsSDK::Cancellation.new
|
|
248
|
+
cancel.cancel
|
|
249
|
+
assert_equal "canceled", @host.get(trace, cancel: cancel).reason
|
|
250
|
+
assert_empty @executor.jobs
|
|
251
|
+
end
|
|
252
|
+
|
|
253
|
+
def test_release_during_snapshot_preparation_cannot_resurrect_a_wait
|
|
254
|
+
activate
|
|
255
|
+
original = MirrorsSDK::Wire.method(:snapshot)
|
|
256
|
+
copying = lambda do |value, **options|
|
|
257
|
+
@host.release("execution")
|
|
258
|
+
original.call(value, **options)
|
|
259
|
+
end
|
|
260
|
+
MirrorsSDK::Wire.stub(:snapshot, copying) do
|
|
261
|
+
assert_equal "execution_closed", @host.get(trace).reason
|
|
262
|
+
end
|
|
263
|
+
assert_empty @executor.jobs
|
|
264
|
+
assert_equal 0, @host.diagnostics[:pending_context]
|
|
265
|
+
end
|
|
266
|
+
|
|
267
|
+
def test_host_thread_interruption_releases_retrieval_capacity
|
|
268
|
+
activate
|
|
269
|
+
wait = start
|
|
270
|
+
entered = Queue.new
|
|
271
|
+
thread = Thread.new { entered << true; @host.await(wait) }
|
|
272
|
+
entered.pop
|
|
273
|
+
eventually { thread.status == "sleep" }
|
|
274
|
+
thread.kill
|
|
275
|
+
thread.join
|
|
276
|
+
assert wait.job.token.canceled?
|
|
277
|
+
assert_equal 0, @host.diagnostics[:pending_context]
|
|
278
|
+
ensure
|
|
279
|
+
thread&.kill
|
|
280
|
+
thread&.join
|
|
281
|
+
end
|
|
282
|
+
|
|
283
|
+
def test_fail_open_for_invalid_options_evidence_and_transport
|
|
284
|
+
activate
|
|
285
|
+
[nil, 0, 1001, 1.5, true].each do |timeout|
|
|
286
|
+
assert_equal "invalid_options", @host.get(trace, timeout_ms: timeout).reason
|
|
287
|
+
end
|
|
288
|
+
assert_equal "invalid_evidence", @host.get({ "id" => "bad", "messages" => [Object.new] }).reason
|
|
289
|
+
wait = start
|
|
290
|
+
wait.job.reject(StandardError.new("scripted failure"))
|
|
291
|
+
assert_equal "transport", @host.await(wait).reason
|
|
292
|
+
invalid = MirrorsSDK::ContextHost.new(nil)
|
|
293
|
+
assert_equal "invalid_options", invalid.get(trace).reason
|
|
294
|
+
end
|
|
295
|
+
end
|