@openwop/openwop-conformance 1.0.0

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 (175) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +241 -0
  3. package/api/asyncapi.yaml +481 -0
  4. package/api/openapi.yaml +830 -0
  5. package/api/redocly.yaml +8 -0
  6. package/coverage.md +80 -0
  7. package/dist/cli.js +161 -0
  8. package/fixtures/conformance-a2a-task-roundtrip.json +27 -0
  9. package/fixtures/conformance-agent-identity.json +27 -0
  10. package/fixtures/conformance-agent-low-confidence.json +29 -0
  11. package/fixtures/conformance-agent-memory-cross-tenant.json +28 -0
  12. package/fixtures/conformance-agent-memory-redaction.json +32 -0
  13. package/fixtures/conformance-agent-memory-roundtrip.json +32 -0
  14. package/fixtures/conformance-agent-memory-ttl.json +31 -0
  15. package/fixtures/conformance-agent-pack-export.json +26 -0
  16. package/fixtures/conformance-agent-pack-install.json +26 -0
  17. package/fixtures/conformance-agent-pack-provenance.json +31 -0
  18. package/fixtures/conformance-agent-reasoning.json +29 -0
  19. package/fixtures/conformance-approval.json +27 -0
  20. package/fixtures/conformance-cancellable.json +33 -0
  21. package/fixtures/conformance-cap-breach.json +27 -0
  22. package/fixtures/conformance-capability-missing.json +23 -0
  23. package/fixtures/conformance-channel-ttl.json +60 -0
  24. package/fixtures/conformance-clarification.json +30 -0
  25. package/fixtures/conformance-conversation-capability-negotiation.json +23 -0
  26. package/fixtures/conformance-conversation-lifecycle.json +32 -0
  27. package/fixtures/conformance-conversation-replay.json +33 -0
  28. package/fixtures/conformance-conversation-vs-clarification.json +26 -0
  29. package/fixtures/conformance-delay.json +33 -0
  30. package/fixtures/conformance-dispatch-loop.json +38 -0
  31. package/fixtures/conformance-failure.json +23 -0
  32. package/fixtures/conformance-idempotent.json +30 -0
  33. package/fixtures/conformance-identity.json +32 -0
  34. package/fixtures/conformance-interrupt-auth-required.json +28 -0
  35. package/fixtures/conformance-interrupt-external-event.json +33 -0
  36. package/fixtures/conformance-interrupt-parent-child-cancel-child.json +27 -0
  37. package/fixtures/conformance-interrupt-parent-child-cancel.json +26 -0
  38. package/fixtures/conformance-interrupt-quorum.json +30 -0
  39. package/fixtures/conformance-mcp-tool-roundtrip.json +32 -0
  40. package/fixtures/conformance-message-reducer.json +31 -0
  41. package/fixtures/conformance-multi-node.json +21 -0
  42. package/fixtures/conformance-noop.json +23 -0
  43. package/fixtures/conformance-orchestrator-dispatch.json +47 -0
  44. package/fixtures/conformance-orchestrator-low-confidence.json +41 -0
  45. package/fixtures/conformance-orchestrator-terminate.json +44 -0
  46. package/fixtures/conformance-stream-text.json +26 -0
  47. package/fixtures/conformance-subworkflow-child.json +21 -0
  48. package/fixtures/conformance-subworkflow-parent.json +49 -0
  49. package/fixtures/conformance-version-fold.json +23 -0
  50. package/fixtures/conformance-wasm-pack-roundtrip.json +25 -0
  51. package/fixtures/pack-manifests/pack-private-example.json +26 -0
  52. package/fixtures.md +404 -0
  53. package/package.json +48 -0
  54. package/schemas/README.md +75 -0
  55. package/schemas/agent-manifest.schema.json +107 -0
  56. package/schemas/agent-ref.schema.json +53 -0
  57. package/schemas/capabilities.schema.json +287 -0
  58. package/schemas/channel-written-payload.schema.json +55 -0
  59. package/schemas/conversation-event.schema.json +120 -0
  60. package/schemas/conversation-turn.schema.json +72 -0
  61. package/schemas/debug-bundle.schema.json +196 -0
  62. package/schemas/dispatch-config.schema.json +46 -0
  63. package/schemas/error-envelope.schema.json +25 -0
  64. package/schemas/memory-entry.schema.json +36 -0
  65. package/schemas/memory-list-options.schema.json +21 -0
  66. package/schemas/node-pack-manifest.schema.json +235 -0
  67. package/schemas/orchestrator-decision.schema.json +60 -0
  68. package/schemas/run-event-payloads.schema.json +663 -0
  69. package/schemas/run-event.schema.json +116 -0
  70. package/schemas/run-options.schema.json +81 -0
  71. package/schemas/run-orchestrator-decided-event.schema.json +20 -0
  72. package/schemas/run-snapshot.schema.json +121 -0
  73. package/schemas/suspend-request.schema.json +182 -0
  74. package/schemas/workflow-definition.schema.json +430 -0
  75. package/src/cli.ts +187 -0
  76. package/src/lib/a2a-fake-peer.ts +233 -0
  77. package/src/lib/canaries.ts +186 -0
  78. package/src/lib/driver.ts +96 -0
  79. package/src/lib/env.ts +49 -0
  80. package/src/lib/fixtures.ts +93 -0
  81. package/src/lib/mcp-fake-server.ts +185 -0
  82. package/src/lib/multi-agent-capabilities.ts +155 -0
  83. package/src/lib/multiProcess.ts +141 -0
  84. package/src/lib/otel-collector.ts +312 -0
  85. package/src/lib/paths.ts +198 -0
  86. package/src/lib/polling.ts +81 -0
  87. package/src/lib/profiles.ts +258 -0
  88. package/src/lib/sse.ts +172 -0
  89. package/src/scenarios/a2a-task-roundtrip.test.ts +149 -0
  90. package/src/scenarios/agentConfidenceEscalation.test.ts +61 -0
  91. package/src/scenarios/agentMemoryCrossTenantIsolation.test.ts +54 -0
  92. package/src/scenarios/agentMemoryRedactionContract.test.ts +46 -0
  93. package/src/scenarios/agentMemoryRoundTrip.test.ts +52 -0
  94. package/src/scenarios/agentMemoryTtlExpiry.test.ts +47 -0
  95. package/src/scenarios/agentMessageReducer.test.ts +57 -0
  96. package/src/scenarios/agentMetadata.test.ts +56 -0
  97. package/src/scenarios/agentPackExport.test.ts +45 -0
  98. package/src/scenarios/agentPackInstall.test.ts +50 -0
  99. package/src/scenarios/agentPackProvenance.test.ts +53 -0
  100. package/src/scenarios/agentReasoningEvents.test.ts +72 -0
  101. package/src/scenarios/append-ordering.test.ts +91 -0
  102. package/src/scenarios/approval-payload.test.ts +120 -0
  103. package/src/scenarios/audit-log-integrity.test.ts +106 -0
  104. package/src/scenarios/auth.test.ts +55 -0
  105. package/src/scenarios/byok-roundtrip.test.ts +166 -0
  106. package/src/scenarios/cancellation.test.ts +68 -0
  107. package/src/scenarios/cap-breach.test.ts +149 -0
  108. package/src/scenarios/channel-ttl.test.ts +70 -0
  109. package/src/scenarios/configurable-schema.test.ts +76 -0
  110. package/src/scenarios/conversationCapabilityNegotiation.test.ts +39 -0
  111. package/src/scenarios/conversationLifecycle.test.ts +64 -0
  112. package/src/scenarios/conversationReplayDeterminism.test.ts +52 -0
  113. package/src/scenarios/conversationVsLegacySuspend.test.ts +46 -0
  114. package/src/scenarios/cost-attribution.test.ts +207 -0
  115. package/src/scenarios/debugBundle.test.ts +222 -0
  116. package/src/scenarios/discovery.test.ts +147 -0
  117. package/src/scenarios/dispatchLoop.test.ts +52 -0
  118. package/src/scenarios/errors.test.ts +144 -0
  119. package/src/scenarios/eventOrdering.test.ts +144 -0
  120. package/src/scenarios/failure-path.test.ts +46 -0
  121. package/src/scenarios/fixtures-gating.test.ts +137 -0
  122. package/src/scenarios/fixtures-valid.test.ts +140 -0
  123. package/src/scenarios/highConcurrency.test.ts +263 -0
  124. package/src/scenarios/idempotency.test.ts +83 -0
  125. package/src/scenarios/idempotencyRetry.test.ts +130 -0
  126. package/src/scenarios/identity-passthrough.test.ts +54 -0
  127. package/src/scenarios/interrupt-approval.test.ts +97 -0
  128. package/src/scenarios/interrupt-auth-required-resume.test.ts +88 -0
  129. package/src/scenarios/interrupt-clarification.test.ts +45 -0
  130. package/src/scenarios/interrupt-external-event-correlation.test.ts +113 -0
  131. package/src/scenarios/interrupt-parent-child-cascade.test.ts +102 -0
  132. package/src/scenarios/interrupt-quorum-resolution.test.ts +97 -0
  133. package/src/scenarios/interruptRace.test.ts +176 -0
  134. package/src/scenarios/maliciousManifest.test.ts +154 -0
  135. package/src/scenarios/mcp-discoverability.test.ts +129 -0
  136. package/src/scenarios/mcp-tool-roundtrip.test.ts +149 -0
  137. package/src/scenarios/multi-node-ordering.test.ts +60 -0
  138. package/src/scenarios/multi-region-idempotency.test.ts +52 -0
  139. package/src/scenarios/orchestratorConservativePath.test.ts +63 -0
  140. package/src/scenarios/orchestratorDispatch.test.ts +66 -0
  141. package/src/scenarios/orchestratorTermination.test.ts +54 -0
  142. package/src/scenarios/otel-emission.test.ts +113 -0
  143. package/src/scenarios/otel-trace-propagation.test.ts +90 -0
  144. package/src/scenarios/pack-registry-publish.test.ts +93 -0
  145. package/src/scenarios/pack-registry.test.ts +328 -0
  146. package/src/scenarios/pause-resume.test.ts +109 -0
  147. package/src/scenarios/policies.test.ts +162 -0
  148. package/src/scenarios/profileDerivation.test.ts +335 -0
  149. package/src/scenarios/providerPolicyEnforcement.test.ts +132 -0
  150. package/src/scenarios/rate-limit-envelope.test.ts +97 -0
  151. package/src/scenarios/redaction.test.ts +254 -0
  152. package/src/scenarios/redactionAdversarial.test.ts +162 -0
  153. package/src/scenarios/replay-fork-arbitrary.test.ts +347 -0
  154. package/src/scenarios/replay-fork.test.ts +216 -0
  155. package/src/scenarios/replayDeterminism.test.ts +171 -0
  156. package/src/scenarios/route-coverage.test.ts +129 -0
  157. package/src/scenarios/runs-lifecycle.test.ts +65 -0
  158. package/src/scenarios/runtime-capabilities.test.ts +118 -0
  159. package/src/scenarios/spec-corpus-validity.test.ts +1257 -0
  160. package/src/scenarios/staleClaim.test.ts +223 -0
  161. package/src/scenarios/stream-modes-buffer.test.ts +148 -0
  162. package/src/scenarios/stream-modes-mixed.test.ts +149 -0
  163. package/src/scenarios/stream-modes.test.ts +139 -0
  164. package/src/scenarios/streamReconnect.test.ts +162 -0
  165. package/src/scenarios/subworkflow.test.ts +126 -0
  166. package/src/scenarios/version-negotiation.test.ts +157 -0
  167. package/src/scenarios/wasm-pack-abi-version-rejection.test.ts +47 -0
  168. package/src/scenarios/wasm-pack-invoke-completed.test.ts +69 -0
  169. package/src/scenarios/wasm-pack-invoke-suspended.test.ts +74 -0
  170. package/src/scenarios/wasm-pack-load.test.ts +75 -0
  171. package/src/scenarios/wasm-pack-memory-cap.test.ts +43 -0
  172. package/src/scenarios/wasm-pack-replay-determinism.test.ts +61 -0
  173. package/src/scenarios/webhook-sig-algorithm.test.ts +61 -0
  174. package/src/setup.ts +173 -0
  175. package/vitest.config.ts +17 -0
