Merge remote-tracking branch 'origin/master' into xtr/react-loop-simplification

# Conflicts:
#	apps/web/tests/markdown-images.e2e.ts
#	docs/cordis-catalog/services.md
#	docs/core-data-structures/session.i18n.yaml
#	docs/event-producer-consumer.md
#	examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/session.jsonl
#	packages/compact/compact-basic/tests/compact-basic.spec.ts
#	packages/context/time-context/tests/invariant.spec.ts
#	packages/context/time-context/tests/time-context.spec.ts
#	packages/core/agent-loop/src/invariant.ts
#	packages/core/agent-loop/tests/contract-regressions.spec.ts
#	packages/core/agent-loop/tests/request-reconstruction.spec.ts
#	packages/core/agent/tests/agent.spec.ts
#	packages/core/session/README.i18n.yaml
#	packages/core/session/src/index.ts
#	packages/core/session/tests/derived-cache.spec.ts
#	packages/core/session/tests/request-header.spec.ts
#	packages/core/session/tests/session.spec.ts
#	packages/core/session/tests/surface.spec.ts
#	packages/fs/tool-str-replace-editor/tests/tools.spec.ts
#	packages/goal/goal/tests/goal.spec.ts
#	packages/plan/plan-mode/tests/plan-mode.spec.ts
#	packages/pty/pty-local/tests/index.spec.ts
#	packages/pty/pty-local/tests/local.spec.ts
#	packages/pty/pty/tests/service.spec.ts
#	packages/pty/tool-bash-persistent/tests/loader-composition.spec.ts
#	packages/pty/tool-bash-persistent/tests/tools.spec.ts
#	packages/pty/tool-pty/tests/loader-composition.spec.ts
#	packages/pty/tool-pty/tests/tools.spec.ts
#	packages/session-title/session-title-all-messages-llm/tests/provider.spec.ts
#	packages/tasks/tasks-local/tests/tasks.spec.ts
#	packages/ui/user-approval/tests/approval.spec.ts
This commit is contained in:
_Kerman
2026-08-05 13:53:35 +08:00
75 changed files with 1087 additions and 358 deletions

View File

