Merge finalized Web transcript parent

# Conflicts:
#	.agents/notes/implemented/architecture/2026-07-30-session-end-seed-log-boundary.i18n.yaml
This commit is contained in:
Tianyi Cui
2026-07-31 14:11:40 +08:00
65 changed files with 1659 additions and 368 deletions

View File

@@ -414,7 +414,9 @@ export class Session {
* start here. Distinct from `header.seedLength`, the DURABLE fork-lineage
* boundary: a resumed session's constructor seed is its full stored log,
* while its header keeps the original fork value — this field is the
* in-process construction fact.
* in-process construction fact. An explicitly supplied empty seed has the
* same value as no seed (0); its `session/end-seed` event preserves the
* lifecycle distinction.
*
* Not persisted itself: a seeded session projects it into the log as the
* `session/end-seed` event, which is what a consumer reading STORED history
@@ -430,7 +432,7 @@ export class Session {
readonly firstLiveSeq: number
constructor(id: SessionId, seed?: readonly SessionEvent[], header?: SessionHeader) {
if (seed) {
if (seed !== undefined) {
// Validate the seed to the SAME invariants `append` enforces, so a
// replay/fork (`ctx.sessions.create(id, { seed })`) cannot construct a
// live log that no persistence backend could store: each event's `data`
@@ -467,7 +469,7 @@ export class Session {
// captures the creation seed: no load-time write. Re-marking is skipped
// because a cold session is resumed on first touch, so repeatedly opening
// one must not grow its log per open.
if (this.firstLiveSeq > 0 && this.log.at(-1)?.type !== 'session/end-seed') {
if (seed !== undefined && this.log.at(-1)?.type !== 'session/end-seed') {
this.append('session/end-seed', {})
}
}

View File

@@ -256,7 +256,9 @@ export interface SessionEventMap {
/**
* Marks the end of a constructor seed. Events before it have smaller seq
* values and came from the seed (resume, fork, or replay); this lifecycle
* produced none of them. This log-only event is the durable projection of
* produced none of them. An explicitly supplied empty seed puts the marker
* at seq 0, distinguishing an empty resumed session from a fresh session.
* This log-only event is the durable projection of
* {@link Session.firstLiveSeq}. Its payload is empty — position and `time`
* carry the meaning.
*

View File

@@ -67,7 +67,7 @@ describe('SessionStore.fork', () => {
const child = sessions.fork(source, undefined, SessionId('empty-child'))
expect(child.events).toEqual([])
expect(inherited(child)).toEqual([])
expect(child.header).toMatchObject({
id: SessionId('empty-child'),
cwd: '/workspace',

View File

@@ -112,9 +112,9 @@ describe('Session properties', () => {
const original = build(events)
const replayed = new Session(SessionId(`replay-${counter++}`), [...original.events])
expect(replayed.deriveMessages()).toEqual(original.deriveMessages())
// A non-empty replay grows by exactly one log-only boundary.
// Every explicit replay grows by exactly one log-only boundary.
expect(replayed.events.slice(0, original.seq)).toEqual(original.events)
expect(replayed.seq).toBe(original.seq === 0 ? 0 : original.seq + 1)
expect(replayed.seq).toBe(original.seq + 1)
}))
})

View File

@@ -194,6 +194,21 @@ describe('Session', () => {
expect(replayed.firstLiveSeq).toBe(original.seq)
})
it('marks an explicitly empty seed without marking a fresh session', () => {
const fresh = new Session(SessionId('fresh-empty'))
expect(fresh.events).toEqual([])
const resumed = new Session(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)
expect(reopened.firstLiveSeq).toBe(1)
expect(reopened.events).toEqual(resumed.events)
})
it('rejects pre-provider request headers and assistant messages on seed/load', () => {
const requestHeader = {
type: 'request/header', seq: 0, time: 1,