@independo/capacitor-inderun 1.0.1-dev.1 → 1.1.0-dev.10

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.
Files changed (33) hide show
  1. package/Package.swift +19 -2
  2. package/README.md +190 -13
  3. package/android/build.gradle.kts +12 -6
  4. package/android/settings.gradle +7 -0
  5. package/android/src/main/kotlin/app/independo/inderun/capacitor/IndeRunCapacitorPlugin.kt +154 -0
  6. package/android/src/main/kotlin/app/independo/inderun/capacitor/IndeRunSerializer.kt +203 -1
  7. package/android/src/main/kotlin/app/independo/inderun/capacitor/IndeRunStreamRegistry.kt +118 -0
  8. package/android/src/test/kotlin/app/independo/inderun/capacitor/IndeRunSerializerCapabilitiesTest.kt +253 -0
  9. package/android/src/test/kotlin/app/independo/inderun/capacitor/IndeRunSerializerStreamTest.kt +226 -0
  10. package/android/src/test/kotlin/app/independo/inderun/capacitor/IndeRunSerializerTest.kt +79 -0
  11. package/android/src/test/kotlin/app/independo/inderun/capacitor/IndeRunStreamRegistryTest.kt +163 -0
  12. package/dist/definitions.d.ts +175 -1
  13. package/dist/definitions.d.ts.map +1 -1
  14. package/dist/errors.d.ts +19 -0
  15. package/dist/errors.d.ts.map +1 -0
  16. package/dist/errors.js +36 -0
  17. package/dist/index.d.ts +17 -4
  18. package/dist/index.d.ts.map +1 -1
  19. package/dist/index.js +44 -13
  20. package/dist/streaming.d.ts +17 -0
  21. package/dist/streaming.d.ts.map +1 -0
  22. package/dist/streaming.js +303 -0
  23. package/dist/web.d.ts +18 -2
  24. package/dist/web.d.ts.map +1 -1
  25. package/dist/web.js +136 -7
  26. package/ios/Sources/IndeRunCapacitorPlugin/IndeRunCapacitorBridge.swift +181 -0
  27. package/ios/Sources/IndeRunCapacitorPlugin/IndeRunCapacitorPlugin.swift +93 -3
  28. package/ios/Sources/IndeRunCapacitorPlugin/IndeRunCapacitorStreamRegistry.swift +121 -0
  29. package/ios/Tests/IndeRunCapacitorTests/IndeRunCapacitorBridgeTests.swift +234 -0
  30. package/ios/Tests/IndeRunCapacitorTests/IndeRunCapacitorStreamCodecTests.swift +178 -0
  31. package/ios/Tests/IndeRunCapacitorTests/IndeRunCapacitorStreamPumpTests.swift +215 -0
  32. package/ios/Tests/IndeRunCapacitorTests/IndeRunCapacitorStreamRegistryTests.swift +154 -0
  33. package/package.json +21 -9
@@ -1,6 +1,7 @@
1
1
  import XCTest
2
2
  import Foundation
3
3
  import IndeRunContracts
4
+ import IndeRunCore
4
5
  @testable import IndeRunCapacitorPlugin
5
6
 
