The loop is now transmission-stateless; a request is a pure function of (session log, this step's rendered assembly, current AgentOptions): - The reconstruction boundary is step/start: the messages snapshot is taken in the same synchronous frame immediately before the step/start append, so the request's messages are exactly the derivation over events[0..stepStartSeq) — an inject() from an agent/request listener (or any concurrent task) lands after the boundary and joins the NEXT request. This changes behavior for a synchronous step/start session/event listener that appends content (master derived after the append, so such a listener could reach the current request): agent/pre-step is the sanctioned seam for current-request content. - agent/request is re-typed to config-only: (agent, turn, step, config: LlmCallConfig, next) → LlmCallConfig. The frozen seed comes from AgentOptions on a loop instance's first request (explicit options beat the logged baseline — fork overrides and resume reconfiguration stay correct) and from the log's folded header afterwards; listeners return a replacement to switch. Content shaping through the request is no longer expressible — model-visible content flows through the log channels. - recordRequestHeader appends whatever header event the request owes the log before dispatch: an 'initial'/'resume' snapshot anchoring each loop instance, a round-trip-verified delta on change, a 'fallback' snapshot when the encoding cannot express it. Session.requestHeader() is the log's incrementally-folded baseline. - Requests are deep-frozen before dispatch (deepFreeze exempts the AbortSignal — freezing one breaks AbortController.abort() outright); frozen + sessionId is the loop-built marker the dev invariant keys on. Ported from #162 and re-anchored on the log: the append-extension / frozen-end-to-end / compaction-resend / prompt-change property tests, plus new specs for the boundary semantics, resume anchoring, and the end-to-end theorem (every recorded request rebuilds byte-equal from the log alone). Live cache-hit e2e (request-cache.e2e.ts) verified against the real DeepSeek API. Snapshot goldens intentionally stale until the single re-record after the compact/summary envelope lands.
57 lines
2.5 KiB
TypeScript
57 lines
2.5 KiB
TypeScript
/**
|
|
* call-config unit tests: field-wise LlmCallConfig equality (the real-change
|
|
* detector behind logged header deltas) and the deepFreeze ownership helper
|
|
* the loop applies to every built request.
|
|
*/
|
|
|
|
import { describe, expect, it } from 'vitest'
|
|
import { callConfigEquals, deepFreeze } from '../src/call-config.ts'
|
|
|
|
describe('callConfigEquals', () => {
|
|
it('compares every field, including the stop list element-wise', () => {
|
|
expect(callConfigEquals({ model: 'm' }, { model: 'm' })).toBe(true)
|
|
expect(callConfigEquals({ model: 'm' }, { model: 'x' })).toBe(false)
|
|
expect(callConfigEquals({ model: 'm', temperature: 0.5 }, { model: 'm' })).toBe(false)
|
|
expect(callConfigEquals({ model: 'm', maxTokens: 1 }, { model: 'm', maxTokens: 2 })).toBe(false)
|
|
expect(callConfigEquals({ model: 'm', stop: ['a'] }, { model: 'm' })).toBe(false)
|
|
expect(callConfigEquals({ model: 'm', stop: ['a'] }, { model: 'm', stop: ['a', 'b'] })).toBe(false)
|
|
expect(callConfigEquals({ model: 'm', stop: ['a'] }, { model: 'm', stop: ['b'] })).toBe(false)
|
|
expect(callConfigEquals({ model: 'm', stop: ['a', 'b'] }, { model: 'm', stop: ['a', 'b'] })).toBe(true)
|
|
})
|
|
})
|
|
|
|
describe('deepFreeze', () => {
|
|
it('freezes nested structure in place and returns the same reference', () => {
|
|
const value = { a: { b: [1, { c: 'x' }] } }
|
|
const frozen = deepFreeze(value)
|
|
expect(frozen).toBe(value)
|
|
expect(Object.isFrozen(value)).toBe(true)
|
|
expect(Object.isFrozen(value.a)).toBe(true)
|
|
expect(Object.isFrozen(value.a.b)).toBe(true)
|
|
expect(Object.isFrozen(value.a.b[1])).toBe(true)
|
|
// ESM runs in strict mode: mutation throws rather than silently failing.
|
|
expect(() => { (value.a.b[1] as { c: string }).c = 'y' }).toThrow(TypeError)
|
|
})
|
|
|
|
it('never freezes an AbortSignal: the live cancellation channel keeps working', () => {
|
|
const controller = new AbortController()
|
|
const request = deepFreeze({ model: 'm', signal: controller.signal })
|
|
expect(Object.isFrozen(request)).toBe(true)
|
|
expect(Object.isFrozen(controller.signal)).toBe(false)
|
|
let fired = false
|
|
controller.signal.addEventListener('abort', () => { fired = true }, { once: true })
|
|
controller.abort('stop')
|
|
expect(fired).toBe(true)
|
|
expect(controller.signal.aborted).toBe(true)
|
|
})
|
|
|
|
it('passes primitives through and terminates on cycles', () => {
|
|
expect(deepFreeze(42)).toBe(42)
|
|
expect(deepFreeze(null)).toBeNull()
|
|
const cyclic = { self: undefined as unknown }
|
|
cyclic.self = cyclic
|
|
deepFreeze(cyclic)
|
|
expect(Object.isFrozen(cyclic)).toBe(true)
|
|
})
|
|
})
|