feat(invariants): dev-mode event-contract assertions + session-log freeze (RFC 005 pt 3, RFC 008)
New @deepseek-ai/dsh-invariants plugin (pure listeners, off in prod) asserts the event taxonomy at runtime — seq monotonicity, turn/step nesting, a tool/result needs a prior tool/call (NOT the converse), legal agent/status transitions — and deep-freezes logged event data so mutating history throws. Seeded sessions are checked + frozen on session/created. The real RFC 008 fix is always-on: deriveMessages now structured-clones the content it emits, so the loop's sanctioned request/adapter mutation can no longer reach back and rewrite the append-only log. The pervasive DeepReadonly<T> type flip is rejected (compile-only, high-noise, castable) — recorded in ADR 0012, which folds in RFC 008. Wired into both demos.
This commit is contained in:
@@ -94,6 +94,14 @@ export class Session {
|
||||
* - `tool/result` → user message carrying a tool-result block
|
||||
* - `context/message` / `steering/message` → tagged synthetic user messages
|
||||
* at their chronological position
|
||||
*
|
||||
* The returned `content` is **deep-cloned** off the logged events: the loop
|
||||
* hands these messages into the mutable `agent/request` waterfall and on to
|
||||
* adapters, where mutating the request is sanctioned — but the session log
|
||||
* is append-only by contract. Cloning at this boundary keeps in-flight
|
||||
* mutation from reaching back and rewriting history (which would silently
|
||||
* break replay equivalence). Cost is one structured clone per step,
|
||||
* negligible next to a model call.
|
||||
*/
|
||||
deriveMessages(): Message[] {
|
||||
const messages: Message[] = []
|
||||
@@ -104,29 +112,29 @@ export class Session {
|
||||
// eslint-disable-next-line @typescript-eslint/switch-exhaustiveness-check
|
||||
switch (event.type) {
|
||||
case 'user/message': {
|
||||
messages.push({ role: 'user', content: event.data.content })
|
||||
messages.push({ role: 'user', content: structuredClone(event.data.content) })
|
||||
break
|
||||
}
|
||||
case 'assistant/message': {
|
||||
messages.push({ role: 'assistant', content: event.data.content })
|
||||
messages.push({ role: 'assistant', content: structuredClone(event.data.content) })
|
||||
break
|
||||
}
|
||||
case 'tool/result': {
|
||||
const { callId, content, isError } = event.data
|
||||
messages.push({
|
||||
role: 'user',
|
||||
content: [{ type: 'tool-result', toolCallId: callId, content, isError }],
|
||||
content: [{ type: 'tool-result', toolCallId: callId, content: structuredClone(content), isError }],
|
||||
})
|
||||
break
|
||||
}
|
||||
case 'context/message': {
|
||||
const { content, source } = event.data
|
||||
messages.push({ role: 'user', content: renderTagged('context', content, source) })
|
||||
messages.push({ role: 'user', content: renderTagged('context', structuredClone(content), source) })
|
||||
break
|
||||
}
|
||||
case 'steering/message': {
|
||||
const { content, source } = event.data
|
||||
messages.push({ role: 'user', content: renderTagged('steering', content, source) })
|
||||
messages.push({ role: 'user', content: renderTagged('steering', structuredClone(content), source) })
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
@@ -54,6 +54,31 @@ describe('Session', () => {
|
||||
expect(replayed.deriveMessages()).toEqual(original.deriveMessages())
|
||||
expect(replayed.seq).toBe(original.seq)
|
||||
})
|
||||
|
||||
it('isolates the log from mutation through a derived message (append-only contract)', () => {
|
||||
const session = new Session(SessionId('s4'))
|
||||
session.append('user/message', { content: [{ type: 'text', text: 'original' }], source: { kind: 'user' } })
|
||||
session.append('tool/result', {
|
||||
turn: 1, step: 1, callId: CallId('c1'),
|
||||
content: [{ type: 'text', text: 'tool out' }], isError: false,
|
||||
})
|
||||
const before = structuredClone(session.events)
|
||||
|
||||
// A request middleware / adapter mutates the messages it was handed.
|
||||
const messages = session.deriveMessages()
|
||||
const userBlock = messages[0]!.content[0]!
|
||||
if (userBlock.type === 'text') userBlock.text = 'HACKED'
|
||||
const toolBlock = messages[1]!.content[0]!
|
||||
if (toolBlock.type === 'tool-result') {
|
||||
toolBlock.content.push({ type: 'text', text: 'injected' })
|
||||
}
|
||||
messages[0]!.content.push({ type: 'text', text: 'extra' })
|
||||
|
||||
// The log is unchanged: deep-equal to the snapshot taken before mutation.
|
||||
expect(session.events).toEqual(before)
|
||||
// And a fresh derivation still reflects the original content.
|
||||
expect(session.deriveMessages()[0]!.content).toEqual([{ type: 'text', text: 'original' }])
|
||||
})
|
||||
})
|
||||
|
||||
describe('SessionStore', () => {
|
||||
|
||||
Reference in New Issue
Block a user