@openagentsinc/conformance-kit 0.2.0-rc.2

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.
@@ -0,0 +1,17 @@
1
+ import {
2
+ applyUiChunk,
3
+ initialUiMessage,
4
+ reduceUiMessageStream,
5
+ UiMessageReducerError,
6
+ } from "@openagentsinc/agent-harness-contract";
7
+
8
+ import { runReducerLaws } from "./reducer-laws.ts";
9
+
10
+ // The kit run against the in-repo reference reducer proves the kit itself works.
11
+ runReducerLaws({
12
+ label: "ui-message-reducer",
13
+ initial: () => initialUiMessage(),
14
+ applyChunk: applyUiChunk,
15
+ isReducerError: (error) => error instanceof UiMessageReducerError,
16
+ reduceStream: (stream) => reduceUiMessageStream(stream),
17
+ });
@@ -0,0 +1,277 @@
1
+ import { Effect, Stream } from "effect";
2
+ import {
3
+ toolIdentity,
4
+ type UiMessage,
5
+ type UiMessageChunk,
6
+ type UiMessageStreamHandle,
7
+ } from "@openagentsinc/agent-harness-contract";
8
+ import { describe, expect, test } from "vite-plus/test";
9
+
10
+ const BASH = toolIdentity("Bash");
11
+
12
+ /**
13
+ * Configuration for {@link runReducerLaws}. The implementation under test is a
14
+ * progressive reducer: a pure `applyChunk` fold step plus its `initial`
15
+ * message. The kit feeds known chunk scripts and asserts the progressive
16
+ * snapshot laws hold. `isReducerError` recognizes the implementation's tagged
17
+ * error so a malformed sequence is proven to fail loudly, never to silently
18
+ * corrupt state.
19
+ */
20
+ export interface ReducerLawsConfig {
21
+ /** A short label naming the implementation under test, used in test titles. */
22
+ readonly label: string;
23
+ /** The empty starting message. */
24
+ readonly initial: () => UiMessage;
25
+ /** The pure fold step. Must throw the tagged reducer error on a malformed sequence. */
26
+ readonly applyChunk: (message: UiMessage, chunk: UiMessageChunk) => UiMessage;
27
+ /** Recognizes the implementation's tagged reducer error. */
28
+ readonly isReducerError: (error: unknown) => boolean;
29
+ /**
30
+ * Optional stream reducer. When present, the kit also proves the streamed
31
+ * fold publishes the same progressive snapshots the pure fold produces and
32
+ * `done` equals the pure fold's final state.
33
+ */
34
+ readonly reduceStream?: (
35
+ stream: Stream.Stream<UiMessageChunk>,
36
+ ) => Effect.Effect<UiMessageStreamHandle<never>>;
37
+ }
38
+
39
+ /**
40
+ * The progressive-reducer snapshot laws, parameterized over any reducer.
41
+ *
42
+ * Promoted, published form of
43
+ * `agent-harness-contract/src/ui-message-reducer.test.ts`:
44
+ *
45
+ * - **Progressive fold.** Text grows across intermediate snapshots and closes
46
+ * on finish; status advances `streaming -> complete`. Every intermediate
47
+ * state is observable, not just the final one.
48
+ * - **Tool state machine.** `input-streaming -> input-available ->
49
+ * output-available` transitions land in order with accumulated input text.
50
+ * - **Transient bypass.** A transient chunk never mutates the persisted message.
51
+ * - **Fail loud, never corrupt.** A malformed sequence (ghost output, state
52
+ * regression, text after end) throws the tagged reducer error.
53
+ */
54
+ export const runReducerLaws = (config: ReducerLawsConfig): void => {
55
+ const { label, initial, applyChunk, isReducerError } = config;
56
+
57
+ /** Fold chunks into every intermediate snapshot, starting from the initial. */
58
+ const snapshotsOf = (
59
+ chunks: ReadonlyArray<UiMessageChunk>,
60
+ start: UiMessage = initial(),
61
+ ): ReadonlyArray<UiMessage> => {
62
+ const snapshots: Array<UiMessage> = [start];
63
+ let current = start;
64
+ for (const chunk of chunks) {
65
+ current = applyChunk(current, chunk);
66
+ snapshots.push(current);
67
+ }
68
+ return snapshots;
69
+ };
70
+
71
+ const textOf = (message: UiMessage): string =>
72
+ message.parts
73
+ .filter((part) => part.type === "text")
74
+ .map((part) => (part.type === "text" ? part.text : ""))
75
+ .join("");
76
+
77
+ const toolStateOf = (message: UiMessage): string | undefined => {
78
+ const part = message.parts.find((candidate) => candidate.type === "tool");
79
+ return part?.type === "tool" ? part.state : undefined;
80
+ };
81
+
82
+ const toolInputTextOf = (message: UiMessage): string | undefined => {
83
+ const part = message.parts.find((candidate) => candidate.type === "tool");
84
+ return part?.type === "tool" ? part.inputText : undefined;
85
+ };
86
+
87
+ describe(`[${label}] reducer — progressive snapshots`, () => {
88
+ test("text grows across intermediate snapshots and closes on finish", () => {
89
+ const chunks: ReadonlyArray<UiMessageChunk> = [
90
+ { type: "message-start", messageId: "t1" },
91
+ { type: "text-delta", id: "msg.t1", delta: "Hello " },
92
+ { type: "text-delta", id: "msg.t1", delta: "world" },
93
+ { type: "text-end", id: "msg.t1" },
94
+ { type: "message-finish", finishReason: "stop" },
95
+ ];
96
+ const snapshots = snapshotsOf(chunks);
97
+
98
+ expect(snapshots.map(textOf)).toEqual([
99
+ "",
100
+ "",
101
+ "Hello ",
102
+ "Hello world",
103
+ "Hello world",
104
+ "Hello world",
105
+ ]);
106
+ expect(snapshots.map((snapshot) => snapshot.status)).toEqual([
107
+ "streaming",
108
+ "streaming",
109
+ "streaming",
110
+ "streaming",
111
+ "streaming",
112
+ "complete",
113
+ ]);
114
+ expect(snapshots.at(-1)?.finishReason).toBe("stop");
115
+ });
116
+
117
+ test("the tool-call state machine transitions land in order with accumulated input", () => {
118
+ const chunks: ReadonlyArray<UiMessageChunk> = [
119
+ {
120
+ type: "tool-input-streaming",
121
+ toolCallId: "call.1",
122
+ tool: BASH,
123
+ inputTextDelta: '{"command":',
124
+ },
125
+ { type: "tool-input-streaming", toolCallId: "call.1", tool: BASH, inputTextDelta: '"ls"}' },
126
+ {
127
+ type: "tool-input-available",
128
+ toolCallId: "call.1",
129
+ tool: BASH,
130
+ inputRef: "input.call.1",
131
+ },
132
+ {
133
+ type: "tool-output-available",
134
+ toolCallId: "call.1",
135
+ tool: BASH,
136
+ resultRef: "result.call.1",
137
+ },
138
+ ];
139
+ const snapshots = snapshotsOf(chunks).slice(1);
140
+
141
+ expect(snapshots.map(toolStateOf)).toEqual([
142
+ "input-streaming",
143
+ "input-streaming",
144
+ "input-available",
145
+ "output-available",
146
+ ]);
147
+ expect(snapshots.map(toolInputTextOf)).toEqual([
148
+ '{"command":',
149
+ '{"command":"ls"}',
150
+ '{"command":"ls"}',
151
+ '{"command":"ls"}',
152
+ ]);
153
+ });
154
+
155
+ test("tool-output-error lands the error state with the safe text", () => {
156
+ const chunks: ReadonlyArray<UiMessageChunk> = [
157
+ { type: "tool-input-available", toolCallId: "call.1", tool: BASH },
158
+ {
159
+ type: "tool-output-error",
160
+ toolCallId: "call.1",
161
+ tool: BASH,
162
+ errorText: "command failed",
163
+ errorRef: "error.call.1",
164
+ },
165
+ ];
166
+ const final = snapshotsOf(chunks).at(-1)!;
167
+ const part = final.parts.find((candidate) => candidate.type === "tool");
168
+ expect(part?.type === "tool" ? part.state : undefined).toBe("output-error");
169
+ const errorText =
170
+ part?.type === "tool" && part.state === "output-error" ? part.errorText : undefined;
171
+ expect(errorText).toBe("command failed");
172
+ });
173
+
174
+ test("transient chunks bypass the persisted message", () => {
175
+ const message = applyChunk(initial(), {
176
+ type: "text-delta",
177
+ id: "m",
178
+ delta: "ephemeral",
179
+ transient: true,
180
+ });
181
+ expect(message).toEqual(initial());
182
+ });
183
+ });
184
+
185
+ describe(`[${label}] reducer — fail loud, never corrupt`, () => {
186
+ test("output for a tool call that never streamed input throws the tagged error", () => {
187
+ let thrown: unknown;
188
+ try {
189
+ applyChunk(initial(), {
190
+ type: "tool-output-available",
191
+ toolCallId: "call.ghost",
192
+ tool: BASH,
193
+ resultRef: "result.ghost",
194
+ });
195
+ } catch (error) {
196
+ thrown = error;
197
+ }
198
+ expect(isReducerError(thrown)).toBe(true);
199
+ });
200
+
201
+ test("a state regression (input after output) throws the tagged error", () => {
202
+ const settled = snapshotsOf([
203
+ { type: "tool-input-available", toolCallId: "call.1", tool: BASH },
204
+ { type: "tool-output-available", toolCallId: "call.1", tool: BASH, resultRef: "r.1" },
205
+ ]).at(-1)!;
206
+ let thrown: unknown;
207
+ try {
208
+ applyChunk(settled, {
209
+ type: "tool-input-streaming",
210
+ toolCallId: "call.1",
211
+ tool: BASH,
212
+ inputTextDelta: "more",
213
+ });
214
+ } catch (error) {
215
+ thrown = error;
216
+ }
217
+ expect(isReducerError(thrown)).toBe(true);
218
+ });
219
+
220
+ test("text delta after text end throws the tagged error", () => {
221
+ const closed = snapshotsOf([
222
+ { type: "text-delta", id: "m", delta: "a" },
223
+ { type: "text-end", id: "m" },
224
+ ]).at(-1)!;
225
+ let thrown: unknown;
226
+ try {
227
+ applyChunk(closed, { type: "text-delta", id: "m", delta: "b" });
228
+ } catch (error) {
229
+ thrown = error;
230
+ }
231
+ expect(isReducerError(thrown)).toBe(true);
232
+ });
233
+ });
234
+
235
+ if (config.reduceStream !== undefined) {
236
+ const reduceStream = config.reduceStream;
237
+ describe(`[${label}] reducer — streamed fold equals pure fold`, () => {
238
+ test("done equals the pure fold's final state", async () => {
239
+ const chunks: ReadonlyArray<UiMessageChunk> = [
240
+ { type: "message-start", messageId: "t1" },
241
+ { type: "text-delta", id: "msg.t1", delta: "Hello " },
242
+ { type: "text-delta", id: "msg.t1", delta: "world" },
243
+ { type: "text-end", id: "msg.t1" },
244
+ { type: "message-finish", finishReason: "stop" },
245
+ ];
246
+ const pureFinal = snapshotsOf(chunks).at(-1)!;
247
+
248
+ const streamedFinal = await Effect.runPromise(
249
+ Effect.gen(function* () {
250
+ const handle = yield* reduceStream(Stream.fromIterable(chunks));
251
+ return yield* handle.done;
252
+ }),
253
+ );
254
+ expect(streamedFinal).toEqual(pureFinal);
255
+ });
256
+
257
+ test("a malformed sequence fails done with the tagged error", async () => {
258
+ const outcome = await Effect.runPromise(
259
+ Effect.gen(function* () {
260
+ const handle = yield* reduceStream(
261
+ Stream.fromIterable<UiMessageChunk>([
262
+ {
263
+ type: "tool-output-available",
264
+ toolCallId: "call.ghost",
265
+ tool: BASH,
266
+ resultRef: "result.ghost",
267
+ },
268
+ ]),
269
+ );
270
+ return yield* Effect.flip(handle.done);
271
+ }),
272
+ );
273
+ expect(isReducerError(outcome)).toBe(true);
274
+ });
275
+ });
276
+ }
277
+ };
@@ -0,0 +1,11 @@
1
+ import { makeRlm, rlmInlineCorpusSourceLayer } from "@openagentsinc/rlm";
2
+
3
+ import { runRlmCapLaws } from "./rlm-cap-laws.ts";
4
+
5
+ // The kit run against the in-repo reference RLM engine proves the kit itself
6
+ // works.
7
+ runRlmCapLaws({
8
+ label: "rlm-engine",
9
+ makeEngine: makeRlm,
10
+ corpusSourceLayer: rlmInlineCorpusSourceLayer,
11
+ });
@@ -0,0 +1,207 @@
1
+ import { Effect, type Layer } from "effect";
2
+ import {
3
+ buildInlineCorpusInput,
4
+ type MakeRlmOptions,
5
+ RLM_REQUEST_SCHEMA_ID,
6
+ RlmCorpusSource,
7
+ type RlmDeterministicRequest,
8
+ type RlmShape,
9
+ type RlmTerminalResult,
10
+ } from "@openagentsinc/rlm";
11
+ import { describe, expect, test } from "vite-plus/test";
12
+
13
+ /** A small inline corpus: four public entries that all contain the word "fact". */
14
+ const corpus = buildInlineCorpusInput({
15
+ corpusRef: "corpus.conformance",
16
+ scopeRef: "scope.conformance",
17
+ entries: [
18
+ {
19
+ scopeRef: "scope.conformance",
20
+ sourceKind: "fixture",
21
+ sourceAddress: { addressSchemaId: "test.addr.v1", encodedAddress: "a0" },
22
+ text: "alpha fact one",
23
+ visibility: "public",
24
+ redactionClass: "none",
25
+ },
26
+ {
27
+ scopeRef: "scope.conformance",
28
+ sourceKind: "fixture",
29
+ sourceAddress: { addressSchemaId: "test.addr.v1", encodedAddress: "a1" },
30
+ text: "beta fact two",
31
+ visibility: "public",
32
+ redactionClass: "none",
33
+ },
34
+ {
35
+ scopeRef: "scope.conformance",
36
+ sourceKind: "fixture",
37
+ sourceAddress: { addressSchemaId: "test.addr.v1", encodedAddress: "a2" },
38
+ text: "gamma fact three",
39
+ visibility: "public",
40
+ redactionClass: "none",
41
+ },
42
+ {
43
+ scopeRef: "scope.conformance",
44
+ sourceKind: "fixture",
45
+ sourceAddress: { addressSchemaId: "test.addr.v1", encodedAddress: "a3" },
46
+ text: "delta fact four",
47
+ visibility: "public",
48
+ redactionClass: "none",
49
+ },
50
+ ],
51
+ });
52
+
53
+ const GENEROUS = {
54
+ maxEntriesScanned: 10_000,
55
+ maxSpans: 64,
56
+ maxCharsPerSpan: 2_048,
57
+ maxObservationChars: 8_192,
58
+ } as const;
59
+
60
+ const deterministicRequest = (params: {
61
+ readonly runRef: string;
62
+ readonly limits: RlmDeterministicRequest["limits"];
63
+ }): RlmDeterministicRequest => ({
64
+ _tag: "Deterministic",
65
+ schemaId: RLM_REQUEST_SCHEMA_ID,
66
+ runRef: params.runRef,
67
+ corpus,
68
+ operation: { _tag: "Grep", pattern: "fact" },
69
+ limits: params.limits,
70
+ });
71
+
72
+ /**
73
+ * Configuration for {@link runRlmCapLaws}. The implementation under test is the
74
+ * recursive-language-model engine, supplied as its factory plus the corpus
75
+ * source layer it runs against. The kit drives deterministic runs whose caps
76
+ * bite, and asserts every cap surfaces as an honest `Partial` — never a
77
+ * `Completed` result that quietly dropped work.
78
+ */
79
+ export interface RlmCapLawsConfig {
80
+ /** A short label naming the implementation under test, used in test titles. */
81
+ readonly label: string;
82
+ /** Build the engine shape (reference: `makeRlm`). */
83
+ readonly makeEngine: (options: MakeRlmOptions) => Effect.Effect<RlmShape, never, RlmCorpusSource>;
84
+ /** The corpus source layer the engine resolves against (reference: `rlmInlineCorpusSourceLayer`). */
85
+ readonly corpusSourceLayer: Layer.Layer<RlmCorpusSource>;
86
+ }
87
+
88
+ /**
89
+ * The RLM cap laws, parameterized over any engine + corpus source.
90
+ *
91
+ * Promoted from `rlm/src/conformance/paper-fidelity.test.ts` and the honesty
92
+ * contract in `rlm/src/schemas/request-result.ts`. The single invariant these
93
+ * laws defend is **no laundering**: every budget/cap that bites must surface as
94
+ * an honest `Partial` whose `honesty.capsHit` names the cap — the engine must
95
+ * never present a truncated run as a `Completed` result.
96
+ *
97
+ * - **Every deterministic cap → honest Partial.** A tight `maxSpans` or
98
+ * `maxEntriesScanned` yields `Partial(cap_truncated)` with the cap in
99
+ * `capsHit` and a `bestOutput` (the partial findings are surfaced, not
100
+ * silently dropped).
101
+ * - **Generous caps → Completed with empty `capsHit`.**
102
+ * - **No laundering.** Across a matrix of limits, a non-empty `capsHit` is
103
+ * never a `Completed` result, and a `Completed` result always has empty
104
+ * `capsHit`.
105
+ * - **Deterministic never touches a model.** A deterministic run reports
106
+ * `usage.modelCalls === 0` and never invokes the supplied model plan.
107
+ */
108
+ export const runRlmCapLaws = (config: RlmCapLawsConfig): void => {
109
+ const { label, makeEngine, corpusSourceLayer } = config;
110
+
111
+ const run = (
112
+ request: RlmDeterministicRequest,
113
+ options: MakeRlmOptions = {},
114
+ ): Promise<RlmTerminalResult> =>
115
+ Effect.runPromise(
116
+ Effect.gen(function* () {
117
+ const shape = yield* makeEngine(options);
118
+ return yield* shape.run(request);
119
+ }).pipe(Effect.provide(corpusSourceLayer)),
120
+ );
121
+
122
+ describe(`[${label}] rlm — every cap yields an honest Partial`, () => {
123
+ test("maxSpans truncation is Partial(cap_truncated) naming the cap with a bestOutput", async () => {
124
+ const result = await run(
125
+ deterministicRequest({ runRef: "run.cap.spans", limits: { ...GENEROUS, maxSpans: 2 } }),
126
+ );
127
+ expect(result._tag).toBe("Partial");
128
+ if (result._tag === "Partial") {
129
+ expect(result.reason).toBe("cap_truncated");
130
+ expect(result.honesty.capsHit).toContain("maxSpans");
131
+ expect(result.bestOutput).toBeDefined();
132
+ }
133
+ });
134
+
135
+ test("maxEntriesScanned truncation is Partial(cap_truncated) naming the cap", async () => {
136
+ const result = await run(
137
+ deterministicRequest({
138
+ runRef: "run.cap.scanned",
139
+ limits: { ...GENEROUS, maxEntriesScanned: 2 },
140
+ }),
141
+ );
142
+ expect(result._tag).toBe("Partial");
143
+ if (result._tag === "Partial") {
144
+ expect(result.reason).toBe("cap_truncated");
145
+ expect(result.honesty.capsHit).toContain("maxEntriesScanned");
146
+ }
147
+ });
148
+
149
+ test("generous caps complete with an empty capsHit", async () => {
150
+ const result = await run(
151
+ deterministicRequest({ runRef: "run.complete", limits: { ...GENEROUS } }),
152
+ );
153
+ expect(result._tag).toBe("Completed");
154
+ if (result._tag === "Completed") {
155
+ expect(result.honesty.capsHit).toEqual([]);
156
+ }
157
+ });
158
+ });
159
+
160
+ describe(`[${label}] rlm — no laundering`, () => {
161
+ test("across a matrix of limits, capsHit is non-empty iff the result is Partial", async () => {
162
+ const cases: ReadonlyArray<{
163
+ readonly runRef: string;
164
+ readonly limits: RlmDeterministicRequest["limits"];
165
+ }> = [
166
+ { runRef: "m.0", limits: { ...GENEROUS } },
167
+ { runRef: "m.1", limits: { ...GENEROUS, maxSpans: 1 } },
168
+ { runRef: "m.2", limits: { ...GENEROUS, maxSpans: 3 } },
169
+ { runRef: "m.3", limits: { ...GENEROUS, maxEntriesScanned: 1 } },
170
+ { runRef: "m.4", limits: { ...GENEROUS, maxEntriesScanned: 3 } },
171
+ ];
172
+ for (const c of cases) {
173
+ const result = await run(deterministicRequest(c));
174
+ const capped = result.honesty.capsHit.length > 0;
175
+ if (capped) {
176
+ // A truncated run is never presented as Completed.
177
+ expect(result._tag).toBe("Partial");
178
+ } else {
179
+ expect(result._tag).toBe("Completed");
180
+ }
181
+ }
182
+ });
183
+ });
184
+
185
+ describe(`[${label}] rlm — deterministic never touches a model`, () => {
186
+ test("a deterministic run reports zero model calls and never invokes the model plan", async () => {
187
+ let modelTouched = false;
188
+ const result = await run(
189
+ deterministicRequest({ runRef: "run.no-model", limits: { ...GENEROUS } }),
190
+ {
191
+ admitSemantic: true,
192
+ model: {
193
+ completeRoot: () =>
194
+ Effect.sync(() => {
195
+ modelTouched = true;
196
+ return { text: "{}" };
197
+ }),
198
+ },
199
+ },
200
+ );
201
+ expect(modelTouched).toBe(false);
202
+ if (result._tag === "Completed" || result._tag === "Partial") {
203
+ expect(result.usage.modelCalls).toBe(0);
204
+ }
205
+ });
206
+ });
207
+ };