@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.
- package/Package.swift +19 -2
- package/README.md +190 -13
- package/android/build.gradle.kts +12 -6
- package/android/settings.gradle +7 -0
- package/android/src/main/kotlin/app/independo/inderun/capacitor/IndeRunCapacitorPlugin.kt +154 -0
- package/android/src/main/kotlin/app/independo/inderun/capacitor/IndeRunSerializer.kt +203 -1
- package/android/src/main/kotlin/app/independo/inderun/capacitor/IndeRunStreamRegistry.kt +118 -0
- package/android/src/test/kotlin/app/independo/inderun/capacitor/IndeRunSerializerCapabilitiesTest.kt +253 -0
- package/android/src/test/kotlin/app/independo/inderun/capacitor/IndeRunSerializerStreamTest.kt +226 -0
- package/android/src/test/kotlin/app/independo/inderun/capacitor/IndeRunSerializerTest.kt +79 -0
- package/android/src/test/kotlin/app/independo/inderun/capacitor/IndeRunStreamRegistryTest.kt +163 -0
- package/dist/definitions.d.ts +175 -1
- package/dist/definitions.d.ts.map +1 -1
- package/dist/errors.d.ts +19 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/errors.js +36 -0
- package/dist/index.d.ts +17 -4
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +44 -13
- package/dist/streaming.d.ts +17 -0
- package/dist/streaming.d.ts.map +1 -0
- package/dist/streaming.js +303 -0
- package/dist/web.d.ts +18 -2
- package/dist/web.d.ts.map +1 -1
- package/dist/web.js +136 -7
- package/ios/Sources/IndeRunCapacitorPlugin/IndeRunCapacitorBridge.swift +181 -0
- package/ios/Sources/IndeRunCapacitorPlugin/IndeRunCapacitorPlugin.swift +93 -3
- package/ios/Sources/IndeRunCapacitorPlugin/IndeRunCapacitorStreamRegistry.swift +121 -0
- package/ios/Tests/IndeRunCapacitorTests/IndeRunCapacitorBridgeTests.swift +234 -0
- package/ios/Tests/IndeRunCapacitorTests/IndeRunCapacitorStreamCodecTests.swift +178 -0
- package/ios/Tests/IndeRunCapacitorTests/IndeRunCapacitorStreamPumpTests.swift +215 -0
- package/ios/Tests/IndeRunCapacitorTests/IndeRunCapacitorStreamRegistryTests.swift +154 -0
- 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
|
+
}
|