Files
deepseek-harness/packages/context/session-reference/src/projection.ts
T
Yixiang TuandTianyi Cui e0bd7e1960 feat(session): add developer changes using historical tool schemas
Session V4 reserves developer-role messages for incremental model-visible
session changes. The role, tool addition/removal blocks, and deferred-loading
marker need a durable representation before independent provider and UI
integrations can emit or consume them. Known developer events must match an
open turn and step; unsupported providers and UI consumers fail explicitly.

Request headers already capture full tool definitions. Persisting another
ToolSchema in each addition would duplicate Session information and create
two definitions that could drift. Additions therefore store toolName, and
the containing developer event uses headerSeq to select one earlier known
request/header for all its additions. A same-name replacement keeps its
historical definition identity through restart, fork and surface changes.
Removals require no definition copy, and ordinary developer messages omit
headerSeq. Generic sourceEventSeqs remains independent derivation metadata.

The rejected inline tool field is optional never with @persistenceReserved.
The schema extractor retains that uninhabited slot, so permitting a future
value requires a format bump instead of being mistaken for compatible
optional metadata. Unrelated optional fields remain additive and readable.

Native and Session admission reject missing, forward, non-header, unknown,
ambiguous and incomplete referenced definitions, and reject the retired
inline tool field. Validate complete schema fields only for referenced
definitions, preserving unrelated header behavior and extra JSON metadata.
Unknown ignorable records remain opaque. Empty developer nodes retain their
surface position without producing a message or shadowing the system head.
No automatic emitter, provider activation, UI implementation, or speculative
public resolver is added.

Keep the core-owned developer source binding beside the introduced role,
including the request-only type rejection and attribution round trips. Update
consumer guards, SDK/snapshot handling, bilingual owner documentation, and
the same unreleased V4 acknowledgement. Keep earlier tool/source migration
work and its documentation in the parent. Preserve the existing recorded
Cordis inspection generation from the original developer change.

Integration keeps the final source layer as the sole parent and preserves
master's Messages input filtering, empty-user omission, empty tool results,
and system-update placement. Developer/deferred guards still run before
conversion. Native V4 admission, header references, reservation declarations,
and generic tooling match the validated marker candidate. Parent-owned
fixture and generator fixes remain in the parent.

Earlier feature validation passed full build, full lint, 41 doc-sync gates,
and 16 hygiene gates. The unchanged native implementation retains that evidence.
The focused Session/V4/request-input run passes 884 tests across 38 files with
100% statement, branch, function and line coverage for the V3-to-V4 package
and Session surface. Consumer tests pass 482 with one existing skip; tool,
message and optional-field tests pass 146. Cordis inspection and compaction
replays pass in both source and built modes (two cases per mode). Ten invalid
referenced-definition/unknown-header controls failed before their scoped
guards and pass after them.

Reservation validation: the actual generated developer root records tool as
optional primitive never. Widening it requires a bump, while traceId remains
additive and preserved; all 85 focused reader/classifier tests pass.

Final-parent validation: 205 tests across eight provider, type, catalog,
Session and native-format files pass. The preserved parent fixture verifies
Messages input-history replay; runtime/declaration and parent-owned-file
comparisons show no loss during the final rebase.

Co-authored-by: Tianyi Cui <[email protected]>
2026-09-18 19:08:11 +08:00

181 lines
7.2 KiB
TypeScript

