@independo/capacitor-inderun 1.0.0 → 1.1.0-dev.1
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 +5 -1
- package/README.md +79 -5
- package/android/build.gradle.kts +5 -5
- package/android/src/main/kotlin/app/independo/inderun/capacitor/IndeRunCapacitorPlugin.kt +119 -0
- package/android/src/main/kotlin/app/independo/inderun/capacitor/IndeRunSerializer.kt +106 -1
- package/android/src/main/kotlin/app/independo/inderun/capacitor/IndeRunStreamRegistry.kt +118 -0
- package/android/src/test/kotlin/app/independo/inderun/capacitor/IndeRunSerializerStreamTest.kt +226 -0
- package/android/src/test/kotlin/app/independo/inderun/capacitor/IndeRunStreamRegistryTest.kt +163 -0
- package/dist/definitions.d.ts +54 -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 +14 -4
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +30 -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 +17 -2
- package/dist/web.d.ts.map +1 -1
- package/dist/web.js +70 -0
- package/ios/Sources/IndeRunCapacitorPlugin/IndeRunCapacitorBridge.swift +134 -0
- package/ios/Sources/IndeRunCapacitorPlugin/IndeRunCapacitorPlugin.swift +70 -1
- package/ios/Sources/IndeRunCapacitorPlugin/IndeRunCapacitorStreamRegistry.swift +121 -0
- package/ios/Tests/IndeRunCapacitorTests/IndeRunCapacitorBridgeTests.swift +31 -1
- 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 +3 -3
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import Foundation
|
|
2
|
+
import IndeRunCore
|
|
3
|
+
|
|
4
|
+
/// Tracks the streaming runs the bridge is currently pumping, keyed by the
|
|
5
|
+
/// bridge-local `streamId`.
|
|
6
|
+
///
|
|
7
|
+
/// Two things make this more than a dictionary. A cancel can arrive before the run
|
|
8
|
+
/// it refers to exists — `startStream` has to reach route selection before there is
|
|
9
|
+
/// anything to cancel — so a cancel requested in that window is recorded and applied
|
|
10
|
+
/// on attach. And cancelling a run is *not* cancelling the pump task: the engine
|
|
11
|
+
/// answers a cancel with its one `cancelled` terminal event, which the pump still has
|
|
12
|
+
/// to deliver. Task cancellation is reserved for teardown, where the webview is going
|
|
13
|
+
/// away and nobody is left to receive a terminal.
|
|
14
|
+
///
|
|
15
|
+
/// Deliberately free of any Capacitor dependency, so it is exercised by `swift test`
|
|
16
|
+
/// on macOS where `canImport(Capacitor)` is false.
|
|
17
|
+
final class StreamRegistry {
|
|
18
|
+
enum AttachOutcome: Equatable {
|
|
19
|
+
case attached
|
|
20
|
+
/// A cancel arrived before the run did; the caller must apply it.
|
|
21
|
+
case cancelRequested(reason: String?)
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
private struct Entry {
|
|
25
|
+
var run: StreamRun?
|
|
26
|
+
var task: Task<Void, Never>?
|
|
27
|
+
var cancelRequested = false
|
|
28
|
+
var cancelReason: String?
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
private let lock = NSLock()
|
|
32
|
+
private var entries: [String: Entry] = [:]
|
|
33
|
+
|
|
34
|
+
var activeCount: Int {
|
|
35
|
+
lock.lock()
|
|
36
|
+
defer { lock.unlock() }
|
|
37
|
+
return entries.count
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/// Reserves the id so a cancel arriving before the run can be recorded rather
|
|
41
|
+
/// than dropped as unknown.
|
|
42
|
+
func open(streamId: String) {
|
|
43
|
+
lock.lock()
|
|
44
|
+
defer { lock.unlock() }
|
|
45
|
+
if entries[streamId] == nil {
|
|
46
|
+
entries[streamId] = Entry()
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
func attach(streamId: String, run: StreamRun) -> AttachOutcome {
|
|
51
|
+
lock.lock()
|
|
52
|
+
guard var entry = entries[streamId] else {
|
|
53
|
+
// Torn down while the engine was still selecting a route.
|
|
54
|
+
lock.unlock()
|
|
55
|
+
return .cancelRequested(reason: nil)
|
|
56
|
+
}
|
|
57
|
+
if entry.cancelRequested {
|
|
58
|
+
let reason = entry.cancelReason
|
|
59
|
+
lock.unlock()
|
|
60
|
+
return .cancelRequested(reason: reason)
|
|
61
|
+
}
|
|
62
|
+
entry.run = run
|
|
63
|
+
entries[streamId] = entry
|
|
64
|
+
lock.unlock()
|
|
65
|
+
return .attached
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
func store(task: Task<Void, Never>, for streamId: String) {
|
|
69
|
+
lock.lock()
|
|
70
|
+
defer { lock.unlock() }
|
|
71
|
+
guard var entry = entries[streamId] else {
|
|
72
|
+
task.cancel()
|
|
73
|
+
return
|
|
74
|
+
}
|
|
75
|
+
entry.task = task
|
|
76
|
+
entries[streamId] = entry
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/// Cancels the run if it has attached, otherwise records the request for attach.
|
|
80
|
+
/// An unknown id — already finished, or never started — is a silent no-op, which
|
|
81
|
+
/// is what makes cancelling after the terminal harmless.
|
|
82
|
+
func requestCancel(streamId: String, reason: String?) {
|
|
83
|
+
lock.lock()
|
|
84
|
+
guard var entry = entries[streamId] else {
|
|
85
|
+
lock.unlock()
|
|
86
|
+
return
|
|
87
|
+
}
|
|
88
|
+
if entry.cancelRequested {
|
|
89
|
+
lock.unlock()
|
|
90
|
+
return
|
|
91
|
+
}
|
|
92
|
+
entry.cancelRequested = true
|
|
93
|
+
entry.cancelReason = reason
|
|
94
|
+
let run = entry.run
|
|
95
|
+
entries[streamId] = entry
|
|
96
|
+
lock.unlock()
|
|
97
|
+
|
|
98
|
+
// Never under the lock: cancel reaches into engine code.
|
|
99
|
+
run?.cancel(reason: reason)
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
func finish(streamId: String) {
|
|
103
|
+
lock.lock()
|
|
104
|
+
defer { lock.unlock() }
|
|
105
|
+
entries.removeValue(forKey: streamId)
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/// Teardown. Cancels each run first so providers unwind, then the pump tasks,
|
|
109
|
+
/// which at this point have nobody left to deliver to.
|
|
110
|
+
func cancelAll(reason: String?) {
|
|
111
|
+
lock.lock()
|
|
112
|
+
let draining = entries
|
|
113
|
+
entries.removeAll()
|
|
114
|
+
lock.unlock()
|
|
115
|
+
|
|
116
|
+
for (_, entry) in draining {
|
|
117
|
+
entry.run?.cancel(reason: reason)
|
|
118
|
+
entry.task?.cancel()
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
}
|
|
@@ -11,7 +11,7 @@ final class IndeRunCapacitorBridgeTests: XCTestCase {
|
|
|
11
11
|
let json: [String: Any] = [
|
|
12
12
|
"openAI": [
|
|
13
13
|
"model": "gpt-5.2",
|
|
14
|
-
"
|
|
14
|
+
"endpointUrl": "/api/inderun/openai-responses",
|
|
15
15
|
"auth": "none",
|
|
16
16
|
"authContextRef": "openai/main",
|
|
17
17
|
"timeoutMs": 30_000
|
|
@@ -28,6 +28,36 @@ final class IndeRunCapacitorBridgeTests: XCTestCase {
|
|
|
28
28
|
XCTAssertNil(options.allowDirectOpenAIEndpoint)
|
|
29
29
|
}
|
|
30
30
|
|
|
31
|
+
// Regression: the wire key is `endpointUrl` (what src/definitions.ts and
|
|
32
|
+
// IndeRunSerializer.kt both use). iOS previously decoded `endpointURL`, so a
|
|
33
|
+
// TypeScript-supplied endpoint was dropped and the provider silently fell back to
|
|
34
|
+
// the default OpenAI Responses endpoint.
|
|
35
|
+
func testDecodesEndpointUrlUsingTheTypeScriptWireKey() throws {
|
|
36
|
+
let json: [String: Any] = [
|
|
37
|
+
"openAI": [
|
|
38
|
+
"model": "gpt-5.2",
|
|
39
|
+
"endpointUrl": "https://proxy.example/api/openai-responses"
|
|
40
|
+
]
|
|
41
|
+
]
|
|
42
|
+
let data = try JSONSerialization.data(withJSONObject: json)
|
|
43
|
+
let options = try JSONDecoder().decode(CapacitorRunOptions.self, from: data)
|
|
44
|
+
|
|
45
|
+
XCTAssertEqual(options.openAI?.endpointURL, "https://proxy.example/api/openai-responses")
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
func testIgnoresTheLegacySwiftCasedEndpointKey() throws {
|
|
49
|
+
let json: [String: Any] = [
|
|
50
|
+
"openAI": [
|
|
51
|
+
"model": "gpt-5.2",
|
|
52
|
+
"endpointURL": "https://proxy.example/api/openai-responses"
|
|
53
|
+
]
|
|
54
|
+
]
|
|
55
|
+
let data = try JSONSerialization.data(withJSONObject: json)
|
|
56
|
+
let options = try JSONDecoder().decode(CapacitorRunOptions.self, from: data)
|
|
57
|
+
|
|
58
|
+
XCTAssertNil(options.openAI?.endpointURL)
|
|
59
|
+
}
|
|
60
|
+
|
|
31
61
|
func testDecodesConfigureOptionsWithAllOptionalFieldsAbsent() throws {
|
|
32
62
|
let data = try JSONSerialization.data(withJSONObject: [String: Any]())
|
|
33
63
|
let options = try JSONDecoder().decode(CapacitorRunOptions.self, from: data)
|
|
@@ -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
|
+
}
|