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.
@@ -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
@@ -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