Merge newest origin/master into token-meter-service
This commit is contained in:
@@ -40,7 +40,7 @@ describe('derived-message cache', () => {
|
||||
const nodes = session.surface.nodes
|
||||
session.append('context/message', {
|
||||
content: [{ type: 'text', text: 'summary' }], source: { kind: 'plugin', plugin: 'compact' },
|
||||
}, { surfaceOp: { op: 'replace', start: nodes[0]!.seq, end: nodes[1]!.seq }, sourceEventSeqs: [nodes[0]!.seq, nodes[1]!.seq] })
|
||||
}, { surfaceOp: { op: 'replace', start: nodes[0]!, end: nodes[1]! }, sourceEventSeqs: [nodes[0]!, nodes[1]!] })
|
||||
|
||||
expect(session.deriveMessages()).toHaveLength(1)
|
||||
expect(session.deriveMessages()).toEqual(scratch(session))
|
||||
|
||||
@@ -1,14 +1,7 @@
|
||||
/**
|
||||
* Request-header utility tests: canonical form, the system line-diff
|
||||
* (prefix/suffix trim), the name-keyed tools delta, config replacement, the
|
||||
* round-trip contract (including the reorder case the encoding cannot
|
||||
* express), and the log fold. These pin the reconstruction algebra: for every
|
||||
* logged delta, apply(prev, delta) === next, and folding a log prefix yields
|
||||
* the header its next request was built under.
|
||||
*/
|
||||
/** Request-header canonicalization, equality, snapshot folding, and format rejection. */
|
||||
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { Session, SessionId, applyHeaderDelta, canonicalHeader, diffHeader, foldRequestHeader, headerEquals } from '@deepseek-ai/dsh-session'
|
||||
import { Session, SessionId, canonicalHeader, foldRequestHeader, headerEquals } from '@deepseek-ai/dsh-session'
|
||||
import type { EpochHeader, SessionEvent } from '@deepseek-ai/dsh-session'
|
||||
import type { Message, ToolSchema } from '@deepseek-ai/dsh-llm'
|
||||
|
||||
@@ -22,165 +15,77 @@ function msg(text: string): Message {
|
||||
return { role: 'user', content: [{ type: 'text', text }] }
|
||||
}
|
||||
|
||||
/** Round-trip helper: diff must reproduce `next` from `prev` exactly. */
|
||||
function roundTrip(prev: EpochHeader, next: EpochHeader): ReturnType<typeof diffHeader> {
|
||||
const delta = diffHeader(prev, next)
|
||||
if (delta !== undefined) {
|
||||
expect(applyHeaderDelta(prev, delta)).toEqual(canonicalHeader(next))
|
||||
}
|
||||
return delta
|
||||
}
|
||||
|
||||
describe('canonicalHeader', () => {
|
||||
it('normalizes empty system and empty tools to absent fields', () => {
|
||||
expect(canonicalHeader({ config: CONFIG, system: '', tools: [] })).toEqual({ config: CONFIG })
|
||||
const full = canonicalHeader({ config: CONFIG, system: 's', tools: [tool('a')] })
|
||||
expect(full.system).toBe('s')
|
||||
expect(full.tools).toHaveLength(1)
|
||||
it('normalizes empty optional fields to absence and preserves populated fields', () => {
|
||||
expect(canonicalHeader({ config: CONFIG, system: '', tools: [], messagePrefix: [] })).toEqual({ config: CONFIG })
|
||||
const full = canonicalHeader({ config: CONFIG, system: 's', tools: [tool('a')], messagePrefix: [msg('p')] })
|
||||
expect(full).toEqual({ config: CONFIG, system: 's', tools: [tool('a')], messagePrefix: [msg('p')] })
|
||||
})
|
||||
})
|
||||
|
||||
describe('diffHeader / applyHeaderDelta', () => {
|
||||
it('returns undefined for equal headers', () => {
|
||||
const header = canonicalHeader({ config: CONFIG, system: 'a\nb', tools: [tool('t')] })
|
||||
expect(diffHeader(header, header)).toBeUndefined()
|
||||
describe('headerEquals', () => {
|
||||
const base = canonicalHeader({ config: CONFIG, system: 's', tools: [tool('a')], messagePrefix: [msg('p')] })
|
||||
|
||||
it('compares every canonical field and preserves tool order', () => {
|
||||
expect(headerEquals(base, structuredClone(base))).toBe(true)
|
||||
expect(headerEquals(base, { ...base, config: { provider: 'mock', model: 'other' } })).toBe(false)
|
||||
expect(headerEquals(base, { ...base, system: 'other' })).toBe(false)
|
||||
expect(headerEquals(base, { ...base, messagePrefix: [msg('other')] })).toBe(false)
|
||||
expect(headerEquals(base, { ...base, tools: [] })).toBe(false)
|
||||
expect(headerEquals(base, { ...base, tools: [tool('a', 'changed')] })).toBe(false)
|
||||
expect(headerEquals({ config: CONFIG, tools: [tool('a'), tool('b')] }, { config: CONFIG, tools: [tool('b'), tool('a')] })).toBe(false)
|
||||
})
|
||||
|
||||
it('encodes a mid-prompt line change as a prefix/suffix trim', () => {
|
||||
const prev = canonicalHeader({ config: CONFIG, system: 'keep1\nold\nkeep2\nkeep3' })
|
||||
const next = canonicalHeader({ config: CONFIG, system: 'keep1\nnew A\nnew B\nkeep2\nkeep3' })
|
||||
const delta = roundTrip(prev, next)
|
||||
expect(delta?.system).toEqual({ keepStart: 1, keepEnd: 2, insert: ['new A', 'new B'] })
|
||||
expect(delta?.tools).toBeUndefined()
|
||||
expect(delta?.config).toBeUndefined()
|
||||
})
|
||||
|
||||
it('degenerates to a full replacement when nothing is shared, and round-trips absence transitions', () => {
|
||||
const none = canonicalHeader({ config: CONFIG })
|
||||
const some = canonicalHeader({ config: CONFIG, system: 'x\ny' })
|
||||
const gained = roundTrip(none, some)
|
||||
expect(gained?.system).toEqual({ keepStart: 0, keepEnd: 0, insert: ['x', 'y'] })
|
||||
const lost = roundTrip(some, none)
|
||||
expect(lost?.system).toEqual({ keepStart: 0, keepEnd: 0, insert: [] })
|
||||
})
|
||||
|
||||
it('does not double-count overlapping prefix and suffix (repeated lines)', () => {
|
||||
const prev = canonicalHeader({ config: CONFIG, system: 'a\na' })
|
||||
const next = canonicalHeader({ config: CONFIG, system: 'a\na\na' })
|
||||
roundTrip(prev, next)
|
||||
})
|
||||
|
||||
it('encodes tool addition, removal, and in-place schema change by name', () => {
|
||||
const prev = canonicalHeader({ config: CONFIG, tools: [tool('keep'), tool('drop'), tool('edit', 'before')] })
|
||||
const next = canonicalHeader({ config: CONFIG, tools: [tool('keep'), tool('edit', 'after'), tool('new')] })
|
||||
const delta = roundTrip(prev, next)
|
||||
expect(delta?.tools?.added.map(t => t.name)).toEqual(['new'])
|
||||
expect(delta?.tools?.removed).toEqual(['drop'])
|
||||
expect(delta?.tools?.changed.map(t => t.name)).toEqual(['edit'])
|
||||
})
|
||||
|
||||
it('round-trips a tool set gained from a tool-less header and lost back to one', () => {
|
||||
const none = canonicalHeader({ config: CONFIG })
|
||||
const some = canonicalHeader({ config: CONFIG, tools: [tool('t')] })
|
||||
const gained = roundTrip(none, some)
|
||||
expect(gained?.tools?.added.map(t => t.name)).toEqual(['t'])
|
||||
const lost = roundTrip(some, none)
|
||||
expect(lost?.tools?.removed).toEqual(['t'])
|
||||
})
|
||||
|
||||
it('cannot express a pure reordering — the writer detects it via the round-trip check', () => {
|
||||
const prev = canonicalHeader({ config: CONFIG, tools: [tool('a'), tool('b')] })
|
||||
const next = canonicalHeader({ config: CONFIG, tools: [tool('b'), tool('a')] })
|
||||
const delta = diffHeader(prev, next)
|
||||
// A delta IS produced (the lists differ)…
|
||||
expect(delta).toBeDefined()
|
||||
// …but applying it cannot reproduce the new order — exactly the case the
|
||||
// writer's guard turns into a 'fallback' snapshot.
|
||||
expect(applyHeaderDelta(prev, delta!)).not.toEqual(next)
|
||||
})
|
||||
|
||||
it('replaces the config whole and leaves untouched parts alone', () => {
|
||||
const prev = canonicalHeader({ config: { provider: 'mock', model: 'm' }, system: 's', tools: [tool('t')] })
|
||||
const next = canonicalHeader({ config: { provider: 'mock', model: 'm2', temperature: 0.1 }, system: 's', tools: [tool('t')] })
|
||||
const delta = roundTrip(prev, next)
|
||||
expect(delta).toEqual({ config: { provider: 'mock', model: 'm2', temperature: 0.1 } })
|
||||
})
|
||||
})
|
||||
|
||||
describe('the session prefix (messagePrefix)', () => {
|
||||
it('canonicalHeader normalizes an empty prefix to an absent field', () => {
|
||||
expect(canonicalHeader({ config: CONFIG, messagePrefix: [] })).toEqual({ config: CONFIG })
|
||||
const full = canonicalHeader({ config: CONFIG, messagePrefix: [msg('p')] })
|
||||
expect(full.messagePrefix).toEqual([msg('p')])
|
||||
})
|
||||
|
||||
it('headerEquals treats absence and empty as one representation, content differences as unequal', () => {
|
||||
expect(headerEquals(canonicalHeader({ config: CONFIG }), { config: CONFIG, messagePrefix: [] })).toBe(true)
|
||||
expect(headerEquals({ config: CONFIG, messagePrefix: [msg('a')] }, { config: CONFIG, messagePrefix: [msg('b')] })).toBe(false)
|
||||
expect(headerEquals({ config: CONFIG, messagePrefix: [msg('a')] }, { config: CONFIG })).toBe(false)
|
||||
})
|
||||
|
||||
it('replaces a changed prefix whole and leaves untouched parts alone', () => {
|
||||
const prev = canonicalHeader({ config: CONFIG, system: 'keep', messagePrefix: [msg('old')] })
|
||||
const next = canonicalHeader({ config: CONFIG, system: 'keep', messagePrefix: [msg('new'), msg('more')] })
|
||||
const delta = roundTrip(prev, next)
|
||||
expect(delta).toEqual({ messagePrefix: [msg('new'), msg('more')] })
|
||||
})
|
||||
|
||||
it('round-trips a prefix gained from a bare header and lost back to one (empty array encodes absence)', () => {
|
||||
const none = canonicalHeader({ config: CONFIG })
|
||||
const some = canonicalHeader({ config: CONFIG, messagePrefix: [msg('p')] })
|
||||
const gained = roundTrip(none, some)
|
||||
expect(gained).toEqual({ messagePrefix: [msg('p')] })
|
||||
const lost = roundTrip(some, none)
|
||||
expect(lost).toEqual({ messagePrefix: [] })
|
||||
})
|
||||
|
||||
it('folds prefix deltas over the log like any other header amendment', () => {
|
||||
const session = new Session(SessionId('fold-prefix'))
|
||||
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
|
||||
const first = canonicalHeader({ config: CONFIG, messagePrefix: [msg('catalog v1')] })
|
||||
session.append('request/header', { header: first, reason: 'initial' })
|
||||
const second = canonicalHeader({ config: CONFIG, messagePrefix: [msg('catalog v2')] })
|
||||
session.append('request/header-delta', diffHeader(first, second)!)
|
||||
expect(foldRequestHeader(session.events)).toEqual(second)
|
||||
session.append('request/header-delta', diffHeader(second, canonicalHeader({ config: CONFIG }))!)
|
||||
expect(foldRequestHeader(session.events)).toEqual({ config: CONFIG })
|
||||
it('treats absent and empty prefix/tool arrays as equivalent canonical absence', () => {
|
||||
expect(headerEquals({ config: CONFIG }, { config: CONFIG, tools: [], messagePrefix: [] })).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('foldRequestHeader', () => {
|
||||
function headerEvents(session: Session): readonly SessionEvent[] {
|
||||
return session.events
|
||||
}
|
||||
|
||||
it('returns undefined on a log with no header events', () => {
|
||||
const session = new Session(SessionId('fold-none'))
|
||||
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
|
||||
expect(foldRequestHeader(headerEvents(session))).toBeUndefined()
|
||||
it('returns the supplied baseline when no snapshot follows', () => {
|
||||
const from: EpochHeader = { config: CONFIG, system: 'baseline' }
|
||||
const unrelated: SessionEvent[] = [
|
||||
{ type: 'turn/start', seq: 0, time: 1, data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } } },
|
||||
]
|
||||
expect(foldRequestHeader(unrelated)).toBeUndefined()
|
||||
expect(foldRequestHeader(unrelated, from)).toBe(from)
|
||||
})
|
||||
|
||||
it('folds snapshot then deltas into the header in force, skipping unrelated events', () => {
|
||||
it('takes the latest full snapshot and skips unrelated events', () => {
|
||||
const session = new Session(SessionId('fold'))
|
||||
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
|
||||
const first = canonicalHeader({ config: { provider: 'mock', model: 'm' }, system: 'a\nb', tools: [tool('t')] })
|
||||
session.append('request/header', { header: first, reason: 'initial' })
|
||||
session.append('request/header', { header: { config: CONFIG, system: 'first' }, reason: 'initial' })
|
||||
session.append('user/message', { content: [{ type: 'text', text: 'hi' }], source: { kind: 'user' } }, { surfaceOp: 'append' })
|
||||
|
||||
const second = canonicalHeader({ config: { provider: 'mock', model: 'm' }, system: 'a\nc', tools: [tool('t')] })
|
||||
session.append('request/header-delta', diffHeader(first, second)!)
|
||||
expect(foldRequestHeader(headerEvents(session))).toEqual(second)
|
||||
|
||||
// A later snapshot replaces the state wholesale (the 'resume'/'fallback' anchor).
|
||||
const third = canonicalHeader({ config: { provider: 'mock', model: 'other' } })
|
||||
session.append('request/header', { header: third, reason: 'resume' })
|
||||
expect(foldRequestHeader(headerEvents(session))).toEqual(third)
|
||||
})
|
||||
|
||||
it('throws on a delta before any snapshot (corrupt log)', () => {
|
||||
const session = new Session(SessionId('fold-corrupt'))
|
||||
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
|
||||
session.append('request/header-delta', { config: { provider: 'mock', model: 'x' } })
|
||||
expect(() => foldRequestHeader(headerEvents(session))).toThrow(/before any request\/header snapshot/)
|
||||
session.append('request/header', { header: { config: { provider: 'mock', model: 'other' }, tools: [] }, reason: 'change' })
|
||||
expect(foldRequestHeader(session.events)).toEqual({ config: { provider: 'mock', model: 'other' } })
|
||||
})
|
||||
})
|
||||
|
||||
describe('legacy request-header format', () => {
|
||||
it('rejects request/header-delta in seeds and untyped appends', () => {
|
||||
const legacy = [{
|
||||
type: 'request/header-delta', seq: 0, time: 1, data: { config: CONFIG },
|
||||
}] as unknown as SessionEvent[]
|
||||
expect(() => new Session(SessionId('legacy'), legacy)).toThrow(/unsupported legacy request\/header-delta/)
|
||||
|
||||
const session = new Session(SessionId('legacy-append-delta'))
|
||||
const appendLegacy = session.append.bind(session) as (type: string, data: unknown) => SessionEvent
|
||||
expect(() => appendLegacy('request/header-delta', { config: CONFIG }))
|
||||
.toThrow(/unsupported legacy request\/header-delta/)
|
||||
expect(session.events).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('rejects the removed fallback reason in seeds and untyped appends', () => {
|
||||
const legacy = [{
|
||||
type: 'request/header', seq: 0, time: 1, data: { header: { config: CONFIG }, reason: 'fallback' },
|
||||
}] as unknown as SessionEvent[]
|
||||
expect(() => new Session(SessionId('legacy-seed-reason'), legacy))
|
||||
.toThrow('unsupported legacy request/header reason "fallback"')
|
||||
|
||||
const session = new Session(SessionId('legacy-append-reason'))
|
||||
const appendLegacy = session.append.bind(session) as (type: string, data: unknown) => SessionEvent
|
||||
expect(() => appendLegacy('request/header', { header: { config: CONFIG }, reason: 'fallback' }))
|
||||
.toThrow('unsupported legacy request/header reason "fallback"')
|
||||
expect(session.events).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -101,13 +101,6 @@ describe('Session', () => {
|
||||
expect(() => new Session(SessionId('old-header'), [requestHeader]))
|
||||
.toThrow('seed request/header at index 0 lacks provider/model')
|
||||
|
||||
const requestDelta = {
|
||||
type: 'request/header-delta', seq: 0, time: 1,
|
||||
data: { config: { model: 'old-model' } },
|
||||
} as unknown as SessionEvent
|
||||
expect(() => new Session(SessionId('old-delta'), [requestDelta]))
|
||||
.toThrow('seed request/header-delta at index 0 lacks provider/model')
|
||||
|
||||
const assistantMessage = {
|
||||
type: 'assistant/message', seq: 0, time: 1,
|
||||
data: { turn: 1, step: 1, content: [{ type: 'text', text: 'old' }] },
|
||||
@@ -1244,8 +1237,8 @@ describe('todo/write event', () => {
|
||||
session.append('todo/write', { todos: [{ content: 'a task', status: 'pending' }] })
|
||||
// The todo event must not add a message to the derived history…
|
||||
expect(session.deriveMessages()).toHaveLength(before)
|
||||
// …and must not appear on the surface linked list.
|
||||
expect(session.surface.nodes.some(node => node.seq === session.seq - 1)).toBe(false)
|
||||
// …and must not appear on the ordered surface.
|
||||
expect(session.surface.nodes).not.toContain(session.seq - 1)
|
||||
})
|
||||
|
||||
it('round-trips through a seeded replay identically (durable, no surfaceOp needed)', () => {
|
||||
|
||||
@@ -95,7 +95,7 @@ describe('foldSurface provenance', () => {
|
||||
})
|
||||
|
||||
describe('SurfaceManager', () => {
|
||||
it('shares exact nodes and nested replacement ranges with foldSurface', () => {
|
||||
it('shares ordered entries and nested replacement ranges with foldSurface', () => {
|
||||
const s = new Session(SessionId('shared-fold'))
|
||||
s.append('user/message', { content: [{ type: 'text', text: 'a' }], source: { kind: 'user' } }, { surfaceOp: 'append' })
|
||||
s.append('user/message', { content: [{ type: 'text', text: 'b' }], source: { kind: 'user' } }, { surfaceOp: 'append' })
|
||||
@@ -108,9 +108,10 @@ describe('SurfaceManager', () => {
|
||||
{ seq: 2, start: 0, end: 0, shadowedSeqs: [0] },
|
||||
{ seq: 3, start: 2, end: 1, shadowedSeqs: [2, 1] },
|
||||
])
|
||||
folded.nodes[0]!.next = 99
|
||||
folded.nodes[0] = 99
|
||||
folded.replacements[0]!.shadowedSeqs.push(99)
|
||||
expect(s.surface.nodes).toEqual([{ seq: 3, prev: null, next: null }])
|
||||
expect(s.surface.nodes).toEqual([3])
|
||||
expect(foldSurface(s.events).nodes).toEqual([3])
|
||||
expect(foldSurface(s.events).replacements[0]!.shadowedSeqs).toEqual([0])
|
||||
})
|
||||
|
||||
@@ -119,7 +120,7 @@ describe('SurfaceManager', () => {
|
||||
s.append('user/message', { content: [{ type: 'text', text: 'a' }], source: { kind: 'user' } }, { surfaceOp: 'append' })
|
||||
s.append('assistant/message', { provenance: { provider: 'mock', model: 'mock' }, turn: 1, step: 1, content: [{ type: 'text', text: 'b' }] }, { surfaceOp: { op: 'replace', start: 0, end: 0 }, sourceEventSeqs: [0] })
|
||||
|
||||
expect(s.surface.nodes).toEqual([{ seq: 1, prev: null, next: null }])
|
||||
expect(s.surface.nodes).toEqual([1])
|
||||
const manager = s.surface as unknown as { _state: object }
|
||||
expect(Object.hasOwn(manager._state, 'replacements')).toBe(false)
|
||||
expect(foldSurface(s.events).replacements).toEqual([
|
||||
@@ -150,7 +151,7 @@ describe('SurfaceManager', () => {
|
||||
|
||||
expect(s.events).toHaveLength(1)
|
||||
s.append('user/message', { content: [{ type: 'text', text: 'b' }], source: { kind: 'user' } }, { surfaceOp: 'append' })
|
||||
expect(s.surface.nodes.map(node => node.seq)).toEqual([0, 1])
|
||||
expect(s.surface.nodes).toEqual([0, 1])
|
||||
})
|
||||
|
||||
it('foldSurface rejects a surface-eligible event without its mandatory marker', () => {
|
||||
@@ -178,18 +179,12 @@ describe('SurfaceManager', () => {
|
||||
.toThrow(/not surface-eligible and cannot carry surfaceOp/)
|
||||
})
|
||||
|
||||
it('rebuilds a linked list from surfaceOp: append markers', () => {
|
||||
it('folds an ordered sequence list from surfaceOp: append markers', () => {
|
||||
const s = surfaceSession()
|
||||
const nodes = s.surface.nodes
|
||||
// Only the user/message and assistant/message carry surfaceOp: 'append'.
|
||||
// The turn boundaries do not have surface markers.
|
||||
expect(nodes.length).toBe(2)
|
||||
expect(nodes[0]!.seq).toBe(1) // user/message (turn/start is seq 0)
|
||||
expect(nodes[0]!.prev).toBeNull()
|
||||
expect(nodes[0]!.next).toBe(2) // assistant/message (seq 2)
|
||||
expect(nodes[1]!.seq).toBe(2)
|
||||
expect(nodes[1]!.prev).toBe(1)
|
||||
expect(nodes[1]!.next).toBeNull()
|
||||
expect(nodes).toEqual([1, 2])
|
||||
})
|
||||
|
||||
it('empty surface yields empty nodes', () => {
|
||||
@@ -210,9 +205,7 @@ describe('SurfaceManager', () => {
|
||||
// Append another surface node
|
||||
s.append('tool/result', { turn: 1, step: 1, callId: CallId('c1'), content: [{ type: 'text', text: 'ok' }], isError: false }, { surfaceOp: 'append' })
|
||||
expect(s.surface.nodes.length).toBe(3)
|
||||
expect(s.surface.nodes[2]!.seq).toBe(4) // seq 4: after turn/end at seq 3
|
||||
expect(s.surface.nodes[2]!.prev).toBe(2)
|
||||
expect(s.surface.nodes[1]!.next).toBe(4)
|
||||
expect(s.surface.nodes[2]!).toBe(4) // seq 4: after turn/end at seq 3
|
||||
})
|
||||
|
||||
it('replays identically from a seeded log with surface markers', () => {
|
||||
@@ -220,21 +213,17 @@ describe('SurfaceManager', () => {
|
||||
original.append('tool/result', { turn: 1, step: 1, callId: CallId('c1'), content: [{ type: 'text', text: 'ok' }], isError: false }, { surfaceOp: 'append' })
|
||||
const replayed = new Session(SessionId('replay'), [...original.events])
|
||||
// Surface rebuilds from the seeded log's markers.
|
||||
expect(replayed.surface.nodes.map(n => n.seq)).toEqual([1, 2, 4])
|
||||
expect(replayed.surface.nodes).toEqual([1, 2, 4])
|
||||
expect(replayed.deriveMessages()).toEqual(original.deriveMessages())
|
||||
})
|
||||
|
||||
it('rebuild with replace operation splices out shadowed nodes', () => {
|
||||
const s = surfaceSession()
|
||||
// Replace surface seqs 1 (user) and 2 (assistant) with the summary.
|
||||
s.append('assistant/message',
|
||||
{ provenance: { provider: 'mock', model: 'mock' }, turn: 2, step: 1, content: [{ type: 'text', text: 'summary' }] },
|
||||
{ surfaceOp: { op: 'replace', start: 1, end: 2 }, sourceEventSeqs: [1, 2] },
|
||||
)
|
||||
expect(s.surface.nodes.length).toBe(1)
|
||||
expect(s.surface.nodes[0]!.seq).toBe(4) // seq of the compaction marker
|
||||
expect(s.surface.nodes[0]!.prev).toBeNull()
|
||||
expect(s.surface.nodes[0]!.next).toBeNull()
|
||||
expect(s.surface.nodes).toEqual([4])
|
||||
})
|
||||
|
||||
it('replace with both ends at real nodes splices only the range', () => {
|
||||
@@ -247,12 +236,7 @@ describe('SurfaceManager', () => {
|
||||
{ provenance: { provider: 'mock', model: 'mock' }, turn: 1, step: 1, content: [{ type: 'text', text: 'summary' }] },
|
||||
{ surfaceOp: { op: 'replace', start: 0, end: 1 }, sourceEventSeqs: [0, 1] },
|
||||
) // seq 3
|
||||
expect(s.surface.nodes.map(n => n.seq)).toEqual([3, 2])
|
||||
// Links: 3 ↔ 2
|
||||
expect(s.surface.nodes[0]!.prev).toBeNull()
|
||||
expect(s.surface.nodes[0]!.next).toBe(2)
|
||||
expect(s.surface.nodes[1]!.prev).toBe(3)
|
||||
expect(s.surface.nodes[1]!.next).toBeNull()
|
||||
expect(s.surface.nodes).toEqual([3, 2])
|
||||
})
|
||||
|
||||
it('single-node replacement (start === end)', () => {
|
||||
@@ -264,9 +248,7 @@ describe('SurfaceManager', () => {
|
||||
{ provenance: { provider: 'mock', model: 'mock' }, turn: 1, step: 1, content: [{ type: 'text', text: 'x' }] },
|
||||
{ surfaceOp: { op: 'replace', start: 1, end: 1 }, sourceEventSeqs: [1] },
|
||||
) // seq 2
|
||||
expect(s.surface.nodes.map(n => n.seq)).toEqual([0, 2])
|
||||
expect(s.surface.nodes[0]!.next).toBe(2)
|
||||
expect(s.surface.nodes[1]!.prev).toBe(0)
|
||||
expect(s.surface.nodes).toEqual([0, 2])
|
||||
})
|
||||
|
||||
it('throws when replace start is not found', () => {
|
||||
@@ -310,7 +292,7 @@ describe('SurfaceManager', () => {
|
||||
expect(logged.sourceEventSeqs).toEqual([0])
|
||||
})
|
||||
|
||||
it('replace starting at non-head position links to previous node correctly', () => {
|
||||
it('replace starting at non-head position preserves surrounding order', () => {
|
||||
const s = new Session(SessionId('mid-replace'))
|
||||
s.append('user/message', { content: [{ type: 'text', text: 'a' }], source: { kind: 'user' } }, { surfaceOp: 'append' }) // seq 0
|
||||
s.append('user/message', { content: [{ type: 'text', text: 'b' }], source: { kind: 'user' } }, { surfaceOp: 'append' }) // seq 1
|
||||
@@ -320,14 +302,7 @@ describe('SurfaceManager', () => {
|
||||
{ provenance: { provider: 'mock', model: 'mock' }, turn: 1, step: 1, content: [{ type: 'text', text: 'x' }] },
|
||||
{ surfaceOp: { op: 'replace', start: 1, end: 1 }, sourceEventSeqs: [1] },
|
||||
) // seq 3
|
||||
expect(s.surface.nodes.map(n => n.seq)).toEqual([0, 3, 2])
|
||||
// Links: 0 → 3 → 2
|
||||
expect(s.surface.nodes[0]!.prev).toBeNull()
|
||||
expect(s.surface.nodes[0]!.next).toBe(3)
|
||||
expect(s.surface.nodes[1]!.prev).toBe(0)
|
||||
expect(s.surface.nodes[1]!.next).toBe(2)
|
||||
expect(s.surface.nodes[2]!.prev).toBe(3)
|
||||
expect(s.surface.nodes[2]!.next).toBeNull()
|
||||
expect(s.surface.nodes).toEqual([0, 3, 2])
|
||||
})
|
||||
|
||||
it('surfaceOp replace object is snapshot so caller mutation is isolated', () => {
|
||||
@@ -501,7 +476,7 @@ describe('SurfaceManager.replaceGeneration', () => {
|
||||
const nodes = s.surface.nodes
|
||||
s.append('context/message', {
|
||||
content: [{ type: 'text', text: 'summary' }], source: { kind: 'plugin', plugin: 'compact' },
|
||||
}, { surfaceOp: { op: 'replace', start: nodes[0]!.seq, end: nodes[1]!.seq }, sourceEventSeqs: [nodes[0]!.seq, nodes[1]!.seq] })
|
||||
}, { surfaceOp: { op: 'replace', start: nodes[0]!, end: nodes[1]! }, sourceEventSeqs: [nodes[0]!, nodes[1]!] })
|
||||
expect(s.surface.replaceGeneration).toBe(1)
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user