@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
@@ -8,9 +8,17 @@ import app.independo.inderun.contracts.IndeRunErrorClass
8
8
  import app.independo.inderun.contracts.Message
9
9
  import app.independo.inderun.contracts.MessageRole
10
10
  import app.independo.inderun.contracts.OptimizeFor
11
+ import app.independo.inderun.contracts.Outcome
11
12
  import app.independo.inderun.contracts.OutputType
13
+ import app.independo.inderun.contracts.Payload
14
+ import app.independo.inderun.contracts.PayloadError
15
+ import app.independo.inderun.contracts.PayloadTelemetry
16
+ import app.independo.inderun.contracts.PayloadUsage
17
+ import app.independo.inderun.contracts.Phase
12
18
  import app.independo.inderun.contracts.PrivacyEnum
13
19
  import app.independo.inderun.contracts.SchemaVersion
20
+ import app.independo.inderun.contracts.StreamEvent
21
+ import app.independo.inderun.contracts.StreamRunHandle
14
22
  import app.independo.inderun.contracts.TaskKind
15
23
  import app.independo.inderun.contracts.TaskRequest
16
24
  import app.independo.inderun.contracts.TaskRequestConstraints
@@ -20,7 +28,9 @@ import app.independo.inderun.contracts.TaskRequestTelemetry
20
28
  import app.independo.inderun.contracts.TaskResult
21
29
  import app.independo.inderun.contracts.TaskResultTelemetry
22
30
  import app.independo.inderun.contracts.TelemetryLevel
23
- import app.independo.inderun.contracts.Usage
31
+ import app.independo.inderun.core.ProviderCapabilitySnapshot
32
+ import app.independo.inderun.core.ProviderDescriptor
33
+ import app.independo.inderun.core.ProviderDynamicCapabilities
24
34
  import com.getcapacitor.JSArray
25
35
  import com.getcapacitor.JSObject
26
36
  import org.json.JSONArray
@@ -106,6 +116,198 @@ object IndeRunSerializer {
106
116
  }
107
117
  }
108
118
 
