Merge remote-tracking branch 'origin/master' into jsonl-packed-chunk-rows
Conflict: the generated event-producer-consumer doc — regenerated over merged sources. Also strips the trailing whitespace / extra EOF blank line that master's vitest.config.ts and client api.ts arrived with (the pre-commit whitespace hook rejects any commit touching them otherwise).
This commit is contained in:
@@ -15,12 +15,21 @@
|
||||
"types": "./lib/types/invariant.d.ts",
|
||||
"default": "./lib/invariant.js"
|
||||
},
|
||||
"./types": {
|
||||
"types": "./lib/types/types.d.ts",
|
||||
"default": "./lib/types/types.js"
|
||||
},
|
||||
"./src/*": "./src/*",
|
||||
"./package.json": "./package.json"
|
||||
"./package.json": "./package.json",
|
||||
"./surface": {
|
||||
"types": "./lib/types/surface.d.ts",
|
||||
"default": "./lib/types/surface.js"
|
||||
}
|
||||
},
|
||||
"files": [
|
||||
"lib/index.js",
|
||||
"lib/invariant.js",
|
||||
"lib/types/**/*.js",
|
||||
"lib/types/**/*.d.ts",
|
||||
"lib/types/**/*.d.ts.map",
|
||||
"src"
|
||||
|
||||
@@ -2,10 +2,12 @@
|
||||
* Surface layer on top of the session event log: an ordered view of events
|
||||
* that produce LLM messages. The append-only log remains the source of truth.
|
||||
*
|
||||
* Browser-safe: web clients consume this subpath export, so it must stay free
|
||||
* of `node:` imports (they break the vite bundle).
|
||||
*
|
||||
* @module @deepseek-ai/dsh-session/surface
|
||||
*/
|
||||
|
||||
import { isDeepStrictEqual } from 'node:util'
|
||||
import type { SessionEvent, SurfaceEvent, SurfaceEventType, SurfaceOp } from './types.ts'
|
||||
|
||||
/** Runtime counterpart of the message-producing event union. */
|
||||
@@ -188,6 +190,24 @@ function replacementRange(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Deep structural equality over the session-event JSON value domain
|
||||
* (null/boolean/number/string, arrays, plain objects). Replaces
|
||||
* `node:util`'s isDeepStrictEqual to keep this module browser-safe.
|
||||
*/
|
||||
function isDeepEqualJson(a: unknown, b: unknown): boolean {
|
||||
if (a === b) return true
|
||||
if (Array.isArray(a) || Array.isArray(b)) {
|
||||
if (!Array.isArray(a) || !Array.isArray(b) || a.length !== b.length) return false
|
||||
return a.every((item, i) => isDeepEqualJson(item, b[i]))
|
||||
}
|
||||
if (typeof a !== 'object' || typeof b !== 'object' || a === null || b === null) return false
|
||||
const aKeys = Object.keys(a)
|
||||
const bRecord = b as Record<string, unknown>
|
||||
if (aKeys.length !== Object.keys(b).length) return false
|
||||
return aKeys.every(key => Object.hasOwn(b, key) && isDeepEqualJson((a as Record<string, unknown>)[key], bRecord[key]))
|
||||
}
|
||||
|
||||
/** Restrict a tool-result replacement to one current result's content. */
|
||||
function assertToolResultRewrite(
|
||||
event: SessionEvent,
|
||||
@@ -207,7 +227,7 @@ function assertToolResultRewrite(
|
||||
const replacementRest = { ...event.data } as Record<string, unknown>
|
||||
delete originalRest['content']
|
||||
delete replacementRest['content']
|
||||
if (!isDeepStrictEqual(originalRest, replacementRest)) {
|
||||
if (!isDeepEqualJson(originalRest, replacementRest)) {
|
||||
throw new Error('tool/result surface replacement may change only content')
|
||||
}
|
||||
}
|
||||
|
||||
@@ -141,6 +141,38 @@ describe('foldSurface tool-result rewrites', () => {
|
||||
]
|
||||
expect(() => foldSurface(events)).toThrow(/may change only content/)
|
||||
})
|
||||
|
||||
it('compares array-valued rest fields structurally (meta arrays: equal accepted, drifted rejected)', () => {
|
||||
const withMeta = (seq: number, meta: unknown, surfaceOp: SurfaceEvent['surfaceOp'] = 'append', sourceEventSeqs?: number[]): SessionEvent => {
|
||||
const event = toolResultEvent(seq, 'c-meta', surfaceOp, sourceEventSeqs)
|
||||
return { ...event, data: { ...(event.data as object), meta } } as SessionEvent
|
||||
}
|
||||
// Structurally equal arrays (fresh references) pass the rest-field equality.
|
||||
expect(() => foldSurface([
|
||||
withMeta(0, { tags: ['a', { n: 1 }] }),
|
||||
withMeta(1, { tags: ['a', { n: 1 }] }, { op: 'replace', start: 0, end: 0 }, [0]),
|
||||
])).not.toThrow()
|
||||
// Same length, drifted element: the array branch must reject.
|
||||
expect(() => foldSurface([
|
||||
withMeta(0, { tags: ['a'] }),
|
||||
withMeta(1, { tags: ['b'] }, { op: 'replace', start: 0, end: 0 }, [0]),
|
||||
])).toThrow(/may change only content/)
|
||||
// Array vs non-array on one side: the mixed-shape guard rejects.
|
||||
expect(() => foldSurface([
|
||||
withMeta(0, { tags: ['a'] }),
|
||||
withMeta(1, { tags: 'a' }, { op: 'replace', start: 0, end: 0 }, [0]),
|
||||
])).toThrow(/may change only content/)
|
||||
// Same key count, different key names: the hasOwn branch rejects.
|
||||
expect(() => foldSurface([
|
||||
withMeta(0, { left: 1 }),
|
||||
withMeta(1, { right: 1 }, { op: 'replace', start: 0, end: 0 }, [0]),
|
||||
])).toThrow(/may change only content/)
|
||||
// Different key counts: the key-length branch rejects.
|
||||
expect(() => foldSurface([
|
||||
withMeta(0, { one: 1 }),
|
||||
withMeta(1, { one: 1, two: 2 }, { op: 'replace', start: 0, end: 0 }, [0]),
|
||||
])).toThrow(/may change only content/)
|
||||
})
|
||||
})
|
||||
|
||||
describe('SurfaceManager', () => {
|
||||
|
||||
Reference in New Issue
Block a user