@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.
- package/LICENSE +201 -0
- package/README.md +241 -0
- package/api/asyncapi.yaml +481 -0
- package/api/openapi.yaml +830 -0
- package/api/redocly.yaml +8 -0
- package/coverage.md +80 -0
- package/dist/cli.js +161 -0
- package/fixtures/conformance-a2a-task-roundtrip.json +27 -0
- package/fixtures/conformance-agent-identity.json +27 -0
- package/fixtures/conformance-agent-low-confidence.json +29 -0
- package/fixtures/conformance-agent-memory-cross-tenant.json +28 -0
- package/fixtures/conformance-agent-memory-redaction.json +32 -0
- package/fixtures/conformance-agent-memory-roundtrip.json +32 -0
- package/fixtures/conformance-agent-memory-ttl.json +31 -0
- package/fixtures/conformance-agent-pack-export.json +26 -0
- package/fixtures/conformance-agent-pack-install.json +26 -0
- package/fixtures/conformance-agent-pack-provenance.json +31 -0
- package/fixtures/conformance-agent-reasoning.json +29 -0
- package/fixtures/conformance-approval.json +27 -0
- package/fixtures/conformance-cancellable.json +33 -0
- package/fixtures/conformance-cap-breach.json +27 -0
- package/fixtures/conformance-capability-missing.json +23 -0
- package/fixtures/conformance-channel-ttl.json +60 -0
- package/fixtures/conformance-clarification.json +30 -0
- package/fixtures/conformance-conversation-capability-negotiation.json +23 -0
- package/fixtures/conformance-conversation-lifecycle.json +32 -0
- package/fixtures/conformance-conversation-replay.json +33 -0
- package/fixtures/conformance-conversation-vs-clarification.json +26 -0
- package/fixtures/conformance-delay.json +33 -0
- package/fixtures/conformance-dispatch-loop.json +38 -0
- package/fixtures/conformance-failure.json +23 -0
- package/fixtures/conformance-idempotent.json +30 -0
- package/fixtures/conformance-identity.json +32 -0
- package/fixtures/conformance-interrupt-auth-required.json +28 -0
- package/fixtures/conformance-interrupt-external-event.json +33 -0
- package/fixtures/conformance-interrupt-parent-child-cancel-child.json +27 -0
- package/fixtures/conformance-interrupt-parent-child-cancel.json +26 -0
- package/fixtures/conformance-interrupt-quorum.json +30 -0
- package/fixtures/conformance-mcp-tool-roundtrip.json +32 -0
- package/fixtures/conformance-message-reducer.json +31 -0
- package/fixtures/conformance-multi-node.json +21 -0
- package/fixtures/conformance-noop.json +23 -0
- package/fixtures/conformance-orchestrator-dispatch.json +47 -0
- package/fixtures/conformance-orchestrator-low-confidence.json +41 -0
- package/fixtures/conformance-orchestrator-terminate.json +44 -0
- package/fixtures/conformance-stream-text.json +26 -0
- package/fixtures/conformance-subworkflow-child.json +21 -0
- package/fixtures/conformance-subworkflow-parent.json +49 -0
- package/fixtures/conformance-version-fold.json +23 -0
- package/fixtures/conformance-wasm-pack-roundtrip.json +25 -0
- package/fixtures/pack-manifests/pack-private-example.json +26 -0
- package/fixtures.md +404 -0
- package/package.json +48 -0
- package/schemas/README.md +75 -0
- package/schemas/agent-manifest.schema.json +107 -0
- package/schemas/agent-ref.schema.json +53 -0
- package/schemas/capabilities.schema.json +287 -0
- package/schemas/channel-written-payload.schema.json +55 -0
- package/schemas/conversation-event.schema.json +120 -0
- package/schemas/conversation-turn.schema.json +72 -0
- package/schemas/debug-bundle.schema.json +196 -0
- package/schemas/dispatch-config.schema.json +46 -0
- package/schemas/error-envelope.schema.json +25 -0
- package/schemas/memory-entry.schema.json +36 -0
- package/schemas/memory-list-options.schema.json +21 -0
- package/schemas/node-pack-manifest.schema.json +235 -0
- package/schemas/orchestrator-decision.schema.json +60 -0
- package/schemas/run-event-payloads.schema.json +663 -0
- package/schemas/run-event.schema.json +116 -0
- package/schemas/run-options.schema.json +81 -0
- package/schemas/run-orchestrator-decided-event.schema.json +20 -0
- package/schemas/run-snapshot.schema.json +121 -0
- package/schemas/suspend-request.schema.json +182 -0
- package/schemas/workflow-definition.schema.json +430 -0
- package/src/cli.ts +187 -0
- package/src/lib/a2a-fake-peer.ts +233 -0
- package/src/lib/canaries.ts +186 -0
- package/src/lib/driver.ts +96 -0
- package/src/lib/env.ts +49 -0
- package/src/lib/fixtures.ts +93 -0
- package/src/lib/mcp-fake-server.ts +185 -0
- package/src/lib/multi-agent-capabilities.ts +155 -0
- package/src/lib/multiProcess.ts +141 -0
- package/src/lib/otel-collector.ts +312 -0
- package/src/lib/paths.ts +198 -0
- package/src/lib/polling.ts +81 -0
- package/src/lib/profiles.ts +258 -0
- package/src/lib/sse.ts +172 -0
- package/src/scenarios/a2a-task-roundtrip.test.ts +149 -0
- package/src/scenarios/agentConfidenceEscalation.test.ts +61 -0
- package/src/scenarios/agentMemoryCrossTenantIsolation.test.ts +54 -0
- package/src/scenarios/agentMemoryRedactionContract.test.ts +46 -0
- package/src/scenarios/agentMemoryRoundTrip.test.ts +52 -0
- package/src/scenarios/agentMemoryTtlExpiry.test.ts +47 -0
- package/src/scenarios/agentMessageReducer.test.ts +57 -0
- package/src/scenarios/agentMetadata.test.ts +56 -0
- package/src/scenarios/agentPackExport.test.ts +45 -0
- package/src/scenarios/agentPackInstall.test.ts +50 -0
- package/src/scenarios/agentPackProvenance.test.ts +53 -0
- package/src/scenarios/agentReasoningEvents.test.ts +72 -0
- package/src/scenarios/append-ordering.test.ts +91 -0
- package/src/scenarios/approval-payload.test.ts +120 -0
- package/src/scenarios/audit-log-integrity.test.ts +106 -0
- package/src/scenarios/auth.test.ts +55 -0
- package/src/scenarios/byok-roundtrip.test.ts +166 -0
- package/src/scenarios/cancellation.test.ts +68 -0
- package/src/scenarios/cap-breach.test.ts +149 -0
- package/src/scenarios/channel-ttl.test.ts +70 -0
- package/src/scenarios/configurable-schema.test.ts +76 -0
- package/src/scenarios/conversationCapabilityNegotiation.test.ts +39 -0
- package/src/scenarios/conversationLifecycle.test.ts +64 -0
- package/src/scenarios/conversationReplayDeterminism.test.ts +52 -0
- package/src/scenarios/conversationVsLegacySuspend.test.ts +46 -0
- package/src/scenarios/cost-attribution.test.ts +207 -0
- package/src/scenarios/debugBundle.test.ts +222 -0
- package/src/scenarios/discovery.test.ts +147 -0
- package/src/scenarios/dispatchLoop.test.ts +52 -0
- package/src/scenarios/errors.test.ts +144 -0
- package/src/scenarios/eventOrdering.test.ts +144 -0
- package/src/scenarios/failure-path.test.ts +46 -0
- package/src/scenarios/fixtures-gating.test.ts +137 -0
- package/src/scenarios/fixtures-valid.test.ts +140 -0
- package/src/scenarios/highConcurrency.test.ts +263 -0
- package/src/scenarios/idempotency.test.ts +83 -0
- package/src/scenarios/idempotencyRetry.test.ts +130 -0
- package/src/scenarios/identity-passthrough.test.ts +54 -0
- package/src/scenarios/interrupt-approval.test.ts +97 -0
- package/src/scenarios/interrupt-auth-required-resume.test.ts +88 -0
- package/src/scenarios/interrupt-clarification.test.ts +45 -0
- package/src/scenarios/interrupt-external-event-correlation.test.ts +113 -0
- package/src/scenarios/interrupt-parent-child-cascade.test.ts +102 -0
- package/src/scenarios/interrupt-quorum-resolution.test.ts +97 -0
- package/src/scenarios/interruptRace.test.ts +176 -0
- package/src/scenarios/maliciousManifest.test.ts +154 -0
- package/src/scenarios/mcp-discoverability.test.ts +129 -0
- package/src/scenarios/mcp-tool-roundtrip.test.ts +149 -0
- package/src/scenarios/multi-node-ordering.test.ts +60 -0
- package/src/scenarios/multi-region-idempotency.test.ts +52 -0
- package/src/scenarios/orchestratorConservativePath.test.ts +63 -0
- package/src/scenarios/orchestratorDispatch.test.ts +66 -0
- package/src/scenarios/orchestratorTermination.test.ts +54 -0
- package/src/scenarios/otel-emission.test.ts +113 -0
- package/src/scenarios/otel-trace-propagation.test.ts +90 -0
- package/src/scenarios/pack-registry-publish.test.ts +93 -0
- package/src/scenarios/pack-registry.test.ts +328 -0
- package/src/scenarios/pause-resume.test.ts +109 -0
- package/src/scenarios/policies.test.ts +162 -0
- package/src/scenarios/profileDerivation.test.ts +335 -0
- package/src/scenarios/providerPolicyEnforcement.test.ts +132 -0
- package/src/scenarios/rate-limit-envelope.test.ts +97 -0
- package/src/scenarios/redaction.test.ts +254 -0
- package/src/scenarios/redactionAdversarial.test.ts +162 -0
- package/src/scenarios/replay-fork-arbitrary.test.ts +347 -0
- package/src/scenarios/replay-fork.test.ts +216 -0
- package/src/scenarios/replayDeterminism.test.ts +171 -0
- package/src/scenarios/route-coverage.test.ts +129 -0
- package/src/scenarios/runs-lifecycle.test.ts +65 -0
- package/src/scenarios/runtime-capabilities.test.ts +118 -0
- package/src/scenarios/spec-corpus-validity.test.ts +1257 -0
- package/src/scenarios/staleClaim.test.ts +223 -0
- package/src/scenarios/stream-modes-buffer.test.ts +148 -0
- package/src/scenarios/stream-modes-mixed.test.ts +149 -0
- package/src/scenarios/stream-modes.test.ts +139 -0
- package/src/scenarios/streamReconnect.test.ts +162 -0
- package/src/scenarios/subworkflow.test.ts +126 -0
- package/src/scenarios/version-negotiation.test.ts +157 -0
- package/src/scenarios/wasm-pack-abi-version-rejection.test.ts +47 -0
- package/src/scenarios/wasm-pack-invoke-completed.test.ts +69 -0
- package/src/scenarios/wasm-pack-invoke-suspended.test.ts +74 -0
- package/src/scenarios/wasm-pack-load.test.ts +75 -0
- package/src/scenarios/wasm-pack-memory-cap.test.ts +43 -0
- package/src/scenarios/wasm-pack-replay-determinism.test.ts +61 -0
- package/src/scenarios/webhook-sig-algorithm.test.ts +61 -0
- package/src/setup.ts +173 -0
- 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
|
+
}
|