/** Current-surface projection and byte-bounded rendering. */
import { isCompactCheckpointSource } from '@deepseek-ai/dsh-compaction'
import type { SessionSurfaceSnapshot } from '@deepseek-ai/dsh-session-query'
import { TextRetainer } from '@deepseek-ai/dsh-output-retention'
import { assertNever } from '@deepseek-ai/dsh-util-values'
import { SessionSeq } from '@deepseek-ai/dsh-session'
import type { OptionalSessionSeq, SessionId } from '@deepseek-ai/dsh-session'
import { stringifyTagSafeJson } from './serialization.ts'
import type { ReferencedConversationItem } from './types.ts'
interface ProjectedItem extends ReferencedConversationItem {
checkpoint: boolean
originalText: string
omittedBytes: number
}
/** Snapshot data serialized inside the untrusted prompt. */
export interface ReferencedSessionData {
sessionId: SessionId
label: string
cwd: string | null
capturedThroughSeq: OptionalSessionSeq
conversation: ReferencedConversationItem[]
}
/** Retention facts stored beside the durable context. */
export interface ReferenceRetentionStats {
compacted: boolean
originalMessages: number
retainedMessages: number
omittedMessages: number
omittedBytes: number
truncated: boolean
}
/** Project current user/assistant conversation while excluding tools, reasoning, and injected context. */
function projectSessionConversation(snapshot: SessionSurfaceSnapshot): ProjectedItem[] {
const conversation: ProjectedItem[] = []
for (const event of snapshot.events) {
switch (event.type) {
case 'user/message': {
const checkpoint = isCompactCheckpointSource(event.data.source)
if (!checkpoint && event.data.source.kind !== 'user') break
const text = textContent(event.data.content)
if (text !== '') conversation.push({ role: 'user', text, checkpoint, originalText: text, omittedBytes: 0 })
break
}
case 'assistant/message': {
const text = textContent(event.data.message.content)
if (text !== '') conversation.push({ role: 'assistant', text, checkpoint: false, originalText: text, omittedBytes: 0 })
break
}
case 'developer/message':
case 'system/message':
case 'tool/result':
break
/* v8 ignore next 2 -- SurfaceEventType is closed and every variant is handled above. */
default:
assertNever(event, 'session-reference surface event')
}
}
return conversation
}
/**
* Fit one projected snapshot into an exact rendered JSON-object byte cap.
* @param snapshot - current-surface source observation.
* @param label - host-provided display label serialized with the source.
* @param maxBytes - maximum UTF-8 bytes for the serialized data object.
* @returns full projected data, retained preview and stats, or `undefined` when fixed data cannot fit.
*/
export function retainReferencedSession(
snapshot: SessionSurfaceSnapshot,
label: string,
maxBytes: number,
): { data: ReferencedSessionData; fullData: ReferencedSessionData; stats: ReferenceRetentionStats } | undefined {
const original = projectSessionConversation(snapshot)
const retained = original.map(item => ({ ...item }))
let omittedMessages = 0
let droppedOmittedBytes = 0
const data = (): ReferencedSessionData => ({
sessionId: snapshot.session.id,
label,
cwd: snapshot.session.cwd ?? null,
capturedThroughSeq: snapshot.capturedThroughSeq === null
? null
: SessionSeq(snapshot.capturedThroughSeq),
conversation: retained.map(({ role, text }) => ({ role, text })),
})
const fullData = data()
const size = (): number => Buffer.byteLength(stringifyTagSafeJson(data()), 'utf8')
while (size() > maxBytes) {
const newestIndex = retained.length - 1
const dropIndex = retained.findIndex((item, index) => !item.checkpoint && index !== newestIndex)
if (dropIndex < 0) break
const removed = retained.splice(dropIndex, 1)[0]
/* v8 ignore next 3 -- dropIndex came from this exact array and is non-negative. */
if (removed === undefined) {
throw new Error('session-reference retention selected a missing message')
}
omittedMessages += 1
droppedOmittedBytes += Buffer.byteLength(removed.originalText, 'utf8')
}
while (size() > maxBytes) {
let longestIndex = -1
let longestBytes = 0
for (const [index, item] of retained.entries()) {
const bytes = Buffer.byteLength(item.text, 'utf8')
if (bytes > longestBytes) {
longestBytes = bytes
longestIndex = index
}
}
if (longestIndex < 0 || longestBytes === 0) return undefined
const overflow = size() - maxBytes
const target = Math.max(0, longestBytes - overflow)
const item = retained[longestIndex]
/* v8 ignore next 3 -- longestIndex was selected from this exact array's entries. */
if (item === undefined) {
throw new Error('session-reference retention selected a missing longest message')
}
const shortened = truncateWithNotice(item.originalText, target)
/* v8 ignore next -- strictly lowering the byte target must change a complete-string retention result. */
if (shortened.text === retained[longestIndex]?.text) return undefined
retained[longestIndex] = { ...item, text: shortened.text, omittedBytes: shortened.omittedBytes }
}
const compacted = original.some(item => item.checkpoint)
const retainedOmittedBytes = retained.reduce((sum, item) => sum + item.omittedBytes, 0)
const omittedBytes = retainedOmittedBytes + droppedOmittedBytes
return {
data: data(),
fullData,
stats: {
compacted,
originalMessages: original.length,
retainedMessages: retained.length,
omittedMessages,
omittedBytes,
truncated: omittedMessages > 0 || omittedBytes > 0,
},
}
}
function textContent(content: readonly { type: string; text?: string }[]): string {
return content.flatMap(block => block.type === 'text' && typeof block.text === 'string' ? [block.text] : []).join('\n')
}
function truncateWithNotice(text: string, maxOutputBytes: number): { text: string; omittedBytes: number } {
/* v8 ignore next -- callers invoke this only with a target smaller than the selected original text. */
if (Buffer.byteLength(text, 'utf8') <= maxOutputBytes) return { text, omittedBytes: 0 }
let low = 0
let high = maxOutputBytes
let best = { text: '', omittedBytes: Buffer.byteLength(text, 'utf8') }
while (low <= high) {
const retainedBytes = Math.floor((low + high) / 2)
const headBytes = Math.ceil(retainedBytes / 2)
const tailBytes = Math.floor(retainedBytes / 2)
const retainer = new TextRetainer({ kind: 'headTail', headBytes, tailBytes })
retainer.push(text)
const result = retainer.finish()
// The complete source string was pushed before `finish()`, so omission is exact.
/* v8 ignore next 3 -- complete-string TextRetainer input cannot report a lower bound. */
if (result.omittedBytes.kind !== 'exact') {
throw new Error('session-reference retention did not report exact omitted bytes')
}
const omitted = result.omittedBytes.count
const candidate = `${result.text}\n[… omitted ${omitted} UTF-8 bytes …]`
if (Buffer.byteLength(candidate, 'utf8') <= maxOutputBytes) {
best = { text: candidate, omittedBytes: omitted }
low = retainedBytes + 1
} else {
high = retainedBytes - 1
}
}
return best
}