|
|
@@ -542,6 +542,96 @@ describe('surface invariants', () => {
|
|
|
}).toThrow(/must be <= end/)
|
|
|
})
|
|
|
|
|
|
+ it('rejects a replace whose sourceEventSeqs omits a shadowed surface node', async () => {
|
|
|
+ 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 })
|
|
|
+ session.append('user/message', { content: [{ type: 'text', text: 'a' }], source: { kind: 'user' } }, { surfaceOp: 'append' }) // seq 2
|
|
|
+ session.append('user/message', { content: [{ type: 'text', text: 'b' }], source: { kind: 'user' } }, { surfaceOp: 'append' }) // seq 3
|
|
|
+ // Replace shadows surface nodes [2, 3] but records provenance for only [2].
|
|
|
+ expect(() => {
|
|
|
+ session.append('assistant/message', { turn: 1, step: 1, content: [{ type: 'text', text: 'sum' }] }, { surfaceOp: { op: 'replace', start: 2, end: 3 }, sourceEventSeqs: [2] })
|
|
|
+ }).toThrow(/must include every shadowed surface node; missing 3/)
|
|
|
+ })
|
|
|
+
|
|
|
+ it('accepts a replace whose sourceEventSeqs covers every shadowed surface node', async () => {
|
|
|
+ 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 })
|
|
|
+ session.append('user/message', { content: [{ type: 'text', text: 'a' }], source: { kind: 'user' } }, { surfaceOp: 'append' }) // seq 2
|
|
|
+ session.append('user/message', { content: [{ type: 'text', text: 'b' }], source: { kind: 'user' } }, { surfaceOp: 'append' }) // seq 3
|
|
|
+ expect(() => {
|
|
|
+ session.append('assistant/message', { turn: 1, step: 1, content: [{ type: 'text', text: 'sum' }] }, { surfaceOp: { op: 'replace', start: 2, end: 3 }, sourceEventSeqs: [2, 3] })
|
|
|
+ }).not.toThrow()
|
|
|
+ })
|
|
|
+
|
|
|
+ it('rejects a replace naming a start seq that is not on the surface', async () => {
|
|
|
+ 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 })
|
|
|
+ session.append('user/message', { content: [{ type: 'text', text: 'a' }], source: { kind: 'user' } }, { surfaceOp: 'append' }) // seq 2
|
|
|
+ // seq 1 (step/start) is a real earlier event but never entered the surface.
|
|
|
+ expect(() => {
|
|
|
+ session.append('assistant/message', { turn: 1, step: 1, content: [] }, { surfaceOp: { op: 'replace', start: 1, end: 2 }, sourceEventSeqs: [1, 2] })
|
|
|
+ }).toThrow(/start seq 1 is not on the surface/)
|
|
|
+ })
|
|
|
+
|
|
|
+ it('rejects a replace naming an end seq that is not on the surface', async () => {
|
|
|
+ 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 })
|
|
|
+ session.append('user/message', { content: [{ type: 'text', text: 'a' }], source: { kind: 'user' } }, { surfaceOp: 'append' }) // seq 2
|
|
|
+ // start (2) is on the surface but end (99) never entered it.
|
|
|
+ expect(() => {
|
|
|
+ session.append('assistant/message', { turn: 1, step: 1, content: [] }, { surfaceOp: { op: 'replace', start: 2, end: 99 }, sourceEventSeqs: [2] })
|
|
|
+ }).toThrow(/end seq 99 is not on the surface/)
|
|
|
+ })
|
|
|
+
|
|
|
+ it('rejects a replace whose range is reversed in surface position after a prior replace reordered it', async () => {
|
|
|
+ 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 })
|
|
|
+ session.append('user/message', { content: [{ type: 'text', text: 'a' }], source: { kind: 'user' } }, { surfaceOp: 'append' }) // seq 2
|
|
|
+ session.append('user/message', { content: [{ type: 'text', text: 'b' }], source: { kind: 'user' } }, { surfaceOp: 'append' }) // seq 3
|
|
|
+ // Replace node 2 (position 0) with seq 4 — surface is now [4, 3], so seq 4
|
|
|
+ // precedes seq 3 in linked-list order even though 4 > 3 numerically.
|
|
|
+ session.append('assistant/message', { turn: 1, step: 1, content: [{ type: 'text', text: 's' }] }, { surfaceOp: { op: 'replace', start: 2, end: 2 }, sourceEventSeqs: [2] }) // seq 4
|
|
|
+ // A replace with start=3, end=4 passes the seq check (3 <= 4) but is
|
|
|
+ // reversed positionally (3 is at pos 1, 4 is at pos 0).
|
|
|
+ expect(() => {
|
|
|
+ session.append('assistant/message', { turn: 1, step: 1, content: [] }, { surfaceOp: { op: 'replace', start: 3, end: 4 }, sourceEventSeqs: [3, 4] }) // seq 5
|
|
|
+ }).toThrow(/is after end seq 4 .* on the surface/)
|
|
|
+ })
|
|
|
+
|
|
|
+ it('rejects a replace that omits sourceEventSeqs entirely', async () => {
|
|
|
+ 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 })
|
|
|
+ session.append('user/message', { content: [{ type: 'text', text: 'a' }], source: { kind: 'user' } }, { surfaceOp: 'append' }) // seq 2
|
|
|
+ // A replace with no sourceEventSeqs records no provenance for the node it shadows.
|
|
|
+ expect(() => {
|
|
|
+ session.append('assistant/message', { turn: 1, step: 1, content: [] }, { surfaceOp: { op: 'replace', start: 2, end: 2 } })
|
|
|
+ }).toThrow(/must include every shadowed surface node; missing 2/)
|
|
|
+ })
|
|
|
+
|
|
|
+ it('catches an incomplete-provenance replace on the load/seed path', async () => {
|
|
|
+ const { ctx } = await setup({ freeze: false })
|
|
|
+ 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 } },
|
|
|
+ { type: 'user/message' as const, seq: 2, time: 0, data: { content: [{ type: 'text' as const, text: 'a' }], source: { kind: 'user' as const } }, surfaceOp: 'append' as const },
|
|
|
+ { type: 'user/message' as const, seq: 3, time: 0, data: { content: [{ type: 'text' as const, text: 'b' }], source: { kind: 'user' as const } }, surfaceOp: 'append' as const },
|
|
|
+ { type: 'assistant/message' as const, seq: 4, time: 0, data: { turn: 1, step: 1, content: [{ type: 'text' as const, text: 'sum' }] }, surfaceOp: { op: 'replace' as const, start: 2, end: 3 }, sourceEventSeqs: [2] },
|
|
|
+ ]
|
|
|
+ expect(() => ctx.sessions.create(undefined, { seed: badSeed })).toThrow(/must include every shadowed surface node; missing 3/)
|
|
|
+ })
|
|
|
+
|
|
|
it('rejects sourceEventSeqs on a non-surface event', async () => {
|
|
|
const { ctx } = await setup()
|
|
|
const session = ctx.sessions.create()
|