@@ -0,0 +1,185 @@
1
+ /**
2
+ * Track 6: in-process synthetic MCP server for roundtrip conformance.
3
+ *
4
+ * Implements just enough of the Model Context Protocol's HTTP/JSON-RPC
5
+ * transport (https://spec.modelcontextprotocol.io/) to exercise the
6
+ * host's MCP-integration code path:
7
+ *
8
+ * - `initialize` — server info + capabilities
9
+ * - `tools/list` — returns a single deterministic `echo` tool
10
+ * - `tools/call name=echo` — records invocation, returns input verbatim
11
+ *
12
+ * Records every invocation in memory so scenarios can assert the host
13
+ * called the expected tool with the expected arguments. The server is
14
+ * Node-stdlib-only (no MCP SDK dependency) — the wire shape is small
15
+ * enough to implement directly.
16
+ *
17
+ * Operator contract: when a host integrates MCP via a configurable
18
+ * server URL, the operator points the host at this fake's endpoint
19
+ * (printed at suite init). Hosts that hardcode MCP servers cannot
20
+ * exercise the roundtrip scenario and the test skips.
21
+ *
22
+ * @see spec/v1/mcp-integration.md
23
+ * @see SECURITY/threat-model-prompt-injection.md §"UNTRUSTED marker"
24
+ */
25
+
26
+ import { createServer, type Server } from 'node:http';
27
+ import type { AddressInfo } from 'node:net';
28
+
29
+ export interface McpInvocation {
30
+ readonly method: string;
31
+ readonly params: unknown;
32
+ readonly timestamp: number;
33
+ }
34
+
35
+ export class McpFakeServer {
36
+ private _server: Server | null = null;
37
+ private _boundPort = 0;
38
+ private readonly _invocations: McpInvocation[] = [];
39
+
40
+ async start(port: number = 0): Promise<void> {
41
+ return new Promise((resolve, reject) => {
42
+ const server = createServer((req, res) => this._handle(req, res));
43
+ server.on('error', reject);
44
+ server.listen(port, '127.0.0.1', () => {
45
+ const addr = server.address() as AddressInfo;
46
+ this._server = server;
47
+ this._boundPort = addr.port;
48
+ resolve();
49
+ });
50
+ });
51
+ }
52
+
53
+ async stop(): Promise<void> {
54
+ if (!this._server) return;
55
+ const server = this._server;
56
+ this._server = null;
57
+ return new Promise((resolve, reject) => {
58
+ server.close((err) => (err ? reject(err) : resolve()));
59
+ });
60
+ }
61
+
62
+ endpoint(): string {
63
+ return `http://127.0.0.1:${this._boundPort}`;
64
+ }
65
+
66
+ invocations(): readonly McpInvocation[] {
67
+ return this._invocations;
68
+ }
69
+
70
+ reset(): void {
71
+ this._invocations.length = 0;
72
+ }
73
+
74
+ private async _handle(
75
+ req: import('node:http').IncomingMessage,
76
+ res: import('node:http').ServerResponse,
77
+ ): Promise<void> {
78
+ if (req.method !== 'POST') {
79
+ res.writeHead(405).end();
80
+ return;
81
+ }
82
+ const chunks: Buffer[] = [];
83
+ for await (const c of req) chunks.push(c as Buffer);
84
+ const body = Buffer.concat(chunks).toString('utf8');
85
+
86
+ let rpc: { jsonrpc?: string; id?: unknown; method?: string; params?: unknown };
87
+ try {
88
+ rpc = JSON.parse(body);
89
+ } catch {
90
+ res.writeHead(400, { 'Content-Type': 'application/json' });
91
+ res.end(JSON.stringify({ jsonrpc: '2.0', error: { code: -32700, message: 'Parse error' } }));
92
+ return;
93
+ }
94
+
95
+ if (typeof rpc.method === 'string') {
96
+ this._invocations.push({
97
+ method: rpc.method,
98
+ params: rpc.params ?? null,
99
+ timestamp: Date.now(),
100
+ });
101
+ }
102
+
103
+ const response = this._respond(rpc);
104
+ res.writeHead(200, { 'Content-Type': 'application/json' });
105
+ res.end(JSON.stringify(response));
106
+ }
107
+
108
+ private _respond(rpc: {
109
+ id?: unknown;
110
+ method?: string;
111
+ params?: unknown;
112
+ }): Record<string, unknown> {
113
+ const id = rpc.id ?? null;
114
+ switch (rpc.method) {
115
+ case 'initialize':
116
+ return {
117
+ jsonrpc: '2.0',
118
+ id,
119
+ result: {
120
+ protocolVersion: '2025-03-26',
121
+ capabilities: { tools: {} },
122
+ serverInfo: { name: 'openwop-conformance-fake-mcp', version: '1.0.0' },
123
+ },
124
+ };
125
+
126
+ case 'tools/list':
127
+ return {
128
+ jsonrpc: '2.0',
129
+ id,
130
+ result: {
131
+ tools: [
132
+ {
133
+ name: 'echo',
134
+ description: 'Returns the `text` argument verbatim. Deterministic.',
135
+ inputSchema: {
136
+ type: 'object',
137
+ properties: { text: { type: 'string' } },
138
+ required: ['text'],
139
+ additionalProperties: false,
140
+ },
141
+ },
142
+ ],
143
+ },
144
+ };
145
+
146
+ case 'tools/call': {
147
+ const params = (rpc.params ?? {}) as { name?: string; arguments?: { text?: string } };
148
+ if (params.name !== 'echo') {
149
+ return {
150
+ jsonrpc: '2.0',
151
+ id,
152
+ error: { code: -32602, message: `Unknown tool: ${params.name}` },
153
+ };
154
+ }
155
+ const text = params.arguments?.text ?? '';
156
+ return {
157
+ jsonrpc: '2.0',
158
+ id,
159
+ result: {
160
+ content: [{ type: 'text', text }],
161
+ isError: false,
162
+ },
163
+ };
164
+ }
165
+
166
+ default:
167
+ return {
168
+ jsonrpc: '2.0',
169
+ id,
170
+ error: { code: -32601, message: `Method not found: ${rpc.method}` },
171
+ };
172
+ }
173
+ }
174
+ }
175
+
176
+ // Module-scope instance + lifecycle helpers, mirroring otel-collector.ts.
177
+ let _instance: McpFakeServer | null = null;
178
+
179
+ export function setMcpFakeServer(s: McpFakeServer | null): void {
180
+ _instance = s;
181
+ }
182
+
183
+ export function getMcpFakeServer(): McpFakeServer | null {
184
+ return _instance;
185
+ }
@@ -0,0 +1,155 @@
1
+ /**
2
+ * Multi-Agent Shift capability-gating helper.
3
+ *
4
+ * Reads the host's `/.well-known/openwop` `capabilities.agents` block
5
+ * + `capabilities.conversationPrimitive` flag at suite init and caches
6
+ * them as discrete predicates. Sibling to `lib/fixtures.ts` — same
7
+ * pattern, different surface.
8
+ *
9
+ * Why: Multi-Agent Shift scenarios (Phases 1-6) gate on per-phase
10
+ * capability flags. Mirrors the fixture-gating pattern from RFC 0003
11
+ * so conformance scenarios skip honestly when the host's advertisement
12
+ * doesn't claim the relevant phase support.
13
+ *
14
+ * Cache lifecycle:
15
+ * - `setMultiAgentCapabilities(...)` populates from a discovery
16
+ * payload. Idempotent — repeated calls with the same payload are
17
+ * no-ops. Setup file calls this from a top-level `await`.
18
+ * - Sync predicates (`isAgentSupported()`, etc.) return false until
19
+ * the cache is set — same defensive default as fixture-gating.
20
+ * - `__resetForTests()` clears the cache for unit tests.
21
+ *
22
+ * @see spec/v1/capabilities.md §`agents`
23
+ * @see spec/v1/capabilities.md §`conversationPrimitive`
24
+ */
25
+
26
+ import type { DiscoveryPayload } from './profiles.js';
27
+
28
+ interface AgentCaps {
29
+ supported: boolean;
30
+ profile: string | undefined;
31
+ modelClasses: ReadonlySet<string>;
32
+ orchestratorPattern: string | undefined;
33
+ memoryBackends: ReadonlySet<string>;
34
+ orchestrator: boolean;
35
+ dispatch: boolean;
36
+ reasoning:
37
+ | {
38
+ verbosity: 'summary' | 'full' | 'off' | undefined;
39
+ tokenLimit: number | undefined;
40
+ }
41
+ | undefined;
42
+ }
43
+
44
+ let _agentCaps: AgentCaps | null = null;
45
+ let _conversationPrimitive = false;
46
+
47
+ function asBoolean(value: unknown): boolean {
48
+ return value === true;
49
+ }
50
+
51
+ function asStringSet(value: unknown): ReadonlySet<string> {
52
+ if (!Array.isArray(value)) return new Set();
53
+ return new Set(value.filter((v): v is string => typeof v === 'string' && v.length > 0));
54
+ }
55
+
56
+ function asString(value: unknown): string | undefined {
57
+ return typeof value === 'string' && value.length > 0 ? value : undefined;
58
+ }
59
+
60
+ /**
61
+ * Populate the cache from a discovery-doc payload. Tolerant of malformed
62
+ * inputs — anything other than the expected shape is treated as "absent."
63
+ */
64
+ export function setMultiAgentCapabilities(c: DiscoveryPayload | null | undefined): void {
65
+ if (!c || typeof c !== 'object') {
66
+ _agentCaps = null;
67
+ _conversationPrimitive = false;
68
+ return;
69
+ }
70
+
71
+ const agentsRaw = (c as { agents?: unknown }).agents;
72
+ if (agentsRaw && typeof agentsRaw === 'object') {
73
+ const a = agentsRaw as Record<string, unknown>;
74
+ const reasoningRaw = a.reasoning;
75
+ const reasoning =
76
+ reasoningRaw && typeof reasoningRaw === 'object'
77
+ ? {
78
+ verbosity: asString((reasoningRaw as Record<string, unknown>).verbosity) as
79
+ | 'summary'
80
+ | 'full'
81
+ | 'off'
82
+ | undefined,
83
+ tokenLimit:
84
+ typeof (reasoningRaw as Record<string, unknown>).tokenLimit === 'number'
85
+ ? ((reasoningRaw as Record<string, unknown>).tokenLimit as number)
86
+ : undefined,
87
+ }
88
+ : undefined;
89
+ _agentCaps = {
90
+ supported: asBoolean(a.supported),
91
+ profile: asString(a.profile),
92
+ modelClasses: asStringSet(a.modelClasses),
93
+ orchestratorPattern: asString(a.orchestratorPattern),
94
+ memoryBackends: asStringSet(a.memoryBackends),
95
+ orchestrator: asBoolean(a.orchestrator),
96
+ dispatch: asBoolean(a.dispatch),
97
+ reasoning,
98
+ };
99
+ } else {
100
+ _agentCaps = null;
101
+ }
102
+
103
+ _conversationPrimitive = asBoolean((c as { conversationPrimitive?: unknown }).conversationPrimitive);
104
+ }
105
+
106
+ /** Phase 1 master switch. */
107
+ export function isAgentSupported(): boolean {
108
+ return _agentCaps?.supported === true;
109
+ }
110
+
111
+ /** Phase 1 reasoning verbosity. */
112
+ export function getReasoningVerbosity(): 'summary' | 'full' | 'off' | undefined {
113
+ return _agentCaps?.reasoning?.verbosity;
114
+ }
115
+
116
+ /** Phase 2 — host supports the named modelClass. */
117
+ export function hasModelClass(modelClass: string): boolean {
118
+ return _agentCaps?.modelClasses.has(modelClass) === true;
119
+ }
120
+
121
+ /** Phase 2 — host advertises an orchestrator pattern (any value). */
122
+ export function hasOrchestratorPattern(): boolean {
123
+ return typeof _agentCaps?.orchestratorPattern === 'string';
124
+ }
125
+
126
+ /** Phase 3 — host advertises long-term memory backend. */
127
+ export function hasLongTermMemory(): boolean {
128
+ return _agentCaps?.memoryBackends.has('long-term') === true;
129
+ }
130
+
131
+ /** Phase 4 — host implements `core.conversationGate` + `conversation.*` suspends. */
132
+ export function isConversationPrimitiveSupported(): boolean {
133
+ return _conversationPrimitive;
134
+ }
135
+
136
+ /** Phase 5 — host implements `core.orchestrator.supervisor` + CP-1. */
137
+ export function isOrchestratorSupported(): boolean {
138
+ return _agentCaps?.orchestrator === true;
139
+ }
140
+
141
+ /** Phase 6 — host implements `core.dispatch` + CP-2. */
142
+ export function isDispatchSupported(): boolean {
143
+ return _agentCaps?.dispatch === true;
144
+ }
145
+
146
+ /** Diagnostic — returns the cached state or `null` if not yet set. */
147
+ export function getCachedAgentCaps(): AgentCaps | null {
148
+ return _agentCaps;
149
+ }
150
+
151
+ /** Test-only reset. */
152
+ export function __resetForTests(): void {
153
+ _agentCaps = null;
154
+ _conversationPrimitive = false;
155
+ }
@@ -0,0 +1,141 @@
1
+ /**
2
+ * Multi-process host orchestrator for the staleClaim conformance
3
+ * scenario.
4
+ *
5
+ * Spawns a host child process directly via `child_process.spawn` so
6
+ * the test has a real PID to SIGKILL. Using `npm start` + killing the
7
+ * npm wrapper does NOT reach the actual host process — that pattern
8
+ * leaves the host running.
9
+ *
10
+ * The harness is small and zero-deps. It assumes the SQLite reference
11
+ * host's `tsx src/server.ts` entrypoint accepts:
12
+ *
13
+ * - OPENWOP_PORT
14
+ * - OPENWOP_API_KEY
15
+ * - OPENWOP_SQLITE_PATH
16
+ * - OPENWOP_CLAIM_TTL_MS
17
+ * - OPENWOP_HEARTBEAT_INTERVAL_MS
18
+ *
19
+ * Other host implementations adapt the spawn command.
20
+ */
21
+
22
+ import { spawn, type ChildProcess } from 'node:child_process';
23
+ import { existsSync } from 'node:fs';
24
+ import { join } from 'node:path';
25
+
26
+ export interface SpawnedHostConfig {
27
+ /** Repo-root-relative path to the host's package directory. */
28
+ readonly packageDir: string;
29
+ /** Bind port. */
30
+ readonly port: number;
31
+ /** Bearer token. */
32
+ readonly apiKey: string;
33
+ /** Absolute path to the SQLite DB file (shared across processes). */
34
+ readonly dbPath: string;
35
+ /** Claim TTL in ms. Tests use a short value (e.g., 2000). */
36
+ readonly claimTtlMs: number;
37
+ /** Heartbeat renewal interval in ms. Tests use ≤ claimTtlMs/2. */
38
+ readonly heartbeatIntervalMs: number;
39
+ }
40
+
41
+ export interface SpawnedHost {
42
+ readonly process: ChildProcess;
43
+ readonly baseUrl: string;
44
+ readonly apiKey: string;
45
+ /** Resolves once `/.well-known/openwop` returns 200. */
46
+ ready(): Promise<void>;
47
+ /** Force-kill (SIGKILL) — does NOT trigger the host's graceful shutdown handler. */
48
+ kill(): Promise<void>;
49
+ /** Graceful kill (SIGTERM) — triggers the host's shutdown handler (releases claims). */
50
+ shutdown(): Promise<void>;
51
+ }
52
+
53
+ /**
54
+ * Find the repo root by walking up from this file until we see the
55
+ * spec corpus marker.
56
+ */
57
+ function findRepoRoot(): string {
58
+ let probe = new URL('.', import.meta.url).pathname;
59
+ for (let i = 0; i < 10; i++) {
60
+ if (existsSync(join(probe, 'spec', 'v1'))) return probe;
61
+ probe = join(probe, '..');
62
+ }
63
+ throw new Error('Could not locate repo root from conformance/src/lib/multiProcess.ts');
64
+ }
65
+
66
+ export async function spawnHost(config: SpawnedHostConfig): Promise<SpawnedHost> {
67
+ const repoRoot = findRepoRoot();
68
+ const cwd = join(repoRoot, config.packageDir);
69
+
70
+ const env = {
71
+ ...process.env,
72
+ OPENWOP_HOST: '127.0.0.1',
73
+ OPENWOP_PORT: String(config.port),
74
+ OPENWOP_API_KEY: config.apiKey,
75
+ OPENWOP_SQLITE_PATH: config.dbPath,
76
+ OPENWOP_CLAIM_TTL_MS: String(config.claimTtlMs),
77
+ OPENWOP_HEARTBEAT_INTERVAL_MS: String(config.heartbeatIntervalMs),
78
+ };
79
+
80
+ // Spawn `npx tsx src/server.ts` directly (not `npm start`) so we get
81
+ // the tsx PID, not the npm wrapper PID. SIGKILL on the npm wrapper
82
+ // does NOT propagate to the tsx child — confirmed by smoke testing
83
+ // when this lib was authored.
84
+ const proc = spawn('npx', ['tsx', 'src/server.ts'], {
85
+ cwd,
86
+ env,
87
+ stdio: ['ignore', 'pipe', 'pipe'],
88
+ });
89
+
90
+ const baseUrl = `http://127.0.0.1:${config.port}`;
91
+ const host: SpawnedHost = {
92
+ process: proc,
93
+ baseUrl,
94
+ apiKey: config.apiKey,
95
+ async ready(): Promise<void> {
96
+ const deadline = Date.now() + 10_000;
97
+ while (Date.now() < deadline) {
98
+ if (proc.exitCode !== null) {
99
+ throw new Error(`Host exited before ready (code ${proc.exitCode})`);
100
+ }
101
+ try {
102
+ const res = await fetch(`${baseUrl}/.well-known/openwop`);
103
+ if (res.ok) return;
104
+ } catch {
105
+ // not yet listening
106
+ }
107
+ await new Promise((r) => setTimeout(r, 200));
108
+ }
109
+ throw new Error(`Host at ${baseUrl} did not become ready within 10s`);
110
+ },
111
+ async kill(): Promise<void> {
112
+ if (proc.pid !== undefined && proc.exitCode === null) {
113
+ proc.kill('SIGKILL');
114
+ }
115
+ await new Promise<void>((resolve) => {
116
+ if (proc.exitCode !== null) {
117
+ resolve();
118
+ return;
119
+ }
120
+ proc.once('exit', () => resolve());
121
+ // Backstop in case the exit event already fired.
122
+ setTimeout(() => resolve(), 1000);
123
+ });
124
+ },
125
+ async shutdown(): Promise<void> {
126
+ if (proc.pid !== undefined && proc.exitCode === null) {
127
+ proc.kill('SIGTERM');
128
+ }
129
+ await new Promise<void>((resolve) => {
130
+ if (proc.exitCode !== null) {
131
+ resolve();
132
+ return;
133
+ }
134
+ proc.once('exit', () => resolve());
135
+ setTimeout(() => resolve(), 5000);
136
+ });
137
+ },
138
+ };
139
+
140
+ return host;
141
+ }