pi-system-one 1.2.0 → 1.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +16 -0
- package/package.json +7 -2
- package/src/tool.ts +136 -8
- package/tests/packaging.test.ts +13 -0
- package/tests/tool.test.ts +239 -0
package/README.md
CHANGED
|
@@ -6,6 +6,22 @@ System One decisions for Pi. Plug in TypeSafe Jev, Reflex, or your own provider.
|
|
|
6
6
|
pi install npm:pi-system-one
|
|
7
7
|
```
|
|
8
8
|
|
|
9
|
+
For Oh My Pi (OMP), install the published package with:
|
|
10
|
+
|
|
11
|
+
```bash
|
|
12
|
+
omp install npm:pi-system-one
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
For a local checkout, install or load the package directory directly:
|
|
16
|
+
|
|
17
|
+
```bash
|
|
18
|
+
omp install /path/to/pi-system-one
|
|
19
|
+
# or
|
|
20
|
+
omp --extension /path/to/pi-system-one
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
Set `SYSTEM_ONE_BASE_URL` before launch, or run `/so config`; the `system_one` tool is registered after the endpoint is configured.
|
|
24
|
+
|
|
9
25
|
<img src="./demo.gif" alt="Description" width="640" >
|
|
10
26
|
|
|
11
27
|
---
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-system-one",
|
|
3
|
-
"version": "1.2.
|
|
3
|
+
"version": "1.2.1",
|
|
4
4
|
"description": "System One decisions for Pi. Plug in TypeSafe Jev, Reflex, or your own provider.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -27,7 +27,7 @@
|
|
|
27
27
|
"coding-agent"
|
|
28
28
|
],
|
|
29
29
|
"dependencies": {
|
|
30
|
-
"system-one-core": "^0.2.
|
|
30
|
+
"system-one-core": "^0.2.1"
|
|
31
31
|
},
|
|
32
32
|
"peerDependencies": {
|
|
33
33
|
"@earendil-works/pi-coding-agent": "*",
|
|
@@ -37,5 +37,10 @@
|
|
|
37
37
|
"extensions": [
|
|
38
38
|
"./src/extension.ts"
|
|
39
39
|
]
|
|
40
|
+
},
|
|
41
|
+
"omp": {
|
|
42
|
+
"extensions": [
|
|
43
|
+
"./src/extension.ts"
|
|
44
|
+
]
|
|
40
45
|
}
|
|
41
46
|
}
|
package/src/tool.ts
CHANGED
|
@@ -176,14 +176,95 @@ function pickAlias(
|
|
|
176
176
|
return undefined;
|
|
177
177
|
}
|
|
178
178
|
|
|
179
|
+
/**
|
|
180
|
+
* Copy-on-write canonicality check for one question. Returns true when
|
|
181
|
+
* running the builder below would produce a key-for-key, value-for-value
|
|
182
|
+
* identical record — in which case the caller reuses the input by
|
|
183
|
+
* reference instead of allocating a copy. Any doubt returns false (the
|
|
184
|
+
* builder then produces the correct output); false negatives only cost an
|
|
185
|
+
* allocation, never correctness.
|
|
186
|
+
*
|
|
187
|
+
* Deliberately allocation-free itself (own-key probes, no entries/keys
|
|
188
|
+
* arrays) so the defensive second normalization in execute() stays cheap.
|
|
189
|
+
*/
|
|
190
|
+
function isCanonicalQuestion(input: Record<string, unknown>): boolean {
|
|
191
|
+
const hasType = Object.hasOwn(input, "type");
|
|
192
|
+
// Probe only: an exact canonical spelling (alias-map value identical to
|
|
193
|
+
// the key) needs no work. Near-miss spellings report non-canonical
|
|
194
|
+
// WITHOUT running full normalization here — the builder below runs it
|
|
195
|
+
// exactly once. Non-string types pass (the builder copies them verbatim),
|
|
196
|
+
// so doubt always errs toward a wasted rebuild, never a wrong share.
|
|
197
|
+
if (
|
|
198
|
+
hasType &&
|
|
199
|
+
typeof input.type === "string" &&
|
|
200
|
+
QUESTION_TYPE_ALIASES.get(input.type) !== input.type
|
|
201
|
+
) {
|
|
202
|
+
return false;
|
|
203
|
+
}
|
|
204
|
+
// Strings reaching here are already canonical, so the raw type doubles
|
|
205
|
+
// as the normalized one for the criteria decisions below.
|
|
206
|
+
const normalizedType = hasType ? input.type : undefined;
|
|
207
|
+
const instructions = pickAlias(input, INSTRUCTIONS_ALIASES);
|
|
208
|
+
// NOTE: no `else` for an own defined "instructions" slot — "instructions"
|
|
209
|
+
// is the first alias, so pickAlias would have returned it above; reaching
|
|
210
|
+
// here with one means it is undefined and the builder omits it too.
|
|
211
|
+
if (instructions !== undefined) {
|
|
212
|
+
// The builder emits own "instructions" with this value; canonical only
|
|
213
|
+
// if it already sits there and no sibling alias key lingers (dropped).
|
|
214
|
+
if (
|
|
215
|
+
!Object.hasOwn(input, "instructions") ||
|
|
216
|
+
input.instructions !== instructions
|
|
217
|
+
) {
|
|
218
|
+
return false;
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
for (const alias of INSTRUCTIONS_ALIASES) {
|
|
222
|
+
if (alias !== "instructions" && Object.hasOwn(input, alias)) return false;
|
|
223
|
+
}
|
|
224
|
+
let criteria = pickAlias(input, CRITERIA_ALIASES);
|
|
225
|
+
if (normalizedType === "noul" && criteria === null) {
|
|
226
|
+
// The builder drops it; canonical only if no own alias key is dropped.
|
|
227
|
+
for (const alias of CRITERIA_ALIASES) {
|
|
228
|
+
if (Object.hasOwn(input, alias)) return false;
|
|
229
|
+
}
|
|
230
|
+
criteria = undefined;
|
|
231
|
+
}
|
|
232
|
+
if (
|
|
233
|
+
normalizedType === "choice" &&
|
|
234
|
+
Array.isArray(criteria) &&
|
|
235
|
+
(criteria as unknown[]).every((item) => typeof item === "string")
|
|
236
|
+
) {
|
|
237
|
+
return false; // The builder folds string arrays to an object.
|
|
238
|
+
}
|
|
239
|
+
if (criteria !== undefined) {
|
|
240
|
+
if (!Object.hasOwn(input, "criteria") || input.criteria !== criteria) {
|
|
241
|
+
return false;
|
|
242
|
+
}
|
|
243
|
+
} else if (Object.hasOwn(input, "criteria")) {
|
|
244
|
+
return false; // Own undefined criteria is dropped by the builder.
|
|
245
|
+
}
|
|
246
|
+
for (const alias of CRITERIA_ALIASES) {
|
|
247
|
+
if (alias !== "criteria" && Object.hasOwn(input, alias)) return false;
|
|
248
|
+
}
|
|
249
|
+
for (const key of QUESTION_JUNK_KEYS) {
|
|
250
|
+
if (Object.hasOwn(input, key)) return false;
|
|
251
|
+
}
|
|
252
|
+
// Any other own key is preserved by reference, so it cannot differ.
|
|
253
|
+
return true;
|
|
254
|
+
}
|
|
255
|
+
|
|
179
256
|
/**
|
|
180
257
|
* Coerce one question into the flat schema shape: canonical `type`,
|
|
181
258
|
* `instructions` / `criteria` resolved through aliases, array-form choice
|
|
182
259
|
* criteria (`["a", "b"]`) folded to `{"a": null, "b": null}`, and
|
|
183
260
|
* naming/junk keys stripped.
|
|
261
|
+
*
|
|
262
|
+
* Pure, idempotent, non-mutating, with structural sharing: canonical input
|
|
263
|
+
* is returned by reference; only repaired questions allocate.
|
|
184
264
|
*/
|
|
185
265
|
function normalizeQuestion(input: unknown): unknown {
|
|
186
266
|
if (!isRecord(input)) return input;
|
|
267
|
+
if (isCanonicalQuestion(input)) return input;
|
|
187
268
|
const out: Record<string, unknown> = {};
|
|
188
269
|
if (Object.hasOwn(input, "type"))
|
|
189
270
|
out.type = normalizeQuestionType(input.type);
|
|
@@ -235,13 +316,15 @@ function normalizeQuestions(input: unknown): unknown {
|
|
|
235
316
|
};
|
|
236
317
|
input.forEach((entry, index) => {
|
|
237
318
|
if (isRecord(entry)) {
|
|
238
|
-
|
|
239
|
-
|
|
319
|
+
// No intermediate `{...rest}` copy: normalizeQuestion() already
|
|
320
|
+
// drops name/id/key as junk, so passing the entry through repairs
|
|
321
|
+
// naming metadata and aliases in a single construction pass.
|
|
322
|
+
const rawName = entry.name ?? entry.id ?? entry.key;
|
|
240
323
|
const base =
|
|
241
324
|
typeof rawName === "string" && rawName.trim() !== ""
|
|
242
325
|
? rawName
|
|
243
326
|
: `q${index + 1}`;
|
|
244
|
-
setOwn(record, claimName(base), normalizeQuestion(
|
|
327
|
+
setOwn(record, claimName(base), normalizeQuestion(entry));
|
|
245
328
|
} else {
|
|
246
329
|
setOwn(record, claimName(`q${index + 1}`), entry);
|
|
247
330
|
}
|
|
@@ -270,9 +353,24 @@ function normalizeQuestions(input: unknown): unknown {
|
|
|
270
353
|
);
|
|
271
354
|
if (knownKind || allQuestionKeys) return { q: normalizeQuestion(input) };
|
|
272
355
|
}
|
|
356
|
+
// Copy-on-write: share the map (and each canonical question) when
|
|
357
|
+
// nothing needs repair; otherwise rebuild with repaired questions and
|
|
358
|
+
// unaffected siblings shared by reference. The scan probes
|
|
359
|
+
// canonicality only — it must never build (and discard) repairs.
|
|
360
|
+
let dirty = false;
|
|
361
|
+
for (const name in input) {
|
|
362
|
+
if (!Object.hasOwn(input, name)) continue;
|
|
363
|
+
const question = input[name];
|
|
364
|
+
if (!isRecord(question) || !isCanonicalQuestion(question)) {
|
|
365
|
+
dirty = true;
|
|
366
|
+
break;
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
if (!dirty) return input;
|
|
273
370
|
const record: Record<string, unknown> = {};
|
|
274
|
-
for (const
|
|
275
|
-
|
|
371
|
+
for (const name in input) {
|
|
372
|
+
if (!Object.hasOwn(input, name)) continue;
|
|
373
|
+
setOwn(record, name, normalizeQuestion(input[name]));
|
|
276
374
|
}
|
|
277
375
|
return record;
|
|
278
376
|
}
|
|
@@ -286,6 +384,38 @@ function normalizeQuestions(input: unknown): unknown {
|
|
|
286
384
|
*/
|
|
287
385
|
export function prepareSystemOneArgs(args: unknown): SystemOneParams {
|
|
288
386
|
if (!isRecord(args)) return args as SystemOneParams;
|
|
387
|
+
const questions =
|
|
388
|
+
args.questions ?? args.question ?? args.queries ?? undefined;
|
|
389
|
+
const normalizedQuestions =
|
|
390
|
+
questions !== undefined ? normalizeQuestions(questions) : undefined;
|
|
391
|
+
// Copy-on-write fast path: canonical state/questions slots, no alias keys
|
|
392
|
+
// to rename, no unknown keys to drop — return the input by reference so
|
|
393
|
+
// the defensive re-normalization in execute() allocates ~nothing.
|
|
394
|
+
const questionsCanonical =
|
|
395
|
+
normalizedQuestions === questions &&
|
|
396
|
+
(questions === undefined
|
|
397
|
+
? !Object.hasOwn(args, "questions") &&
|
|
398
|
+
!Object.hasOwn(args, "question") &&
|
|
399
|
+
!Object.hasOwn(args, "queries")
|
|
400
|
+
: questions === args.questions &&
|
|
401
|
+
!Object.hasOwn(args, "question") &&
|
|
402
|
+
!Object.hasOwn(args, "queries"));
|
|
403
|
+
if (
|
|
404
|
+
questionsCanonical &&
|
|
405
|
+
Object.hasOwn(args, "state") &&
|
|
406
|
+
args.state !== undefined &&
|
|
407
|
+
!Object.hasOwn(args, "context") &&
|
|
408
|
+
!Object.hasOwn(args, "evidence")
|
|
409
|
+
) {
|
|
410
|
+
let knownKeysOnly = true;
|
|
411
|
+
for (const key in args) {
|
|
412
|
+
if (Object.hasOwn(args, key) && key !== "state" && key !== "questions") {
|
|
413
|
+
knownKeysOnly = false;
|
|
414
|
+
break;
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
if (knownKeysOnly) return args as SystemOneParams;
|
|
418
|
+
}
|
|
289
419
|
const out: Record<string, unknown> = {};
|
|
290
420
|
if (args.state !== undefined) {
|
|
291
421
|
out.state = args.state;
|
|
@@ -294,9 +424,7 @@ export function prepareSystemOneArgs(args: unknown): SystemOneParams {
|
|
|
294
424
|
} else if (args.evidence !== undefined) {
|
|
295
425
|
out.state = args.evidence;
|
|
296
426
|
}
|
|
297
|
-
|
|
298
|
-
args.questions ?? args.question ?? args.queries ?? undefined;
|
|
299
|
-
if (questions !== undefined) out.questions = normalizeQuestions(questions);
|
|
427
|
+
if (normalizedQuestions !== undefined) out.questions = normalizedQuestions;
|
|
300
428
|
// Drop unknown top-level keys; a missing `state` / `questions` still
|
|
301
429
|
// produces a clear error from semantic validation in execute().
|
|
302
430
|
return out as SystemOneParams;
|
package/tests/packaging.test.ts
CHANGED
|
@@ -7,6 +7,8 @@
|
|
|
7
7
|
|
|
8
8
|
import assert from "node:assert/strict";
|
|
9
9
|
import { execFileSync } from "node:child_process";
|
|
10
|
+
import { readFileSync } from "node:fs";
|
|
11
|
+
import { join } from "node:path";
|
|
10
12
|
import { describe, it } from "node:test";
|
|
11
13
|
import { fileURLToPath } from "node:url";
|
|
12
14
|
|
|
@@ -32,6 +34,17 @@ function packedFiles(): string[] {
|
|
|
32
34
|
}
|
|
33
35
|
|
|
34
36
|
describe("npm package runtime resources", () => {
|
|
37
|
+
it("declares the extension entry for both Pi and OMP", () => {
|
|
38
|
+
const packageJson = JSON.parse(
|
|
39
|
+
readFileSync(join(packageRoot, "package.json"), "utf-8"),
|
|
40
|
+
) as {
|
|
41
|
+
pi?: { extensions?: string[] };
|
|
42
|
+
omp?: { extensions?: string[] };
|
|
43
|
+
};
|
|
44
|
+
assert.deepEqual(packageJson.pi, { extensions: ["./src/extension.ts"] });
|
|
45
|
+
assert.deepEqual(packageJson.omp, { extensions: ["./src/extension.ts"] });
|
|
46
|
+
});
|
|
47
|
+
|
|
35
48
|
it("packs the skill, its references, and the /judge prompt", () => {
|
|
36
49
|
const files = packedFiles();
|
|
37
50
|
for (const required of [
|
package/tests/tool.test.ts
CHANGED
|
@@ -1075,6 +1075,245 @@ describe("system_one tool", () => {
|
|
|
1075
1075
|
});
|
|
1076
1076
|
});
|
|
1077
1077
|
|
|
1078
|
+
describe("copy-on-write normalization", () => {
|
|
1079
|
+
const canonicalRequest = () => ({
|
|
1080
|
+
state: { incident: "latency" },
|
|
1081
|
+
questions: {
|
|
1082
|
+
rollback: { type: "noul", instructions: "Roll back?" },
|
|
1083
|
+
owner: {
|
|
1084
|
+
type: "choice",
|
|
1085
|
+
instructions: "Which team?",
|
|
1086
|
+
criteria: { frontend: null, backend: null },
|
|
1087
|
+
},
|
|
1088
|
+
},
|
|
1089
|
+
});
|
|
1090
|
+
|
|
1091
|
+
it("returns canonical requests, maps, and questions by reference", () => {
|
|
1092
|
+
const args = canonicalRequest();
|
|
1093
|
+
const out = prepareSystemOneArgs(args) as typeof args;
|
|
1094
|
+
assert.equal(out, args);
|
|
1095
|
+
assert.equal(out.questions, args.questions);
|
|
1096
|
+
assert.equal(out.questions.rollback, args.questions.rollback);
|
|
1097
|
+
assert.equal(out.questions.owner, args.questions.owner);
|
|
1098
|
+
// Key order is not canonicalized: identity means "already final".
|
|
1099
|
+
const reordered = {
|
|
1100
|
+
state: "s",
|
|
1101
|
+
questions: {
|
|
1102
|
+
q: { criteria: { a: null }, instructions: "I?", type: "choice" },
|
|
1103
|
+
},
|
|
1104
|
+
};
|
|
1105
|
+
assert.equal(prepareSystemOneArgs(reordered), reordered);
|
|
1106
|
+
});
|
|
1107
|
+
|
|
1108
|
+
it("structurally shares unaffected siblings on partial repair", () => {
|
|
1109
|
+
const args = {
|
|
1110
|
+
state: "s",
|
|
1111
|
+
questions: {
|
|
1112
|
+
fine: { type: "noul", instructions: "OK?" },
|
|
1113
|
+
sloppy: { type: "Choice", prompt: "Pick?", options: ["a", "b"] },
|
|
1114
|
+
},
|
|
1115
|
+
};
|
|
1116
|
+
const out = prepareSystemOneArgs(args) as typeof args;
|
|
1117
|
+
assert.notEqual(out, args);
|
|
1118
|
+
assert.notEqual(out.questions, args.questions);
|
|
1119
|
+
assert.notEqual(out.questions.sloppy, args.questions.sloppy);
|
|
1120
|
+
assert.equal(out.questions.fine, args.questions.fine);
|
|
1121
|
+
assert.deepEqual(out.questions.sloppy, {
|
|
1122
|
+
type: "choice",
|
|
1123
|
+
instructions: "Pick?",
|
|
1124
|
+
criteria: { a: null, b: null },
|
|
1125
|
+
});
|
|
1126
|
+
});
|
|
1127
|
+
|
|
1128
|
+
it("is idempotent and allocates nothing new on the second pass", () => {
|
|
1129
|
+
const once = prepareSystemOneArgs(canonicalRequest()) as Record<
|
|
1130
|
+
string,
|
|
1131
|
+
unknown
|
|
1132
|
+
>;
|
|
1133
|
+
const twice = prepareSystemOneArgs(once);
|
|
1134
|
+
assert.deepEqual(twice, once);
|
|
1135
|
+
assert.equal(twice, once);
|
|
1136
|
+
// Repair once, then the repaired output is itself canonical.
|
|
1137
|
+
const repairedOnce = prepareSystemOneArgs({
|
|
1138
|
+
state: "s",
|
|
1139
|
+
questions: [{ name: "q", type: "boolean", prompt: "Y?" }],
|
|
1140
|
+
});
|
|
1141
|
+
assert.equal(prepareSystemOneArgs(repairedOnce), repairedOnce);
|
|
1142
|
+
});
|
|
1143
|
+
|
|
1144
|
+
it("never mutates its input", () => {
|
|
1145
|
+
const deepFreeze = (v: unknown): void => {
|
|
1146
|
+
if (typeof v === "object" && v !== null && !Object.isFrozen(v)) {
|
|
1147
|
+
Object.freeze(v);
|
|
1148
|
+
for (const k of Object.keys(v)) {
|
|
1149
|
+
deepFreeze((v as Record<string, unknown>)[k]);
|
|
1150
|
+
}
|
|
1151
|
+
}
|
|
1152
|
+
};
|
|
1153
|
+
const sloppy = {
|
|
1154
|
+
context: { incident: "latency" },
|
|
1155
|
+
question: [
|
|
1156
|
+
{ name: "a", type: "Rating", prompt: "Rate?", levels: ["1", "5"] },
|
|
1157
|
+
{ id: "b", type: "noul", instructions: "OK?", extra: 1 },
|
|
1158
|
+
],
|
|
1159
|
+
};
|
|
1160
|
+
deepFreeze(sloppy);
|
|
1161
|
+
const out = prepareSystemOneArgs(sloppy);
|
|
1162
|
+
assert.deepEqual(out, {
|
|
1163
|
+
state: { incident: "latency" },
|
|
1164
|
+
questions: {
|
|
1165
|
+
a: {
|
|
1166
|
+
type: "score",
|
|
1167
|
+
instructions: "Rate?",
|
|
1168
|
+
criteria: ["1", "5"],
|
|
1169
|
+
},
|
|
1170
|
+
b: { type: "noul", instructions: "OK?", extra: 1 },
|
|
1171
|
+
},
|
|
1172
|
+
});
|
|
1173
|
+
});
|
|
1174
|
+
|
|
1175
|
+
it("repairs every non-canonical shape exactly (no false sharing)", () => {
|
|
1176
|
+
// [input, expected output, sharesInputRef]: the last two entries are
|
|
1177
|
+
// already builder fixed points, so sharing them is correct.
|
|
1178
|
+
const cases: Array<[unknown, unknown, boolean]> = [
|
|
1179
|
+
// Alias type spelling.
|
|
1180
|
+
[
|
|
1181
|
+
{
|
|
1182
|
+
state: "s",
|
|
1183
|
+
questions: { q: { type: "Yes/No", instructions: "Y?" } },
|
|
1184
|
+
},
|
|
1185
|
+
{
|
|
1186
|
+
state: "s",
|
|
1187
|
+
questions: { q: { type: "noul", instructions: "Y?" } },
|
|
1188
|
+
},
|
|
1189
|
+
false,
|
|
1190
|
+
],
|
|
1191
|
+
// Alias field names.
|
|
1192
|
+
[
|
|
1193
|
+
{
|
|
1194
|
+
state: "s",
|
|
1195
|
+
questions: { q: { type: "choice", prompt: "P?", choices: ["a"] } },
|
|
1196
|
+
},
|
|
1197
|
+
{
|
|
1198
|
+
state: "s",
|
|
1199
|
+
questions: {
|
|
1200
|
+
q: { type: "choice", instructions: "P?", criteria: { a: null } },
|
|
1201
|
+
},
|
|
1202
|
+
},
|
|
1203
|
+
false,
|
|
1204
|
+
],
|
|
1205
|
+
// Junk keys dropped.
|
|
1206
|
+
[
|
|
1207
|
+
{
|
|
1208
|
+
state: "s",
|
|
1209
|
+
questions: {
|
|
1210
|
+
q: {
|
|
1211
|
+
type: "noul",
|
|
1212
|
+
instructions: "Y?",
|
|
1213
|
+
description: "d",
|
|
1214
|
+
title: "t",
|
|
1215
|
+
},
|
|
1216
|
+
},
|
|
1217
|
+
},
|
|
1218
|
+
{
|
|
1219
|
+
state: "s",
|
|
1220
|
+
questions: { q: { type: "noul", instructions: "Y?" } },
|
|
1221
|
+
},
|
|
1222
|
+
false,
|
|
1223
|
+
],
|
|
1224
|
+
// Undefined-valued lingering alias key is still dropped.
|
|
1225
|
+
[
|
|
1226
|
+
{
|
|
1227
|
+
state: "s",
|
|
1228
|
+
questions: {
|
|
1229
|
+
q: { type: "noul", instructions: "Y?", prompt: undefined },
|
|
1230
|
+
},
|
|
1231
|
+
},
|
|
1232
|
+
{
|
|
1233
|
+
state: "s",
|
|
1234
|
+
questions: { q: { type: "noul", instructions: "Y?" } },
|
|
1235
|
+
},
|
|
1236
|
+
false,
|
|
1237
|
+
],
|
|
1238
|
+
// Own undefined instructions key is preserved (builder copies it).
|
|
1239
|
+
[
|
|
1240
|
+
{
|
|
1241
|
+
state: "s",
|
|
1242
|
+
questions: { q: { type: "noul", instructions: undefined } },
|
|
1243
|
+
},
|
|
1244
|
+
{
|
|
1245
|
+
state: "s",
|
|
1246
|
+
questions: { q: { type: "noul", instructions: undefined } },
|
|
1247
|
+
},
|
|
1248
|
+
true,
|
|
1249
|
+
],
|
|
1250
|
+
// noul null criteria reads as omitted.
|
|
1251
|
+
[
|
|
1252
|
+
{
|
|
1253
|
+
state: "s",
|
|
1254
|
+
questions: {
|
|
1255
|
+
q: { type: "noul", instructions: "Y?", criteria: null },
|
|
1256
|
+
},
|
|
1257
|
+
},
|
|
1258
|
+
{
|
|
1259
|
+
state: "s",
|
|
1260
|
+
questions: { q: { type: "noul", instructions: "Y?" } },
|
|
1261
|
+
},
|
|
1262
|
+
false,
|
|
1263
|
+
],
|
|
1264
|
+
// Singular question alias + array form + naming metadata.
|
|
1265
|
+
[
|
|
1266
|
+
{ context: "s", question: { type: "boolean", prompt: "Y?" } },
|
|
1267
|
+
{
|
|
1268
|
+
state: "s",
|
|
1269
|
+
questions: { q: { type: "noul", instructions: "Y?" } },
|
|
1270
|
+
},
|
|
1271
|
+
false,
|
|
1272
|
+
],
|
|
1273
|
+
// Unknown top-level keys dropped; missing state preserved as-is.
|
|
1274
|
+
[
|
|
1275
|
+
{
|
|
1276
|
+
state: "s",
|
|
1277
|
+
questions: { q: { type: "noul", instructions: "Y?" } },
|
|
1278
|
+
extra: 1,
|
|
1279
|
+
},
|
|
1280
|
+
{
|
|
1281
|
+
state: "s",
|
|
1282
|
+
questions: { q: { type: "noul", instructions: "Y?" } },
|
|
1283
|
+
},
|
|
1284
|
+
false,
|
|
1285
|
+
],
|
|
1286
|
+
// Empty choice array still folds (every() on [] is true).
|
|
1287
|
+
[
|
|
1288
|
+
{
|
|
1289
|
+
state: "s",
|
|
1290
|
+
questions: {
|
|
1291
|
+
q: { type: "choice", instructions: "P?", criteria: [] },
|
|
1292
|
+
},
|
|
1293
|
+
},
|
|
1294
|
+
{
|
|
1295
|
+
state: "s",
|
|
1296
|
+
questions: {
|
|
1297
|
+
q: { type: "choice", instructions: "P?", criteria: {} },
|
|
1298
|
+
},
|
|
1299
|
+
},
|
|
1300
|
+
false,
|
|
1301
|
+
],
|
|
1302
|
+
// Non-string type passes through untouched (fixed point).
|
|
1303
|
+
[
|
|
1304
|
+
{ state: "s", questions: { q: { type: 5, instructions: "Y?" } } },
|
|
1305
|
+
{ state: "s", questions: { q: { type: 5, instructions: "Y?" } } },
|
|
1306
|
+
true,
|
|
1307
|
+
],
|
|
1308
|
+
];
|
|
1309
|
+
for (const [input, expected, sharesRef] of cases) {
|
|
1310
|
+
const out = prepareSystemOneArgs(input);
|
|
1311
|
+
assert.deepEqual(out, expected);
|
|
1312
|
+
assert.equal(out === input, sharesRef);
|
|
1313
|
+
}
|
|
1314
|
+
});
|
|
1315
|
+
});
|
|
1316
|
+
|
|
1078
1317
|
describe("backend minima guardrails", () => {
|
|
1079
1318
|
function stubTool() {
|
|
1080
1319
|
let calls = 0;
|