6
7
  final class IndeRunCapacitorBridgeTests: XCTestCase {
@@ -86,6 +87,22 @@ final class IndeRunCapacitorBridgeTests: XCTestCase {
86
87
  XCTAssertEqual(options.allowDirectOpenAIEndpoint, true)
87
88
  }
88
89
 
90
+ /// `systemModel` and `onnx` are web-only bootstrap keys, like
91
+ /// `allowDirectOpenAIEndpoint`. Native registers its own on-device provider, so it must
92
+ /// ignore them rather than fail to decode the options object that carries them.
93
+ func testIgnoresTheWebOnlyProviderBootstrapKeys() throws {
94
+ let json: [String: Any] = [
95
+ "openAI": ["model": "gpt-5.2", "auth": "none"],
96
+ "systemModel": ["id": "local.system-model.web", "timeoutMs": 30_000],
97
+ "onnx": ["modelPackage": ["id": "demo", "format": "onnx"]]
98
+ ]
99
+ let data = try JSONSerialization.data(withJSONObject: json)
100
+ let options = try JSONDecoder().decode(CapacitorRunOptions.self, from: data)
101
+
102
+ XCTAssertEqual(options.openAI?.model, "gpt-5.2")
103
+ XCTAssertEqual(options.openAI?.auth, "none")
104
+ }
105
+
89
106
  // MARK: - encode(error:)
90
107
 
91
108
  func testEncodesIndeRunErrorRequiredFieldsOnly() throws {
@@ -134,6 +151,223 @@ final class IndeRunCapacitorBridgeTests: XCTestCase {
134
151
  XCTAssertEqual(encoded["runId"] as? String, "run_abc")
135
152
  }
136
153
 
154
+ /// A routing refusal carries its plan diagnostics on `details`, and since inderun 0.3.0
155
+ /// it does so on iOS too, not just on the web SDK. The shape is nested — a
156
+ /// `rejectedProviders` array of objects each carrying its own `reasons` array — and it
157
+ /// crosses as `[String: JSONAny]`. This is the data a provider-refusal UI reads, so a
158
+ /// regression would be invisible until a demo showed an empty table.
159
+ func testEncodesNestedRoutePlanDiagnosticsInDetails() throws {
160
+ let bridge = IndeRunCapacitorBridge()
161
+ let error = IndeRunError(
162
+ details: [
163
+ "failureCode": JSONAny("CapabilityMismatch"),
164
+ "rejectedProviders": JSONAny([
165
+ [
166
+ "providerId": "apple.foundation-models",
167
+ "reasons": [["code": "capability_unavailable", "message": "Apple Intelligence unavailable."]]
168
+ ],
169
+ [
170
+ "providerId": "openai",
171
+ "reasons": [["code": "privacy_constraint", "message": "local_required forbids cloud."]]
172
+ ]
173
+ ])
174
+ ],
175
+ errorClass: .CapabilityMismatch,
176
+ message: "No eligible provider can stream this request.",
177
+ providerId: nil,
178
+ retryable: nil,
179
+ retryAfterMs: nil,
180
+ runId: nil,
181
+ schemaVersion: .the10
182
+ )
183
+
184
+ let encoded = try bridge.encode(error: error)
185
+ let details = try XCTUnwrap(encoded["details"] as? [String: Any])
186
+ XCTAssertEqual(details["failureCode"] as? String, "CapabilityMismatch")
187
+
188
+ let rejected = try XCTUnwrap(details["rejectedProviders"] as? [[String: Any]])
189
+ XCTAssertEqual(rejected.count, 2)
190
+ XCTAssertEqual(rejected[0]["providerId"] as? String, "apple.foundation-models")
191
+
192
+ let reasons = try XCTUnwrap(rejected[0]["reasons"] as? [[String: Any]])
193
+ XCTAssertEqual(reasons[0]["code"] as? String, "capability_unavailable")
194
+ XCTAssertEqual(
195
+ (try XCTUnwrap(rejected[1]["reasons"] as? [[String: Any]]))[0]["code"] as? String,
196
+ "privacy_constraint"
197
+ )
198
+ }
199
+
200
+ // MARK: - encode(capabilities:)
201
+
202
+ private func makeSnapshot(
203
+ providerId: String,
204
+ type: ProviderDescriptor.ProviderType,
205
+ transport: ProviderDescriptor.TransportType,
206
+ streamingStyle: ProviderDescriptor.StreamingStyle? = nil,
207
+ streaming: Bool,
208
+ cancel: ProviderDescriptor.CancelSemantics,
209
+ limits: ProviderDescriptor.ResourceLimits? = nil,
210
+ privacy: ProviderDescriptor.PrivacyDescriptor? = nil,
211
+ capabilities: ProviderDynamicCapabilities
212
+ ) -> ProviderCapabilitySnapshot {
213
+ ProviderCapabilitySnapshot(
214
+ providerId: providerId,
215
+ descriptor: ProviderDescriptor(
216
+ id: providerId,
217
+ type: type,
218
+ transport: transport,
219
+ streamingStyle: streamingStyle,
220
+ supports: ProviderDescriptor.SupportsCapabilities(
221
+ run: true,
222
+ streaming: streaming,
223
+ realtime: false,
224
+ tools: false,
225
+ reasoningEvents: false,
226
+ structuredOutput: false,
227
+ multimodal: false
228
+ ),
229
+ cancel: cancel,
230
+ tasks: ["text_to_text"],
231
+ limits: limits,
232
+ privacy: privacy
233
+ ),
234
+ capabilities: capabilities
235
+ )
236
+ }
237
+
238
+ func testEncodesCapabilitySnapshotsInsideTheProvidersEnvelope() throws {
239
+ let bridge = IndeRunCapacitorBridge()
240
+ let encoded = try bridge.encode(capabilities: [
241
+ makeSnapshot(
242
+ providerId: "openai",
243
+ type: .cloud,
244
+ transport: .http,
245
+ streamingStyle: .tokens,
246
+ streaming: true,
247
+ cancel: .hard,
248
+ limits: ProviderDescriptor.ResourceLimits(maxInputTokens: 128_000, maxOutputTokens: 4_096),
249
+ privacy: ProviderDescriptor.PrivacyDescriptor(dataLeavesDevice: true, regions: ["us"]),
250
+ capabilities: ProviderDynamicCapabilities(available: true)
251
+ )
252
+ ])
253
+
254
+ let providers = try XCTUnwrap(encoded["providers"] as? [[String: Any]])
255
+ XCTAssertEqual(providers.count, 1)
256
+ XCTAssertEqual(providers[0]["providerId"] as? String, "openai")
257
+
258
+ let descriptor = try XCTUnwrap(providers[0]["descriptor"] as? [String: Any])
259
+ XCTAssertEqual(descriptor["id"] as? String, "openai")
260
+ XCTAssertEqual(descriptor["type"] as? String, "cloud")
261
+ XCTAssertEqual(descriptor["transport"] as? String, "http")
262
+ XCTAssertEqual(descriptor["streamingStyle"] as? String, "tokens")
263
+ XCTAssertEqual(descriptor["cancel"] as? String, "hard")
264
+ XCTAssertEqual(descriptor["tasks"] as? [String], ["text_to_text"])
265
+
266
+ let supports = try XCTUnwrap(descriptor["supports"] as? [String: Any])
267
+ XCTAssertEqual(supports["run"] as? Bool, true)
268
+ XCTAssertEqual(supports["streaming"] as? Bool, true)
269
+ XCTAssertEqual(supports["multimodal"] as? Bool, false)
270
+
271
+ let limits = try XCTUnwrap(descriptor["limits"] as? [String: Any])
272
+ XCTAssertEqual(limits["maxInputTokens"] as? Int, 128_000)
273
+ XCTAssertNil(limits["maxImageBytes"])
274
+
275
+ let privacy = try XCTUnwrap(descriptor["privacy"] as? [String: Any])
276
+ XCTAssertEqual(privacy["dataLeavesDevice"] as? Bool, true)
277
+ XCTAssertEqual(privacy["regions"] as? [String], ["us"])
278
+ }
279
+
280
+ /// `in_process` is the one descriptor constant a future upstream rename to Swift
281
+ /// casing would silently corrupt on the wire, since the JS union spells it out.
282
+ func testEncodesTheInProcessTransportWithItsWireSpelling() throws {
283
+ let bridge = IndeRunCapacitorBridge()
284
+ let encoded = try bridge.encode(capabilities: [
285
+ makeSnapshot(
286
+ providerId: "local.onnx.genai.apple",
287
+ type: .local,
288
+ transport: .inProcess,
289
+ streaming: false,
290
+ cancel: .soft,
291
+ capabilities: ProviderDynamicCapabilities(available: false, reason: "No model package.")
292
+ )
293
+ ])
294
+
295
+ let providers = try XCTUnwrap(encoded["providers"] as? [[String: Any]])
296
+ let descriptor = try XCTUnwrap(providers[0]["descriptor"] as? [String: Any])
297
+ XCTAssertEqual(descriptor["transport"] as? String, "in_process")
298
+ XCTAssertNil(descriptor["streamingStyle"])
299
+
300
+ let capabilities = try XCTUnwrap(providers[0]["capabilities"] as? [String: Any])
301
+ XCTAssertEqual(capabilities["available"] as? Bool, false)
302
+ XCTAssertEqual(capabilities["reason"] as? String, "No model package.")
303
+ }
304
+
305
+ /// Absence means "inherit the static declaration". A null would be a third state
306
+ /// consumers do not have, so the unset optionals must not reach the wire at all.
307
+ func testOmitsUnsetDynamicCapabilityFlagsRatherThanEncodingNull() throws {
308
+ let bridge = IndeRunCapacitorBridge()
309
+ let encoded = try bridge.encode(capabilities: [
310
+ makeSnapshot(
311
+ providerId: "apple.foundation-models",
312
+ type: .local,
313
+ transport: .systemService,
314
+ streamingStyle: .snapshots,
315
+ streaming: true,
316
+ cancel: .soft,
317
+ capabilities: ProviderDynamicCapabilities(available: true)
318
+ )
319
+ ])
320
+
321
+ let providers = try XCTUnwrap(encoded["providers"] as? [[String: Any]])
322
+ let descriptor = try XCTUnwrap(providers[0]["descriptor"] as? [String: Any])
323
+ XCTAssertEqual(descriptor["transport"] as? String, "system_service")
324
+ XCTAssertEqual(descriptor["streamingStyle"] as? String, "snapshots")
325
+ XCTAssertNil(descriptor["limits"])
326
+ XCTAssertNil(descriptor["privacy"])
327
+
328
+ let capabilities = try XCTUnwrap(providers[0]["capabilities"] as? [String: Any])
329
+ XCTAssertNil(capabilities["streamingAvailable"])
330
+ XCTAssertNil(capabilities["streamingUnavailableReason"])
331
+ XCTAssertNil(capabilities["cancellationAvailable"])
332
+ XCTAssertNil(capabilities["reason"])
333
+ }
334
+
335
+ func testEncodesStreamingTakenAwayAtRuntime() throws {
336
+ let bridge = IndeRunCapacitorBridge()
337
+ let encoded = try bridge.encode(capabilities: [
338
+ makeSnapshot(
339
+ providerId: "openai",
340
+ type: .cloud,
341
+ transport: .http,
342
+ streamingStyle: .tokens,
343
+ streaming: true,
344
+ cancel: .hard,
345
+ capabilities: ProviderDynamicCapabilities(
346
+ available: true,
347
+ streamingAvailable: false,
348
+ streamingUnavailableReason: "Host has no streaming HTTP client.",
349
+ cancellationAvailable: true
350
+ )
351
+ )
352
+ ])
353
+
354
+ let providers = try XCTUnwrap(encoded["providers"] as? [[String: Any]])
355
+ let capabilities = try XCTUnwrap(providers[0]["capabilities"] as? [String: Any])
356
+ XCTAssertEqual(capabilities["streamingAvailable"] as? Bool, false)
357
+ XCTAssertEqual(
358
+ capabilities["streamingUnavailableReason"] as? String,
359
+ "Host has no streaming HTTP client."
360
+ )
361
+ XCTAssertEqual(capabilities["cancellationAvailable"] as? Bool, true)
362
+ }
363
+
364
+ func testEncodesAnEmptyRegistryAsAnEmptyProvidersArray() throws {
365
+ let bridge = IndeRunCapacitorBridge()
366
+ let encoded = try bridge.encode(capabilities: [])
367
+
368
+ XCTAssertEqual(try XCTUnwrap(encoded["providers"] as? [[String: Any]]).count, 0)
369
+ }
370
+
137
371
  // MARK: - mapAuthMode
138
372
 
139
373
  func testMapAuthModeNoneString() {
@@ -0,0 +1,178 @@
1
+ import XCTest
2
+ import Foundation
3
+ import IndeRunContracts
4
+ import IndeRunCore
5
+ @testable import IndeRunCapacitorPlugin
6
+
7
+ /// Encoding is what the JS side actually consumes, so these assert the exact key
8
+ /// sets rather than round-tripping: an optional that encodes as `null` instead of
9
+ /// being absent changes which union branch the consumer reconstitutes.
10
+ final class IndeRunCapacitorStreamCodecTests: XCTestCase {
11
+
12
+ private func event(
13
+ type: String,
14
+ sequence: Int = 0,
15
+ payload: Payload?,
16
+ runId: String = "run_1"
17
+ ) -> StreamEvent {
18
+ StreamEvent(
19
+ payload: payload,
20
+ runId: runId,
21
+ schemaVersion: .the10,
22
+ sequence: sequence,
23
+ timestamp: 1_700_000_000_000,
24
+ type: type
25
+ )
26
+ }
27
+
28
+ func testEncodesContentDeltaWithOnlyTheTextField() throws {
29
+ let bridge = IndeRunCapacitorBridge()
30
+ let encoded = try bridge.encode(
31
+ streamEvent: event(type: "content_delta", payload: .content(text: "hello"))
32
+ )
33
+
34
+ XCTAssertEqual(encoded["schemaVersion"] as? String, "1.0")
35
+ XCTAssertEqual(encoded["runId"] as? String, "run_1")
36
+ XCTAssertEqual(encoded["sequence"] as? Int, 0)
37
+ XCTAssertEqual(encoded["type"] as? String, "content_delta")
38
+
39
+ let payload = try XCTUnwrap(encoded["payload"] as? [String: Any])
40
+ XCTAssertEqual(payload["text"] as? String, "hello")
41
+ XCTAssertEqual(Set(payload.keys), ["text"])
42
+ }
43
+
44
+ func testEncodesTerminalCompletedOutcome() throws {
45
+ let bridge = IndeRunCapacitorBridge()
46
+ let outcome = StreamTerminalOutcome.completed(
47
+ runId: "run_1",
48
+ finalText: "all done",
49
+ finishReason: .stop,
50
+ usage: TaskResultUsage(inputTokens: 3, outputTokens: 5, totalTokens: 8),
51
+ telemetry: StreamTerminalOutcomeTelemetry(providerUsed: "openai", totalMs: 42)
52
+ )
53
+
54
+ let encoded = try bridge.encode(
55
+ streamEvent: event(type: "terminal", sequence: 4, payload: Payload(terminalOutcome: outcome))
56
+ )
57
+ let payload = try XCTUnwrap(encoded["payload"] as? [String: Any])
58
+
59
+ XCTAssertEqual(payload["outcome"] as? String, "completed")
60
+ XCTAssertEqual(payload["finalText"] as? String, "all done")
61
+ XCTAssertEqual(payload["finishReason"] as? String, "stop")
62
+ XCTAssertEqual((payload["usage"] as? [String: Any])?["totalTokens"] as? Int, 8)
63
+ XCTAssertEqual((payload["telemetry"] as? [String: Any])?["providerUsed"] as? String, "openai")
64
+ // The error/cancellation branches' fields must not leak into a completion.
65
+ XCTAssertNil(payload["error"])
66
+ XCTAssertNil(payload["partialText"])
67
+ XCTAssertNil(payload["reason"])
68
+ }
69
+
70
+ /// Terminal errors nest deeper than anything `run()` produces, which is the case
71
+ /// that would break `encodeObject`'s `as? JSObject` cast if a nil ever encoded as
72
+ /// NSNull.
73
+ func testEncodesTerminalErrorOutcomeWithNestedDetails() throws {
74
+ let bridge = IndeRunCapacitorBridge()
75
+ let error = IndeRunError(
76
+ details: ["endpoint": JSONAny("https://example.test")],
77
+ errorClass: .RateLimited,
78
+ message: "Too many requests.",
79
+ providerId: "openai",
80
+ retryable: true,
81
+ retryAfterMs: 2_000,
82
+ runId: "run_1",
83
+ schemaVersion: .the10
84
+ )
85
+ let outcome = StreamTerminalOutcome.failed(runId: "run_1", error: error, partialText: "half ")
86
+
87
+ let encoded = try bridge.encode(
88
+ streamEvent: event(type: "terminal", sequence: 7, payload: Payload(terminalOutcome: outcome))
89
+ )
90
+ let payload = try XCTUnwrap(encoded["payload"] as? [String: Any])
91
+ let encodedError = try XCTUnwrap(payload["error"] as? [String: Any])
92
+
93
+ XCTAssertEqual(payload["outcome"] as? String, "error")
94
+ XCTAssertEqual(payload["partialText"] as? String, "half ")
95
+ XCTAssertEqual(encodedError["errorClass"] as? String, "RateLimited")
96
+ XCTAssertEqual(encodedError["retryAfterMs"] as? Int, 2_000)
97
+ XCTAssertEqual((encodedError["details"] as? [String: Any])?["endpoint"] as? String,
98
+ "https://example.test")
99
+ }
100
+
101
+ func testEncodesTerminalCancelledOutcomeWithAndWithoutReason() throws {
102
+ let bridge = IndeRunCapacitorBridge()
103
+
104
+ let withReason = try bridge.encode(
105
+ streamEvent: event(
106
+ type: "terminal",
107
+ payload: Payload(
108
+ terminalOutcome: .cancelled(runId: "run_1", partialText: "part", reason: "user left")
109
+ )
110
+ )
111
+ )
112
+ XCTAssertEqual((withReason["payload"] as? [String: Any])?["reason"] as? String, "user left")
113
+
114
+ let withoutReason = try bridge.encode(
115
+ streamEvent: event(
116
+ type: "terminal",
117
+ payload: Payload(
118
+ terminalOutcome: .cancelled(runId: "run_1", partialText: "part", reason: nil)
119
+ )
120
+ )
121
+ )
122
+ let payload = try XCTUnwrap(withoutReason["payload"] as? [String: Any])
123
+ XCTAssertEqual(payload["outcome"] as? String, "cancelled")
124
+ XCTAssertEqual(payload["partialText"] as? String, "part")
125
+ // Absent, not null: a null would decode as a present-but-empty reason.
126
+ XCTAssertFalse(payload.keys.contains("reason"))
127
+ }
128
+
129
+ func testEncodesLifecyclePhase() throws {
130
+ let bridge = IndeRunCapacitorBridge()
131
+ let payload = Payload(
132
+ text: nil, phase: .providerSelected, finalText: nil, finishReason: nil, outcome: nil,
133
+ runId: nil, schemaVersion: nil, telemetry: nil, usage: nil, error: nil,
134
+ partialText: nil, reason: nil
135
+ )
136
+
137
+ let encoded = try bridge.encode(streamEvent: event(type: "lifecycle", payload: payload))
138
+ XCTAssertEqual((encoded["payload"] as? [String: Any])?["phase"] as? String, "provider_selected")
139
+ }
140
+
141
+ /// The schema closes its union with an open catch-all branch; an unrecognized
142
+ /// type must cross the bridge untouched rather than be rejected.
143
+ func testEncodesAnUnknownEventTypeVerbatimWithoutPayload() throws {
144
+ let bridge = IndeRunCapacitorBridge()
145
+ let encoded = try bridge.encode(
146
+ streamEvent: event(type: "some_future_type", sequence: 2, payload: nil)
147
+ )
148
+
149
+ XCTAssertEqual(encoded["type"] as? String, "some_future_type")
150
+ XCTAssertFalse(encoded.keys.contains("payload"))
151
+ }
152
+
153
+ func testEncodesStreamRunHandleWithAndWithoutProviderId() throws {
154
+ let bridge = IndeRunCapacitorBridge()
155
+
156
+ let withProvider = try bridge.encode(
157
+ handle: StreamRunHandle(
158
+ providerId: "openai",
159
+ runId: "run_1",
160
+ schemaVersion: .the10,
161
+ startedAt: 1_700_000_000_000
162
+ )
163
+ )
164
+ XCTAssertEqual(withProvider["providerId"] as? String, "openai")
165
+ XCTAssertEqual(withProvider["runId"] as? String, "run_1")
166
+ XCTAssertEqual(withProvider["startedAt"] as? Double, 1_700_000_000_000)
167
+
168
+ let withoutProvider = try bridge.encode(
169
+ handle: StreamRunHandle(
170
+ providerId: nil,
171
+ runId: "run_1",
172
+ schemaVersion: .the10,
173
+ startedAt: 1_700_000_000_000
174
+ )
175
+ )
176
+ XCTAssertFalse(withoutProvider.keys.contains("providerId"))
177
+ }
178
+ }
@@ -0,0 +1,215 @@
1
+ import XCTest
2
+ import Foundation
3
+ import IndeRunContracts
4
+ import IndeRunCore
5
+ @testable import IndeRunCapacitorPlugin
6
+
7
+ /// Collects what the pump hands to its sinks.
8
+ private final class SinkRecorder: @unchecked Sendable {
9
+ private let lock = NSLock()
10
+ private var events: [JSObject] = []
11
+ private var errors: [JSObject] = []
12
+
13
+ func recordEvent(_ event: JSObject) {
14
+ lock.lock()
15
+ defer { lock.unlock() }
16
+ events.append(event)
17
+ }
18
+
19
+ func recordError(_ error: JSObject) {
20
+ lock.lock()
21
+ defer { lock.unlock() }
22
+ errors.append(error)
23
+ }
24
+
25
+ var eventTypes: [String] {
26
+ lock.lock()
27
+ defer { lock.unlock() }
28
+ return events.compactMap { $0["type"] as? String }
29
+ }
30
+
31
+ var eventSequences: [Int] {
32
+ lock.lock()
33
+ defer { lock.unlock() }
34
+ return events.compactMap { $0["sequence"] as? Int }
35
+ }
36
+
37
+ var errorClasses: [String] {
38
+ lock.lock()
39
+ defer { lock.unlock() }
40
+ return errors.compactMap { $0["errorClass"] as? String }
41
+ }
42
+ }
43
+
44
+ final class IndeRunCapacitorStreamPumpTests: XCTestCase {
45
+
46
+ private func event(_ sequence: Int, type: String = "content_delta") -> StreamEvent {
47
+ StreamEvent(
48
+ payload: .content(text: "chunk\(sequence)"),
49
+ runId: "run_1",
50
+ schemaVersion: .the10,
51
+ sequence: sequence,
52
+ timestamp: 1_700_000_000_000,
53
+ type: type
54
+ )
55
+ }
56
+
57
+ private func cancelledTerminal(_ sequence: Int) -> StreamEvent {
58
+ StreamEvent(
59
+ payload: Payload(
60
+ terminalOutcome: .cancelled(runId: "run_1", partialText: "part", reason: "stopped")
61
+ ),
62
+ runId: "run_1",
63
+ schemaVersion: .the10,
64
+ sequence: sequence,
65
+ timestamp: 1_700_000_000_000,
66
+ type: "terminal"
67
+ )
68
+ }
69
+
70
+ private func makeRun(
71
+ continuation: inout AsyncThrowingStream<StreamEvent, Error>.Continuation?,
72
+ cancel: @escaping @Sendable (String?) -> Void = { _ in }
73
+ ) -> StreamRun {
74
+ let (events, made) = AsyncThrowingStream<StreamEvent, Error>.makeStream()
75
+ continuation = made
76
+ return StreamRun(
77
+ handle: StreamRunHandle(
78
+ providerId: nil,
79
+ runId: "run_1",
80
+ schemaVersion: .the10,
81
+ startedAt: 1_700_000_000_000
82
+ ),
83
+ events: events,
84
+ cancel: cancel
85
+ )
86
+ }
87
+
88
+ private func pump(_ bridge: IndeRunCapacitorBridge, _ run: StreamRun, _ recorder: SinkRecorder) -> Task<Void, Never> {
89
+ Task {
90
+ await bridge.pump(
91
+ streamId: "s1",
92
+ run: run,
93
+ onEvent: { _, event in recorder.recordEvent(event) },
94
+ onError: { _, error in recorder.recordError(error) }
95
+ )
96
+ }
97
+ }
98
+
99
+ func testForwardsEveryEventInOrderAndFinishesTheRegistryEntry() async {
100
+ let bridge = IndeRunCapacitorBridge()
101
+ let recorder = SinkRecorder()
102
+ var continuation: AsyncThrowingStream<StreamEvent, Error>.Continuation?
103
+ let run = makeRun(continuation: &continuation)
104
+
105
+ bridge.streams.open(streamId: "s1")
106
+ bridge.streams.attach(streamId: "s1", run: run)
107
+ let task = pump(bridge, run, recorder)
108
+
109
+ continuation?.yield(event(0))
110
+ continuation?.yield(event(1))
111
+ continuation?.finish()
112
+ await task.value
113
+
114
+ XCTAssertEqual(recorder.eventSequences, [0, 1])
115
+ XCTAssertTrue(recorder.errorClasses.isEmpty)
116
+ XCTAssertEqual(bridge.streams.activeCount, 0)
117
+ }
118
+
119
+ /// A provider failure is already a terminal event by the time it reaches the
120
+ /// pump; anything actually thrown out of the sequence is a bridge fault.
121
+ func testMapsAThrownStreamErrorToASingleNormalizedErrorSink() async {
122
+ let bridge = IndeRunCapacitorBridge()
123
+ let recorder = SinkRecorder()
124
+ var continuation: AsyncThrowingStream<StreamEvent, Error>.Continuation?
125
+ let run = makeRun(continuation: &continuation)
126
+
127
+ bridge.streams.open(streamId: "s1")
128
+ let task = pump(bridge, run, recorder)
129
+
130
+ continuation?.yield(event(0))
131
+ continuation?.finish(
132
+ throwing: IndeRunException(errorClass: .Internal, message: "pump blew up")
133
+ )
134
+ await task.value
135
+
136
+ XCTAssertEqual(recorder.eventSequences, [0])
137
+ XCTAssertEqual(recorder.errorClasses, ["Internal"])
138
+ XCTAssertEqual(bridge.streams.activeCount, 0)
139
+ }
140
+
141
+ /// Cancelling a run must not stop the pump: the engine answers a cancel with its
142
+ /// one `cancelled` terminal, and that still has to be delivered.
143
+ func testCancelDuringEmitStillForwardsTheCancelledTerminal() async {
144
+ let bridge = IndeRunCapacitorBridge()
145
+ let recorder = SinkRecorder()
146
+ var continuation: AsyncThrowingStream<StreamEvent, Error>.Continuation?
147
+
148
+ // Stand in for the engine: a cancel produces the cancelled terminal and ends
149
+ // the sequence.
150
+ nonisolated(unsafe) var capture: AsyncThrowingStream<StreamEvent, Error>.Continuation?
151
+ let run = makeRun(continuation: &continuation, cancel: { _ in
152
+ capture?.yield(
153
+ StreamEvent(
154
+ payload: Payload(
155
+ terminalOutcome: .cancelled(runId: "run_1", partialText: "chunk0", reason: "stopped")
156
+ ),
157
+ runId: "run_1",
158
+ schemaVersion: .the10,
159
+ sequence: 1,
160
+ timestamp: 1_700_000_000_000,
161
+ type: "terminal"
162
+ )
163
+ )
164
+ capture?.finish()
165
+ })
166
+ capture = continuation
167
+
168
+ bridge.streams.open(streamId: "s1")
169
+ bridge.streams.attach(streamId: "s1", run: run)
170
+ let task = pump(bridge, run, recorder)
171
+
172
+ continuation?.yield(event(0))
173
+ bridge.streams.requestCancel(streamId: "s1", reason: "stopped")
174
+ await task.value
175
+
176
+ XCTAssertEqual(recorder.eventTypes, ["content_delta", "terminal"])
177
+ XCTAssertTrue(recorder.errorClasses.isEmpty)
178
+ }
179
+
180
+ /// Teardown cancels the pump task itself. That is our own doing, with no one left
181
+ /// to receive anything, so it must not be reported as a bridge failure.
182
+ func testOwnTaskCancellationProducesNoErrorSink() async {
183
+ let bridge = IndeRunCapacitorBridge()
184
+ let recorder = SinkRecorder()
185
+ var continuation: AsyncThrowingStream<StreamEvent, Error>.Continuation?
186
+ let run = makeRun(continuation: &continuation)
187
+
188
+ bridge.streams.open(streamId: "s1")
189
+ let task = pump(bridge, run, recorder)
190
+
191
+ continuation?.yield(event(0))
192
+ continuation?.finish(throwing: CancellationError())
193
+ await task.value
194
+
195
+ XCTAssertEqual(recorder.eventSequences, [0])
196
+ XCTAssertTrue(recorder.errorClasses.isEmpty)
197
+ }
198
+
199
+ func testForwardsAnUnknownEventTypeWithoutTreatingItAsTerminal() async {
200
+ let bridge = IndeRunCapacitorBridge()
201
+ let recorder = SinkRecorder()
202
+ var continuation: AsyncThrowingStream<StreamEvent, Error>.Continuation?
203
+ let run = makeRun(continuation: &continuation)
204
+
205
+ bridge.streams.open(streamId: "s1")
206
+ let task = pump(bridge, run, recorder)
207
+
208
+ continuation?.yield(event(0, type: "some_future_type"))
209
+ continuation?.yield(cancelledTerminal(1))
210
+ continuation?.finish()
211
+ await task.value
212
+
213
+ XCTAssertEqual(recorder.eventTypes, ["some_future_type", "terminal"])
214
+ }
215
+ }