|
|
|
|
@@ -8,10 +8,10 @@ import * as Invariants from '@deepseek-ai/dsh-invariants'
|
|
|
|
|
import { InvariantError } from '@deepseek-ai/dsh-invariants'
|
|
|
|
|
|
|
|
|
|
/** A Context with the session store and the invariants plugin registered. */
|
|
|
|
|
async function setup(config?: { freeze?: boolean }) {
|
|
|
|
|
async function setup() {
|
|
|
|
|
const ctx = new Context()
|
|
|
|
|
await ctx.plugin(SessionStore)
|
|
|
|
|
const fiber = await ctx.plugin(Invariants, config ?? {})
|
|
|
|
|
const fiber = await ctx.plugin(Invariants)
|
|
|
|
|
return { ctx, fiber }
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
@@ -22,7 +22,7 @@ function mockAgent(id: string): Agent {
|
|
|
|
|
|
|
|
|
|
describe('session-log invariants', () => {
|
|
|
|
|
it('accepts a well-formed turn/step/tool sequence', async () => {
|
|
|
|
|
const { ctx } = await setup({ freeze: false })
|
|
|
|
|
const { ctx } = await setup()
|
|
|
|
|
const session = ctx.sessions.create()
|
|
|
|
|
expect(() => {
|
|
|
|
|
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
|
|
|
|
|
@@ -38,7 +38,7 @@ describe('session-log invariants', () => {
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('rejects a non-monotonic seq (replay spine)', async () => {
|
|
|
|
|
const { ctx } = await setup({ freeze: false })
|
|
|
|
|
const { ctx } = await setup()
|
|
|
|
|
const session = ctx.sessions.create()
|
|
|
|
|
// Session.append enforces seq-contiguity at the source, so drive the
|
|
|
|
|
// invariants seq check directly via session/event with a regressing seq.
|
|
|
|
|
@@ -48,7 +48,7 @@ describe('session-log invariants', () => {
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('rejects a turn/start while another turn is open', async () => {
|
|
|
|
|
const { ctx } = await setup({ freeze: false })
|
|
|
|
|
const { ctx } = await setup()
|
|
|
|
|
const session = ctx.sessions.create()
|
|
|
|
|
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
|
|
|
|
|
expect(() => session.append('turn/start', { turn: 2, trigger: { kind: 'message', source: { kind: 'user' } } }))
|
|
|
|
|
@@ -56,7 +56,7 @@ describe('session-log invariants', () => {
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('rejects a turn/end that does not match the open turn', async () => {
|
|
|
|
|
const { ctx } = await setup({ freeze: false })
|
|
|
|
|
const { ctx } = await setup()
|
|
|
|
|
const session = ctx.sessions.create()
|
|
|
|
|
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
|
|
|
|
|
expect(() => session.append('turn/end', { turn: 2, reason: { kind: 'completed' } }))
|
|
|
|
|
@@ -64,14 +64,14 @@ describe('session-log invariants', () => {
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('rejects a step/start outside its declared turn', async () => {
|
|
|
|
|
const { ctx } = await setup({ freeze: false })
|
|
|
|
|
const { ctx } = await setup()
|
|
|
|
|
const session = ctx.sessions.create()
|
|
|
|
|
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
|
|
|
|
|
expect(() => session.append('step/start', { turn: 2, step: 1 })).toThrow(/open turn is 1/)
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('rejects a step/end that does not match the open step', async () => {
|
|
|
|
|
const { ctx } = await setup({ freeze: false })
|
|
|
|
|
const { ctx } = await setup()
|
|
|
|
|
const session = ctx.sessions.create()
|
|
|
|
|
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
|
|
|
|
|
session.append('step/start', { turn: 1, step: 1 })
|
|
|
|
|
@@ -79,7 +79,7 @@ describe('session-log invariants', () => {
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('rejects an assistant/chunk outside an open step', async () => {
|
|
|
|
|
const { ctx } = await setup({ freeze: false })
|
|
|
|
|
const { ctx } = await setup()
|
|
|
|
|
const session = ctx.sessions.create()
|
|
|
|
|
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
|
|
|
|
|
expect(() => session.append('assistant/chunk', { turn: 1, step: 1, chunk: { type: 'text-delta', index: 0, text: 'x' } }))
|
|
|
|
|
@@ -87,7 +87,7 @@ describe('session-log invariants', () => {
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('rejects a message event appended outside any open turn (turn-enclosure)', async () => {
|
|
|
|
|
const { ctx } = await setup({ freeze: false })
|
|
|
|
|
const { ctx } = await setup()
|
|
|
|
|
const session = ctx.sessions.create()
|
|
|
|
|
// No turn open: every message-bearing event must be turn-enclosed (the turn-enclosure RFC).
|
|
|
|
|
expect(() => session.append('user/message', { content: [{ type: 'text', text: 'hi' }], source: { kind: 'user' } }, { surfaceOp: 'append' }))
|
|
|
|
|
@@ -97,7 +97,7 @@ describe('session-log invariants', () => {
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('rejects steering and plugin-added events appended outside any open turn', async () => {
|
|
|
|
|
const { ctx } = await setup({ freeze: false })
|
|
|
|
|
const { ctx } = await setup()
|
|
|
|
|
const session = ctx.sessions.create()
|
|
|
|
|
// steering/message is turn-scoped: outside a turn it would land past the
|
|
|
|
|
// commit boundary and be dropped on resume (the turn-enclosure RFC).
|
|
|
|
|
@@ -113,7 +113,7 @@ describe('session-log invariants', () => {
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('accepts message events once a turn is open', async () => {
|
|
|
|
|
const { ctx } = await setup({ freeze: false })
|
|
|
|
|
const { ctx } = await setup()
|
|
|
|
|
const session = ctx.sessions.create()
|
|
|
|
|
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
|
|
|
|
|
expect(() => session.append('user/message', { content: [{ type: 'text', text: 'hi' }], source: { kind: 'user' } }, { surfaceOp: 'append' }))
|
|
|
|
|
@@ -121,7 +121,7 @@ describe('session-log invariants', () => {
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('rejects a tool/result with no prior tool/call', async () => {
|
|
|
|
|
const { ctx } = await setup({ freeze: false })
|
|
|
|
|
const { ctx } = await setup()
|
|
|
|
|
const session = ctx.sessions.create()
|
|
|
|
|
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
|
|
|
|
|
session.append('step/start', { turn: 1, step: 1 })
|
|
|
|
|
@@ -130,7 +130,7 @@ describe('session-log invariants', () => {
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('allows a synthetic interrupted tool/result from crash repair without a prior tool/call event', async () => {
|
|
|
|
|
const { ctx } = await setup({ freeze: false })
|
|
|
|
|
const { ctx } = await setup()
|
|
|
|
|
const session = ctx.sessions.create()
|
|
|
|
|
expect(() => {
|
|
|
|
|
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
|
|
|
|
|
@@ -152,7 +152,7 @@ describe('session-log invariants', () => {
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('allows a tool/call with no matching tool/result (thrown waterfall ends the step)', async () => {
|
|
|
|
|
const { ctx } = await setup({ freeze: false })
|
|
|
|
|
const { ctx } = await setup()
|
|
|
|
|
const session = ctx.sessions.create()
|
|
|
|
|
expect(() => {
|
|
|
|
|
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
|
|
|
|
|
@@ -164,7 +164,7 @@ describe('session-log invariants', () => {
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('holds seeded sessions to the contract on session/created', async () => {
|
|
|
|
|
const { ctx } = await setup({ freeze: false })
|
|
|
|
|
const { ctx } = await setup()
|
|
|
|
|
// A seq-contiguous, serializable seed (so it passes Session's constructor
|
|
|
|
|
// validation) that nonetheless violates turn nesting — a second turn/start
|
|
|
|
|
// while the first turn is still open — must be rejected by the invariants
|
|
|
|
|
@@ -177,7 +177,7 @@ describe('session-log invariants', () => {
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('tracks turns per session independently', async () => {
|
|
|
|
|
const { ctx } = await setup({ freeze: false })
|
|
|
|
|
const { ctx } = await setup()
|
|
|
|
|
const a = ctx.sessions.create(SessionId('a'))
|
|
|
|
|
const b = ctx.sessions.create(SessionId('b'))
|
|
|
|
|
a.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
|
|
|
|
|
@@ -186,7 +186,7 @@ describe('session-log invariants', () => {
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('accepts multiple steps in a turn and consecutive turns', async () => {
|
|
|
|
|
const { ctx } = await setup({ freeze: false })
|
|
|
|
|
const { ctx } = await setup()
|
|
|
|
|
const session = ctx.sessions.create()
|
|
|
|
|
expect(() => {
|
|
|
|
|
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
|
|
|
|
|
@@ -203,7 +203,7 @@ describe('session-log invariants', () => {
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('rejects a skipped turn number', async () => {
|
|
|
|
|
const { ctx } = await setup({ freeze: false })
|
|
|
|
|
const { ctx } = await setup()
|
|
|
|
|
const session = ctx.sessions.create()
|
|
|
|
|
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
|
|
|
|
|
session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
|
|
|
|
|
@@ -212,7 +212,7 @@ describe('session-log invariants', () => {
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('rejects a skipped step number within a turn', async () => {
|
|
|
|
|
const { ctx } = await setup({ freeze: false })
|
|
|
|
|
const { ctx } = await setup()
|
|
|
|
|
const session = ctx.sessions.create()
|
|
|
|
|
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
|
|
|
|
|
session.append('step/start', { turn: 1, step: 1 })
|
|
|
|
|
@@ -222,7 +222,7 @@ describe('session-log invariants', () => {
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('rejects a turn/end while a step is still open', async () => {
|
|
|
|
|
const { ctx } = await setup({ freeze: false })
|
|
|
|
|
const { ctx } = await setup()
|
|
|
|
|
const session = ctx.sessions.create()
|
|
|
|
|
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
|
|
|
|
|
session.append('step/start', { turn: 1, step: 1 })
|
|
|
|
|
@@ -231,7 +231,7 @@ describe('session-log invariants', () => {
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('rejects a step/start while a step is still open', async () => {
|
|
|
|
|
const { ctx } = await setup({ freeze: false })
|
|
|
|
|
const { ctx } = await setup()
|
|
|
|
|
const session = ctx.sessions.create()
|
|
|
|
|
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
|
|
|
|
|
session.append('step/start', { turn: 1, step: 1 })
|
|
|
|
|
@@ -239,7 +239,7 @@ describe('session-log invariants', () => {
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('rejects a tool/result satisfying a call from a previous step', async () => {
|
|
|
|
|
const { ctx } = await setup({ freeze: false })
|
|
|
|
|
const { ctx } = await setup()
|
|
|
|
|
const session = ctx.sessions.create()
|
|
|
|
|
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
|
|
|
|
|
session.append('step/start', { turn: 1, step: 1 })
|
|
|
|
|
@@ -252,7 +252,7 @@ describe('session-log invariants', () => {
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('rejects an assistant/message naming the wrong step', async () => {
|
|
|
|
|
const { ctx } = await setup({ freeze: false })
|
|
|
|
|
const { ctx } = await setup()
|
|
|
|
|
const session = ctx.sessions.create()
|
|
|
|
|
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
|
|
|
|
|
session.append('step/start', { turn: 1, step: 1 })
|
|
|
|
|
@@ -266,7 +266,7 @@ describe('HMR state rebuild', () => {
|
|
|
|
|
const ctx = new Context()
|
|
|
|
|
await ctx.plugin(SessionStore)
|
|
|
|
|
// First registration, mid-turn: a turn is open when the plugin reloads.
|
|
|
|
|
const first = await ctx.plugin(Invariants, { freeze: false })
|
|
|
|
|
const first = await ctx.plugin(Invariants)
|
|
|
|
|
const session = ctx.sessions.create()
|
|
|
|
|
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
|
|
|
|
|
session.append('step/start', { turn: 1, step: 1 })
|
|
|
|
|
@@ -274,7 +274,7 @@ describe('HMR state rebuild', () => {
|
|
|
|
|
|
|
|
|
|
// Re-apply (HMR): the fresh fiber must replay the existing log so the open
|
|
|
|
|
// step is known — the next chunk must NOT be a false positive.
|
|
|
|
|
await ctx.plugin(Invariants, { freeze: false })
|
|
|
|
|
await ctx.plugin(Invariants)
|
|
|
|
|
expect(() => session.append('assistant/chunk', { turn: 1, step: 1, chunk: { type: 'text-delta', index: 0, text: 'h' } }))
|
|
|
|
|
.not.toThrow()
|
|
|
|
|
// And a genuine violation is still caught after the rebuild.
|
|
|
|
|
@@ -283,76 +283,49 @@ describe('HMR state rebuild', () => {
|
|
|
|
|
})
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
describe('dev-freeze', () => {
|
|
|
|
|
it('freezes appended event data so mutating a logged event throws', async () => {
|
|
|
|
|
const { ctx } = await setup() // freeze defaults true
|
|
|
|
|
const session = ctx.sessions.create()
|
|
|
|
|
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
|
|
|
|
|
describe('session immutability', () => {
|
|
|
|
|
it('always freezes appended event data without the invariants plugin', () => {
|
|
|
|
|
const session = new Session(SessionId('appended'))
|
|
|
|
|
const event = session.append('user/message', { content: [{ type: 'text', text: 'hi' }], source: { kind: 'user' } }, { surfaceOp: 'append' })
|
|
|
|
|
expect(Object.isFrozen(event)).toBe(true)
|
|
|
|
|
expect(Object.isFrozen(event.data)).toBe(true)
|
|
|
|
|
expect(Object.isFrozen(event.data.content)).toBe(true)
|
|
|
|
|
expect(Object.isFrozen(event.data.content[0])).toBe(true)
|
|
|
|
|
expect(Object.isFrozen(session.events)).toBe(true)
|
|
|
|
|
expect(() => { (event.data.content[0] as { text: string }).text = 'HACKED' }).toThrow()
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('does not freeze when freeze:false', async () => {
|
|
|
|
|
const { ctx } = await setup({ freeze: false })
|
|
|
|
|
const session = ctx.sessions.create()
|
|
|
|
|
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
|
|
|
|
|
const event = session.append('user/message', { content: [{ type: 'text', text: 'hi' }], source: { kind: 'user' } }, { surfaceOp: 'append' })
|
|
|
|
|
expect(Object.isFrozen(event)).toBe(false)
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('freezes seeded events on session/created', async () => {
|
|
|
|
|
const { ctx } = await setup()
|
|
|
|
|
it('always freezes seeded events without the invariants plugin', () => {
|
|
|
|
|
const seed = [
|
|
|
|
|
{ type: 'turn/start' as const, seq: 0, time: 0, data: { turn: 1, trigger: { kind: 'message' as const, source: { kind: 'user' as const } } } },
|
|
|
|
|
{ type: 'user/message' as const, seq: 1, time: 0, data: { content: [{ type: 'text' as const, text: 'seeded' }], source: { kind: 'user' as const } }, surfaceOp: 'append' as const },
|
|
|
|
|
]
|
|
|
|
|
const session = ctx.sessions.create(undefined, { seed })
|
|
|
|
|
const session = new Session(SessionId('seeded'), seed)
|
|
|
|
|
expect(Object.isFrozen(seed[0])).toBe(false)
|
|
|
|
|
expect(Object.isFrozen(session.events)).toBe(true)
|
|
|
|
|
expect(Object.isFrozen(session.events[0])).toBe(true)
|
|
|
|
|
expect(Object.isFrozen(session.events[0]?.data)).toBe(true)
|
|
|
|
|
expect(Object.isFrozen(session.events[1]?.data)).toBe(true)
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('freezes mutable descendants of a shallow-frozen event datum', async () => {
|
|
|
|
|
const { ctx } = await setup()
|
|
|
|
|
const session = ctx.sessions.create()
|
|
|
|
|
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
|
|
|
|
|
// A caller hands in a SHALLOW-frozen block whose nested array is still
|
|
|
|
|
// mutable. deepFreeze must descend into the already-frozen object and
|
|
|
|
|
// freeze the descendant, not short-circuit on the frozen container —
|
|
|
|
|
// otherwise dev-mode misses exactly the history mutation the dev-invariants RFC catches.
|
|
|
|
|
// `append` snapshots `data`, so the freeze applies to the LOGGED clone, not
|
|
|
|
|
// the caller's input — read the event back and assert on its data.
|
|
|
|
|
it('snapshots and freezes descendants of a shallow-frozen caller value', () => {
|
|
|
|
|
const session = new Session(SessionId('shallow-frozen'))
|
|
|
|
|
const innerContent: { type: 'text'; text: string }[] = [{ type: 'text', text: 'inner' }]
|
|
|
|
|
const block = Object.freeze({ type: 'tool-result' as const, toolCallId: CallId('c1'), content: innerContent, isError: false })
|
|
|
|
|
const event = session.append('user/message', { content: [block], source: { kind: 'user' } }, { surfaceOp: 'append' })
|
|
|
|
|
const logged = event.data.content[0] as { content: { type: 'text'; text: string }[] }
|
|
|
|
|
expect(Object.isFrozen(innerContent)).toBe(false)
|
|
|
|
|
expect(Object.isFrozen(logged.content)).toBe(true)
|
|
|
|
|
expect(Object.isFrozen(logged.content[0])).toBe(true)
|
|
|
|
|
innerContent[0]!.text = 'caller mutation'
|
|
|
|
|
expect(logged.content[0]!.text).toBe('inner')
|
|
|
|
|
expect(() => { logged.content.push({ type: 'text', text: 'mutation' }) }).toThrow()
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('terminates on a cyclic event datum (WeakSet guard)', async () => {
|
|
|
|
|
const { ctx } = await setup()
|
|
|
|
|
const session = ctx.sessions.create()
|
|
|
|
|
// The deep-freeze WeakSet guard must terminate on a self-referential
|
|
|
|
|
// structure rather than recursing forever. Session.append now rejects
|
|
|
|
|
// non-serializable (incl. cyclic) data at the source, so drive the freeze
|
|
|
|
|
// handler directly via hand-built session/events — exactly the shape the
|
|
|
|
|
// invariants listener receives. Open a turn first (seq 0) so the cyclic
|
|
|
|
|
// user/message (seq 1) satisfies the turn-enclosure invariant.
|
|
|
|
|
ctx.emit(scopeTarget(session, undefined), 'session/event', session, { type: 'turn/start', seq: 0, time: 1, data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } } } as never)
|
|
|
|
|
const cyclic: Record<string, unknown> = { type: 'text', text: 'x' }
|
|
|
|
|
cyclic['self'] = cyclic
|
|
|
|
|
const event = { type: 'user/message', seq: 1, time: 1, data: { content: [cyclic], source: { kind: 'user' } } }
|
|
|
|
|
expect(() => { ctx.emit(scopeTarget(session, undefined), 'session/event', session, event as never) }).not.toThrow()
|
|
|
|
|
expect(Object.isFrozen(cyclic)).toBe(true)
|
|
|
|
|
})
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
describe('agent status invariants', () => {
|
|
|
|
|
it('accepts legal transitions: idle→running→idle and →disposed', async () => {
|
|
|
|
|
const { ctx } = await setup({ freeze: false })
|
|
|
|
|
const { ctx } = await setup()
|
|
|
|
|
const agent = mockAgent('a1')
|
|
|
|
|
expect(() => {
|
|
|
|
|
ctx.emit(scopeTarget(agent, agent), 'agent/status', agent, 'idle')
|
|
|
|
|
@@ -363,28 +336,28 @@ describe('agent status invariants', () => {
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('accepts running→disposed', async () => {
|
|
|
|
|
const { ctx } = await setup({ freeze: false })
|
|
|
|
|
const { ctx } = await setup()
|
|
|
|
|
const agent = mockAgent('a2')
|
|
|
|
|
ctx.emit(scopeTarget(agent, agent), 'agent/status', agent, 'running')
|
|
|
|
|
expect(() => { ctx.emit(scopeTarget(agent, agent), 'agent/status', agent, 'disposed') }).not.toThrow()
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('rejects a no-op transition', async () => {
|
|
|
|
|
const { ctx } = await setup({ freeze: false })
|
|
|
|
|
const { ctx } = await setup()
|
|
|
|
|
const agent = mockAgent('a3')
|
|
|
|
|
ctx.emit(scopeTarget(agent, agent), 'agent/status', agent, 'running')
|
|
|
|
|
expect(() => { ctx.emit(scopeTarget(agent, agent), 'agent/status', agent, 'running') }).toThrow(/no-op transition/)
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('rejects leaving the terminal disposed state', async () => {
|
|
|
|
|
const { ctx } = await setup({ freeze: false })
|
|
|
|
|
const { ctx } = await setup()
|
|
|
|
|
const agent = mockAgent('a4')
|
|
|
|
|
ctx.emit(scopeTarget(agent, agent), 'agent/status', agent, 'disposed')
|
|
|
|
|
expect(() => { ctx.emit(scopeTarget(agent, agent), 'agent/status', agent, 'idle') }).toThrow(/left terminal state disposed/)
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('tracks status per agent independently', async () => {
|
|
|
|
|
const { ctx } = await setup({ freeze: false })
|
|
|
|
|
const { ctx } = await setup()
|
|
|
|
|
const a = mockAgent('a5')
|
|
|
|
|
const b = mockAgent('b5')
|
|
|
|
|
ctx.emit(scopeTarget(a, a), 'agent/status', a, 'running')
|
|
|
|
|
@@ -401,10 +374,10 @@ describe('HMR safety', () => {
|
|
|
|
|
|
|
|
|
|
await fiber.dispose()
|
|
|
|
|
|
|
|
|
|
// After disposal: no freezing, no assertions. An event that WOULD have
|
|
|
|
|
// violated the open-turn rule now passes silently, and is not frozen.
|
|
|
|
|
// After disposal the plugin's assertions are gone, so an event that would
|
|
|
|
|
// violate the open-turn rule passes. Session still owns immutability.
|
|
|
|
|
const event = session.append('turn/start', { turn: 2, trigger: { kind: 'message', source: { kind: 'user' } } })
|
|
|
|
|
expect(Object.isFrozen(event)).toBe(false)
|
|
|
|
|
expect(Object.isFrozen(event)).toBe(true)
|
|
|
|
|
// A no-op status transition no longer throws either.
|
|
|
|
|
const agent = mockAgent('hmr')
|
|
|
|
|
ctx.emit(scopeTarget(agent, agent), 'agent/status', agent, 'idle')
|
|
|
|
|
@@ -419,16 +392,17 @@ describe('HMR safety', () => {
|
|
|
|
|
expect(err.message).toBe('invariant violated: seq must strictly increase')
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('does not leak listeners across dispose (no stale freezing)', async () => {
|
|
|
|
|
it('does not leak listeners across dispose', async () => {
|
|
|
|
|
const { ctx, fiber } = await setup()
|
|
|
|
|
await fiber.dispose()
|
|
|
|
|
const spy = vi.fn()
|
|
|
|
|
ctx.on('session/event', spy)
|
|
|
|
|
const session = ctx.sessions.create()
|
|
|
|
|
session.append('user/message', { content: [{ type: 'text', text: 'x' }], source: { kind: 'user' } }, { surfaceOp: 'append' })
|
|
|
|
|
// our own spy fires, proving events still flow — but the plugin's frozen.
|
|
|
|
|
// The spy proves events still flow after plugin disposal. Session, not the
|
|
|
|
|
// disposed listener, freezes the accepted record.
|
|
|
|
|
expect(spy).toHaveBeenCalledOnce()
|
|
|
|
|
expect(Object.isFrozen(session.events[0])).toBe(false)
|
|
|
|
|
expect(Object.isFrozen(session.events[0])).toBe(true)
|
|
|
|
|
})
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
@@ -640,7 +614,7 @@ describe('surface invariants', () => {
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('catches an incomplete-provenance replace on the load/seed path', async () => {
|
|
|
|
|
const { ctx } = await setup({ freeze: false })
|
|
|
|
|
const { ctx } = await setup()
|
|
|
|
|
const badSeed = [
|
|
|
|
|
{ type: 'turn/start' as const, seq: 0, time: 0, data: { turn: 1, trigger: { kind: 'message' as const, source: { kind: 'user' as const } } } },
|
|
|
|
|
{ type: 'step/start' as const, seq: 1, time: 0, data: { turn: 1, step: 1 } },
|
|
|
|
|
@@ -655,10 +629,10 @@ describe('surface invariants', () => {
|
|
|
|
|
const { ctx } = await setup()
|
|
|
|
|
const session = ctx.sessions.create()
|
|
|
|
|
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
|
|
|
|
|
// Type system prevents surface metadata on non-surface events; this test
|
|
|
|
|
// exercises the runtime guard against casts or persisted-data bypass.
|
|
|
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-return
|
|
|
|
|
expect(() => (session.append as any)('turn/end', { turn: 1, reason: { kind: 'completed' } }, { sourceEventSeqs: [0] }))
|
|
|
|
|
// Session rejects this at its own acceptance boundary. Emit a hand-built
|
|
|
|
|
// record to cover the listener's defensive check for alternate producers.
|
|
|
|
|
const event = { type: 'turn/end', seq: 1, time: 1, data: { turn: 1, reason: { kind: 'completed' } }, sourceEventSeqs: [0] }
|
|
|
|
|
expect(() => { ctx.emit(scopeTarget(session, undefined), 'session/event', session, event as never) })
|
|
|
|
|
.toThrow(/cannot carry sourceEventSeqs/)
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
@@ -666,8 +640,8 @@ describe('surface invariants', () => {
|
|
|
|
|
const { ctx } = await setup()
|
|
|
|
|
const session = ctx.sessions.create()
|
|
|
|
|
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
|
|
|
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-return
|
|
|
|
|
expect(() => (session.append as any)('turn/end', { turn: 1, reason: { kind: 'completed' } }, { surfaceOp: 'append' }))
|
|
|
|
|
const event = { type: 'turn/end', seq: 1, time: 1, data: { turn: 1, reason: { kind: 'completed' } }, surfaceOp: 'append' }
|
|
|
|
|
expect(() => { ctx.emit(scopeTarget(session, undefined), 'session/event', session, event as never) })
|
|
|
|
|
.toThrow(/cannot carry surfaceOp/)
|
|
|
|
|
})
|
|
|
|
|
})
|
|
|
|
|
@@ -675,7 +649,7 @@ describe('surface invariants', () => {
|
|
|
|
|
describe('request-reconstruction cross-check (llm/stream)', () => {
|
|
|
|
|
/** Session with a boundary: one derivable user message, an open step, and the header event the loop would have logged. */
|
|
|
|
|
async function requestSetup() {
|
|
|
|
|
const { ctx } = await setup({ freeze: false })
|
|
|
|
|
const { ctx } = await setup()
|
|
|
|
|
const session = ctx.sessions.create(SessionId('req-check'))
|
|
|
|
|
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
|
|
|
|
|
session.append('user/message', { content: [{ type: 'text', text: 'hi' }], source: { kind: 'user' } }, { surfaceOp: 'append' })
|
|
|
|
|
@@ -737,7 +711,7 @@ describe('request-reconstruction cross-check (llm/stream)', () => {
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('rejects a loop-built request with no header event or no step/start in its log', async () => {
|
|
|
|
|
const { ctx } = await setup({ freeze: false })
|
|
|
|
|
const { ctx } = await setup()
|
|
|
|
|
const session = ctx.sessions.create(SessionId('req-bare'))
|
|
|
|
|
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
|
|
|
|
|
const bare = Object.freeze({ model: 'm', messages: Object.freeze([]), sessionId: session.id })
|
|
|
|
|
@@ -778,7 +752,7 @@ describe('request cross-check ordering (prepend)', () => {
|
|
|
|
|
const ctx = new Context()
|
|
|
|
|
await ctx.plugin(SessionStore)
|
|
|
|
|
ctx.on('llm/stream', () => (async function* () {})() as never) // short-circuits, no next()
|
|
|
|
|
await ctx.plugin(Invariants, { freeze: false })
|
|
|
|
|
await ctx.plugin(Invariants)
|
|
|
|
|
|
|
|
|
|
const session = ctx.sessions.create(SessionId('prepend-check'))
|
|
|
|
|
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
|
|
|
|
|
|