Fix: status could not be serialised when the probe SUCCEEDED
The probe returned `error: undefined` on a dimension match. `undefined` is not a JSON value -- JSON.stringify silently drops it from an object -- so the shape looked harmless and was not: the harness enforces the declared output schema against the canonical value before materialising it, and an explicit undefined property fails that check. A perfectly healthy status call could not be returned at all. Fixed at the boundary rather than at each site. Every return now funnels through `toJsonValue`, which drops undefined properties exactly as JSON.stringify would, maps non-finite numbers to null (same reasoning: JSON.stringify(NaN) is "null"), turns an undefined array slot into null rather than shifting indices, and refuses a circular value instead of recursing forever. `update` was carrying `errorCode: undefined` and `ensureSlopo` a `detail: undefined` on the same path, so a per-site fix would have left two more of these waiting. The probe also now omits `error` rather than assigning undefined, since it is a plausible thing for another consumer to read. Four regression tests, and they were checked against a negative control: with the sanitiser reverted and the undefined restored, exactly the "probe SUCCEEDS" test fails with "does not survive a JSON round trip" and the others still pass, which is what makes it a test of this bug rather than of the JSON round trip in general.
This commit is contained in:
1 parent
534efa8b50
commit
46124b11aa
5 files changed
+249
-9
No files matched your search
+65
@@ -0,0 +1,65 @@
|
||||
/**
|
||||
* Coerce a value into something that is genuinely lossless JSON.
|
||||
*
|
||||
* `undefined` is the trap. It is not a JSON value: `JSON.stringify` silently
|
||||
* drops it from an object, so a result carrying `{ ok: true, error: undefined }`
|
||||
* looks harmless on the way out and is not — the harness enforces the declared
|
||||
* output schema against the canonical value *before* materialising it, and an
|
||||
* `undefined` property is not valid lossless JSON. The tool call fails with a
|
||||
* serialisation error the caller cannot act on.
|
||||
*
|
||||
* Building the value through one chokepoint is what makes that impossible to
|
||||
* reintroduce: any future branch may write `foo: undefined` and it is dropped,
|
||||
* exactly as `JSON.stringify` would have dropped it.
|
||||
*
|
||||
* Non-finite numbers become `null` for the same reason (`JSON.stringify(NaN)`
|
||||
* is `"null"`), and a circular reference throws rather than recursing forever.
|
||||
* Handles are not JSON values at all, so they are converted to `"<handle>"`.
|
||||
*/
|
||||
|
||||
/**
|
||||
* @param {unknown} value
|
||||
* @param {Set<object>} [seen]
|
||||
* @returns {unknown} a value that survives JSON round-tripping unchanged
|
||||
*/
|
||||
export function toJsonValue(value, seen = new Set()) {
|
||||
if (value === null) return null
|
||||
switch (typeof value) {
|
||||
case 'string':
|
||||
case 'boolean':
|
||||
return value
|
||||
case 'number':
|
||||
return Number.isFinite(value) ? value : null
|
||||
case 'bigint':
|
||||
return value.toString()
|
||||
case 'undefined':
|
||||
case 'function':
|
||||
case 'symbol':
|
||||
return undefined
|
||||
case 'object':
|
||||
break
|
||||
default:
|
||||
return undefined
|
||||
}
|
||||
|
||||
if (seen.has(value)) throw new TypeError('cannot serialise a circular value')
|
||||
seen.add(value)
|
||||
try {
|
||||
if (Array.isArray(value)) {
|
||||
// An array slot cannot be dropped the way an object key can, so an
|
||||
// unserialisable element becomes null rather than shifting the indices.
|
||||
return value.map((item) => {
|
||||
const converted = toJsonValue(item, seen)
|
||||
return converted === undefined ? null : converted
|
||||
})
|
||||
}
|
||||
const output = {}
|
||||
for (const [key, entry] of Object.entries(value)) {
|
||||
const converted = toJsonValue(entry, seen)
|
||||
if (converted !== undefined) output[key] = converted
|
||||
}
|
||||
return output
|
||||
} finally {
|
||||
seen.delete(value)
|
||||
}
|
||||
}
|
||||
+8
-5
@@ -143,7 +143,7 @@ export async function probeEmbeddingEndpoint({
|
||||
}
|
||||
const actual = vector.length
|
||||
const expected = Number(dimensions)
|
||||
return {
|
||||
const result = {
|
||||
checked: true,
|
||||
reachable: true,
|
||||
ok: actual === expected,
|
||||
@@ -151,11 +151,14 @@ export async function probeEmbeddingEndpoint({
|
||||
sentModel,
|
||||
dimensions: actual,
|
||||
expected,
|
||||
error:
|
||||
actual === expected
|
||||
? undefined
|
||||
: `the model returns ${actual} dimensions but embeddingDimensions is ${expected}`,
|
||||
}
|
||||
// Omitted rather than assigned undefined: an explicit `undefined` property
|
||||
// is not lossless JSON and fails the tool's output validation before the
|
||||
// result is ever materialised.
|
||||
if (actual !== expected) {
|
||||
result.error = `the model returns ${actual} dimensions but embeddingDimensions is ${expected}`
|
||||
}
|
||||
return result
|
||||
} catch (error) {
|
||||
const aborted = error?.name === 'AbortError'
|
||||
return {
|
||||
|
||||
Reference in new issue
Block a user