119
+ /**
120
+ * Wire encoding for `checkCapabilities()`.
121
+ *
122
+ * Note the enums are written with `.name`, which is the opposite of what
123
+ * [encodeOutcome] and [encodePhase] below have to do. Those contract enums are
124
+ * generated without raw values, so their Kotlin constant names are camelCase and the
125
+ * snake_case wire spelling has to be written by hand. `ProviderDescriptor`'s enums are
126
+ * declared with the wire spelling *as* the constant name (`in_process`,
127
+ * `system_service`), so `.name` already is the wire value — and writing them by hand
128
+ * here would be the thing that eventually drifts.
129
+ *
130
+ * The array is wrapped in a `providers` object because a Capacitor `PluginCall` cannot
131
+ * resolve a top-level array. `IndeRunCapacitor` unwraps it on the JS side.
132
+ */
133
+ fun encodeCapabilitySnapshots(snapshots: List<ProviderCapabilitySnapshot>): JSObject {
134
+ return JSObject().apply {
135
+ put(
136
+ "providers",
137
+ JSArray().apply {
138
+ snapshots.forEach { snapshot ->
139
+ put(
140
+ JSObject().apply {
141
+ put("providerId", snapshot.providerId)
142
+ put("descriptor", encodeProviderDescriptor(snapshot.descriptor))
143
+ put("capabilities", encodeProviderDynamicCapabilities(snapshot.capabilities))
144
+ }
145
+ )
146
+ }
147
+ }
148
+ )
149
+ }
150
+ }
151
+
152
+ private fun encodeProviderDescriptor(descriptor: ProviderDescriptor): JSObject {
153
+ return JSObject().apply {
154
+ put("id", descriptor.id)
155
+ put("type", descriptor.type.name)
156
+ put("transport", descriptor.transport.name)
157
+ descriptor.streamingStyle?.let { put("streamingStyle", it.name) }
158
+ put(
159
+ "supports",
160
+ JSObject().apply {
161
+ put("run", descriptor.supports.run)
162
+ put("streaming", descriptor.supports.streaming)
163
+ put("realtime", descriptor.supports.realtime)
164
+ put("tools", descriptor.supports.tools)
165
+ put("reasoningEvents", descriptor.supports.reasoningEvents)
166
+ put("structuredOutput", descriptor.supports.structuredOutput)
167
+ put("multimodal", descriptor.supports.multimodal)
168
+ }
169
+ )
170
+ put("cancel", descriptor.cancel.name)
171
+ put("tasks", JSArray().apply { descriptor.tasks.forEach { put(it) } })
172
+ descriptor.limits?.let { limits ->
173
+ put(
174
+ "limits",
175
+ JSObject().apply {
176
+ limits.maxInputTokens?.let { put("maxInputTokens", it) }
177
+ limits.maxOutputTokens?.let { put("maxOutputTokens", it) }
178
+ limits.maxImageBytes?.let { put("maxImageBytes", it) }
179
+ limits.maxAudioSeconds?.let { put("maxAudioSeconds", it) }
180
+ }
181
+ )
182
+ }
183
+ descriptor.privacy?.let { privacy ->
184
+ put(
185
+ "privacy",
186
+ JSObject().apply {
187
+ put("dataLeavesDevice", privacy.dataLeavesDevice)
188
+ privacy.regions?.let { regions ->
189
+ put("regions", JSArray().apply { regions.forEach { put(it) } })
190
+ }
191
+ }
192
+ )
193
+ }
194
+ }
195
+ }
196
+
197
+ /**
198
+ * The three nullable flags are **omitted** rather than written as null. Absence is the
199
+ * contract's "inherit the static declaration" state — `descriptor.supports.streaming`
200
+ * for streaming, `descriptor.cancel != none` for cancellation — and a null would be a
201
+ * third state no consumer has.
202
+ */
203
+ private fun encodeProviderDynamicCapabilities(capabilities: ProviderDynamicCapabilities): JSObject {
204
+ return JSObject().apply {
205
+ put("available", capabilities.available)
206
+ capabilities.reason?.let { put("reason", it) }
207
+ capabilities.streamingAvailable?.let { put("streamingAvailable", it) }
208
+ capabilities.streamingUnavailableReason?.let { put("streamingUnavailableReason", it) }
209
+ capabilities.cancellationAvailable?.let { put("cancellationAvailable", it) }
210
+ }
211
+ }
212
+
213
+ fun encodeStreamRunHandle(handle: StreamRunHandle): JSObject {
214
+ return JSObject().apply {
215
+ put("schemaVersion", handle.schemaVersion.rawValue)
216
+ put("runId", handle.runId)
217
+ put("startedAt", handle.startedAt)
218
+ handle.providerId?.let { put("providerId", it) }
219
+ }
220
+ }
221
+
222
+ /**
223
+ * Note what is deliberately *not* here: a `when` over [StreamEvent.type].
224
+ *
225
+ * The inbound parsers in this file throw on an unrecognized enum value, and that is
226
+ * right for them — those are closed contract enums on a request, where an unknown
227
+ * value really is an invalid request. `type` is the opposite: stream-event.schema.json
228
+ * closes its union with an explicit catch-all branch, and contracts/README.md requires
229
+ * SDKs to treat an unrecognized type as ignore-or-pass-through rather than an error, so
230
+ * that an additive minor revision does not break older consumers. Copying the
231
+ * throw-on-unknown pattern here would turn the one mechanism protecting forward
232
+ * compatibility into a guaranteed hard failure at the bridge hop. The type crosses as
233
+ * the string it is.
234
+ */
235
+ fun encodeStreamEvent(event: StreamEvent): JSObject {
236
+ return JSObject().apply {
237
+ put("schemaVersion", event.schemaVersion.rawValue)
238
+ put("runId", event.runId)
239
+ put("sequence", event.sequence)
240
+ put("timestamp", event.timestamp)
241
+ put("type", event.type)
242
+ event.payload?.let { put("payload", encodeStreamPayload(it)) }
243
+ }
244
+ }
245
+
246
+ /**
247
+ * quicktype flattens the event union, so [Payload] carries every branch's fields as
248
+ * optionals. Emitting only the non-null ones is what reconstitutes the correct branch
249
+ * on the JS side: each branch's `outcome` discriminator plus its required peer field
250
+ * (finalText / error / partialText) is exactly the set that is populated.
251
+ */
252
+ private fun encodeStreamPayload(payload: Payload): JSObject {
253
+ return JSObject().apply {
254
+ payload.text?.let { put("text", it) }
255
+ payload.phase?.let { put("phase", phaseValue(it)) }
256
+ payload.finalText?.let { put("finalText", it) }
257
+ payload.finishReason?.let { put("finishReason", it.rawValue) }
258
+ payload.outcome?.let { put("outcome", outcomeValue(it)) }
259
+ payload.runId?.let { put("runId", it) }
260
+ payload.schemaVersion?.let { put("schemaVersion", it.rawValue) }
261
+ payload.telemetry?.let { put("telemetry", encodeStreamPayloadTelemetry(it)) }
262
+ payload.usage?.let { put("usage", encodeStreamPayloadUsage(it)) }
263
+ payload.error?.let { put("error", encodeStreamPayloadError(it)) }
264
+ payload.partialText?.let { put("partialText", it) }
265
+ payload.reason?.let { put("reason", it) }
266
+ }
267
+ }
268
+
269
+ private fun encodeStreamPayloadError(error: PayloadError): JSObject {
270
+ return JSObject().apply {
271
+ put("schemaVersion", error.schemaVersion.rawValue)
272
+ put("errorClass", error.errorClass.rawValue)
273
+ put("message", error.message)
274
+ error.providerId?.let { put("providerId", it) }
275
+ error.retryable?.let { put("retryable", it) }
276
+ error.retryAfterMs?.let { put("retryAfterMs", it) }
277
+ error.details?.let { put("details", JSONObject(it)) }
278
+ }
279
+ }
280
+
281
+ private fun encodeStreamPayloadTelemetry(telemetry: PayloadTelemetry): JSObject {
282
+ return JSObject().apply {
283
+ put("providerUsed", telemetry.providerUsed)
284
+ put("totalMs", telemetry.totalMs)
285
+ }
286
+ }
287
+
288
+ private fun encodeStreamPayloadUsage(usage: PayloadUsage): JSObject {
289
+ return JSObject().apply {
290
+ usage.inputTokens?.let { put("inputTokens", it) }
291
+ usage.outputTokens?.let { put("outputTokens", it) }
292
+ usage.totalTokens?.let { put("totalTokens", it) }
293
+ }
294
+ }
295
+
296
+ // Outcome and Phase are generated without a rawValue, unlike every other contract enum
297
+ // here, so the wire strings have to be written out. Both `when`s are exhaustive with no
298
+ // `else`: if a regeneration ever adds a constant, this must fail to compile rather than
299
+ // silently serialize the wrong discriminator.
300
+ private fun outcomeValue(outcome: Outcome): String = when (outcome) {
301
+ Outcome.Completed -> "completed"
302
+ Outcome.Error -> "error"
303
+ Outcome.Cancelled -> "cancelled"
304
+ }
305
+
306
+ private fun phaseValue(phase: Phase): String = when (phase) {
307
+ Phase.ProviderSelected -> "provider_selected"
308
+ Phase.Started -> "started"
309
+ }
310
+
109
311
  private fun parseOpenAIOptions(json: JSONObject): OpenAIProviderBootstrapOptions {
110
312
  return OpenAIProviderBootstrapOptions(
111
313
  model = json.getString("model"),
@@ -0,0 +1,118 @@
1
+ package app.independo.inderun.capacitor
2
+
3
+ import app.independo.inderun.core.StreamRun
4
+ import kotlinx.coroutines.Job
5
+
6
+ /**
7
+ * The outcome of attaching a freshly started run to its reserved id.
8
+ */
9
+ internal sealed interface AttachOutcome {
10
+ data object Attached : AttachOutcome
11
+
12
+ /** A cancel arrived before the run did; the caller must apply it. */
13
+ data class CancelRequested(val reason: String?) : AttachOutcome
14
+ }
15
+
16
+ /**
17
+ * Tracks the streaming runs the plugin is currently collecting, keyed by the
18
+ * bridge-local `streamId`.
19
+ *
20
+ * Two things make this more than a map. A cancel can arrive before the run it refers
21
+ * to exists — `startStream` has to reach route selection first — so a cancel in that
22
+ * window is recorded and applied on attach. And cancelling a run is *not* cancelling
23
+ * the collecting job: the engine answers a cancel with its one `cancelled` terminal
24
+ * event, which the collector still has to deliver. Job cancellation is reserved for
25
+ * teardown, where the webview is going away and nobody is left to receive a terminal.
26
+ *
27
+ * Deliberately free of any Capacitor dependency, so it is exercised by plain JVM unit
28
+ * tests.
29
+ */
30
+ internal class IndeRunStreamRegistry {
31
+ private class Entry {
32
+ var run: StreamRun? = null
33
+ var job: Job? = null
34
+ var cancelRequested: Boolean = false
35
+ var cancelReason: String? = null
36
+ }
37
+
38
+ private val lock = Any()
39
+ private val entries = mutableMapOf<String, Entry>()
40
+
41
+ val activeCount: Int
42
+ get() = synchronized(lock) { entries.size }
43
+
44
+ /** Reserves the id so a cancel arriving before the run is recorded, not dropped. */
45
+ fun open(streamId: String) {
46
+ synchronized(lock) {
47
+ entries.getOrPut(streamId) { Entry() }
48
+ }
49
+ }
50
+
51
+ fun attach(streamId: String, run: StreamRun): AttachOutcome {
52
+ synchronized(lock) {
53
+ // Torn down while the engine was still selecting a route.
54
+ val entry = entries[streamId] ?: return AttachOutcome.CancelRequested(null)
55
+ if (entry.cancelRequested) {
56
+ return AttachOutcome.CancelRequested(entry.cancelReason)
57
+ }
58
+ entry.run = run
59
+ return AttachOutcome.Attached
60
+ }
61
+ }
62
+
63
+ fun attachJob(streamId: String, job: Job) {
64
+ val orphaned = synchronized(lock) {
65
+ val entry = entries[streamId]
66
+ if (entry == null) {
67
+ true
68
+ } else {
69
+ entry.job = job
70
+ false
71
+ }
72
+ }
73
+ if (orphaned) {
74
+ job.cancel()
75
+ }
76
+ }
77
+
78
+ /**
79
+ * Cancels the run if it has attached, otherwise records the request for attach. An
80
+ * unknown id — already finished, or never started — is a silent no-op, which is what
81
+ * makes cancelling after the terminal harmless.
82
+ */
83
+ fun requestCancel(streamId: String, reason: String?) {
84
+ val run = synchronized(lock) {
85
+ val entry = entries[streamId] ?: return
86
+ if (entry.cancelRequested) return
87
+ entry.cancelRequested = true
88
+ entry.cancelReason = reason
89
+ entry.run
90
+ }
91
+
92
+ // Never under the lock: cancel reaches into engine code.
93
+ run?.cancel(reason)
94
+ }
95
+
96
+ fun close(streamId: String) {
97
+ synchronized(lock) {
98
+ entries.remove(streamId)
99
+ }
100
+ }
101
+
102
+ /**
103
+ * Teardown. Cancels each run first so providers unwind, then the collecting jobs,
104
+ * which at this point have nobody left to deliver to.
105
+ */
106
+ fun cancelAll(reason: String?) {
107
+ val draining = synchronized(lock) {
108
+ val copy = entries.values.toList()
109
+ entries.clear()
110
+ copy
111
+ }
112
+
113
+ for (entry in draining) {
114
+ entry.run?.cancel(reason)
115
+ entry.job?.cancel()
116
+ }
117
+ }
118
+ }
@@ -0,0 +1,253 @@
1
+ package app.independo.inderun.capacitor
2
+
3
+ import app.independo.inderun.core.ProviderCapabilitySnapshot
4
+ import app.independo.inderun.core.ProviderDescriptor
5
+ import app.independo.inderun.core.ProviderDynamicCapabilities
6
+ import org.junit.Assert.assertEquals
7
+ import org.junit.Assert.assertFalse
8
+ import org.junit.Assert.assertTrue
9
+ import org.junit.Test
10
+
11
+ class IndeRunSerializerCapabilitiesTest {
12
+
13
+ private fun snapshot(
14
+ providerId: String,
15
+ type: ProviderDescriptor.ProviderType,
16
+ transport: ProviderDescriptor.TransportType,
17
+ streamingStyle: ProviderDescriptor.StreamingStyle? = null,
18
+ streaming: Boolean,
19
+ cancel: ProviderDescriptor.CancelSemantics,
20
+ limits: ProviderDescriptor.ResourceLimits? = null,
21
+ privacy: ProviderDescriptor.PrivacyDescriptor? = null,
22
+ capabilities: ProviderDynamicCapabilities
23
+ ): ProviderCapabilitySnapshot = ProviderCapabilitySnapshot(
24
+ providerId = providerId,
25
+ descriptor = ProviderDescriptor(
26
+ id = providerId,
27
+ type = type,
28
+ transport = transport,
29
+ streamingStyle = streamingStyle,
30
+ supports = ProviderDescriptor.SupportsCapabilities(
31
+ run = true,
32
+ streaming = streaming,
33
+ realtime = false,
34
+ tools = false,
35
+ reasoningEvents = false,
36
+ structuredOutput = false,
37
+ multimodal = false
38
+ ),
39
+ cancel = cancel,
40
+ tasks = listOf("text_to_text"),
41
+ limits = limits,
42
+ privacy = privacy
43
+ ),
44
+ capabilities = capabilities
45
+ )
46
+
47
+ @Test
48
+ fun `encodeCapabilitySnapshots wraps the snapshots in a providers array`() {
49
+ val encoded = IndeRunSerializer.encodeCapabilitySnapshots(
50
+ listOf(
51
+ snapshot(
52
+ providerId = "openai",
53
+ type = ProviderDescriptor.ProviderType.cloud,
54
+ transport = ProviderDescriptor.TransportType.http,
55
+ streamingStyle = ProviderDescriptor.StreamingStyle.tokens,
56
+ streaming = true,
57
+ cancel = ProviderDescriptor.CancelSemantics.hard,
58
+ limits = ProviderDescriptor.ResourceLimits(
59
+ maxInputTokens = 128_000,
60
+ maxOutputTokens = 4_096
61
+ ),
62
+ privacy = ProviderDescriptor.PrivacyDescriptor(
63
+ dataLeavesDevice = true,
64
+ regions = listOf("us")
65
+ ),
66
+ capabilities = ProviderDynamicCapabilities(available = true)
67
+ )
68
+ )
69
+ )
70
+
71
+ val providers = encoded.getJSONArray("providers")
72
+ assertEquals(1, providers.length())
73
+
74
+ val provider = providers.getJSONObject(0)
75
+ assertEquals("openai", provider.getString("providerId"))
76
+
77
+ val descriptor = provider.getJSONObject("descriptor")
78
+ assertEquals("openai", descriptor.getString("id"))
79
+ assertEquals("cloud", descriptor.getString("type"))
80
+ assertEquals("http", descriptor.getString("transport"))
81
+ assertEquals("tokens", descriptor.getString("streamingStyle"))
82
+ assertEquals("hard", descriptor.getString("cancel"))
83
+ assertEquals(1, descriptor.getJSONArray("tasks").length())
84
+ assertEquals("text_to_text", descriptor.getJSONArray("tasks").getString(0))
85
+
86
+ val supports = descriptor.getJSONObject("supports")
87
+ assertTrue(supports.getBoolean("run"))
88
+ assertTrue(supports.getBoolean("streaming"))
89
+ assertFalse(supports.getBoolean("realtime"))
90
+ assertFalse(supports.getBoolean("tools"))
91
+ assertFalse(supports.getBoolean("reasoningEvents"))
92
+ assertFalse(supports.getBoolean("structuredOutput"))
93
+ assertFalse(supports.getBoolean("multimodal"))
94
+
95
+ val limits = descriptor.getJSONObject("limits")
96
+ assertEquals(128_000, limits.getInt("maxInputTokens"))
97
+ assertEquals(4_096, limits.getInt("maxOutputTokens"))
98
+ assertFalse(limits.has("maxImageBytes"))
99
+ assertFalse(limits.has("maxAudioSeconds"))
100
+
101
+ val privacy = descriptor.getJSONObject("privacy")
102
+ assertTrue(privacy.getBoolean("dataLeavesDevice"))
103
+ assertEquals("us", privacy.getJSONArray("regions").getString(0))
104
+
105
+ assertTrue(provider.getJSONObject("capabilities").getBoolean("available"))
106
+ }
107
+
108
+ /**
109
+ * `in_process` and `system_service` are the two descriptor constants a future upstream
110
+ * rename to camelCase would silently corrupt on the wire, because the TypeScript union
111
+ * in src/definitions.ts spells them out. Asserting the literals is the guard.
112
+ */
113
+ @Test
114
+ fun `encodeCapabilitySnapshots writes snake case transport constants verbatim`() {
115
+ val encoded = IndeRunSerializer.encodeCapabilitySnapshots(
116
+ listOf(
117
+ snapshot(
118
+ providerId = "local.onnx.genai.android",
119
+ type = ProviderDescriptor.ProviderType.local,
120
+ transport = ProviderDescriptor.TransportType.in_process,
121
+ streaming = false,
122
+ cancel = ProviderDescriptor.CancelSemantics.soft,
123
+ capabilities = ProviderDynamicCapabilities(
124
+ available = false,
125
+ reason = "No model package configured."
126
+ )
127
+ ),
128
+ snapshot(
129
+ providerId = "android.mlkit.genai",
130
+ type = ProviderDescriptor.ProviderType.local,
131
+ transport = ProviderDescriptor.TransportType.system_service,
132
+ streamingStyle = ProviderDescriptor.StreamingStyle.chunks,
133
+ streaming = true,
134
+ cancel = ProviderDescriptor.CancelSemantics.soft,
135
+ capabilities = ProviderDynamicCapabilities(available = true)
136
+ )
137
+ )
138
+ )
139
+
140
+ val providers = encoded.getJSONArray("providers")
141
+ assertEquals(
142
+ "in_process",
143
+ providers.getJSONObject(0).getJSONObject("descriptor").getString("transport")
144
+ )
145
+ assertEquals(
146
+ "system_service",
147
+ providers.getJSONObject(1).getJSONObject("descriptor").getString("transport")
148
+ )
149
+ assertEquals(
150
+ "chunks",
151
+ providers.getJSONObject(1).getJSONObject("descriptor").getString("streamingStyle")
152
+ )
153
+ }
154
+
155
+ /**
156
+ * Absence is the contract's "inherit the static declaration" state, so the nullable
157
+ * flags must not reach the wire as nulls — a null would be a third state no consumer
158
+ * has. Same for the optional descriptor sub-objects.
159
+ */
160
+ @Test
161
+ fun `encodeCapabilitySnapshots omits unset optional fields instead of writing null`() {
162
+ val encoded = IndeRunSerializer.encodeCapabilitySnapshots(
163
+ listOf(
164
+ snapshot(
165
+ providerId = "android.mlkit.genai",
166
+ type = ProviderDescriptor.ProviderType.local,
167
+ transport = ProviderDescriptor.TransportType.system_service,
168
+ streaming = true,
169
+ cancel = ProviderDescriptor.CancelSemantics.soft,
170
+ capabilities = ProviderDynamicCapabilities(available = true)
171
+ )
172
+ )
173
+ )
174
+
175
+ val provider = encoded.getJSONArray("providers").getJSONObject(0)
176
+ val descriptor = provider.getJSONObject("descriptor")
177
+ assertFalse(descriptor.has("streamingStyle"))
178
+ assertFalse(descriptor.has("limits"))
179
+ assertFalse(descriptor.has("privacy"))
180
+
181
+ val capabilities = provider.getJSONObject("capabilities")
182
+ assertFalse(capabilities.has("reason"))
183
+ assertFalse(capabilities.has("streamingAvailable"))
184
+ assertFalse(capabilities.has("streamingUnavailableReason"))
185
+ assertFalse(capabilities.has("cancellationAvailable"))
186
+ }
187
+
188
+ @Test
189
+ fun `encodeCapabilitySnapshots encodes streaming taken away at runtime`() {
190
+ val encoded = IndeRunSerializer.encodeCapabilitySnapshots(
191
+ listOf(
192
+ snapshot(
193
+ providerId = "openai",
194
+ type = ProviderDescriptor.ProviderType.cloud,
195
+ transport = ProviderDescriptor.TransportType.http,
196
+ streamingStyle = ProviderDescriptor.StreamingStyle.tokens,
197
+ streaming = true,
198
+ cancel = ProviderDescriptor.CancelSemantics.hard,
199
+ capabilities = ProviderDynamicCapabilities(
200
+ available = true,
201
+ streamingAvailable = false,
202
+ streamingUnavailableReason = "Host has no streaming HTTP client.",
203
+ cancellationAvailable = true
204
+ )
205
+ )
206
+ )
207
+ )
208
+
209
+ val capabilities = encoded.getJSONArray("providers")
210
+ .getJSONObject(0)
211
+ .getJSONObject("capabilities")
212
+ assertTrue(capabilities.getBoolean("available"))
213
+ assertFalse(capabilities.getBoolean("streamingAvailable"))
214
+ assertEquals(
215
+ "Host has no streaming HTTP client.",
216
+ capabilities.getString("streamingUnavailableReason")
217
+ )
218
+ assertTrue(capabilities.getBoolean("cancellationAvailable"))
219
+ }
220
+
221
+ @Test
222
+ fun `encodeCapabilitySnapshots encodes an empty registry as an empty array`() {
223
+ val encoded = IndeRunSerializer.encodeCapabilitySnapshots(emptyList())
224
+
225
+ assertTrue(encoded.has("providers"))
226
+ assertEquals(0, encoded.getJSONArray("providers").length())
227
+ }
228
+
229
+ /**
230
+ * Every enum constant of every descriptor enum maps to the wire spelling the
231
+ * TypeScript unions declare. Exhaustive by construction: `values()` grows when
232
+ * upstream adds a constant, and a renamed one fails here rather than in a demo.
233
+ */
234
+ @Test
235
+ fun `every descriptor enum constant maps to its declared wire value`() {
236
+ assertEquals(
237
+ listOf("local", "edge", "cloud"),
238
+ ProviderDescriptor.ProviderType.values().map { it.name }
239
+ )
240
+ assertEquals(
241
+ listOf("in_process", "system_service", "http", "sse", "realtime"),
242
+ ProviderDescriptor.TransportType.values().map { it.name }
243
+ )
244
+ assertEquals(
245
+ listOf("tokens", "chunks", "snapshots"),
246
+ ProviderDescriptor.StreamingStyle.values().map { it.name }
247
+ )
248
+ assertEquals(
249
+ listOf("hard", "soft", "none"),
250
+ ProviderDescriptor.CancelSemantics.values().map { it.name }
251
+ )
252
+ }
253
+ }