fs write/edit now emit a result-time contextual-diff tool_call_update
(the applied hunk with ±3 context lines, one hunk per replace_all site),
matching what claude-agent-acp sends and what makes an editor render the
change in place. The call-time snippet diff stays; the result hunk
supersedes it (ACP content-replace).
Mechanism:
- A persisted tool-private `meta` channel: execute may return
`{ content, meta }`; `meta` (JsonValue) rides on the tool/result event
and is handed back to presentResult, so the diff reproduces on replay
(event-sourced). JsonValue is now exported from dsh-session.
- The backend returns raw before/after text (storage facts) on
FsWriteOutcome/FsEditOutcome; the tool computes the hunk via the npm
`diff` package's structuredPatch. A create has no before → no result
diff; a failed/aborted mutation carries no meta.
- ToolResultView gains a DiffResultView; the bridge's result-side switch
renders it as {type:'diff'} content blocks.
RFC: docs/rfc/implemented/architecture/2026-07-02-result-time-applied-hunk-diffs.md
(justifies the npm `diff` runtime dep over vendoring and the meta channel);
the render-intent-union RFC's Non-goal is updated to record this shipped.
All fs snapshot goldens re-recorded; edit/overwrite gain the contextual
result diff, create/read/policy-reject unchanged in structure.
82 lines
3.6 KiB
TypeScript
82 lines
3.6 KiB
TypeScript
/**
|
|
* JSON-serializability validation for session event data.
|
|
*
|
|
* The session event log is the durable source of truth (the event-sourcing / session-persistence RFCs): every
|
|
* `event.data` must round-trip losslessly through JSON so any persistence
|
|
* backend can store and reload it byte-identically. This invariant belongs to
|
|
* the log itself — `Session.append` enforces it at the source, so a
|
|
* non-serializable event never enters `session.events` and the live log can
|
|
* never diverge from what a backend can persist. Backends re-use the same
|
|
* predicate to validate their own `append(events)` entry point (replay/fork
|
|
* paths that do not go through a live `Session`).
|
|
*
|
|
* @module @deepseek-ai/dsh-session/json
|
|
*/
|
|
|
|
/**
|
|
* A value that round-trips losslessly through JSON: `null`, a boolean, a finite
|
|
* number, a string, an array of such values, or a plain object whose values are
|
|
* such values. The static type companion to {@link isJsonValue} (which validates
|
|
* the same shape at runtime). Use it to type a payload that must survive
|
|
* session-log persistence and replay byte-identically — e.g. a tool's private
|
|
* presentation `meta`.
|
|
*/
|
|
export type JsonValue = null | boolean | number | string | JsonValue[] | { [key: string]: JsonValue }
|
|
|
|
/**
|
|
* Whether `value` is losslessly JSON-serializable: only `null`, finite numbers,
|
|
* booleans, strings, plain arrays, and plain objects of such values. Rejects
|
|
* `BigInt`, function, symbol, `undefined`, non-finite numbers (`NaN`/`Infinity`,
|
|
* which `JSON.stringify` turns into `null`), and exotic objects (`Map`/`Set`/
|
|
* `Date`/class instances) — anything `JSON.stringify` would drop, throw on, or
|
|
* convert lossily. Sparse arrays are rejected too: a hole serializes to `null`,
|
|
* so `[1, , 3]` would not round-trip. Detects circular references (which would
|
|
* throw) and reports them as non-serializable rather than propagating the throw.
|
|
*
|
|
* Scope — matches `JSON.stringify` exactly: only an object's OWN ENUMERABLE
|
|
* STRING-keyed properties are inspected (`Object.values`). Symbol-keyed and
|
|
* non-enumerable properties are NOT examined, because `JSON.stringify` likewise
|
|
* drops them — they never reach the durable form, so a non-serializable value
|
|
* hiding under a symbol/non-enumerable key cannot make the round-trip lossy.
|
|
* Getters are invoked during the check (again as `JSON.stringify` would), so the
|
|
* contract is for plain data records, not objects with side-effecting accessors.
|
|
*/
|
|
export function isJsonValue(value: unknown, seen: Set<object> = new Set()): boolean {
|
|
if (value === null) return true
|
|
switch (typeof value) {
|
|
case 'boolean':
|
|
case 'string':
|
|
return true
|
|
case 'number':
|
|
return Number.isFinite(value)
|
|
case 'bigint':
|
|
case 'function':
|
|
case 'symbol':
|
|
case 'undefined':
|
|
return false
|
|
case 'object':
|
|
break // handled below
|
|
}
|
|
// object
|
|
if (seen.has(value)) return false // circular
|
|
seen.add(value)
|
|
try {
|
|
if (Array.isArray(value)) {
|
|
// Reject sparse arrays: a hole is skipped by `every`/`forEach` but
|
|
// JSON.stringify writes it as `null`, so `[1, , 3]` would round-trip
|
|
// lossily. Require every index 0..length-1 to be an OWN property.
|
|
for (let i = 0; i < value.length; i++) {
|
|
if (!Object.prototype.hasOwnProperty.call(value, i)) return false
|
|
if (!isJsonValue(value[i], seen)) return false
|
|
}
|
|
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
|
|
return Object.values(value).every(v => isJsonValue(v, seen))
|
|
} finally {
|
|
seen.delete(value)
|
|
}
|
|
}
|