@openwop/openwop-conformance 2.0.0-rc.50 → 2.0.0-rc.52
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/README.md +2 -2
- package/dist/spec-artifacts.lock.json +2 -2
- package/package.json +2 -2
- package/requirements.json +186 -3
- package/scenario-majors.json +8 -2
- package/schemas/CORPUS-STAMP.json +11 -11
- package/src/lib/sse.ts +8 -0
- package/src/scenarios/v2-run-pause-resume.test.ts +8 -0
- package/src/scenarios/v2-sse-last-event-id.test.ts +93 -0
- package/src/scenarios/v2-stream-mode-refusal.test.ts +118 -0
package/README.md
CHANGED
|
@@ -112,7 +112,7 @@ Exit code is non-zero on any failed assertion. `--certify` distinguishes: `0`
|
|
|
112
112
|
|
|
113
113
|
## What's Covered
|
|
114
114
|
|
|
115
|
-
The current suite has
|
|
115
|
+
The current suite has 515 scenario files under `src/scenarios/`.
|
|
116
116
|
- 2026-09-03 (suite `1.157.0 -> 1.158.0`, gap G17): NEW `idempotency-concurrent-claim.test.ts` — drives the new `host-sample-test-seams.md` §25 concurrent duplicate-delivery seam for the RFC 0150 §B / `idempotency.md` §"Concurrent duplicates (Layer 2)" atomic-claim MUST, which is unconditional and had no witness of any kind. Asserts every executor mints the SAME `logicalInvocationId` **before** asserting `delivered === 1` — without the identity check a host passes by minting different ids and never colliding, one effect because nothing raced. Not profile-gated and so not opt-out-able (the obligation is unconditional); an unmounted seam records `blocked`, which is not certifiable. Graduates `layer2-invocation-claim-atomic` reference-impl -> protocol.
|
|
117
117
|
- 2026-08-19 (suite `1.137.0 → 1.138.0`): NEW `durability-poison-exhaustion.test.ts` — RFC 0158 §C.8, the FIRST row of that RFC's conformance table to land. Asserts what `failure-path.test.ts` cannot: not just that deterministically failing work reaches terminal, but that attempts STOP — counted on the log, re-counted after a scaled quiet window, asserted unchanged. A host still redelivering records more. Seam-gated on the existing event-log seam (`blocked` = unobservable, not unmet) and outside every profile floor.
|
|
118
118
|
- 2026-08-19 (suite `1.136.15 → 1.137.0`): NEW `replay-fanout-suppression.test.ts` — capability-gated on `webhooks.supported`, **outside every profile floor**; witnesses `replay.md` §"Host-initiated fan-out is an external effect", which was the largest normative MUST NOT on the replay surface with no scenario and no SECURITY invariant. Three legs in ONE `it` against ONE receiver and ONE subscription — a positive control, the MUST NOT, and a `branch` boundary leg — because "no delivery arrived" passes identically when delivery never worked, so absence is asserted only after presence is proven on that exact wiring. A host with an SSRF guard correctly refuses the loopback receiver and records `blocked`: **unobservable, not unmet.**
|
|
@@ -457,7 +457,7 @@ Server-required (added in 1.7.0):
|
|
|
457
457
|
| ------------- | ----------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
458
458
|
| **Redaction** | [`capabilities.md`](../spec/v1/capabilities.md) §"Secrets" + NFR-7 + §"aiProviders" | Vendor-neutral assertions that the server doesn't leak secret material. Three scenario groups: (a) discovery shape contract — `secrets` + `aiProviders` advertisements are well-formed regardless of `secrets.supported`; when `supported === true`, scopes MUST be non-empty + `resolution === 'host-managed'`; `byok ⊆ supported`. (b) bearer-token redaction — invalid Bearer canary in `Authorization` header is not echoed in the 401 response body. (c) credentialRef echo control — gated on `secrets.supported === true`; canary planted in `configurable.ai.credentialRef` MUST NOT appear in any RunEvent payload (poll-based capture; transport-agnostic). Uses runtime-built canary fixtures (`lib/canaries.ts`) that defeat static secret scanners. 6 scenarios. |
|
|
459
459
|
|
|
460
|
-
Current source tree:
|
|
460
|
+
Current source tree: 515 scenario files. Use [`coverage.md`](./coverage.md) for current grade/gap tracking.
|
|
461
461
|
|
|
462
462
|
## Remaining Gaps
|
|
463
463
|
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
{
|
|
2
2
|
"package": "@openwop/spec-artifacts",
|
|
3
|
-
"version": "2.0.0-rc.
|
|
4
|
-
"stampSha256": "
|
|
3
|
+
"version": "2.0.0-rc.52",
|
|
4
|
+
"stampSha256": "81cdf1c9820f034ec60ac59b15a2e1166af70f2e9744c72251ecd1cd88783bc7"
|
|
5
5
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@openwop/openwop-conformance",
|
|
3
|
-
"version": "2.0.0-rc.
|
|
3
|
+
"version": "2.0.0-rc.52",
|
|
4
4
|
"description": "Production-ready black-box conformance suite for OpenWOP v1.0 compliant servers.",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -56,6 +56,6 @@
|
|
|
56
56
|
"@openwop/spec-artifacts": "file:../spec-artifacts"
|
|
57
57
|
},
|
|
58
58
|
"peerDependencies": {
|
|
59
|
-
"@openwop/spec-artifacts": "2.0.0-rc.
|
|
59
|
+
"@openwop/spec-artifacts": "2.0.0-rc.52"
|
|
60
60
|
}
|
|
61
61
|
}
|
package/requirements.json
CHANGED
|
@@ -2,9 +2,9 @@
|
|
|
2
2
|
"$comment": "GENERATED by conformance/scripts/generate-requirement-registry.mjs — do not edit. One record per it()/test() in src/scenarios. Ids: openwop.it.<file-stem>.<title-slug>[~n] (src/lib/requirement-ids.ts). A record with id null has an interpolated title; its run-time row is keyed by the rendered title and maps here by file+line only. Renamed ids need a row in requirement-aliases.json.",
|
|
3
3
|
"generatedFrom": "src/scenarios/*.test.ts",
|
|
4
4
|
"counts": {
|
|
5
|
-
"files":
|
|
6
|
-
"tests":
|
|
7
|
-
"withStableId":
|
|
5
|
+
"files": 564,
|
|
6
|
+
"tests": 2148,
|
|
7
|
+
"withStableId": 2147,
|
|
8
8
|
"interpolatedTitles": 1,
|
|
9
9
|
"explicitIds": 2057
|
|
10
10
|
},
|
|
@@ -28464,6 +28464,16 @@
|
|
|
28464
28464
|
"section": "spec/v2/core/events.md §run.paused",
|
|
28465
28465
|
"requirement": null,
|
|
28466
28466
|
"interpolated": true
|
|
28467
|
+
},
|
|
28468
|
+
{
|
|
28469
|
+
"section": null,
|
|
28470
|
+
"requirement": null,
|
|
28471
|
+
"interpolated": true
|
|
28472
|
+
},
|
|
28473
|
+
{
|
|
28474
|
+
"section": null,
|
|
28475
|
+
"requirement": null,
|
|
28476
|
+
"interpolated": true
|
|
28467
28477
|
}
|
|
28468
28478
|
]
|
|
28469
28479
|
},
|
|
@@ -28500,6 +28510,179 @@
|
|
|
28500
28510
|
}
|
|
28501
28511
|
]
|
|
28502
28512
|
},
|
|
28513
|
+
{
|
|
28514
|
+
"id": "openwop.it.v2-sse-last-event-id.last-event-id-resumes-at-the-next-sequence-never-re-emits-the-resumption-point-l",
|
|
28515
|
+
"file": "v2-sse-last-event-id.test.ts",
|
|
28516
|
+
"line": 59,
|
|
28517
|
+
"title": "Last-Event-ID resumes at the next sequence, never re-emits the resumption point, loses nothing, and at the last id yields nothing",
|
|
28518
|
+
"explicitId": null,
|
|
28519
|
+
"citations": [
|
|
28520
|
+
{
|
|
28521
|
+
"section": null,
|
|
28522
|
+
"requirement": null,
|
|
28523
|
+
"interpolated": true
|
|
28524
|
+
},
|
|
28525
|
+
{
|
|
28526
|
+
"section": null,
|
|
28527
|
+
"requirement": "the completed log MUST stream as at least one frame",
|
|
28528
|
+
"interpolated": true
|
|
28529
|
+
},
|
|
28530
|
+
{
|
|
28531
|
+
"section": null,
|
|
28532
|
+
"requirement": null,
|
|
28533
|
+
"interpolated": true
|
|
28534
|
+
},
|
|
28535
|
+
{
|
|
28536
|
+
"section": null,
|
|
28537
|
+
"requirement": null,
|
|
28538
|
+
"interpolated": true
|
|
28539
|
+
},
|
|
28540
|
+
{
|
|
28541
|
+
"section": null,
|
|
28542
|
+
"requirement": "frames MUST arrive in log order",
|
|
28543
|
+
"interpolated": true
|
|
28544
|
+
},
|
|
28545
|
+
{
|
|
28546
|
+
"section": null,
|
|
28547
|
+
"requirement": null,
|
|
28548
|
+
"interpolated": true
|
|
28549
|
+
},
|
|
28550
|
+
{
|
|
28551
|
+
"section": null,
|
|
28552
|
+
"requirement": null,
|
|
28553
|
+
"interpolated": true
|
|
28554
|
+
},
|
|
28555
|
+
{
|
|
28556
|
+
"section": null,
|
|
28557
|
+
"requirement": null,
|
|
28558
|
+
"interpolated": true
|
|
28559
|
+
},
|
|
28560
|
+
{
|
|
28561
|
+
"section": null,
|
|
28562
|
+
"requirement": null,
|
|
28563
|
+
"interpolated": true
|
|
28564
|
+
},
|
|
28565
|
+
{
|
|
28566
|
+
"section": null,
|
|
28567
|
+
"requirement": null,
|
|
28568
|
+
"interpolated": true
|
|
28569
|
+
},
|
|
28570
|
+
{
|
|
28571
|
+
"section": null,
|
|
28572
|
+
"requirement": "the host MUST close the empty resume itself",
|
|
28573
|
+
"interpolated": true
|
|
28574
|
+
},
|
|
28575
|
+
{
|
|
28576
|
+
"section": null,
|
|
28577
|
+
"requirement": null,
|
|
28578
|
+
"interpolated": true
|
|
28579
|
+
},
|
|
28580
|
+
{
|
|
28581
|
+
"section": null,
|
|
28582
|
+
"requirement": null,
|
|
28583
|
+
"interpolated": true
|
|
28584
|
+
}
|
|
28585
|
+
]
|
|
28586
|
+
},
|
|
28587
|
+
{
|
|
28588
|
+
"id": "openwop.it.v2-stream-mode-refusal.a-value-outside-the-pattern-a-forbidden-combination-and-an-unimplemented-mode-ar",
|
|
28589
|
+
"file": "v2-stream-mode-refusal.test.ts",
|
|
28590
|
+
"line": 64,
|
|
28591
|
+
"title": "a value outside the pattern, a forbidden combination, and an unimplemented mode are refused 400 unsupported_stream_mode with details.supported — before content negotiation",
|
|
28592
|
+
"explicitId": null,
|
|
28593
|
+
"citations": [
|
|
28594
|
+
{
|
|
28595
|
+
"section": null,
|
|
28596
|
+
"requirement": null,
|
|
28597
|
+
"interpolated": true
|
|
28598
|
+
},
|
|
28599
|
+
{
|
|
28600
|
+
"section": null,
|
|
28601
|
+
"requirement": "the refusal MUST be unsupported_stream_mode",
|
|
28602
|
+
"interpolated": true
|
|
28603
|
+
},
|
|
28604
|
+
{
|
|
28605
|
+
"section": null,
|
|
28606
|
+
"requirement": null,
|
|
28607
|
+
"interpolated": true
|
|
28608
|
+
},
|
|
28609
|
+
{
|
|
28610
|
+
"section": null,
|
|
28611
|
+
"requirement": "a host MUST implement updates, so supported MUST list it",
|
|
28612
|
+
"interpolated": true
|
|
28613
|
+
},
|
|
28614
|
+
{
|
|
28615
|
+
"section": null,
|
|
28616
|
+
"requirement": null,
|
|
28617
|
+
"interpolated": true
|
|
28618
|
+
},
|
|
28619
|
+
{
|
|
28620
|
+
"section": null,
|
|
28621
|
+
"requirement": null,
|
|
28622
|
+
"interpolated": true
|
|
28623
|
+
},
|
|
28624
|
+
{
|
|
28625
|
+
"section": null,
|
|
28626
|
+
"requirement": "the refusal MUST be unsupported_stream_mode",
|
|
28627
|
+
"interpolated": true
|
|
28628
|
+
},
|
|
28629
|
+
{
|
|
28630
|
+
"section": null,
|
|
28631
|
+
"requirement": null,
|
|
28632
|
+
"interpolated": true
|
|
28633
|
+
},
|
|
28634
|
+
{
|
|
28635
|
+
"section": null,
|
|
28636
|
+
"requirement": "the refusal MUST be unsupported_stream_mode",
|
|
28637
|
+
"interpolated": true
|
|
28638
|
+
}
|
|
28639
|
+
]
|
|
28640
|
+
},
|
|
28641
|
+
{
|
|
28642
|
+
"id": "openwop.it.v2-stream-mode-refusal.updates-streams-200-text-event-stream-with-frames-bufferms-yields-at-least-one-b",
|
|
28643
|
+
"file": "v2-stream-mode-refusal.test.ts",
|
|
28644
|
+
"line": 95,
|
|
28645
|
+
"title": "updates streams 200 text/event-stream with frames; bufferMs yields at least one batch frame whose data is an array and loses nothing; without bufferMs no frame is batch",
|
|
28646
|
+
"explicitId": null,
|
|
28647
|
+
"citations": [
|
|
28648
|
+
{
|
|
28649
|
+
"section": null,
|
|
28650
|
+
"requirement": null,
|
|
28651
|
+
"interpolated": true
|
|
28652
|
+
},
|
|
28653
|
+
{
|
|
28654
|
+
"section": "spec/v2/core/events.md §SSE frames",
|
|
28655
|
+
"requirement": "the completed run's log MUST stream as at least one frame before the server closes"
|
|
28656
|
+
},
|
|
28657
|
+
{
|
|
28658
|
+
"section": "spec/v2/core/events.md §SSE frames",
|
|
28659
|
+
"requirement": null,
|
|
28660
|
+
"interpolated": true
|
|
28661
|
+
},
|
|
28662
|
+
{
|
|
28663
|
+
"section": "spec/v2/core/events.md §SSE frames",
|
|
28664
|
+
"requirement": "without bufferMs no frame is a batch (the control for the batch leg)"
|
|
28665
|
+
},
|
|
28666
|
+
{
|
|
28667
|
+
"section": "spec/v2/core/events.md §SSE frames",
|
|
28668
|
+
"requirement": null,
|
|
28669
|
+
"interpolated": true
|
|
28670
|
+
},
|
|
28671
|
+
{
|
|
28672
|
+
"section": "spec/v2/core/events.md §SSE frames",
|
|
28673
|
+
"requirement": null,
|
|
28674
|
+
"interpolated": true
|
|
28675
|
+
},
|
|
28676
|
+
{
|
|
28677
|
+
"section": "spec/v2/core/events.md §SSE frames",
|
|
28678
|
+
"requirement": "a batch frame's data MUST be an array of RunEventDoc"
|
|
28679
|
+
},
|
|
28680
|
+
{
|
|
28681
|
+
"section": "spec/v2/core/events.md §SSE frames",
|
|
28682
|
+
"requirement": "batching MUST NOT lose or reorder events: the flattened buffered stream equals the unbuffered log (a one-element batch and an unbatched frame are both tolerated)"
|
|
28683
|
+
}
|
|
28684
|
+
]
|
|
28685
|
+
},
|
|
28503
28686
|
{
|
|
28504
28687
|
"id": "openwop.it.v2-stream-sse-projection.every-data-frame-on-the-major-2-stream-carries-the-tenant-bound-runid-of-the-run",
|
|
28505
28688
|
"file": "v2-stream-sse-projection.test.ts",
|
package/scenario-majors.json
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
{
|
|
2
2
|
"$comment": "GENERATED by conformance/scripts/generate-scenario-majors.mjs (RFC 0168 §D.3). Do not edit; add a file to BOTH_MAJORS in the generator to target both majors.",
|
|
3
3
|
"counts": {
|
|
4
|
-
"files":
|
|
4
|
+
"files": 515,
|
|
5
5
|
"v1": 445,
|
|
6
|
-
"v2":
|
|
6
|
+
"v2": 71
|
|
7
7
|
},
|
|
8
8
|
"majors": {
|
|
9
9
|
"a2a-1-0-agent-card.test.ts": [
|
|
@@ -1411,6 +1411,12 @@
|
|
|
1411
1411
|
"v2-run-snapshot-etag.test.ts": [
|
|
1412
1412
|
2
|
|
1413
1413
|
],
|
|
1414
|
+
"v2-sse-last-event-id.test.ts": [
|
|
1415
|
+
2
|
|
1416
|
+
],
|
|
1417
|
+
"v2-stream-mode-refusal.test.ts": [
|
|
1418
|
+
2
|
|
1419
|
+
],
|
|
1414
1420
|
"v2-stream-sse-projection.test.ts": [
|
|
1415
1421
|
2
|
|
1416
1422
|
],
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"_comment": "Provenance of @openwop/spec-artifacts (RFC 0168 §D.2). files: SHA-256 per file; the conformance suite compares the installed peer against dist/spec-artifacts.lock.json at start.",
|
|
3
3
|
"package": "@openwop/spec-artifacts",
|
|
4
|
-
"version": "2.0.0-rc.
|
|
4
|
+
"version": "2.0.0-rc.52",
|
|
5
5
|
"corpusTag": null,
|
|
6
6
|
"files": {
|
|
7
7
|
"api/.redocly.lint-ignore.yaml": "bf5a8350b88a72fa43f59605ed8d903ed24b6cfccda5e45509c9f6ed9ee4e712",
|
|
@@ -9,9 +9,9 @@
|
|
|
9
9
|
"api/grpc/openwop.proto": "c3e72bb17cba514ee98feb6434e6c9b6ea6795bfd086489ec69fd882dd1ad977",
|
|
10
10
|
"api/openapi.yaml": "39081c59fb696159806b0f2f9a42e7e9ff830d622fcf2ad4159b21357580a955",
|
|
11
11
|
"api/redocly.yaml": "b0604c89b2ca6d5076ec25725c539dad44a741a811fe524439ee6daef8baa09f",
|
|
12
|
-
"api/seams-v2.yaml": "
|
|
13
|
-
"api/v2/asyncapi.yaml": "
|
|
14
|
-
"api/v2/openapi.yaml": "
|
|
12
|
+
"api/seams-v2.yaml": "54fced8c0f57bb6af60f5d6f35d2da1b7e262ea895a225daa4ce13df3f5eb488",
|
|
13
|
+
"api/v2/asyncapi.yaml": "4d337c6dcf7ff3d3c0256ae6d2388aa644741464b3e6911d2d4156655c7909e0",
|
|
14
|
+
"api/v2/openapi.yaml": "c61a55d1cab7db1747d12fa6af82089ae21cac1415efd28cdf3ecb30c226c11b",
|
|
15
15
|
"api/v2/redocly.yaml": "1e66b60e6118ad11a823bb620678be464d99dfe50a40e3e6f93ec9429b88b34c",
|
|
16
16
|
"schemas/README.md": "0c0b737ffcf8f30e7d2809cec8a498232de710f41443212922ad8337cdde0b51",
|
|
17
17
|
"schemas/a2a-task-state.schema.json": "c9365918f993f943b4b619d42551eb066a1ed33a08d895d51b395432a5b1f1bc",
|
|
@@ -141,7 +141,7 @@
|
|
|
141
141
|
"schemas/v2/envelopes/schema.request.schema.json": "be2c5f7aa881cdd252ccff47ac02193659e1393633a1541f4297383205d384a2",
|
|
142
142
|
"schemas/v2/envelopes/schema.response.schema.json": "d049a081267c06c28879c331b147f3ed821b14ccfd93005c67aff64db0a8c68e",
|
|
143
143
|
"schemas/v2/envelopes/ui.a2ui-surface.schema.json": "0a06fbbdc8ba797a6818c79336a98e211ac5ba5747945b4e4098072cd3974d79",
|
|
144
|
-
"schemas/v2/error-envelope.schema.json": "
|
|
144
|
+
"schemas/v2/error-envelope.schema.json": "72d3332fad8fa36d07439c830acf352eefc6da2379374faa2ebbe49e98b8cb08",
|
|
145
145
|
"schemas/v2/eval-summary.schema.json": "bcb165148531d33b7dc01de83da3a049d19ebbef6d12300b17a234c7b03d77d9",
|
|
146
146
|
"schemas/v2/export-bundle.schema.json": "bd28905a7729437ab7f2ad8bb33e13a371b2bf5605a7eb2962a0d246de312d69",
|
|
147
147
|
"schemas/v2/form-content-pack-manifest.schema.json": "307884e57946703b6f86ec283034bf56beb68bea4a000743ebbc9eebeed31b07",
|
|
@@ -169,7 +169,7 @@
|
|
|
169
169
|
"schemas/v2/residency.schema.json": "af2658a7104cbcacd0e20a91226271c4a77f91ef46ae97612768fe743e6091d8",
|
|
170
170
|
"schemas/v2/run-ancestry-response.schema.json": "907e36341ba83a2147a5d76963950b6dcd47506b663ecbccae9dfa553f3724d3",
|
|
171
171
|
"schemas/v2/run-diff-response.schema.json": "6ab6cd265dc7e0a2086c6659c46aa0e7b43e6302949f0adac5d7deb1c7c0ecd3",
|
|
172
|
-
"schemas/v2/run-event-payloads.schema.json": "
|
|
172
|
+
"schemas/v2/run-event-payloads.schema.json": "3a7161af78b0dd2730309371f5ea8b889d92a37c708db05e4354209f12ffbe30",
|
|
173
173
|
"schemas/v2/run-event.schema.json": "1a9946374af9323b71635d00fed22abe02c300fa2cf3ae3e9c30f3e553e408ec",
|
|
174
174
|
"schemas/v2/run-options.schema.json": "ff240805478158cbd2f661979c5eb4f5eda11f9c053b7ba587cb0211a9252d74",
|
|
175
175
|
"schemas/v2/run-orchestrator-decided-event.schema.json": "bb76e15ff72e669f12938684033b55dc567470e1a378e19ffc3fa0e4034ab5a7",
|
|
@@ -199,7 +199,7 @@
|
|
|
199
199
|
"schemas/workspace-file.schema.json": "464de85c2a068243084ee9c1d969bc7cd5d8f7948574e58450d6493c38a0e1e4",
|
|
200
200
|
"spec/v1/alias-detectors.json": "fee4594ef49953953ffcd0b3813300067d16b3e65ebff2aac722034ac9b3f545",
|
|
201
201
|
"spec/v1/capability-declaration-classes.json": "e7729aed5c4b4e1dd02abab0530f14cc95f5d4070fe51fb139e7f5cccefa00c6",
|
|
202
|
-
"spec/v1/core-standard-manifest.json": "
|
|
202
|
+
"spec/v1/core-standard-manifest.json": "6cca7e93821c9bb93c1a4b206e2300be1b2e7f6bea8eb4fdb870cf5c9e0a44d4",
|
|
203
203
|
"spec/v1/deprecations.json": "1d5acb69a9b8ccb57275a95605f74aef1d920685f8407c9d382a46b59dc803bb",
|
|
204
204
|
"spec/v1/deprecations.schema.json": "18c87e78bedc210431f795ae44c5b5d202f2f3317850d5cf86867d4f1fa1cdfb",
|
|
205
205
|
"spec/v1/event-codemap.json": "3da60d884157793a360da532a9fcbbfb5285636db325a74cec94b34622186d97",
|
|
@@ -213,7 +213,7 @@
|
|
|
213
213
|
"spec/v1/spec-gaps.json": "6cc9962c6b969f632e07a78b52a4f61447ff579e2990cbae989866f584a86042",
|
|
214
214
|
"spec/v2/declaration.json": "c15a6a8f08ebd326d1b60398d93d7f7d5abe5c1b8284874fdb13b59f44443210",
|
|
215
215
|
"spec/v2/declaration.schema.json": "b0881a76228dfbd1b85a2ec7b7259a60197f768344535cdb475db6ff719b0e54",
|
|
216
|
-
"spec/v2/errors.json": "
|
|
216
|
+
"spec/v2/errors.json": "f179414a92f30b5dadf26e9e57699649a67324bd7186d5287647d17605d137d3",
|
|
217
217
|
"spec/v2/event-codemap.json": "37869a03cdfd8758b57e6f350641a6ebbd383231cebda51f5473ad85f94ab74c",
|
|
218
218
|
"spec/v2/event-codemap.schema.json": "ce2d0ca085d7668d7ef745d2560b385ec6881d24a6d4b47c49931986b2ba642d",
|
|
219
219
|
"spec/v2/ext/provider-idempotency/registry.json": "5b5fecad604fb4ab38da39f5abe697a4e57b05406c398f8afeb377331b2bd965",
|
|
@@ -226,12 +226,12 @@
|
|
|
226
226
|
"spec/v2/facets/replay.schema.json": "0a5aefb0108c708c6794fadd533cf256709704c4f16dbf44cd63fa8980c78018",
|
|
227
227
|
"spec/v2/facets/sandbox.schema.json": "e75f54486d387b2d089791aa93b5093cd1450f1920753a0f97fc23acdfe978ed",
|
|
228
228
|
"spec/v2/facets/webhooks.schema.json": "0b766033ef4b4c6c2ac6f9979a19843ba024d3a1d1576972dcb55aaba52f1b9a",
|
|
229
|
-
"spec/v2/id-field-bindings.json": "
|
|
229
|
+
"spec/v2/id-field-bindings.json": "d9d1f8e266660b581f11020b9511a65c677e026b1a71ada870682ad77e18555d",
|
|
230
230
|
"spec/v2/path-manifest.json": "034152e09b1458c66810d4050e20a273b2b9b8fe2d92b66e8b819477de58a1be",
|
|
231
231
|
"spec/v2/peer-dependency-aliases.json": "d10299280abee08258502925bc327293ee413e0108cd6e6ec75ff6110653308d",
|
|
232
232
|
"spec/v2/profiles.json": "5019ac8209540bfbeee9c3232530d4e599be0ab926d817ff48edcec8419eb209",
|
|
233
|
-
"spec/v2/release.json": "
|
|
233
|
+
"spec/v2/release.json": "80a24ebc5baa87dfe19476762f1999354fb522e58e115e52777618ca7272c298",
|
|
234
234
|
"spec/v2/retention-floors.json": "eaf3722d95c79947af1d4269ef85117e126518c588cfcf1a2b21b97269f51624"
|
|
235
235
|
},
|
|
236
|
-
"corpusCommit": "
|
|
236
|
+
"corpusCommit": "7dded60251d6e769e05b8131728b66c2652318fb"
|
|
237
237
|
}
|
package/src/lib/sse.ts
CHANGED
|
@@ -33,6 +33,13 @@ export interface SseSubscribeOptions {
|
|
|
33
33
|
readonly lastEventId?: string;
|
|
34
34
|
/** Optional fetch-level abort. Useful for cancellation in long tests. */
|
|
35
35
|
readonly signal?: AbortSignal;
|
|
36
|
+
/**
|
|
37
|
+
* Extra request headers. A major-2 caller MUST pass `OpenWOP-Version: 2.0`:
|
|
38
|
+
* this helper sends none by itself, and a header-less request on an
|
|
39
|
+
* unversioned path is served the host's `preferredVersion` major
|
|
40
|
+
* (versioning.md §1.3) — 1.x through the overlap.
|
|
41
|
+
*/
|
|
42
|
+
readonly extraHeaders?: Record<string, string>;
|
|
36
43
|
}
|
|
37
44
|
|
|
38
45
|
export interface SseSubscribeResult {
|
|
@@ -62,6 +69,7 @@ export async function subscribe(
|
|
|
62
69
|
if (opts.lastEventId) {
|
|
63
70
|
headers['Last-Event-ID'] = opts.lastEventId;
|
|
64
71
|
}
|
|
72
|
+
for (const [k, v] of Object.entries(opts.extraHeaders ?? {})) headers[k] = v;
|
|
65
73
|
|
|
66
74
|
const internalAbort = new AbortController();
|
|
67
75
|
const timeoutHandle = setTimeout(() => internalAbort.abort(), timeoutMs);
|
|
@@ -102,6 +102,14 @@ describe('v2 run-pause-resume (runs.md §Pause and resume)', () => {
|
|
|
102
102
|
const types = await eventTypes(c.runId);
|
|
103
103
|
const p = types?.indexOf('run.paused') ?? -1; const r = types?.indexOf('run.resumed') ?? -1;
|
|
104
104
|
expect(p >= 0 && r > p, req(ID, 'spec/v2/core/events.md §run.paused', `the log MUST carry run.paused then run.resumed (types: ${(types ?? []).join(', ')})`)).toBe(true);
|
|
105
|
+
// An immediate pause cuts the executing attempt BETWEEN events: no terminal
|
|
106
|
+
// node event for it, ever — a node.failed here makes a replay fold a
|
|
107
|
+
// failure the source never had (runs.md §Pause and resume; replay.md).
|
|
108
|
+
const between = (types ?? []).slice(p + 1, r);
|
|
109
|
+
expect(between.some((t) => t === 'node.failed' || t === 'node.completed' || t === 'node.cancelled'), req(ID, DOC, `with drainPolicy immediate the interrupted attempt MUST NOT record a terminal node event (between run.paused and run.resumed: ${between.join(', ') || 'nothing'})`)).toBe(false);
|
|
110
|
+
const poll = await http(() => driver.get(`/runs/${enc(c.runId)}/events/poll?timeout=1`));
|
|
111
|
+
const paused = ((poll?.json as { events?: Array<{ type?: unknown; payload?: { drainPolicy?: unknown } }> } | null)?.events ?? []).find((e) => e.type === 'run.paused');
|
|
112
|
+
expect(paused?.payload?.drainPolicy, req(ID, DOC, `run.paused's payload MUST echo the request's drainPolicy word (got ${String(paused?.payload?.drainPolicy)})`)).toBe('immediate');
|
|
105
113
|
} finally {
|
|
106
114
|
await http(() => driver.post(`/runs/${enc(c.runId)}/cancel`, { reason: 'conformance cleanup' }));
|
|
107
115
|
}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `spec/v2/core/events.md` §SSE frames — `Last-Event-ID` resumes every mode
|
|
3
|
+
* (suite 2.0.0, target major 2; unaided; one run created).
|
|
4
|
+
*
|
|
5
|
+
* The host MUST look up the event with that sequence, MUST begin at the next
|
|
6
|
+
* sequence, and MUST NOT re-emit the resumption point; in `values` mode the
|
|
7
|
+
* resumption MUST emit a `state.snapshot` first. Frames carry `id:` = the
|
|
8
|
+
* sequence (0-based: the first frame's id is `0`, so ids are tested for
|
|
9
|
+
* presence and parsed, never for truthiness).
|
|
10
|
+
*
|
|
11
|
+
* Legs and controls, all on one completed noop run:
|
|
12
|
+
* 1. the full stream: ≥1 frame, numeric ids, closed by the server;
|
|
13
|
+
* 2. resume at the first id: the first resumed id is greater, the resumption
|
|
14
|
+
* id is absent, and the resumed set equals the full set minus every id at
|
|
15
|
+
* or below it — one assertion catches loss and duplication;
|
|
16
|
+
* 3. resume at the last id: zero frames, closed by server (a host that ignores
|
|
17
|
+
* the header re-sends all N against an expected 0; a host that closes
|
|
18
|
+
* every stream empty fails leg 1);
|
|
19
|
+
* 4. if `values` is served: a `values` resume's first frame is
|
|
20
|
+
* `event: state.snapshot` (a frame name, not a type — §SSE frames).
|
|
21
|
+
*
|
|
22
|
+
* @see spec/v2/core/events.md §SSE frames
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
import { describe, it, expect } from 'vitest';
|
|
26
|
+
import { driver, type OpenWOPResponse } from '../lib/driver.js';
|
|
27
|
+
import { subscribe, type SseEvent } from '../lib/sse.js';
|
|
28
|
+
import { v2Discovery } from '../lib/v2.js';
|
|
29
|
+
import { readErrorCode } from '../lib/error-envelope.js';
|
|
30
|
+
import { softSkip } from '../lib/soft-skip.js';
|
|
31
|
+
import { req } from '../lib/requirement-ids.js';
|
|
32
|
+
|
|
33
|
+
const ID = 'openwop.requirement.0171.sse-last-event-id';
|
|
34
|
+
const DOC = 'spec/v2/core/events.md §SSE frames';
|
|
35
|
+
const NOOP = 'conformance-noop';
|
|
36
|
+
const V2 = { 'OpenWOP-Version': '2.0' };
|
|
37
|
+
|
|
38
|
+
async function discovery(): Promise<Record<string, unknown> | null> { try { return await v2Discovery(); } catch { return null; } }
|
|
39
|
+
async function http(fn: () => Promise<OpenWOPResponse>): Promise<OpenWOPResponse | null> { try { return await fn(); } catch { return null; } }
|
|
40
|
+
const enc = (id: string): string => encodeURIComponent(id);
|
|
41
|
+
/** The frame's sequence, or null when the frame carries no id — `0` is a valid id. */
|
|
42
|
+
function seqOf(f: SseEvent): number | null { if (f.id === null || f.id === '') return null; const n = Number.parseInt(f.id, 10); return Number.isFinite(n) ? n : null; }
|
|
43
|
+
|
|
44
|
+
async function createSettled(): Promise<{ runId: string } | { reason: string }> {
|
|
45
|
+
const res = await http(() => driver.post('/runs', { workflowId: NOOP }));
|
|
46
|
+
if (res === null) return { reason: 'POST /runs unreachable (fetch failed)' };
|
|
47
|
+
const runId = (res.json as { runId?: unknown } | null)?.runId;
|
|
48
|
+
if (res.status !== 201 || typeof runId !== 'string') return { reason: `POST /runs answered ${res.status} ${readErrorCode(res.json) ?? ''}`.trim() };
|
|
49
|
+
const t0 = Date.now();
|
|
50
|
+
while (Date.now() - t0 < 10_000) {
|
|
51
|
+
const s = await http(() => driver.get(`/runs/${enc(runId)}`));
|
|
52
|
+
if (s?.status === 200 && ['completed', 'failed', 'cancelled'].includes(String((s.json as { status?: unknown }).status))) return { runId };
|
|
53
|
+
await new Promise((r) => setTimeout(r, 250));
|
|
54
|
+
}
|
|
55
|
+
return { reason: 'the noop run did not settle within 10 s' };
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
describe('v2 sse-last-event-id (events.md §SSE frames)', () => {
|
|
59
|
+
it('Last-Event-ID resumes at the next sequence, never re-emits the resumption point, loses nothing, and at the last id yields nothing', async () => {
|
|
60
|
+
if (!(await discovery())) return softSkip('blocked', 'v2 discovery unreachable');
|
|
61
|
+
const c = await createSettled(); if ('reason' in c) return softSkip('blocked', c.reason);
|
|
62
|
+
const path = `/runs/${enc(c.runId)}/events?streamMode=debug`;
|
|
63
|
+
const full = await subscribe(path, { timeoutMs: 8_000, extraHeaders: V2 });
|
|
64
|
+
if (full.status === 404) return softSkip('blocked', 'GET /runs/{runId}/events answered 404 — streamRunEvents is a core operation and is not mounted');
|
|
65
|
+
expect(full.status, req(ID, DOC, `the stream MUST answer 200 — got ${full.status}`)).toBe(200);
|
|
66
|
+
expect(full.events.length, req(ID, DOC, 'the completed log MUST stream as at least one frame')).toBeGreaterThan(0);
|
|
67
|
+
expect(full.closedBy, req(ID, DOC, `the host MUST close after the terminal event (closed by ${full.closedBy})`)).toBe('server');
|
|
68
|
+
const ids = full.events.map(seqOf);
|
|
69
|
+
expect(ids.every((n) => n !== null), req(ID, DOC, `every frame MUST carry id: = its sequence (frames without a parseable id: ${ids.filter((n) => n === null).length} of ${ids.length})`)).toBe(true);
|
|
70
|
+
const seqs = ids as number[];
|
|
71
|
+
expect([...seqs].sort((a, b) => a - b), req(ID, DOC, 'frames MUST arrive in log order')).toEqual(seqs);
|
|
72
|
+
const first = seqs[0]!; const last = seqs[seqs.length - 1]!;
|
|
73
|
+
|
|
74
|
+
const resumed = await subscribe(path, { timeoutMs: 8_000, extraHeaders: V2, lastEventId: String(first) });
|
|
75
|
+
expect(resumed.status, req(ID, DOC, `a resume with Last-Event-ID MUST answer 200 — got ${resumed.status}`)).toBe(200);
|
|
76
|
+
const rseqs = resumed.events.map(seqOf).filter((n): n is number => n !== null);
|
|
77
|
+
expect(rseqs.includes(first), req(ID, DOC, `the host MUST NOT re-emit the resumption point (Last-Event-ID ${first} re-emitted)`)).toBe(false);
|
|
78
|
+
expect(rseqs, req(ID, DOC, `the host MUST begin at the next sequence and lose nothing: resumed ids (${rseqs.join(',')}) MUST equal the full set minus every id ≤ ${first} (${seqs.filter((n) => n > first).join(',')})`)).toEqual(seqs.filter((n) => n > first));
|
|
79
|
+
|
|
80
|
+
const atEnd = await subscribe(path, { timeoutMs: 8_000, extraHeaders: V2, lastEventId: String(last) });
|
|
81
|
+
expect(atEnd.status, req(ID, DOC, `a resume at the terminal sequence MUST answer 200 — got ${atEnd.status}`)).toBe(200);
|
|
82
|
+
expect(atEnd.events.length, req(ID, DOC, `a resume at the last sequence (${last}) MUST emit nothing and close — a host ignoring the header re-sends all ${seqs.length} (got ${atEnd.events.length})`)).toBe(0);
|
|
83
|
+
expect(atEnd.closedBy, req(ID, DOC, 'the host MUST close the empty resume itself')).toBe('server');
|
|
84
|
+
|
|
85
|
+
// values: resumption MUST emit a state.snapshot first — only where the host serves values.
|
|
86
|
+
const probe = await http(() => driver.get(`/runs/${enc(c.runId)}/events?streamMode=bogus`, { headers: { Accept: 'text/event-stream' } }));
|
|
87
|
+
const supported = (probe?.json as { details?: { supported?: unknown } } | null)?.details?.supported;
|
|
88
|
+
if (!Array.isArray(supported) || !supported.includes('values')) return softSkip('inapplicable', `values mode not served (details.supported: ${JSON.stringify(supported)}) — the state.snapshot-first resumption leg does not apply`);
|
|
89
|
+
const values = await subscribe(`/runs/${enc(c.runId)}/events?streamMode=values`, { timeoutMs: 8_000, extraHeaders: V2, lastEventId: String(first) });
|
|
90
|
+
expect(values.status, req(ID, DOC, `a values resume MUST answer 200 — got ${values.status}`)).toBe(200);
|
|
91
|
+
expect(values.events[0]?.event ?? null, req(ID, DOC, `in values mode a resumption MUST emit a state.snapshot first (first frame: ${values.events[0]?.event ?? 'none'})`)).toBe('state.snapshot');
|
|
92
|
+
}, 45_000);
|
|
93
|
+
});
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `spec/v2/core/events.md` §The events channel — `streamMode` is one pattern,
|
|
3
|
+
* refused with `400 unsupported_stream_mode`, validated before content
|
|
4
|
+
* negotiation; `bufferMs` batches (suite 2.0.0, target major 2; unaided; one
|
|
5
|
+
* run created).
|
|
6
|
+
*
|
|
7
|
+
* Legs:
|
|
8
|
+
* 1. `?streamMode=bogus` → 400 `unsupported_stream_mode`, `details.supported`
|
|
9
|
+
* a non-empty array of individual modes; the same request with
|
|
10
|
+
* `Accept: application/json` → still 400, never 406 (validation runs
|
|
11
|
+
* before content negotiation; the only v2 406 is a version mismatch and
|
|
12
|
+
* this request names a listed major); `?streamMode=updates,values` → 400
|
|
13
|
+
* (`values` never combines); a mode absent from `supported`, if any → 400.
|
|
14
|
+
* 2. control: `?streamMode=updates` → 200 `text/event-stream` with ≥1 frame
|
|
15
|
+
* (a host that answers 400 to every mode fails here; `updates` is a MUST).
|
|
16
|
+
* 3. `?bufferMs=200` → at least one `event: batch` frame whose `data:` is an
|
|
17
|
+
* array of RunEventDoc, and the flattened frames equal the log in order;
|
|
18
|
+
* without `bufferMs` no frame is `batch` (the control). "Every frame is
|
|
19
|
+
* batch" is deliberately NOT asserted: a consumer MUST tolerate an
|
|
20
|
+
* unbatched frame beside a one-element batch (§SSE frames).
|
|
21
|
+
*
|
|
22
|
+
* @see spec/v2/core/events.md §The events channel, §SSE frames
|
|
23
|
+
* @see spec/v2/errors.json unsupported_stream_mode
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
import { describe, it, expect } from 'vitest';
|
|
27
|
+
import { driver, type OpenWOPResponse } from '../lib/driver.js';
|
|
28
|
+
import { subscribe, type SseEvent } from '../lib/sse.js';
|
|
29
|
+
import { v2Discovery } from '../lib/v2.js';
|
|
30
|
+
import { readErrorCode } from '../lib/error-envelope.js';
|
|
31
|
+
import { softSkip } from '../lib/soft-skip.js';
|
|
32
|
+
import { req } from '../lib/requirement-ids.js';
|
|
33
|
+
|
|
34
|
+
const ID = 'openwop.requirement.0171.stream-mode-refusal';
|
|
35
|
+
const DOC = 'spec/v2/core/events.md §The events channel';
|
|
36
|
+
const NOOP = 'conformance-noop';
|
|
37
|
+
const MODES = ['values', 'updates', 'messages', 'debug'];
|
|
38
|
+
const V2 = { 'OpenWOP-Version': '2.0' };
|
|
39
|
+
|
|
40
|
+
async function discovery(): Promise<Record<string, unknown> | null> { try { return await v2Discovery(); } catch { return null; } }
|
|
41
|
+
async function http(fn: () => Promise<OpenWOPResponse>): Promise<OpenWOPResponse | null> { try { return await fn(); } catch { return null; } }
|
|
42
|
+
const enc = (id: string): string => encodeURIComponent(id);
|
|
43
|
+
|
|
44
|
+
async function createSettled(): Promise<{ runId: string } | { reason: string }> {
|
|
45
|
+
const res = await http(() => driver.post('/runs', { workflowId: NOOP }));
|
|
46
|
+
if (res === null) return { reason: 'POST /runs unreachable (fetch failed)' };
|
|
47
|
+
const runId = (res.json as { runId?: unknown } | null)?.runId;
|
|
48
|
+
if (res.status !== 201 || typeof runId !== 'string') return { reason: `POST /runs answered ${res.status} ${readErrorCode(res.json) ?? ''}`.trim() };
|
|
49
|
+
const t0 = Date.now();
|
|
50
|
+
while (Date.now() - t0 < 10_000) {
|
|
51
|
+
const s = await http(() => driver.get(`/runs/${enc(runId)}`));
|
|
52
|
+
if (s?.status === 200 && ['completed', 'failed', 'cancelled'].includes(String((s.json as { status?: unknown }).status))) return { runId };
|
|
53
|
+
await new Promise((r) => setTimeout(r, 250));
|
|
54
|
+
}
|
|
55
|
+
return { reason: 'the noop run did not settle within 10 s' };
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** The events a frame carries: a batch frame's array flattened, a plain frame's one document. */
|
|
59
|
+
function docsOf(frame: SseEvent): Array<Record<string, unknown>> {
|
|
60
|
+
try { const p = JSON.parse(frame.data) as unknown; return (Array.isArray(p) ? p : [p]).filter((x): x is Record<string, unknown> => !!x && typeof x === 'object'); } catch { return []; }
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
describe('v2 stream-mode-refusal (events.md §The events channel)', () => {
|
|
64
|
+
it('a value outside the pattern, a forbidden combination, and an unimplemented mode are refused 400 unsupported_stream_mode with details.supported — before content negotiation', async () => {
|
|
65
|
+
if (!(await discovery())) return softSkip('blocked', 'v2 discovery unreachable');
|
|
66
|
+
const c = await createSettled(); if ('reason' in c) return softSkip('blocked', c.reason);
|
|
67
|
+
const path = `/runs/${enc(c.runId)}/events`;
|
|
68
|
+
const bogus = await http(() => driver.get(`${path}?streamMode=bogus`, { headers: { Accept: 'text/event-stream' } }));
|
|
69
|
+
if (bogus === null) return softSkip('blocked', 'GET /runs/{runId}/events unreachable (fetch failed)');
|
|
70
|
+
if (bogus.status === 404) return softSkip('blocked', 'GET /runs/{runId}/events answered 404 — streamRunEvents is a core operation (runs.md §Surface) and is not mounted');
|
|
71
|
+
expect(bogus.status, req(ID, DOC, `a streamMode outside the pattern MUST be refused 400 — got ${bogus.status}`)).toBe(400);
|
|
72
|
+
expect(readErrorCode(bogus.json), req(ID, DOC, 'the refusal MUST be unsupported_stream_mode')).toBe('unsupported_stream_mode');
|
|
73
|
+
const supported = (bogus.json as { details?: { supported?: unknown } } | null)?.details?.supported;
|
|
74
|
+
expect(Array.isArray(supported) && supported.length > 0 && supported.every((m) => MODES.includes(String(m))), req(ID, DOC, `details.supported MUST list each individual mode the host serves (got ${JSON.stringify(supported)})`)).toBe(true);
|
|
75
|
+
expect((supported as string[]).includes('updates'), req(ID, DOC, 'a host MUST implement updates, so supported MUST list it')).toBe(true);
|
|
76
|
+
|
|
77
|
+
const negotiated = await http(() => driver.get(`${path}?streamMode=bogus`, { headers: { Accept: 'application/json' } }));
|
|
78
|
+
expect(negotiated?.status ?? null, req(ID, DOC, `validation MUST run before any content negotiation: a bogus streamMode with Accept: application/json is still 400 unsupported_stream_mode, never 406 (the only v2 406 is a version mismatch and this request names a listed major) — got ${negotiated?.status ?? 'no response'}`)).toBe(400);
|
|
79
|
+
|
|
80
|
+
const combo = await http(() => driver.get(`${path}?streamMode=updates,values`, { headers: { Accept: 'text/event-stream' } }));
|
|
81
|
+
expect(combo?.status ?? null, req(ID, DOC, `values never combines: streamMode=updates,values is outside the pattern and MUST be refused 400 — got ${combo?.status ?? 'no response'}`)).toBe(400);
|
|
82
|
+
expect(readErrorCode(combo?.json), req(ID, DOC, 'the refusal MUST be unsupported_stream_mode')).toBe('unsupported_stream_mode');
|
|
83
|
+
|
|
84
|
+
// The unimplemented-mode leg has something to request only when a mode is
|
|
85
|
+
// absent from details.supported; a host serving all four has no such mode,
|
|
86
|
+
// and the three refusals above are its witness.
|
|
87
|
+
const missing = MODES.find((m) => !(supported as string[]).includes(m));
|
|
88
|
+
if (missing !== undefined) {
|
|
89
|
+
const unimpl = await http(() => driver.get(`${path}?streamMode=${missing}`, { headers: { Accept: 'text/event-stream' } }));
|
|
90
|
+
expect(unimpl?.status ?? null, req(ID, DOC, `a mode the host does not implement (${missing}, absent from details.supported) MUST be refused 400 — got ${unimpl?.status ?? 'no response'}`)).toBe(400);
|
|
91
|
+
expect(readErrorCode(unimpl?.json), req(ID, DOC, 'the refusal MUST be unsupported_stream_mode')).toBe('unsupported_stream_mode');
|
|
92
|
+
}
|
|
93
|
+
}, 30_000);
|
|
94
|
+
|
|
95
|
+
it('updates streams 200 text/event-stream with frames; bufferMs yields at least one batch frame whose data is an array and loses nothing; without bufferMs no frame is batch', async () => {
|
|
96
|
+
if (!(await discovery())) return softSkip('blocked', 'v2 discovery unreachable');
|
|
97
|
+
const c = await createSettled(); if ('reason' in c) return softSkip('blocked', c.reason);
|
|
98
|
+
const path = `/runs/${enc(c.runId)}/events`;
|
|
99
|
+
const plain = await subscribe(`${path}?streamMode=updates`, { timeoutMs: 8_000, extraHeaders: V2 });
|
|
100
|
+
if (plain.status === 404) return softSkip('blocked', 'GET /runs/{runId}/events answered 404 — not mounted');
|
|
101
|
+
expect(plain.status, req(ID, DOC, `updates is a MUST: streamMode=updates MUST answer 200 — got ${plain.status} (the control for the refusals: a host answering 400 to every mode fails here)`)).toBe(200);
|
|
102
|
+
expect(plain.events.length, req(ID, 'spec/v2/core/events.md §SSE frames', 'the completed run\'s log MUST stream as at least one frame before the server closes')).toBeGreaterThan(0);
|
|
103
|
+
expect(plain.closedBy, req(ID, 'spec/v2/core/events.md §SSE frames', `the host MUST close after the terminal event (closed by ${plain.closedBy})`)).toBe('server');
|
|
104
|
+
expect(plain.events.some((f) => f.event === 'batch'), req(ID, 'spec/v2/core/events.md §SSE frames', 'without bufferMs no frame is a batch (the control for the batch leg)')).toBe(false);
|
|
105
|
+
const plainDocs = plain.events.flatMap(docsOf).map((d) => d['sequence']);
|
|
106
|
+
|
|
107
|
+
const buffered = await subscribe(`${path}?streamMode=updates&bufferMs=200`, { timeoutMs: 8_000, extraHeaders: V2 });
|
|
108
|
+
expect(buffered.status, req(ID, 'spec/v2/core/events.md §SSE frames', `streamMode=updates&bufferMs=200 MUST answer 200 — got ${buffered.status}`)).toBe(200);
|
|
109
|
+
const batches = buffered.events.filter((f) => f.event === 'batch');
|
|
110
|
+
expect(batches.length, req(ID, 'spec/v2/core/events.md §SSE frames', `with bufferMs the host accumulates events into event: batch frames — none seen among ${buffered.events.length} frame(s) (${[...new Set(buffered.events.map((f) => f.event))].join(', ')})`)).toBeGreaterThan(0);
|
|
111
|
+
for (const b of batches) {
|
|
112
|
+
let parsed: unknown; try { parsed = JSON.parse(b.data); } catch { parsed = undefined; }
|
|
113
|
+
expect(Array.isArray(parsed), req(ID, 'spec/v2/core/events.md §SSE frames', 'a batch frame\'s data MUST be an array of RunEventDoc')).toBe(true);
|
|
114
|
+
}
|
|
115
|
+
const bufferedDocs = buffered.events.flatMap(docsOf).map((d) => d['sequence']);
|
|
116
|
+
expect(bufferedDocs, req(ID, 'spec/v2/core/events.md §SSE frames', 'batching MUST NOT lose or reorder events: the flattened buffered stream equals the unbuffered log (a one-element batch and an unbatched frame are both tolerated)')).toEqual(plainDocs);
|
|
117
|
+
}, 30_000);
|
|
118
|
+
});
|