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,93 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "test_helper"
|
|
4
|
+
require_relative "../lib/mirrors_sdk/executor"
|
|
5
|
+
require_relative "../lib/mirrors_sdk/circuit"
|
|
6
|
+
|
|
7
|
+
class ExecutorTest < Minitest::Test
|
|
8
|
+
def test_capacity_includes_canceled_but_physically_running_work
|
|
9
|
+
executor = MirrorsSDK::Executor.new(size: 1, capacity: 1)
|
|
10
|
+
entered, release = Queue.new, Queue.new
|
|
11
|
+
future = executor.submit { entered << true; release.pop; :late }
|
|
12
|
+
entered.pop
|
|
13
|
+
future.cancel
|
|
14
|
+
assert future.done?
|
|
15
|
+
assert_raises(MirrorsSDK::CanceledError) { future.value }
|
|
16
|
+
assert_nil executor.submit { :overflow }
|
|
17
|
+
assert_equal 1, executor.diagnostics[:pending]
|
|
18
|
+
release << true
|
|
19
|
+
deadline = monotonic + 1
|
|
20
|
+
sleep 0.001 until executor.diagnostics[:pending].zero? || monotonic > deadline
|
|
21
|
+
assert_equal 0, executor.diagnostics[:pending]
|
|
22
|
+
next_job = executor.submit { :next }
|
|
23
|
+
assert next_job.wait(1)
|
|
24
|
+
assert_equal :next, next_job.value
|
|
25
|
+
ensure
|
|
26
|
+
release << true if release
|
|
27
|
+
executor.shutdown if executor
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def test_shutdown_returns_without_waiting_for_stalled_worker
|
|
31
|
+
executor = MirrorsSDK::Executor.new(size: 1, capacity: 2)
|
|
32
|
+
entered, release = Queue.new, Queue.new
|
|
33
|
+
running = executor.submit { entered << true; release.pop }
|
|
34
|
+
entered.pop
|
|
35
|
+
queued = executor.submit { flunk "Canceled queued work must not run" }
|
|
36
|
+
started = monotonic
|
|
37
|
+
executor.shutdown
|
|
38
|
+
assert_operator monotonic - started, :<, 0.1
|
|
39
|
+
assert running.done?
|
|
40
|
+
assert queued.done?
|
|
41
|
+
assert_nil executor.submit { :closed }
|
|
42
|
+
assert_equal 1, executor.diagnostics[:pending]
|
|
43
|
+
ensure
|
|
44
|
+
release << true if release
|
|
45
|
+
executor.shutdown if executor
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def test_wait_is_bounded_and_callbacks_are_outside_locks
|
|
49
|
+
future = MirrorsSDK::Future.new
|
|
50
|
+
assert_equal false, future.wait(0.005)
|
|
51
|
+
seen = []
|
|
52
|
+
future.on_complete { |finished| seen << finished.value }
|
|
53
|
+
assert future.resolve(:first)
|
|
54
|
+
refute future.resolve(:second)
|
|
55
|
+
future.on_complete { |finished| seen << finished.value }
|
|
56
|
+
assert_equal [:first, :first], seen
|
|
57
|
+
token = MirrorsSDK::Cancellation.new
|
|
58
|
+
unlink = token.on_cancel { flunk "Unsubscribed callback ran" }
|
|
59
|
+
unlink.call
|
|
60
|
+
token.on_cancel { seen << token.reason }
|
|
61
|
+
assert token.cancel("disabled")
|
|
62
|
+
refute token.cancel
|
|
63
|
+
token.on_cancel { seen << token.reason }
|
|
64
|
+
assert_equal [:first, :first, "disabled", "disabled"], seen
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def test_circuit_ignores_late_success_and_allows_only_one_recovery_probe
|
|
68
|
+
clock = 10.0
|
|
69
|
+
circuit = MirrorsSDK::Circuit.new(1, 1000, clock: -> { clock })
|
|
70
|
+
old = circuit.acquire
|
|
71
|
+
circuit.acquire.call(false)
|
|
72
|
+
old.call(true)
|
|
73
|
+
assert_nil circuit.acquire
|
|
74
|
+
clock = 11.0
|
|
75
|
+
first = circuit.acquire
|
|
76
|
+
refute_nil first
|
|
77
|
+
assert_nil circuit.acquire
|
|
78
|
+
first.call(nil)
|
|
79
|
+
second = circuit.acquire
|
|
80
|
+
refute_nil second
|
|
81
|
+
second.call(false)
|
|
82
|
+
assert_nil circuit.acquire
|
|
83
|
+
clock = 12.0
|
|
84
|
+
circuit.acquire.call(true)
|
|
85
|
+
refute_nil circuit.acquire
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
private
|
|
89
|
+
|
|
90
|
+
def monotonic
|
|
91
|
+
Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
92
|
+
end
|
|
93
|
+
end
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
{
|
|
2
|
+
"protocol": 1,
|
|
3
|
+
"agentId": "agent-1",
|
|
4
|
+
"sessionId": "session-1",
|
|
5
|
+
"branchId": "main",
|
|
6
|
+
"events": [
|
|
7
|
+
{
|
|
8
|
+
"sequence": 1,
|
|
9
|
+
"kind": "messages",
|
|
10
|
+
"data": [
|
|
11
|
+
{ "role": "user", "content": "Find account a" },
|
|
12
|
+
{
|
|
13
|
+
"role": "assistant",
|
|
14
|
+
"content": [
|
|
15
|
+
{
|
|
16
|
+
"type": "tool-call",
|
|
17
|
+
"callId": "call-1",
|
|
18
|
+
"name": "lookup",
|
|
19
|
+
"arguments": { "account": "a" }
|
|
20
|
+
}
|
|
21
|
+
]
|
|
22
|
+
},
|
|
23
|
+
{
|
|
24
|
+
"role": "tool",
|
|
25
|
+
"content": [
|
|
26
|
+
{
|
|
27
|
+
"type": "tool-result",
|
|
28
|
+
"callId": "call-1",
|
|
29
|
+
"name": "lookup",
|
|
30
|
+
"value": { "found": true },
|
|
31
|
+
"error": false
|
|
32
|
+
}
|
|
33
|
+
]
|
|
34
|
+
}
|
|
35
|
+
]
|
|
36
|
+
}
|
|
37
|
+
]
|
|
38
|
+
}
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "test_helper"
|
|
4
|
+
require_relative "support/runtime_server"
|
|
5
|
+
|
|
6
|
+
class IntegrationTest < Minitest::Test
|
|
7
|
+
def setup
|
|
8
|
+
@server = RuntimeServer.new
|
|
9
|
+
@client = client
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def teardown
|
|
13
|
+
@client.dispose
|
|
14
|
+
@server.close
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def test_capture_and_context_match_existing_protocols
|
|
18
|
+
wait_for_active
|
|
19
|
+
evidence = trace.merge("conversationId" => "chat", "conversationRevision" => 3,
|
|
20
|
+
"agentContext" => { "systemPrompt" => "Original prompt", "skills" => [{ "name" => "search", "content" => "Search first" }] })
|
|
21
|
+
prefix = @client.capture_trace(evidence)
|
|
22
|
+
result = @client.get_context(evidence)
|
|
23
|
+
assert_equal @server.context_text, result.context
|
|
24
|
+
assert_equal "accepted", result.delivery
|
|
25
|
+
assert_equal "accepted", prefix.wait(1).status
|
|
26
|
+
final = @client.capture_trace(evidence.merge("turnLatencyMs" => 12.5, "user" => { "id" => "user-1" }), final: true, terminal: "completed")
|
|
27
|
+
assert_equal "accepted", final.wait(1).status
|
|
28
|
+
assert_equal "queued", final.receipt.ingestion["status"]
|
|
29
|
+
captures = @server.requests("trace")
|
|
30
|
+
assert_equal [1, 2], captures.map { |record| record[:body]["ordinal"] }
|
|
31
|
+
assert captures.all? { |record| record[:body]["captureOnly"] && record[:body]["protocol"] == 3 }
|
|
32
|
+
assert_equal "Original prompt", captures.last[:body]["trace"]["agentContext"]["systemPrompt"]
|
|
33
|
+
assert_equal 12.5, captures.last[:body]["trace"]["turnLatencyMs"]
|
|
34
|
+
request = @server.requests("retrieve-context").last
|
|
35
|
+
assert_equal 1, request[:body]["protocol"]
|
|
36
|
+
assert_equal Digest::SHA256.hexdigest("retrieve-context\n#{request[:raw]}"), request[:headers]["idempotency-key"]
|
|
37
|
+
assert_equal "execution_closed", @client.get_context(evidence).reason
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def test_capture_does_not_wait_for_http_or_hold_context
|
|
41
|
+
wait_for_active
|
|
42
|
+
@server.capture_delay = 0.2
|
|
43
|
+
evidence = trace
|
|
44
|
+
started = monotonic
|
|
45
|
+
ticket = @client.capture_trace(evidence)
|
|
46
|
+
assert_operator monotonic - started, :<, 0.1
|
|
47
|
+
evidence["messages"][0]["content"] = "Mutated after capture"
|
|
48
|
+
eventually { @server.requests("trace").any? }
|
|
49
|
+
assert_equal "Hello", @server.requests("trace").first[:body]["trace"]["messages"][0]["content"]
|
|
50
|
+
assert_equal @server.context_text, @client.get_context(trace).context
|
|
51
|
+
assert_equal "pending", ticket.receipt.status
|
|
52
|
+
assert_equal "accepted", ticket.wait(1).status
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def test_context_timeout_cancel_and_late_reply_never_reused
|
|
56
|
+
wait_for_active
|
|
57
|
+
@server.context_delay = 0.2
|
|
58
|
+
started = monotonic
|
|
59
|
+
result = @client.get_context(trace, timeout_ms: 40)
|
|
60
|
+
assert_equal "timeout", result.reason
|
|
61
|
+
assert_empty result.context
|
|
62
|
+
assert_operator monotonic - started, :<, 0.15
|
|
63
|
+
cancel = MirrorsSDK::Cancellation.new
|
|
64
|
+
waiting = Thread.new { @client.get_context(trace("cancel"), cancel: cancel) }
|
|
65
|
+
eventually { @server.requests("retrieve-context").length >= 2 }
|
|
66
|
+
cancel.cancel
|
|
67
|
+
assert waiting.join(0.2)
|
|
68
|
+
assert_equal "canceled", waiting.value.reason
|
|
69
|
+
@server.context_delay = 0
|
|
70
|
+
@server.context_text = "Fresh decision"
|
|
71
|
+
assert_equal "Fresh decision", @client.get_context(trace).context
|
|
72
|
+
ensure
|
|
73
|
+
waiting&.kill if waiting&.alive?
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def test_shadow_off_and_expired_mode
|
|
77
|
+
wait_for_active
|
|
78
|
+
@server.mode = "shadow"
|
|
79
|
+
@server.version = "version-2"
|
|
80
|
+
eventually { @client.get_context(trace).reason == "shadow" }
|
|
81
|
+
eventually { @server.requests("retrieve-context").any? { |record| record[:body]["shadowOnly"] } }
|
|
82
|
+
@server.mode = "off"
|
|
83
|
+
@server.version = "version-3"
|
|
84
|
+
eventually { @client.get_context(trace).reason == "off" }
|
|
85
|
+
count = @server.requests("retrieve-context").length
|
|
86
|
+
assert_empty @client.get_context(trace).context
|
|
87
|
+
assert_equal count, @server.requests("retrieve-context").length
|
|
88
|
+
@server.mode_status = 503
|
|
89
|
+
eventually { @client.get_context(trace).reason == "mode_expired" }
|
|
90
|
+
assert_equal count, @server.requests("retrieve-context").length
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def test_changed_capture_and_final_invalidate_pending_context
|
|
94
|
+
wait_for_active
|
|
95
|
+
@server.context_delay = 0.2
|
|
96
|
+
waiting = Thread.new { @client.get_context(trace) }
|
|
97
|
+
eventually { @server.requests("retrieve-context").any? }
|
|
98
|
+
ticket = @client.capture_trace(trace.merge("messages" => [{ "role" => "user", "content" => "New evidence" }]))
|
|
99
|
+
assert waiting.join(0.2)
|
|
100
|
+
assert_equal "superseded", waiting.value.reason
|
|
101
|
+
assert_equal "accepted", ticket.wait(1).status
|
|
102
|
+
waiting = Thread.new { @client.get_context(trace) }
|
|
103
|
+
eventually { @server.requests("retrieve-context").length == 2 }
|
|
104
|
+
final = @client.capture_trace(trace, final: true)
|
|
105
|
+
assert waiting.join(0.2)
|
|
106
|
+
assert_equal "execution_closed", waiting.value.reason
|
|
107
|
+
assert_equal "accepted", final.wait(1).status
|
|
108
|
+
ensure
|
|
109
|
+
waiting&.kill if waiting&.alive?
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def test_uncertain_prefix_replays_identical_bytes_before_final
|
|
113
|
+
@server.capture_failures = 1
|
|
114
|
+
prefix = @client.capture_trace(trace)
|
|
115
|
+
assert_equal "unconfirmed", prefix.wait(1).status
|
|
116
|
+
final = @client.capture_trace(trace, final: true, terminal: "completed")
|
|
117
|
+
assert_equal "accepted", final.wait(1).status
|
|
118
|
+
records = @server.requests("trace")
|
|
119
|
+
assert_equal 3, records.length
|
|
120
|
+
assert_equal records[0][:raw], records[1][:raw]
|
|
121
|
+
assert_equal records[0][:headers]["idempotency-key"], records[1][:headers]["idempotency-key"]
|
|
122
|
+
assert_equal 2, records[2][:body]["ordinal"]
|
|
123
|
+
assert_same final, @client.capture_trace(trace, final: true, terminal: "completed")
|
|
124
|
+
assert_equal "terminal_conflict", @client.capture_trace(trace, final: true, terminal: "failed").receipt.reason
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
def test_flush_snapshots_pending_and_close_disposes
|
|
128
|
+
@server.capture_delay = 0.15
|
|
129
|
+
ticket = @client.capture_trace(trace, final: true)
|
|
130
|
+
result = @client.flush(timeout_ms: 10)
|
|
131
|
+
assert_equal 1, result.pending
|
|
132
|
+
refute result.complete?
|
|
133
|
+
result = @client.close(timeout_ms: 1000)
|
|
134
|
+
assert result.complete?
|
|
135
|
+
assert_equal "accepted", ticket.receipt.status
|
|
136
|
+
assert_equal "closed", @client.capture_trace(trace("another")).receipt.reason
|
|
137
|
+
assert_equal "closed", @client.get_context(trace("another")).reason
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
private
|
|
141
|
+
|
|
142
|
+
def client(**options)
|
|
143
|
+
MirrorsSDK::Mirrors.new(api_url: @server.url, api_key: "test-only", agent_id: "agent",
|
|
144
|
+
heartbeat_interval_ms: 20, mode_lease_ms: 250, **options)
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
def wait_for_active
|
|
148
|
+
eventually { @client.get_context(trace("warmup")).delivery == "accepted" }
|
|
149
|
+
# Subsequent tests count only the requests belonging to their own execution.
|
|
150
|
+
@server.instance_variable_get(:@lock).synchronize do
|
|
151
|
+
@server.instance_variable_get(:@records).clear
|
|
152
|
+
end
|
|
153
|
+
end
|
|
154
|
+
end
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "test_helper"
|
|
4
|
+
require_relative "support/runtime_server"
|
|
5
|
+
|
|
6
|
+
class LifecycleTest < Minitest::Test
|
|
7
|
+
def setup
|
|
8
|
+
@server = RuntimeServer.new
|
|
9
|
+
@client = MirrorsSDK::Mirrors.new(api_url: @server.url, api_key: "test-only", agent_id: "agent")
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def teardown
|
|
13
|
+
@client.dispose
|
|
14
|
+
@server.close
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def test_final_terminal_is_detached_before_background_work
|
|
18
|
+
captured = []
|
|
19
|
+
worker = @client.instance_variable_get(:@worker)
|
|
20
|
+
worker.define_singleton_method(:capture) do |value, **_options|
|
|
21
|
+
captured << value
|
|
22
|
+
MirrorsSDK::Future.new
|
|
23
|
+
end
|
|
24
|
+
terminal = String.new("completed")
|
|
25
|
+
first = @client.capture_trace(trace, final: true, terminal: terminal)
|
|
26
|
+
terminal.replace("failed")
|
|
27
|
+
assert_equal "completed", captured.first["terminal"]
|
|
28
|
+
assert_same first, @client.capture_trace(trace, final: true, terminal: "completed")
|
|
29
|
+
assert_equal "terminal_conflict", @client.capture_trace(trace, final: true, terminal: terminal).receipt.reason
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def test_raw_input_hooks_are_never_called_even_on_failure_paths
|
|
33
|
+
evidence = trace.merge("unsupported" => Object.new)
|
|
34
|
+
%i[[] class instance_of? to_json].each do |method|
|
|
35
|
+
evidence.define_singleton_method(method) { |*_args| raise "application hook executed" }
|
|
36
|
+
end
|
|
37
|
+
assert_equal "invalid_evidence", @client.capture_trace(evidence).receipt.reason
|
|
38
|
+
@client.set_enabled(false)
|
|
39
|
+
assert_equal "disabled", @client.get_context(evidence).reason
|
|
40
|
+
assert_equal "disabled", @client.capture_trace(evidence).receipt.reason
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def test_rejected_final_releases_runtime_prefix_state
|
|
44
|
+
assert_equal "accepted", @client.capture_trace(trace).wait(1).status
|
|
45
|
+
worker = @client.instance_variable_get(:@worker)
|
|
46
|
+
runtime = worker.instance_variable_get(:@capture)
|
|
47
|
+
assert_equal 1, runtime.diagnostics[:active]
|
|
48
|
+
worker.define_singleton_method(:capture) { |_value, **_options| nil }
|
|
49
|
+
assert_equal "capacity", @client.capture_trace(trace, final: true).receipt.reason
|
|
50
|
+
assert_equal 0, runtime.diagnostics[:active]
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def test_disable_cancels_work_and_reenable_requires_new_identity
|
|
54
|
+
@server.capture_delay = 0.2
|
|
55
|
+
ticket = @client.capture_trace(trace)
|
|
56
|
+
eventually { @server.requests("trace").any? }
|
|
57
|
+
@client.set_enabled(false)
|
|
58
|
+
assert_equal "unconfirmed", ticket.wait(0.1).status
|
|
59
|
+
assert_equal "disabled", @client.get_context(trace).reason
|
|
60
|
+
@client.set_enabled(true)
|
|
61
|
+
assert_equal "terminal_conflict", @client.capture_trace(trace).receipt.reason
|
|
62
|
+
@server.capture_delay = 0
|
|
63
|
+
assert_equal "accepted", @client.capture_trace(trace("fresh"), final: true).wait(1).status
|
|
64
|
+
@client.dispose
|
|
65
|
+
@client.set_enabled(true)
|
|
66
|
+
assert_equal "closed", @client.capture_trace(trace("later")).receipt.reason
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def test_source_metadata_stays_fixed_but_user_and_latency_belong_to_final
|
|
70
|
+
initial = trace.merge("conversationId" => "chat", "conversationRevision" => 1)
|
|
71
|
+
assert_equal "accepted", @client.capture_trace(initial).wait(1).status
|
|
72
|
+
changed = initial.merge("conversationRevision" => 2)
|
|
73
|
+
assert_equal "invalid_evidence", @client.capture_trace(changed).receipt.reason
|
|
74
|
+
final = initial.merge("user" => { "id" => "user" }, "turnLatencyMs" => 0)
|
|
75
|
+
assert_equal "accepted", @client.capture_trace(final, final: true).wait(1).status
|
|
76
|
+
assert_equal "terminal_conflict", @client.capture_trace(changed).receipt.reason
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def test_final_capacity_is_independent_of_saturated_prefixes
|
|
80
|
+
@server.capture_delay = 0.15
|
|
81
|
+
tickets = 16.times.map { |index| @client.capture_trace(trace("prefix-#{index}")) }
|
|
82
|
+
assert tickets.all? { |ticket| ticket.receipt.status == "pending" }
|
|
83
|
+
assert_equal "capacity", @client.capture_trace(trace("overflow")).receipt.reason
|
|
84
|
+
final = @client.capture_trace(trace("final"), final: true)
|
|
85
|
+
assert_equal "accepted", final.wait(1).status
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def test_forked_client_does_not_touch_inherited_locks
|
|
89
|
+
skip "fork unavailable on this platform" unless Process.respond_to?(:fork)
|
|
90
|
+
reader, writer = IO.pipe
|
|
91
|
+
inherited_lock = @client.instance_variable_get(:@lock)
|
|
92
|
+
ready, release = Queue.new, Queue.new
|
|
93
|
+
holder = Thread.new { inherited_lock.synchronize { ready << true; release.pop } }
|
|
94
|
+
ready.pop
|
|
95
|
+
pid = Process.fork do
|
|
96
|
+
reader.close
|
|
97
|
+
result = [@client.capture_trace(trace).receipt.reason, @client.get_context(trace).reason,
|
|
98
|
+
@client.diagnostics[:reason], @client.close.unconfirmed]
|
|
99
|
+
@client.release_trace("execution")
|
|
100
|
+
@client.set_enabled(false)
|
|
101
|
+
@client.dispose
|
|
102
|
+
writer.write(JSON.generate(result))
|
|
103
|
+
writer.close
|
|
104
|
+
exit! 0
|
|
105
|
+
end
|
|
106
|
+
writer.close
|
|
107
|
+
assert IO.select([reader], nil, nil, 2), "Forked SDK call waited on a vanished thread's lock"
|
|
108
|
+
assert_equal ["forked_process", "forked_process", "forked_process", 1], JSON.parse(reader.read)
|
|
109
|
+
Process.wait(pid)
|
|
110
|
+
ensure
|
|
111
|
+
release << true if release
|
|
112
|
+
holder&.join(0.5)
|
|
113
|
+
reader&.close unless reader&.closed?
|
|
114
|
+
writer&.close unless writer&.closed?
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def test_invalid_configuration_fails_open_and_timer_freezes
|
|
118
|
+
invalid = MirrorsSDK::Mirrors.new(api_url: "http://untrusted.invalid", api_key: "test-only", agent_id: "agent")
|
|
119
|
+
assert_equal "invalid_options", invalid.get_context(trace).reason
|
|
120
|
+
assert_equal "invalid_options", invalid.capture_trace(trace).receipt.reason
|
|
121
|
+
assert invalid.flush.complete?
|
|
122
|
+
timer = invalid.start_turn
|
|
123
|
+
elapsed = timer.finish
|
|
124
|
+
assert_operator elapsed, :>=, 0
|
|
125
|
+
assert_equal elapsed, timer.finish
|
|
126
|
+
invalid.dispose
|
|
127
|
+
end
|
|
128
|
+
end
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "test_helper"
|
|
4
|
+
require "mirrors_sdk/snapshot"
|
|
5
|
+
|
|
6
|
+
class SnapshotTest < Minitest::Test
|
|
7
|
+
def test_copies_nested_values_and_strings_without_serialization_hooks
|
|
8
|
+
text = +"original"
|
|
9
|
+
source = { "nested" => [text, { "number" => 1.5 }] }
|
|
10
|
+
def source.to_json(*)
|
|
11
|
+
raise "application hook must not run"
|
|
12
|
+
end
|
|
13
|
+
def source.each_pair(*)
|
|
14
|
+
raise "application hook must not run"
|
|
15
|
+
end
|
|
16
|
+
encoded = MirrorsSDK::Snapshot.encode(source)
|
|
17
|
+
cloned = MirrorsSDK::Snapshot.copy(source)
|
|
18
|
+
text.replace("changed")
|
|
19
|
+
source["nested"] << false
|
|
20
|
+
assert_equal "original", cloned["nested"][0]
|
|
21
|
+
assert_equal({ "nested" => ["original", { "number" => 1.5 }] }, JSON.parse(encoded))
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def test_rejects_custom_types_cycles_and_non_json_values
|
|
25
|
+
hostile = Class.new(Hash) { def each_pair; raise "hook"; end }.new
|
|
26
|
+
hostile_string = Class.new(String).new("x")
|
|
27
|
+
cycle = []
|
|
28
|
+
cycle << cycle
|
|
29
|
+
values = [hostile, hostile_string, Object.new, { x: 1 }, cycle, Float::NAN, Float::INFINITY]
|
|
30
|
+
values.each do |value|
|
|
31
|
+
assert_raises(ArgumentError) { MirrorsSDK::Snapshot.encode(value) }
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def test_limits_bound_depth_collections_nodes_and_encoded_bytes
|
|
36
|
+
deep = nil
|
|
37
|
+
34.times { deep = [deep] }
|
|
38
|
+
[deep, Array.new(10_001), Array.new(6) { Array.new(10_000, 0) }, "💡" * 250_000].each do |value|
|
|
39
|
+
assert_raises(ArgumentError) { MirrorsSDK::Snapshot.encode(value) }
|
|
40
|
+
end
|
|
41
|
+
assert_raises(ArgumentError) { MirrorsSDK::Snapshot.encode((+"\xff").force_encoding("UTF-8")) }
|
|
42
|
+
assert_raises(ArgumentError) { MirrorsSDK::Snapshot.encode("\u0000" * 200_000) }
|
|
43
|
+
assert_equal '{"text":"é"}', MirrorsSDK::Snapshot.encode({ "text" => "é" })
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def test_integers_must_not_silently_round_at_the_typescript_boundary
|
|
47
|
+
[9_007_199_254_740_992, -9_007_199_254_740_992, 10**1000].each do |number|
|
|
48
|
+
assert_raises(ArgumentError) { MirrorsSDK::Snapshot.encode({ "number" => number }) }
|
|
49
|
+
end
|
|
50
|
+
[9_007_199_254_740_991, -9_007_199_254_740_991, 1.0e20].each do |number|
|
|
51
|
+
assert_equal number, JSON.parse(MirrorsSDK::Snapshot.encode({ "number" => number }))["number"]
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def test_expired_deadline_is_checked_before_copying_and_encoding
|
|
56
|
+
expired = Process.clock_gettime(Process::CLOCK_MONOTONIC) - 1
|
|
57
|
+
assert_raises(Timeout::Error) { MirrorsSDK::Snapshot.copy({ "x" => 1 }, deadline: expired) }
|
|
58
|
+
assert_raises(Timeout::Error) { MirrorsSDK::Snapshot.encode({ "x" => 1 }, deadline: expired) }
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def test_oversized_string_sources_are_rejected_before_copy_or_transcode
|
|
62
|
+
sources = ["a" * 101, ("a" * 201).encode(Encoding::UTF_16LE)]
|
|
63
|
+
sources.each do |source|
|
|
64
|
+
String.stub(:new, ->(*) { raise "Oversized source was copied" }) do
|
|
65
|
+
assert_raises(ArgumentError) { MirrorsSDK::Snapshot.copy(source, limit: 100) }
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def test_utf16_and_utf32_sources_keep_the_exact_utf8_output_budget
|
|
71
|
+
[Encoding::UTF_16LE, Encoding::UTF_32BE].each do |encoding|
|
|
72
|
+
source = ("a" * 90).encode(encoding)
|
|
73
|
+
assert_operator source.bytesize, :>, 100
|
|
74
|
+
assert_equal "a" * 90, MirrorsSDK::Snapshot.copy(source, limit: 100)
|
|
75
|
+
assert_raises(ArgumentError) { MirrorsSDK::Snapshot.copy(source, limit: 50) }
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def test_copying_a_string_does_not_start_transcoding_after_its_deadline
|
|
80
|
+
clock = [0, 0, 1]
|
|
81
|
+
Process.stub(:clock_gettime, ->(*) { clock.shift || 1 }) do
|
|
82
|
+
assert_raises(Timeout::Error) do
|
|
83
|
+
MirrorsSDK::Snapshot.copy("original", deadline: 0.5)
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
assert_empty clock
|
|
87
|
+
end
|
|
88
|
+
end
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "socket"
|
|
4
|
+
require "json"
|
|
5
|
+
require "time"
|
|
6
|
+
|
|
7
|
+
# Real loopback HTTP, with independently held requests. Never contacts a provider.
|
|
8
|
+
class RuntimeServer
|
|
9
|
+
attr_accessor :mode, :version, :mode_status, :lease_ms, :context_text,
|
|
10
|
+
:capture_delay, :context_delay, :mode_delay, :capture_failures
|
|
11
|
+
attr_reader :url
|
|
12
|
+
|
|
13
|
+
def initialize
|
|
14
|
+
@mode, @version, @mode_status, @lease_ms = "active", "version-1", 200, 250
|
|
15
|
+
@context_text = "Check the account before changing it."
|
|
16
|
+
@capture_delay, @context_delay, @mode_delay, @capture_failures = 0, 0, 0, 0
|
|
17
|
+
@lock, @records, @connections, @handlers = Mutex.new, [], [], []
|
|
18
|
+
@server = TCPServer.new("127.0.0.1", 0)
|
|
19
|
+
@url = "http://127.0.0.1:#{@server.addr[1]}"
|
|
20
|
+
@acceptor = Thread.new do
|
|
21
|
+
loop do
|
|
22
|
+
socket = @server.accept
|
|
23
|
+
@connections << socket
|
|
24
|
+
@handlers << Thread.new(socket) { |connection| handle(connection) }
|
|
25
|
+
end
|
|
26
|
+
rescue IOError, Errno::EBADF
|
|
27
|
+
nil
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def requests(endpoint)
|
|
32
|
+
@lock.synchronize { @records.select { |record| record[:path].end_with?("/#{endpoint}") } }
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def close
|
|
36
|
+
@server.close
|
|
37
|
+
@acceptor.join(0.2)
|
|
38
|
+
@connections.each { |socket| socket.close unless socket.closed? }
|
|
39
|
+
@handlers.each { |thread| thread.join(0.2) || thread.kill }
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
private
|
|
43
|
+
|
|
44
|
+
def handle(socket)
|
|
45
|
+
request = socket.gets
|
|
46
|
+
return unless request
|
|
47
|
+
|
|
48
|
+
path = request.split[1]
|
|
49
|
+
headers = {}
|
|
50
|
+
while (line = socket.gets) && line != "\r\n"
|
|
51
|
+
key, value = line.split(":", 2)
|
|
52
|
+
headers[key.downcase] = value.strip
|
|
53
|
+
end
|
|
54
|
+
raw = socket.read(headers.fetch("content-length", "0").to_i)
|
|
55
|
+
body = raw.empty? ? nil : JSON.parse(raw)
|
|
56
|
+
@lock.synchronize { @records << { path: path, raw: raw, body: body, headers: headers } }
|
|
57
|
+
if path.end_with?("/mode")
|
|
58
|
+
sleep @mode_delay
|
|
59
|
+
answer(socket, { "protocol" => 1, "agentId" => "agent", "mode" => @mode,
|
|
60
|
+
"version" => @version, "issuedAt" => utc, "expiresAt" => utc(@lease_ms) }, @mode_status)
|
|
61
|
+
elsif path.end_with?("/trace")
|
|
62
|
+
sleep @capture_delay
|
|
63
|
+
fail_request = @lock.synchronize do
|
|
64
|
+
@capture_failures -= 1 if @capture_failures.positive?
|
|
65
|
+
end
|
|
66
|
+
return answer(socket, {}, 503) if fail_request
|
|
67
|
+
|
|
68
|
+
response = { "protocol" => 3, "agentId" => "agent", "traceId" => body["trace"]["id"],
|
|
69
|
+
"requestId" => body["requestId"], "ordinal" => body["ordinal"], "final" => body["final"],
|
|
70
|
+
"decisionId" => "capture-decision", "expiresAt" => utc(1000), "mode" => @mode,
|
|
71
|
+
"role" => "system", "context" => nil,
|
|
72
|
+
"ingestion" => body["final"] ? { "status" => "queued", "jobId" => "job-1" } : nil }
|
|
73
|
+
%w[terminal captureOnly].each { |key| response[key] = body[key] if body.key?(key) }
|
|
74
|
+
answer(socket, response)
|
|
75
|
+
elsif path.end_with?("/retrieve-context")
|
|
76
|
+
sleep @context_delay
|
|
77
|
+
response = { "protocol" => 1, "agentId" => "agent", "traceId" => body["trace"]["id"],
|
|
78
|
+
"requestId" => body["requestId"], "decisionId" => "context-decision",
|
|
79
|
+
"issuedAt" => utc, "expiresAt" => utc(4000), "mode" => @mode,
|
|
80
|
+
"modeVersion" => @version, "role" => "system", "reason" => "matched",
|
|
81
|
+
"context" => @mode == "active" && !body["shadowOnly"] ? @context_text : nil }
|
|
82
|
+
response["shadowOnly"] = body["shadowOnly"] if body.key?("shadowOnly")
|
|
83
|
+
answer(socket, response)
|
|
84
|
+
else
|
|
85
|
+
answer(socket, {}, 404)
|
|
86
|
+
end
|
|
87
|
+
rescue IOError, SystemCallError
|
|
88
|
+
nil
|
|
89
|
+
ensure
|
|
90
|
+
socket.close unless socket.closed?
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def answer(socket, body, status = 200)
|
|
94
|
+
encoded = JSON.generate(body)
|
|
95
|
+
socket.write("HTTP/1.1 #{status} Response\r\nContent-Type: application/json\r\nContent-Length: #{encoded.bytesize}\r\nConnection: close\r\n\r\n#{encoded}")
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def utc(offset_ms = 0)
|
|
99
|
+
(Time.now + offset_ms / 1000.0).utc.iso8601(3)
|
|
100
|
+
end
|
|
101
|
+
end
|
data/test/test_helper.rb
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
$LOAD_PATH.unshift(File.expand_path("../lib", __dir__))
|
|
4
|
+
require "mirrors_sdk"
|
|
5
|
+
require "minitest/autorun"
|
|
6
|
+
|
|
7
|
+
module SDKTestHelpers
|
|
8
|
+
def monotonic
|
|
9
|
+
Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def eventually(timeout: 3)
|
|
13
|
+
deadline = monotonic + timeout
|
|
14
|
+
loop do
|
|
15
|
+
result = yield
|
|
16
|
+
return result if result
|
|
17
|
+
raise "Condition did not become true before the test deadline" if monotonic >= deadline
|
|
18
|
+
|
|
19
|
+
sleep 0.005
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def trace(id = "execution")
|
|
24
|
+
{ "id" => id, "messages" => [{ "role" => "user", "content" => "Hello" }] }
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
class Minitest::Test
|
|
29
|
+
include SDKTestHelpers
|
|
30
|
+
end
|