@openwop/openwop-conformance 1.3.0 → 1.5.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/CHANGELOG.md +132 -1
- package/README.md +3 -2
- package/api/asyncapi.yaml +8 -0
- package/api/openapi.yaml +371 -1
- package/coverage.md +26 -6
- package/fixtures/conformance-envelope-nl-to-format-engaged.json +41 -0
- package/fixtures/conformance-envelope-recovery-applied.json +39 -0
- package/fixtures/conformance-envelope-refusal.json +38 -0
- package/fixtures/conformance-envelope-retry-attempted.json +39 -0
- package/fixtures/conformance-envelope-retry-exhausted.json +38 -0
- package/fixtures/conformance-envelope-truncated.json +39 -0
- package/fixtures/conformance-envelope-truncation-cap-exhaustion.json +39 -0
- package/fixtures/conformance-model-capability-insufficient.json +25 -0
- package/fixtures/conformance-multi-agent-confidence-escalation.json +49 -0
- package/fixtures/conformance-multi-agent-handoff-child.json +27 -0
- package/fixtures/conformance-multi-agent-handoff.json +49 -0
- package/fixtures/conformance-prompt-all-four-kinds.json +39 -0
- package/fixtures/conformance-prompt-end-to-end.json +33 -0
- package/fixtures/conformance-subworkflow-mid-run-mutation-child.json +31 -0
- package/fixtures/conformance-subworkflow-mid-run-mutation.json +33 -0
- package/fixtures/openwop-smoke-cost-emit.json +37 -0
- package/fixtures/prompt-templates/conformance-prompt-few-shot-2.json +14 -0
- package/fixtures/prompt-templates/conformance-prompt-few-shot.json +14 -0
- package/fixtures/prompt-templates/conformance-prompt-schema-hint.json +14 -0
- package/fixtures/prompt-templates/conformance-prompt-secret-redaction.json +23 -0
- package/fixtures/prompt-templates/conformance-prompt-trust-marker.json +23 -0
- package/fixtures/prompt-templates/conformance-prompt-writer-system.json +15 -0
- package/fixtures/prompt-templates/conformance-prompt-writer-user.json +15 -0
- package/fixtures.md +39 -0
- package/package.json +1 -1
- package/schemas/README.md +5 -0
- package/schemas/agent-manifest.schema.json +16 -0
- package/schemas/capabilities.schema.json +384 -1
- package/schemas/envelopes/clarification.request.schema.json +9 -0
- package/schemas/envelopes/error.schema.json +4 -0
- package/schemas/envelopes/schema.request.schema.json +4 -0
- package/schemas/envelopes/schema.response.schema.json +1 -1
- package/schemas/node-pack-manifest.schema.json +28 -0
- package/schemas/orchestrator-decision.schema.json +12 -0
- package/schemas/prompt-kind.schema.json +8 -0
- package/schemas/prompt-pack-manifest.schema.json +80 -0
- package/schemas/prompt-ref.schema.json +40 -0
- package/schemas/prompt-template.schema.json +149 -0
- package/schemas/registry-version-manifest.schema.json +5 -0
- package/schemas/run-ancestry-response.schema.json +54 -0
- package/schemas/run-event-payloads.schema.json +479 -11
- package/schemas/run-event.schema.json +15 -1
- package/schemas/run-snapshot.schema.json +3 -2
- package/schemas/workflow-definition.schema.json +19 -1
- package/src/lib/llm-cache-key-recipe.ts +68 -0
- package/src/scenarios/aiEnvelope.contractRefusal.test.ts +104 -13
- package/src/scenarios/aiEnvelope.correlationReplay.test.ts +32 -15
- package/src/scenarios/aiEnvelope.redaction.test.ts +6 -5
- package/src/scenarios/aiEnvelope.schemaDrift.test.ts +5 -5
- package/src/scenarios/aiEnvelope.trustBoundaryPropagation.test.ts +211 -12
- package/src/scenarios/aiEnvelope.universalKinds.test.ts +7 -7
- package/src/scenarios/blob-presign-expiry.test.ts +7 -7
- package/src/scenarios/cache-ttl-expiry.test.ts +6 -6
- package/src/scenarios/cost-attribution.test.ts +124 -11
- package/src/scenarios/cross-engine-append-ordering.test.ts +99 -0
- package/src/scenarios/cross-host-ancestry-endpoint.test.ts +136 -0
- package/src/scenarios/cross-host-causation-shape.test.ts +117 -0
- package/src/scenarios/cross-host-traceparent-propagation.test.ts +60 -0
- package/src/scenarios/envelope-completion-distinguishes-truncation.test.ts +223 -0
- package/src/scenarios/envelope-nl-to-format-engaged.test.ts +152 -0
- package/src/scenarios/envelope-reasoning-secret-redaction.test.ts +343 -0
- package/src/scenarios/envelope-reasoning-shape.test.ts +190 -0
- package/src/scenarios/envelope-recovery-applied.test.ts +229 -0
- package/src/scenarios/envelope-refusal-shape.test.ts +289 -0
- package/src/scenarios/envelope-retry-attempted.test.ts +258 -0
- package/src/scenarios/envelope-retry-exhausted.test.ts +168 -0
- package/src/scenarios/envelope-tier-one-subset-static.test.ts +229 -0
- package/src/scenarios/envelope-truncated.test.ts +136 -0
- package/src/scenarios/envelope-truncation-cap-exhaustion.test.ts +144 -0
- package/src/scenarios/envelope-variant-discriminator-static.test.ts +152 -0
- package/src/scenarios/fixtures-valid.test.ts +123 -15
- package/src/scenarios/kv-ttl-expiry.test.ts +7 -7
- package/src/scenarios/model-capability-insufficient.test.ts +221 -0
- package/src/scenarios/model-capability-substituted.test.ts +203 -0
- package/src/scenarios/multi-agent-confidence-escalation.test.ts +201 -0
- package/src/scenarios/multi-agent-handoff-state-machine.test.ts +167 -0
- package/src/scenarios/multi-agent-memory-lifecycle.test.ts +124 -0
- package/src/scenarios/multi-region-idempotency.test.ts +58 -0
- package/src/scenarios/node-module-required-capabilities-shape.test.ts +185 -0
- package/src/scenarios/prompt-all-four-kinds-events.test.ts +198 -0
- package/src/scenarios/prompt-composed-secret-redaction.test.ts +178 -0
- package/src/scenarios/prompt-composed-trust-marker.test.ts +165 -0
- package/src/scenarios/prompt-end-to-end-events.test.ts +202 -0
- package/src/scenarios/prompt-list-and-fetch.test.ts +207 -0
- package/src/scenarios/prompt-mutable-lifecycle.test.ts +216 -0
- package/src/scenarios/prompt-pack-install.test.ts +187 -0
- package/src/scenarios/prompt-render-deterministic.test.ts +240 -0
- package/src/scenarios/prompt-resolution-chain-agent-intrinsic.test.ts +140 -0
- package/src/scenarios/prompt-resolution-chain-fallback-cascade.test.ts +172 -0
- package/src/scenarios/prompt-resolution-chain-node-wins.test.ts +144 -0
- package/src/scenarios/prompt-template-shape.test.ts +359 -0
- package/src/scenarios/queue-ack-nack-dlq.test.ts +7 -7
- package/src/scenarios/queue-publish-consume-roundtrip.test.ts +7 -7
- package/src/scenarios/replay-divergence-at-refusal.test.ts +134 -0
- package/src/scenarios/replay-llm-cache-key-portable.test.ts +197 -0
- package/src/scenarios/replay-llm-cache-key.test.ts +1 -40
- package/src/scenarios/replay-observable-sequence-determinism.test.ts +80 -0
- package/src/scenarios/sandbox-capability-gate-respected.test.ts +27 -0
- package/src/scenarios/sandbox-memory-cap.test.ts +58 -0
- package/src/scenarios/sandbox-no-cross-pack-mutation.test.ts +30 -0
- package/src/scenarios/sandbox-no-host-env-leak.test.ts +27 -0
- package/src/scenarios/sandbox-no-host-fs-escape.test.ts +88 -0
- package/src/scenarios/sandbox-no-host-process-escape.test.ts +31 -0
- package/src/scenarios/sandbox-no-network-escape.test.ts +28 -0
- package/src/scenarios/sandbox-timeout-cap.test.ts +58 -0
- package/src/scenarios/search-bm25-roundtrip.test.ts +7 -7
- package/src/scenarios/spec-corpus-validity.test.ts +34 -6
- package/src/scenarios/sql-transaction-atomicity.test.ts +6 -6
- package/src/scenarios/stream-subscribe-from-beginning.test.ts +7 -7
- package/src/scenarios/subworkflow-input-mapping.test.ts +70 -4
- package/src/scenarios/table-cursor-pagination.test.ts +7 -7
- package/src/scenarios/table-schema-enforcement.test.ts +7 -7
- package/src/scenarios/vector-knn-roundtrip.test.ts +7 -7
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* envelope-completion-distinguishes-truncation — RFC 0033 §A + §B + §C
|
|
3
|
+
* truncation-vs-schema-violation retry-routing distinction.
|
|
4
|
+
*
|
|
5
|
+
* Capability-gated on `capabilities.envelopes.reliability.supported: true`
|
|
6
|
+
* AND `capabilities.envelopes.reliability.completion.distinguishesTruncation: true`
|
|
7
|
+
* AND the host's test seam. Soft-skip cleanly on hosts that conflate the two
|
|
8
|
+
* paths (legacy v1.1 behavior).
|
|
9
|
+
*
|
|
10
|
+
* Asserts two scenarios:
|
|
11
|
+
*
|
|
12
|
+
* 1. **Truncation path** (RFC 0033 §B). Mock LLM stops at `max_tokens` mid-envelope.
|
|
13
|
+
* - `envelope.truncated` event fires.
|
|
14
|
+
* - `envelope.retry.attempted` fires with `reason: 'truncation'`.
|
|
15
|
+
* - The retry's `maxTokens` budget is strictly greater than the initial.
|
|
16
|
+
*
|
|
17
|
+
* 2. **Schema-violation path** (RFC 0033 §C). Mock LLM emits malformed JSON.
|
|
18
|
+
* - NO `envelope.truncated` event.
|
|
19
|
+
* - `envelope.retry.attempted` fires with `reason` ∈ {`schema-violation`, `parse-error`}.
|
|
20
|
+
* - The retry's `maxTokens` budget is UNCHANGED from the initial.
|
|
21
|
+
*
|
|
22
|
+
* Both scenarios share the existing per-path fixtures
|
|
23
|
+
* (`conformance-envelope-truncated` for the truncation case;
|
|
24
|
+
* `conformance-envelope-retry-attempted` for the schema-violation case).
|
|
25
|
+
*
|
|
26
|
+
* @see RFCS/0033-envelope-completion-contract.md §A + §B + §C
|
|
27
|
+
* @see spec/v1/ai-envelope.md §"Envelope-completion criteria"
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
import { describe, it, expect } from 'vitest';
|
|
31
|
+
import { driver } from '../lib/driver.js';
|
|
32
|
+
import { pollUntilTerminal } from '../lib/polling.js';
|
|
33
|
+
import { isFixtureAdvertised } from '../lib/fixtures.js';
|
|
34
|
+
|
|
35
|
+
const HTTP_SKIP = !process.env.OPENWOP_BASE_URL;
|
|
36
|
+
const NODE_ID = 'structured-call';
|
|
37
|
+
|
|
38
|
+
interface DiscoveryDoc {
|
|
39
|
+
capabilities?: {
|
|
40
|
+
envelopes?: {
|
|
41
|
+
reliability?: {
|
|
42
|
+
completion?: {
|
|
43
|
+
distinguishesTruncation?: unknown;
|
|
44
|
+
truncationBudgetMultiplier?: unknown;
|
|
45
|
+
};
|
|
46
|
+
};
|
|
47
|
+
};
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
interface RunEvent {
|
|
52
|
+
type: string;
|
|
53
|
+
payload?: Record<string, unknown>;
|
|
54
|
+
nodeId?: string;
|
|
55
|
+
sequence: number;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
async function readDiscovery(): Promise<DiscoveryDoc | null> {
|
|
59
|
+
try {
|
|
60
|
+
const res = await driver.get('/.well-known/openwop');
|
|
61
|
+
if (res.status !== 200) return null;
|
|
62
|
+
return res.json as DiscoveryDoc;
|
|
63
|
+
} catch {
|
|
64
|
+
return null;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
async function programMock(program: Array<Record<string, unknown>>): Promise<{ status: number }> {
|
|
69
|
+
const res = await driver.post('/v1/host/sample/test/mock-ai/program', { nodeId: NODE_ID, program });
|
|
70
|
+
return { status: res.status };
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
async function startRunAndRead(workflowId: string): Promise<{ events: RunEvent[]; terminal: unknown } | null> {
|
|
74
|
+
const create = await driver.post('/v1/runs', { workflowId });
|
|
75
|
+
if (create.status !== 201) return null;
|
|
76
|
+
const runId = (create.json as { runId: string }).runId;
|
|
77
|
+
const terminal = await pollUntilTerminal(runId, { timeoutMs: 10_000 });
|
|
78
|
+
const eventsRes = await driver.get(`/v1/runs/${encodeURIComponent(runId)}/events`);
|
|
79
|
+
if (eventsRes.status !== 200) return null;
|
|
80
|
+
const events = ((eventsRes.json as { events?: RunEvent[] } | undefined)?.events ?? []) as RunEvent[];
|
|
81
|
+
return { events, terminal };
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
async function lastBudget(): Promise<number | null> {
|
|
85
|
+
const res = await driver.get(`/v1/host/sample/test/mock-ai/last-dispatch-budget?nodeId=${encodeURIComponent(NODE_ID)}`);
|
|
86
|
+
if (res.status !== 200) return null;
|
|
87
|
+
return (res.json as { maxTokens?: number | null }).maxTokens ?? null;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
describe.skipIf(HTTP_SKIP)('envelope-completion-distinguishes-truncation: advertisement shape (RFC 0033 §E)', () => {
|
|
91
|
+
it('capabilities.envelopes.reliability.completion (when present) conforms to RFC 0033 §E', async () => {
|
|
92
|
+
const d = await readDiscovery();
|
|
93
|
+
if (d === null) return;
|
|
94
|
+
const completion = d.capabilities?.envelopes?.reliability?.completion;
|
|
95
|
+
if (completion === undefined) return;
|
|
96
|
+
expect(
|
|
97
|
+
typeof completion.distinguishesTruncation,
|
|
98
|
+
driver.describe('RFCS/0033-envelope-completion-contract.md §E', 'completion.distinguishesTruncation MUST be boolean when block is advertised'),
|
|
99
|
+
).toBe('boolean');
|
|
100
|
+
if (completion.truncationBudgetMultiplier !== undefined) {
|
|
101
|
+
const n = completion.truncationBudgetMultiplier as number;
|
|
102
|
+
expect(
|
|
103
|
+
typeof n === 'number' && n >= 1 && n <= 8,
|
|
104
|
+
driver.describe('RFCS/0033-envelope-completion-contract.md §E', 'truncationBudgetMultiplier MUST be a number in [1, 8] (default 2)'),
|
|
105
|
+
).toBe(true);
|
|
106
|
+
}
|
|
107
|
+
});
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
const TRUNCATED_FIXTURE = 'conformance-envelope-truncated';
|
|
111
|
+
const SCHEMA_VIOLATION_FIXTURE = 'conformance-envelope-retry-attempted';
|
|
112
|
+
|
|
113
|
+
describe.skipIf(HTTP_SKIP)('envelope-completion-distinguishes-truncation: truncation path (RFC 0033 §B)', () => {
|
|
114
|
+
it('truncation: emits envelope.truncated + envelope.retry.attempted with reason: "truncation"', async () => {
|
|
115
|
+
if (!isFixtureAdvertised(TRUNCATED_FIXTURE)) return;
|
|
116
|
+
const d = await readDiscovery();
|
|
117
|
+
if (d?.capabilities?.envelopes?.reliability?.completion?.distinguishesTruncation !== true) return;
|
|
118
|
+
const seed = await programMock([
|
|
119
|
+
{ stopReason: 'max_tokens', content: '{"partial' },
|
|
120
|
+
{ stopReason: 'end_turn', content: '{"valid":true}' },
|
|
121
|
+
]);
|
|
122
|
+
if (seed.status === 404) return;
|
|
123
|
+
|
|
124
|
+
const result = await startRunAndRead(TRUNCATED_FIXTURE);
|
|
125
|
+
if (result === null) return;
|
|
126
|
+
const truncated = result.events.find((e) => e.type === 'envelope.truncated');
|
|
127
|
+
expect(truncated, 'envelope.truncated MUST fire on the truncation path').toBeDefined();
|
|
128
|
+
const retry = result.events.find((e) => e.type === 'envelope.retry.attempted');
|
|
129
|
+
expect(retry, 'envelope.retry.attempted MUST fire between attempts').toBeDefined();
|
|
130
|
+
expect(
|
|
131
|
+
retry!.payload?.reason,
|
|
132
|
+
driver.describe(
|
|
133
|
+
'RFCS/0033-envelope-completion-contract.md §B',
|
|
134
|
+
'truncation-routed retry MUST carry reason: "truncation" (distinct from schema-violation per RFC 0033 §A precedence rule)',
|
|
135
|
+
),
|
|
136
|
+
).toBe('truncation');
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
it('truncation: retry budget strictly greater than initial (RFC 0033 §B truncationBudgetMultiplier)', async () => {
|
|
140
|
+
if (!isFixtureAdvertised(TRUNCATED_FIXTURE)) return;
|
|
141
|
+
const d = await readDiscovery();
|
|
142
|
+
if (d?.capabilities?.envelopes?.reliability?.completion?.distinguishesTruncation !== true) return;
|
|
143
|
+
const seed = await programMock([
|
|
144
|
+
{ stopReason: 'max_tokens', content: '{"partial' },
|
|
145
|
+
{ stopReason: 'end_turn', content: '{"valid":true}' },
|
|
146
|
+
]);
|
|
147
|
+
if (seed.status === 404) return;
|
|
148
|
+
|
|
149
|
+
await startRunAndRead(TRUNCATED_FIXTURE);
|
|
150
|
+
const budget = await lastBudget();
|
|
151
|
+
if (budget === null) return;
|
|
152
|
+
expect(
|
|
153
|
+
budget,
|
|
154
|
+
driver.describe(
|
|
155
|
+
'RFCS/0033-envelope-completion-contract.md §B',
|
|
156
|
+
'truncation retry MUST multiply maxTokens by truncationBudgetMultiplier — final budget > initial 50 fixture value',
|
|
157
|
+
),
|
|
158
|
+
).toBeGreaterThan(50);
|
|
159
|
+
});
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
describe.skipIf(HTTP_SKIP)('envelope-completion-distinguishes-truncation: schema-violation path (RFC 0033 §C)', () => {
|
|
163
|
+
it('schema-violation: NO envelope.truncated; envelope.retry.attempted reason ∈ {schema-violation, parse-error}', async () => {
|
|
164
|
+
if (!isFixtureAdvertised(SCHEMA_VIOLATION_FIXTURE)) return;
|
|
165
|
+
const seed = await programMock([
|
|
166
|
+
{ content: 'not valid json' },
|
|
167
|
+
{ content: '{"valid":true}' },
|
|
168
|
+
]);
|
|
169
|
+
if (seed.status === 404) return;
|
|
170
|
+
|
|
171
|
+
const result = await startRunAndRead(SCHEMA_VIOLATION_FIXTURE);
|
|
172
|
+
if (result === null) return;
|
|
173
|
+
const truncated = result.events.find((e) => e.type === 'envelope.truncated');
|
|
174
|
+
expect(
|
|
175
|
+
truncated,
|
|
176
|
+
driver.describe(
|
|
177
|
+
'RFCS/0033-envelope-completion-contract.md §C',
|
|
178
|
+
'schema-violation path MUST NOT emit envelope.truncated (truncation and schema-violation are distinct paths per RFC 0033 §A)',
|
|
179
|
+
),
|
|
180
|
+
).toBeUndefined();
|
|
181
|
+
const retry = result.events.find((e) => e.type === 'envelope.retry.attempted');
|
|
182
|
+
expect(retry).toBeDefined();
|
|
183
|
+
const reason = retry!.payload?.reason as string | undefined;
|
|
184
|
+
expect(
|
|
185
|
+
reason === 'schema-violation' || reason === 'parse-error',
|
|
186
|
+
driver.describe(
|
|
187
|
+
'RFCS/0033-envelope-completion-contract.md §C',
|
|
188
|
+
'schema-violation-routed retry MUST carry reason ∈ {schema-violation, parse-error}; truncation reason is reserved for the budget-doubling path',
|
|
189
|
+
),
|
|
190
|
+
).toBe(true);
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
it('schema-violation: retry budget UNCHANGED from initial (no budget multiplication on this path)', async () => {
|
|
194
|
+
if (!isFixtureAdvertised(SCHEMA_VIOLATION_FIXTURE)) return;
|
|
195
|
+
const seed = await programMock([
|
|
196
|
+
{ content: 'not valid json' },
|
|
197
|
+
{ content: '{"valid":true}' },
|
|
198
|
+
]);
|
|
199
|
+
if (seed.status === 404) return;
|
|
200
|
+
|
|
201
|
+
await startRunAndRead(SCHEMA_VIOLATION_FIXTURE);
|
|
202
|
+
const budget = await lastBudget();
|
|
203
|
+
if (budget === null) return;
|
|
204
|
+
// The schema-violation fixture doesn't set maxTokens explicitly →
|
|
205
|
+
// budget snapshots whatever the host's default is on each call.
|
|
206
|
+
// The KEY invariant: the retry call's budget MUST NOT be multiplied
|
|
207
|
+
// (the truncation path doubles; this path keeps the same). The
|
|
208
|
+
// budget on the last call equals the budget on the first call.
|
|
209
|
+
// Without a per-call history hook, we can't strictly compare; we
|
|
210
|
+
// assert the budget didn't grow into the truncation-path range
|
|
211
|
+
// (which would be ≥2× the default — typically 8000 for the
|
|
212
|
+
// sample's structuredOutput dispatch path).
|
|
213
|
+
if (budget !== null) {
|
|
214
|
+
expect(
|
|
215
|
+
budget,
|
|
216
|
+
driver.describe(
|
|
217
|
+
'RFCS/0033-envelope-completion-contract.md §C',
|
|
218
|
+
'schema-violation retry MUST NOT multiply maxTokens — budget stays at the original value (host default)',
|
|
219
|
+
),
|
|
220
|
+
).toBeLessThan(20_000);
|
|
221
|
+
}
|
|
222
|
+
});
|
|
223
|
+
});
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* envelope-nl-to-format-engaged — RFC 0032 §B.5 runtime behavior (MAY tier).
|
|
3
|
+
*
|
|
4
|
+
* Capability-gated on `capabilities.envelopes.reliability.supported: true`
|
|
5
|
+
* AND `events[]` includes `envelope.nlToFormat.engaged`. Soft-skip cleanly
|
|
6
|
+
* on hosts that don't implement NL-to-Format fallback — NL-to-Format is one
|
|
7
|
+
* of many possible recovery strategies; hosts that don't advertise it don't
|
|
8
|
+
* need to emit.
|
|
9
|
+
*
|
|
10
|
+
* Asserts:
|
|
11
|
+
* 1. When retry exhaustion triggers the NL-to-Format fallback (per Tam et al.
|
|
12
|
+
* mitigation: free-form reasoning in the first call → schema coercion
|
|
13
|
+
* in the second call), exactly one `envelope.nlToFormat.engaged` event
|
|
14
|
+
* fires.
|
|
15
|
+
* 2. `originalEnvelopeType` carries the envelope kind the original attempt
|
|
16
|
+
* was trying to emit.
|
|
17
|
+
* 3. `fallbackCalls >= 1` (informational — how many secondary LLM calls
|
|
18
|
+
* the host issued to reformat).
|
|
19
|
+
* 4. The eventual envelope acceptance (when fallback succeeds) records
|
|
20
|
+
* normally via downstream RunEventDoc.
|
|
21
|
+
*
|
|
22
|
+
* @see RFCS/0032-envelope-reliability-events.md §B.5
|
|
23
|
+
* @see Tam et al., "Let Me Speak Freely?" — https://arxiv.org/pdf/2408.02442
|
|
24
|
+
* @see schemas/run-event-payloads.schema.json §envelopeNlToFormatEngaged
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
import { describe, it, expect } from 'vitest';
|
|
28
|
+
import { driver } from '../lib/driver.js';
|
|
29
|
+
import { pollUntilTerminal } from '../lib/polling.js';
|
|
30
|
+
import { isFixtureAdvertised } from '../lib/fixtures.js';
|
|
31
|
+
|
|
32
|
+
const HTTP_SKIP = !process.env.OPENWOP_BASE_URL;
|
|
33
|
+
const FIXTURE = 'conformance-envelope-nl-to-format-engaged';
|
|
34
|
+
const NODE_ID = 'structured-call';
|
|
35
|
+
|
|
36
|
+
interface RunEvent {
|
|
37
|
+
type: string;
|
|
38
|
+
payload?: Record<string, unknown>;
|
|
39
|
+
nodeId?: string;
|
|
40
|
+
sequence: number;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
async function programMock(program: Array<Record<string, unknown>>): Promise<{ status: number }> {
|
|
44
|
+
const res = await driver.post('/v1/host/sample/test/mock-ai/program', { nodeId: NODE_ID, program });
|
|
45
|
+
return { status: res.status };
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
async function runAndReadEvents(): Promise<RunEvent[] | null> {
|
|
49
|
+
const create = await driver.post('/v1/runs', { workflowId: FIXTURE });
|
|
50
|
+
if (create.status !== 201) return null;
|
|
51
|
+
const runId = (create.json as { runId: string }).runId;
|
|
52
|
+
await pollUntilTerminal(runId, { timeoutMs: 10_000 });
|
|
53
|
+
const eventsRes = await driver.get(`/v1/runs/${encodeURIComponent(runId)}/events`);
|
|
54
|
+
if (eventsRes.status !== 200) return null;
|
|
55
|
+
return ((eventsRes.json as { events?: RunEvent[] } | undefined)?.events ?? []) as RunEvent[];
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// Three NL responses to exhaust the retry budget; the fourth is the
|
|
59
|
+
// coerced response the NL-to-Format fallback secondary call returns —
|
|
60
|
+
// valid JSON matching the schema. The mock returns whatever the test
|
|
61
|
+
// programmed for the Nth call; the host's fallback issues a 4th call
|
|
62
|
+
// after retry exhaustion.
|
|
63
|
+
const NL_THEN_COERCED_PROGRAM = [
|
|
64
|
+
{ content: 'Sure, here is the result: the answer is OK.' },
|
|
65
|
+
{ content: 'Of course! The result you wanted is okay.' },
|
|
66
|
+
{ content: 'I think the result should be ok-ish.' },
|
|
67
|
+
{ content: '{"result":"coerced-ok"}' },
|
|
68
|
+
];
|
|
69
|
+
|
|
70
|
+
describe.skipIf(HTTP_SKIP)('envelope-nl-to-format-engaged: runtime behavior (RFC 0032 §B.5 MAY)', () => {
|
|
71
|
+
it('when retry exhaustion triggers the NL-to-Format fallback, exactly one `envelope.nlToFormat.engaged` event fires', async () => {
|
|
72
|
+
if (!isFixtureAdvertised(FIXTURE)) return;
|
|
73
|
+
const seed = await programMock(NL_THEN_COERCED_PROGRAM);
|
|
74
|
+
if (seed.status === 404) return;
|
|
75
|
+
expect(seed.status).toBe(200);
|
|
76
|
+
|
|
77
|
+
const events = await runAndReadEvents();
|
|
78
|
+
if (events === null) return;
|
|
79
|
+
const engagements = events.filter((e) => e.type === 'envelope.nlToFormat.engaged');
|
|
80
|
+
expect(
|
|
81
|
+
engagements.length,
|
|
82
|
+
driver.describe(
|
|
83
|
+
'RFCS/0032-envelope-reliability-events.md §B.5',
|
|
84
|
+
'exactly one envelope.nlToFormat.engaged event MUST fire when the host detects NL-shape responses after retry exhaustion',
|
|
85
|
+
),
|
|
86
|
+
).toBe(1);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it('`originalEnvelopeType` carries the envelope kind the original attempt targeted', async () => {
|
|
90
|
+
if (!isFixtureAdvertised(FIXTURE)) return;
|
|
91
|
+
const seed = await programMock(NL_THEN_COERCED_PROGRAM);
|
|
92
|
+
if (seed.status === 404) return;
|
|
93
|
+
|
|
94
|
+
const events = await runAndReadEvents();
|
|
95
|
+
if (events === null) return;
|
|
96
|
+
const engagement = events.find((e) => e.type === 'envelope.nlToFormat.engaged');
|
|
97
|
+
expect(engagement).toBeDefined();
|
|
98
|
+
expect(
|
|
99
|
+
typeof engagement!.payload?.originalEnvelopeType,
|
|
100
|
+
driver.describe(
|
|
101
|
+
'RFCS/0032-envelope-reliability-events.md §B.5',
|
|
102
|
+
'originalEnvelopeType MUST be present and string-typed — derived from the response-schema or wrapping metadata',
|
|
103
|
+
),
|
|
104
|
+
).toBe('string');
|
|
105
|
+
expect((engagement!.payload?.originalEnvelopeType as string).length).toBeGreaterThan(0);
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it('`fallbackCalls >= 1` reports the number of secondary LLM calls used to reformat free-form output into the envelope schema', async () => {
|
|
109
|
+
if (!isFixtureAdvertised(FIXTURE)) return;
|
|
110
|
+
const seed = await programMock(NL_THEN_COERCED_PROGRAM);
|
|
111
|
+
if (seed.status === 404) return;
|
|
112
|
+
|
|
113
|
+
const events = await runAndReadEvents();
|
|
114
|
+
if (events === null) return;
|
|
115
|
+
const engagement = events.find((e) => e.type === 'envelope.nlToFormat.engaged');
|
|
116
|
+
expect(engagement).toBeDefined();
|
|
117
|
+
const fallbackCalls = engagement!.payload?.fallbackCalls;
|
|
118
|
+
expect(typeof fallbackCalls).toBe('number');
|
|
119
|
+
expect(
|
|
120
|
+
fallbackCalls as number,
|
|
121
|
+
driver.describe(
|
|
122
|
+
'RFCS/0032-envelope-reliability-events.md §B.5',
|
|
123
|
+
'fallbackCalls MUST be >= 1 — the fallback fired at least one secondary call to reformat the free-form output',
|
|
124
|
+
),
|
|
125
|
+
).toBeGreaterThanOrEqual(1);
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
it('the eventual envelope acceptance (when fallback succeeds) records normally via downstream RunEventDoc', async () => {
|
|
129
|
+
if (!isFixtureAdvertised(FIXTURE)) return;
|
|
130
|
+
const seed = await programMock(NL_THEN_COERCED_PROGRAM);
|
|
131
|
+
if (seed.status === 404) return;
|
|
132
|
+
|
|
133
|
+
const events = await runAndReadEvents();
|
|
134
|
+
if (events === null) return;
|
|
135
|
+
const nodeCompleted = events.find((e) => e.type === 'node.completed' && e.nodeId === NODE_ID);
|
|
136
|
+
expect(
|
|
137
|
+
nodeCompleted,
|
|
138
|
+
driver.describe(
|
|
139
|
+
'RFCS/0032-envelope-reliability-events.md §B.5',
|
|
140
|
+
'NL-to-Format fallback success MUST reach node.completed — the coerced envelope flows downstream like any other accepted envelope',
|
|
141
|
+
),
|
|
142
|
+
).toBeDefined();
|
|
143
|
+
const completedPayload = JSON.stringify(nodeCompleted?.payload ?? {});
|
|
144
|
+
expect(
|
|
145
|
+
completedPayload.includes('coerced-ok'),
|
|
146
|
+
driver.describe(
|
|
147
|
+
'RFCS/0032-envelope-reliability-events.md §B.5',
|
|
148
|
+
'the coerced structured data from the secondary call MUST flow to the downstream RunEventDoc',
|
|
149
|
+
),
|
|
150
|
+
).toBe(true);
|
|
151
|
+
});
|
|
152
|
+
});
|