@@ -2,6 +2,7 @@ import { describe, expect, expectTypeOf, it, vi } from 'vitest'
import { Context } from 'cordis'
import { createUserMessage, CallId, createMessage, createToolResultMessage, MessageId, ReasoningEffortId } from '@deepseek-ai/dsh-llm'
import SessionStore, {
adoptSessionEvent,
SESSION_FORMAT_VERSION,
Session,
SessionEvent,
@@ -13,7 +14,7 @@ import type { CreateSessionOptions, SessionEventType, SessionHeader, SessionSurf
describe('Session', () => {
it('finds the latest closed turn that entered a model step', () => {
const session = new Session(SessionId('last-message-turn'))
const session = Session.create(SessionId('last-message-turn'))
session.append('turn/start', { turn: 1 })
session.append('turn/end', { turn: 1, reason: { kind: 'blocked' } })
@@ -29,7 +30,7 @@ describe('Session', () => {
})
it('exposes one stable readonly surface view', () => {
const session = new Session(SessionId('surface-view'))
const session = Session.create(SessionId('surface-view'))
const surface = session.surface
expectTypeOf(surface).toEqualTypeOf<SessionSurface>()
@@ -37,7 +38,7 @@ describe('Session', () => {
})
it('derives message history from the event log', () => {
const session = new Session(SessionId('s1'))
const session = Session.create(SessionId('s1'))
session.append('turn/start', { turn: 1 })
session.append('user/message', createUserMessage({
content: [{ type: 'text', text: 'hello' }], source: { kind: 'user' },
@@ -77,7 +78,7 @@ describe('Session', () => {
it('accepts and round-trips a max-tokens turn/end reason', () => {
// The max-tokens TurnEndReason variant carries no extra data, so it must
// append and persist like any other reason (JSON-serializable, no fields).
const session = new Session(SessionId('s1'))
const session = Session.create(SessionId('s1'))
session.append('turn/start', { turn: 1 })
session.append('turn/end', { turn: 1, reason: { kind: 'max-tokens' } })
@@ -88,10 +89,10 @@ describe('Session', () => {
})
it('round-trips an aborted turn with its cancellation cause', () => {
const session = new Session(SessionId('aborted'))
const session = Session.create(SessionId('aborted'))
session.append('turn/start', { turn: 1 })
session.append('turn/end', { turn: 1, reason: { kind: 'aborted', reason: { kind: 'user' } } })
const replayed = new Session(SessionId('aborted-replay'), structuredClone(session.events))
const replayed = Session.create(SessionId('aborted-replay'), structuredClone(session.events))
expect(replayed.events.slice(0, -1)).toEqual(session.events)
const turnEnd = replayed.events.findLast(event => event.type === 'turn/end')
expect(turnEnd?.type === 'turn/end' && turnEnd.data.reason)
@@ -99,7 +100,7 @@ describe('Session', () => {
})
it('renders injected-context and user messages as plain user content', () => {
const session = new Session(SessionId('s2'))
const session = Session.create(SessionId('s2'))
session.append('user/message', createUserMessage({
content: [{ type: 'text', text: 'file changed: a.ts' }],
source: { kind: 'plugin', plugin: 'watcher' },
@@ -117,7 +118,7 @@ describe('Session', () => {
})
it('keeps the exact identified context message in durable history and projection', () => {
const session = new Session(SessionId('s2-raw'))
const session = Session.create(SessionId('s2-raw'))
const message = createUserMessage({
content: [{ type: 'text', text: '<system-reminder>Additional instructions from: pkg/AGENTS.md</system-reminder>' }],
source: { kind: 'plugin', plugin: 'workspace-context' },
@@ -130,7 +131,7 @@ describe('Session', () => {
})
it('replays identically from a seeded event log', () => {
const original = new Session(SessionId('s3'))
const original = Session.create(SessionId('s3'))
original.append('turn/start', { turn: 1 })
original.append('user/message', createUserMessage({
content: [{ type: 'text', text: 'q' }], source: { kind: 'user' },
@@ -148,7 +149,7 @@ describe('Session', () => {
}, { surfaceOp: 'append' })
original.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
const replayed = new Session(SessionId('s3-replay'), [...original.events])
const replayed = Session.create(SessionId('s3-replay'), [...original.events])
expect(replayed.deriveMessages()).toEqual(original.deriveMessages())
// The seed verbatim, plus the end-seed event the constructor appends.
expect(replayed.events.slice(0, original.seq)).toEqual(original.events)
@@ -157,16 +158,16 @@ describe('Session', () => {
})
it('marks an explicitly empty seed without marking a fresh session', () => {
const fresh = new Session(SessionId('fresh-empty'))
const fresh = Session.create(SessionId('fresh-empty'))
expect(fresh.events).toEqual([])
const resumed = new Session(SessionId('resumed-empty'), [])
const resumed = Session.create(SessionId('resumed-empty'), [])
expect(resumed.firstLiveSeq).toBe(0)
expect(resumed.events).toMatchObject([
{ type: 'session/end-seed', seq: 0, data: {} },
])
const reopened = new Session(SessionId('reopened-empty'), resumed.events)
const reopened = Session.create(SessionId('reopened-empty'), resumed.events)
expect(reopened.firstLiveSeq).toBe(1)
expect(reopened.events).toEqual(resumed.events)
})
@@ -176,7 +177,7 @@ describe('Session', () => {
type: 'request/header', seq: 0, time: 1,
data: { header: { config: { model: 'old-model' } }, reason: 'initial' },
} as unknown as SessionEvent
expect(() => new Session(SessionId('old-header'), [requestHeader]))
expect(() => Session.create(SessionId('old-header'), [requestHeader]))
.toThrow('seed request/header at index 0 lacks provider/model')
const assistantMessage = {
@@ -184,20 +185,20 @@ describe('Session', () => {
data: { turn: 1, step: 1, content: [{ type: 'text', text: 'old' }] },
surfaceOp: 'append',
} as unknown as SessionEvent
expect(() => new Session(SessionId('old-assistant'), [assistantMessage]))
expect(() => Session.create(SessionId('old-assistant'), [assistantMessage]))
.toThrow('seed assistant/message at index 0 lacks an identified message')
const malformedHeader = {
type: 'request/header', seq: 0, time: 1,
data: { header: 'old-header' },
} as unknown as SessionEvent
expect(() => new Session(SessionId('malformed-header'), [malformedHeader]))
expect(() => Session.create(SessionId('malformed-header'), [malformedHeader]))
.toThrow('seed request/header at index 0 lacks provider/model')
const unrelatedPrimitiveData = {
type: 'plugin/event', seq: 0, time: 1, data: null,
} as unknown as SessionEvent
expect(new Session(SessionId('primitive-plugin-data'), [unrelatedPrimitiveData]).events.slice(0, 1))
expect(Session.create(SessionId('primitive-plugin-data'), [unrelatedPrimitiveData]).events.slice(0, 1))
.toEqual([unrelatedPrimitiveData])
})
@@ -312,7 +313,7 @@ describe('Session', () => {
for (const { name, event, message } of invalid) {
expect(
() => new Session(SessionId(`invalid-${name}`), [event as unknown as SessionEvent]),
() => Session.create(SessionId(`invalid-${name}`), [event as unknown as SessionEvent]),
name,
).toThrow(message)
}
@@ -348,6 +349,45 @@ describe('Session', () => {
.toEqual([{ type: 'plugin-block', value: 1 }])
})
it('adopts exclusively owned messages in place and keeps snapshots detached', () => {
const owned = {
type: 'user/message',
seq: 0,
time: 1,
surfaceOp: 'append',
data: {
id: 'owned-message',
role: 'user',
content: [{ type: 'text', text: 'owned' }],
source: { kind: 'user' },
},
} as SessionEvent<'user/message'>
expect(adoptSessionEvent(owned)).toBe(owned)
expect(Object.isFrozen(owned.data)).toBe(true)
expect(Object.isFrozen(owned.data.content)).toBe(true)
const source = structuredClone(owned)
const snapshot = snapshotSessionEvent(source)
expect(snapshot).not.toBe(source)
expect(snapshot.data).not.toBe(source.data)
expect(snapshot.data.content).not.toBe(source.data.content)
})
it('validates message shape before adopting ownership', () => {
const malformed = {
type: 'user/message',
seq: 0,
time: 1,
data: {
id: 'wrong-role',
role: 'assistant',
content: [],
source: { kind: 'user' },
},
} as unknown as SessionEvent
expect(() => adoptSessionEvent(malformed)).toThrow('message must have role "user"')
})
it('round-trips a non-empty reasoning effort and rejects invalid durable values', () => {
const valid = {
type: 'request/header',
@@ -364,7 +404,7 @@ describe('Session', () => {
reason: 'initial',
},
} as const
expect(new Session(SessionId('reasoning-effort'), [valid]).events[0])
expect(Session.create(SessionId('reasoning-effort'), [valid]).events[0])
.toEqual(valid)
for (const reasoningEffort of ['', 1]) {
@@ -372,7 +412,7 @@ describe('Session', () => {
if (invalid.type !== 'request/header') throw new Error('test fixture must be a request header')
const config = invalid.data.header.config as unknown as Record<string, unknown>
config.reasoningEffort = reasoningEffort
expect(() => new Session(SessionId('invalid-reasoning-effort'), [invalid]))
expect(() => Session.create(SessionId('invalid-reasoning-effort'), [invalid]))
.toThrow('seed request/header at index 0 has an invalid reasoningEffort')
}
})
@@ -394,7 +434,7 @@ describe('Session', () => {
reason: 'initial',
},
} as const
expect(new Session(SessionId('adapter-defaults'), [valid]).events[0]).toEqual(valid)
expect(Session.create(SessionId('adapter-defaults'), [valid]).events[0]).toEqual(valid)
for (const adapterDefaults of [
null,
@@ -406,13 +446,13 @@ describe('Session', () => {
const invalid = structuredClone(valid) as unknown as SessionEvent
if (invalid.type !== 'request/header') throw new Error('test fixture must be a request header')
invalid.data.header.adapterDefaults = adapterDefaults as never
expect(() => new Session(SessionId('invalid-adapter-defaults'), [invalid]))
expect(() => Session.create(SessionId('invalid-adapter-defaults'), [invalid]))
.toThrow('seed request/header at index 0 has invalid adapterDefaults')
}
})
it('isolates the log from mutation through a derived message (append-only contract)', () => {
const session = new Session(SessionId('s4'))
const session = Session.create(SessionId('s4'))
session.append('user/message', createUserMessage({
content: [{ type: 'text', text: 'original' }], source: { kind: 'user' },
}), { surfaceOp: 'append' })
@@ -446,7 +486,7 @@ describe('Session', () => {
})
it('rejects non-JSON-serializable event data at the source (incl. sparse arrays)', () => {
const session = new Session(SessionId('s5'))
const session = Session.create(SessionId('s5'))
const bad = (extra: unknown) => () => session.append('user/message', { content: [{ type: 'text', text: 'x' }], source: { kind: 'user' }, extra } as never, { surfaceOp: 'append' })
expect(bad(1n)).toThrow(/non-JSON-serializable/)
expect(bad(() => 0)).toThrow(/non-JSON-serializable/)
@@ -473,7 +513,7 @@ describe('Session', () => {
})
it('rejects a surface-eligible append with no surfaceOp marker (runtime guard for the union-widening loophole)', () => {
const session = new Session(SessionId('s5b'))
const session = Session.create(SessionId('s5b'))
session.append('turn/start', { turn: 1 })
// A widened SessionEventType bypasses the overload's conditional requirement,
// so the runtime guard must still reject the missing surface marker.
@@ -487,7 +527,7 @@ describe('Session', () => {
})
it('accepts dense arrays and nested plain objects', () => {
const session = new Session(SessionId('s6'))
const session = Session.create(SessionId('s6'))
expect(() => session.append('user/message', { content: [{ type: 'text', text: 'x' }], source: { kind: 'user' }, extra: [1, 2, [3, { a: null, b: true }]] } as never, { surfaceOp: 'append' })).not.toThrow()
expect(session.events).toHaveLength(1)
})
@@ -498,7 +538,7 @@ describe('Session', () => {
const badSeed = [
{ type: 'user/message' as const, seq: 0, time: 1, data: { content: [{ type: 'text' as const, text: 'x' }], source: { kind: 'user' as const }, bad: 1n } },
] as unknown as SessionEvent[]
expect(() => new Session(SessionId('seed-bad'), badSeed)).toThrow(/losslessly JSON-serializable/)
expect(() => Session.create(SessionId('seed-bad'), badSeed)).toThrow(/losslessly JSON-serializable/)
})
it('validates seed events: rejects a non-contiguous seq', () => {
@@ -506,7 +546,7 @@ describe('Session', () => {
{ type: 'turn/start' as const, seq: 0, time: 1, data: { turn: 1 } },
{ type: 'turn/end' as const, seq: 5, time: 2, data: { turn: 1, reason: { kind: 'completed' as const } } }, // gap: expected seq 1
] as SessionEvent[]
expect(() => new Session(SessionId('seed-gap'), gapSeed)).toThrow(/contiguous|seq/)
expect(() => Session.create(SessionId('seed-gap'), gapSeed)).toThrow(/contiguous|seq/)
})
it('validates seed events: rejects a surface-eligible event missing its surfaceOp marker', () => {
@@ -521,7 +561,7 @@ describe('Session', () => {
}) },
{ type: 'turn/end' as const, seq: 2, time: 3, data: { turn: 1, reason: { kind: 'completed' as const } } },
] as SessionEvent[]
expect(() => new Session(SessionId('seed-no-marker'), markerlessSeed)).toThrow(/requires a surfaceOp marker/)
expect(() => Session.create(SessionId('seed-no-marker'), markerlessSeed)).toThrow(/requires a surfaceOp marker/)
})
it('accepts a well-formed contiguous serializable seed', () => {
@@ -532,7 +572,7 @@ describe('Session', () => {
}), surfaceOp: 'append' as const },
{ type: 'turn/end' as const, seq: 2, time: 3, data: { turn: 1, reason: { kind: 'completed' as const } } },
] as SessionEvent[]
const session = new Session(SessionId('seed-ok'), goodSeed)
const session = Session.create(SessionId('seed-ok'), goodSeed)
expect(session.events.slice(0, 3)).toEqual(goodSeed)
expect(session.firstLiveSeq).toBe(3)
})
@@ -555,7 +595,7 @@ describe('Session', () => {
},
})
const session = new Session(SessionId('seed-entry-snapshot'), seed)
const session = Session.create(SessionId('seed-entry-snapshot'), seed)
expect(reads).toBe(1)
expect(session.events.slice(0, 1)).toEqual([accepted])
@@ -572,7 +612,7 @@ describe('Session', () => {
})
const seed = [{ type: 'test/unstable', seq: 0, time: 1, data }] as unknown as SessionEvent[]
const session = new Session(SessionId('seed-nested-drift'), seed)
const session = Session.create(SessionId('seed-nested-drift'), seed)
expect(reads).toBe(1)
expect(session.events[0]!.data).toEqual({ value: 'accepted' })
@@ -589,7 +629,7 @@ describe('Session', () => {
surfaceOp: { op: 'replace', start: 1n, end: 2 },
}] as unknown as SessionEvent[]
expect(() => new Session(SessionId('seed-bad-metadata'), seed))
expect(() => Session.create(SessionId('seed-bad-metadata'), seed))
.toThrow(/losslessly JSON-serializable/)
})
@@ -609,7 +649,7 @@ describe('Session', () => {
surfaceOp: new ReplaceOp(),
}] as unknown as SessionEvent[]
expect(() => new Session(SessionId('seed-exotic-metadata'), seed))
expect(() => Session.create(SessionId('seed-exotic-metadata'), seed))
.toThrow(/losslessly JSON-serializable/)
})
@@ -622,7 +662,7 @@ describe('Session', () => {
}
const seed: SessionEvent[] = [new SeedEvent()]
expect(() => new Session(SessionId('seed-exotic-shell'), seed))
expect(() => Session.create(SessionId('seed-exotic-shell'), seed))
.toThrow(/not losslessly JSON-serializable/)
})
@@ -634,7 +674,7 @@ describe('Session', () => {
data: { turn: 1 },
}) as unknown as SessionEvent
const session = new Session(SessionId('seed-null-prototype'), [event])
const session = Session.create(SessionId('seed-null-prototype'), [event])
expect(session.events.slice(0, 1)).toEqual([{ ...event }])
})
@@ -667,7 +707,7 @@ describe('Session', () => {
sourceEventSeqs: [0],
}] as unknown as SessionEvent[]
const session = new Session(SessionId('seed-unstable-metadata'), seed)
const session = Session.create(SessionId('seed-unstable-metadata'), seed)
const event = session.events[1]!
if (event.type !== 'user/message') throw new Error('test fixture must remain a user/message')
@@ -704,7 +744,7 @@ describe('Session', () => {
}] as unknown as SessionEvent[]
try {
expect(() => new Session(SessionId('seed-non-error-metadata-failure'), seed))
expect(() => Session.create(SessionId('seed-non-error-metadata-failure'), seed))
.toThrow(`invalid seed event at index 1: ${expected}`)
} finally {
hasOwn.mockRestore()
@@ -721,7 +761,7 @@ describe('Session', () => {
}, surfaceOp: 'append' as const },
{ type: 'turn/end' as const, seq: 2, time: 3, data: { turn: 1, reason: { kind: 'completed' as const } } },
] as SessionEvent[]
const session = new Session(SessionId('seed-snapshot'), seed)
const session = Session.create(SessionId('seed-snapshot'), seed)
// Mutate the ORIGINAL seed objects after construction: a shared reference
// would let this rewrite the forked log (or reintroduce non-serializable
// data past validation). The snapshot must shield session.events.
@@ -734,7 +774,7 @@ describe('Session', () => {
})
it('snapshots append data: mutating the passed object after append does not affect session.events', () => {
const session = new Session(SessionId('append-snapshot'))
const session = Session.create(SessionId('append-snapshot'))
const data = {
id: MessageId('append-input'),
role: 'user' as const,
@@ -754,7 +794,7 @@ describe('Session', () => {
})
it('reads a nested append-data getter once and stores its first JSON value', () => {
const session = new Session(SessionId('append-nested-drift'))
const session = Session.create(SessionId('append-nested-drift'))
let reads = 0
const data = Object.defineProperty({}, 'value', {
enumerable: true,
@@ -772,7 +812,7 @@ describe('Session', () => {
})
it('rejects non-JSON surface metadata before appending the event', () => {
const session = new Session(SessionId('append-bad-metadata'))
const session = Session.create(SessionId('append-bad-metadata'))
expect(() => session.append(
'user/message',
@@ -790,7 +830,7 @@ describe('Session', () => {
readonly start = 0
readonly end = 0
}
const session = new Session(SessionId('append-exotic-metadata'))
const session = Session.create(SessionId('append-exotic-metadata'))
expect(() => session.append(
'user/message',
@@ -803,7 +843,7 @@ describe('Session', () => {
})
it('reads a nested append-metadata getter once and stores its first JSON value', () => {
const session = new Session(SessionId('append-unstable-metadata'))
const session = Session.create(SessionId('append-unstable-metadata'))
const source = session.append(
'user/message',
createUserMessage({
@@ -834,7 +874,7 @@ describe('Session', () => {
})
it('rejects invalid plain surface metadata shapes at append', () => {
const session = new Session(SessionId('append-invalid-surface-shape'))
const session = Session.create(SessionId('append-invalid-surface-shape'))
const appendRaw = session.append.bind(session) as unknown as (
type: SessionEventType,
data: unknown,
@@ -855,7 +895,7 @@ describe('Session', () => {
})
it('rejects surface metadata on non-surface append and seed events', () => {
const session = new Session(SessionId('non-surface-metadata'))
const session = Session.create(SessionId('non-surface-metadata'))
const appendRaw = session.append.bind(session) as unknown as (
type: SessionEventType,
data: unknown,
@@ -867,7 +907,7 @@ describe('Session', () => {
{ turn: 1 },
{ surfaceOp: 'append' },
)).toThrow(/not surface-eligible and cannot carry surfaceOp/)
expect(() => new Session(SessionId('non-surface-metadata-seed'), [{
expect(() => Session.create(SessionId('non-surface-metadata-seed'), [{
type: 'turn/start',
seq: 0,
time: 1,
@@ -878,7 +918,7 @@ describe('Session', () => {
})
it('deep-freezes seeded and appended event snapshots', () => {
const seeded = new Session(SessionId('seed-frozen'), [{
const seeded = Session.create(SessionId('seed-frozen'), [{
type: 'turn/start',
seq: 0,
time: 1,
@@ -890,7 +930,7 @@ describe('Session', () => {
expect(Object.isFrozen(seededEvent.data)).toBe(true)
expect(() => { seededEvent.data.turn = 99 }).toThrow(TypeError)
const appended = new Session(SessionId('append-frozen'))
const appended = Session.create(SessionId('append-frozen'))
const appendedEvent = appended.append('todo/write', {
todos: [{ content: 'first', status: 'pending' }],
})
@@ -902,7 +942,7 @@ describe('Session', () => {
})
it('returns cached frozen event-array snapshots that do not grow after append', () => {
const session = new Session(SessionId('events-snapshot'))
const session = Session.create(SessionId('events-snapshot'))
session.append('turn/start', { turn: 1 })
const before = session.events
const beforeEvent = before[0]!
@@ -931,7 +971,7 @@ describe('Session', () => {
seedLength: 2,
}
const session = new Session(SessionId('header-owned'), undefined, input)
const session = Session.create(SessionId('header-owned'), undefined, input)
input.cwd = '/caller-mutated'
expect(session.header).toEqual({
@@ -956,15 +996,15 @@ describe('Session', () => {
readonly createdAt = 123
}
expect(() => new Session(SessionId('header-invalid'), undefined, new ExoticHeader()))
expect(() => Session.create(SessionId('header-invalid'), undefined, new ExoticHeader()))
.toThrow(/not losslessly JSON-serializable/)
expect(() => new Session(SessionId('header-invalid'), undefined, {
expect(() => Session.create(SessionId('header-invalid'), undefined, {
version: SESSION_FORMAT_VERSION,
id: SessionId('header-invalid'),
createdAt: 123,
parentSession: 1n,
} as unknown as SessionHeader)).toThrow(/not losslessly JSON-serializable/)
expect(() => new Session(SessionId('header-invalid'), undefined, {
expect(() => Session.create(SessionId('header-invalid'), undefined, {
version: SESSION_FORMAT_VERSION,
id: SessionId('other'),
createdAt: 123,
@@ -991,7 +1031,7 @@ describe('Session', () => {
]
for (const { header, error } of cases) {
expect(() => new Session(SessionId('header-shape'), undefined, header as SessionHeader)).toThrow(error)
expect(() => Session.create(SessionId('header-shape'), undefined, header as SessionHeader)).toThrow(error)
}
})
@@ -1015,7 +1055,7 @@ describe('Session', () => {
]
for (const [index, event] of cases.entries()) {
expect(() => new Session(SessionId(`bad-envelope-${index}`), [event as SessionEvent]))
expect(() => Session.create(SessionId(`bad-envelope-${index}`), [event as SessionEvent]))
.toThrow(/invalid event envelope/)
}
})
@@ -1103,7 +1143,7 @@ describe('SessionStore', () => {
const secondCtx = new Context()
await firstCtx.plugin(SessionStore)
await secondCtx.plugin(SessionStore)
const session = new Session(SessionId('owned-key'))
const session = Session.create(SessionId('owned-key'))
const detachFirst = firstCtx.sessions.enter(session)
expect(() => secondCtx.sessions.enter(session)).toThrow(/already attached to a store/)
@@ -1260,7 +1300,7 @@ describe('SessionStore', () => {
})
it('a bare Session() constructed without the store still exposes a current-version header', () => {
const session = new Session(SessionId('bare'))
const session = Session.create(SessionId('bare'))
expect(session.header).toMatchObject({ version: SESSION_FORMAT_VERSION, id: 'bare' })
expect(typeof session.header.createdAt).toBe('number')
})
@@ -1578,7 +1618,7 @@ describe('SessionStore', () => {
it('does not let internal dispatch replace the disposed callback tuple', async () => {
const ctx = new Context()
await ctx.plugin(SessionStore)
const replacement = new Session(SessionId('replacement-disposed'))
const replacement = Session.create(SessionId('replacement-disposed'))
const heard: Session[] = []
ctx.on('internal/dispatch', (_mode, name, args) => {
if (name === 'session/disposed') args[0] = replacement
@@ -1596,7 +1636,7 @@ describe('SessionStore', () => {
describe('todo/write event', () => {
it('appends the whole-list snapshot and isolates the log from later mutation', () => {
const session = new Session(SessionId('t1'))
const session = Session.create(SessionId('t1'))
const todos: TodoItem[] = [
{ content: 'plan the work', status: 'in_progress' },
{ content: 'write the code', status: 'pending' },
@@ -1618,7 +1658,7 @@ describe('todo/write event', () => {
})
it('is last-write-wins: the current list is the most recent todo/write', () => {
const session = new Session(SessionId('t2'))
const session = Session.create(SessionId('t2'))
session.append('todo/write', { todos: [{ content: 'first', status: 'pending' }] })
session.append('todo/write', { todos: [
{ content: 'first', status: 'completed' },
@@ -1633,7 +1673,7 @@ describe('todo/write event', () => {
})
it('is NOT a surface event: it produces no derived message and joins no surface node', () => {
const session = new Session(SessionId('t3'))
const session = Session.create(SessionId('t3'))
session.append('user/message', createUserMessage({
content: [{ type: 'text', text: 'q' }], source: { kind: 'user' },
}), { surfaceOp: 'append' })
@@ -1646,12 +1686,12 @@ describe('todo/write event', () => {
})
it('round-trips through a seeded replay identically (durable, no surfaceOp needed)', () => {
const original = new Session(SessionId('t4'))
const original = Session.create(SessionId('t4'))
original.append('turn/start', { turn: 1 })
original.append('todo/write', { todos: [{ content: 'only', status: 'completed' }] })
original.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
// Seeding a non-surface event with no surfaceOp must not throw.
const replayed = new Session(SessionId('t4-replay'), [...original.events])
const replayed = Session.create(SessionId('t4-replay'), [...original.events])
expect(replayed.events.findLast(e => e.type === 'todo/write')!.data.todos)
.toEqual([{ content: 'only', status: 'completed' }])
expect(replayed.events.slice(0, original.seq)).toEqual(original.events)