Merge branch 'codex/tool-json-schema-dsl' into codex/canonical-tool-output

# Conflicts:
#	packages/core/tools/src/schema.ts
This commit is contained in:
Tianyi Cui
2026-07-21 21:44:00 +08:00
7 changed files with 75 additions and 11 deletions

View File

@@ -12,6 +12,18 @@
*/
export type JsonValue = null | boolean | number | string | JsonValue[] | { [key: string]: JsonValue }
/** Whether an array uses one realm's intrinsic `Array.prototype`, not a subclass or forged prototype. */
function hasPlainArrayPrototype(value: unknown[]): boolean {
const prototype: unknown = Object.getPrototypeOf(value)
return Array.isArray(prototype) && Object.getPrototypeOf(Object.getPrototypeOf(prototype)) === null
}
/** Whether an object is a plain or null-prototype record from any JavaScript realm. */
function hasPlainObjectPrototype(value: object): boolean {
const prototype: unknown = Object.getPrototypeOf(value)
return prototype === null || Object.getPrototypeOf(prototype) === null
}
/**
* Validate and detach lossless JSON in one read per property, so a stateful
* getter cannot change between validation and copying. Accepts ordinary arrays,
@@ -46,7 +58,7 @@ export function snapshotJsonValue<T>(value: T): T | undefined {
ancestors.add(current)
try {
if (Array.isArray(current)) {
if (Object.getPrototypeOf(current) !== Array.prototype) return undefined
if (!hasPlainArrayPrototype(current)) return undefined
const length = current.length
// Every ordinary array owns `length`; dense indexed elements account
// for the remaining keys. Anything else would be lost by JSON and by
@@ -62,8 +74,7 @@ export function snapshotJsonValue<T>(value: T): T | undefined {
return snapshot
}
const prototype = Object.getPrototypeOf(current) as unknown
if (prototype !== Object.prototype && prototype !== null) return undefined
if (!hasPlainObjectPrototype(current)) return undefined
const snapshot: { [key: string]: JsonValue } = {}
for (const key of Object.keys(current)) {
const item = visit((current as Record<string, unknown>)[key])
@@ -115,7 +126,7 @@ export function isJsonValue(value: unknown, seen: Set<object> = new Set()): bool
seen.add(value)
try {
if (Array.isArray(value)) {
if (Object.getPrototypeOf(value) !== Array.prototype) return false
if (!hasPlainArrayPrototype(value)) return false
if (Reflect.ownKeys(value).length !== value.length + 1) return false
// Reject sparse arrays: a hole is skipped by `every`/`forEach` but
// JSON.stringify writes it as `null`, so `[1, , 3]` would round-trip
@@ -127,8 +138,7 @@ export function isJsonValue(value: unknown, seen: Set<object> = new Set()): bool
return true
}
// Plain object only (reject Map/Set/Date/class instances).
const proto = Object.getPrototypeOf(value) as unknown
if (proto !== Object.prototype && proto !== null) return false
if (!hasPlainObjectPrototype(value)) return false
return Object.values(value).every(v => isJsonValue(v, seen))
} finally {
seen.delete(value)

View File

@@ -1,5 +1,6 @@
import { runInNewContext } from 'node:vm'
import { describe, expect, it } from 'vitest'
import { isJsonValue, snapshotJsonValue } from '@deepseek-ai/dsh-session'
import { isJsonValue, snapshotJsonValue, type JsonValue } from '@deepseek-ai/dsh-session'
describe('snapshotJsonValue', () => {
it('copies the complete JSON scalar vocabulary and rejects unsupported scalars', () => {
@@ -36,6 +37,22 @@ describe('snapshotJsonValue', () => {
expect(Object.getPrototypeOf(snapshot.list[0])).toBe(Object.prototype)
})
it('accepts intrinsic plain containers from another JavaScript realm', () => {
const foreign = runInNewContext('({ object: { nested: [1] }, array: [2, { ok: true }] })') as {
object: { nested: number[] }
array: JsonValue[]
}
expect(isJsonValue(foreign.object)).toBe(true)
expect(isJsonValue(foreign.array)).toBe(true)
const objectSnapshot = snapshotJsonValue(foreign.object)!
const arraySnapshot = snapshotJsonValue(foreign.array)!
expect(objectSnapshot).toEqual({ nested: [1] })
expect(arraySnapshot).toEqual([2, { ok: true }])
expect(Object.getPrototypeOf(objectSnapshot)).toBe(Object.prototype)
expect(Object.getPrototypeOf(arraySnapshot)).toBe(Array.prototype)
})
it('reads each object value and array slot once while materializing', () => {
class Exotic {
readonly accepted = false
@@ -77,10 +94,17 @@ describe('snapshotJsonValue', () => {
Object.defineProperty(symbolDecorated, Symbol('extra'), { value: true })
const cyclic: Record<string, unknown> = {}
cyclic.self = cyclic
const foreignExotics = runInNewContext(`(() => {
class Box { constructor() { this.value = 1 } }
class List extends Array {}
return [new Box(), new List(1)]
})()`) as [object, unknown[]]
expect(snapshotJsonValue(new ExoticObject())).toBeUndefined()
expect(snapshotJsonValue(new Map([['value', 1]]))).toBeUndefined()
expect(snapshotJsonValue(new ExoticArray(1))).toBeUndefined()
expect(snapshotJsonValue(foreignExotics[0])).toBeUndefined()
expect(snapshotJsonValue(foreignExotics[1])).toBeUndefined()
expect(snapshotJsonValue(sparse)).toBeUndefined()
expect(snapshotJsonValue(compensatedSparse)).toBeUndefined()
expect(snapshotJsonValue(decorated)).toBeUndefined()