feat(session): project the inherited-history boundary into the log

A plugin owning a standalone open/close bracket cannot tell a dead marker
from a live one: an unmatched `compact/start` reads identically whether the
previous writer died mid-compaction or a compaction is running now.
`Session.firstLiveSeq` already holds that answer exactly, but only in memory.

Append the log-only `session/inherited` event at that seq from the seeded
constructor — the single waist all six seeded-start paths pass through
(resume, configured startup on a persisted id, `sessions.fork()`, a subagent
fork child, `adopt()`'s live prefix, and a bare seeded `create`). Read it
through the new `isInheritedSeq(events, seq)`.

The constructor placement means persistence needs no changes: the marker is
already in `events` when a backend captures the creation seed, so it rides
the ordinary seed path with no load-time write. It also covers fork, where
the inherited bracket's owner may still be running — the case a
persistence-layer boundary could not reach.

Activity ordering excludes the boundary through `lastActivityTime()`, since
lazy resume makes browsing a pickup and the three call sites would otherwise
float every opened session to the top of a picker or list.
This commit is contained in:
Hypatia May
2026-07-30 11:38:51 +08:00
parent 2a53806275
commit b341155652
41 changed files with 850 additions and 122 deletions
+100 -1
View File
@@ -1,6 +1,6 @@
import { describe, expect, it } from 'vitest'
import { CallId , createMessage, createToolResultMessage } from '@deepseek-ai/dsh-llm'
import { interruptedTurnClosers, TOOL_NOT_STARTED, TOOL_OUTCOME_UNKNOWN } from '../src/index.ts'
import { interruptedTurnClosers, isInheritedSeq, lastActivityTime, TOOL_NOT_STARTED, TOOL_OUTCOME_UNKNOWN } from '../src/index.ts'
import type { SessionEvent, SurfaceEvent } from '../src/index.ts'
/**
@@ -273,3 +273,102 @@ describe('interruptedTurnClosers', () => {
expect(closers.map(e => e.type)).toEqual(['step/end', 'turn/end'])
})
})
/**
* The stored-history reading of the inherited boundary. A bracket owner calls
* this on an unmatched opening marker to decide whether the operation can still
* be running, so the classification of the marker's own seq — and of the
* boundary seq itself — is the contract.
*/
describe('isInheritedSeq', () => {
const inheritedAt = (seq: number): SessionEvent =>
({ type: 'session/inherited', seq, time: seq, data: {} })
it('classifies nothing as inherited in a log without a boundary', () => {
const events: SessionEvent[] = [
userTurnStart(1, 0),
{ type: 'turn/end', seq: 1, time: 1, data: { turn: 1, reason: { kind: 'completed' } } },
]
expect(isInheritedSeq(events, 0)).toBe(false)
expect(isInheritedSeq(events, 1)).toBe(false)
})
it('treats an empty log as owning nothing', () => {
expect(isInheritedSeq([], 0)).toBe(false)
})
it('splits the log at the boundary', () => {
// seqs 0-1 inherited; the boundary at 2; seq 3 written by this lifecycle.
const events: SessionEvent[] = [
userTurnStart(1, 0),
{ type: 'turn/end', seq: 1, time: 1, data: { turn: 1, reason: { kind: 'completed' } } },
inheritedAt(2),
userTurnStart(2, 3),
]
expect(isInheritedSeq(events, 0)).toBe(true)
expect(isInheritedSeq(events, 1)).toBe(true)
// The boundary's own seq counts as inherited: it belongs to the pickup.
expect(isInheritedSeq(events, 2)).toBe(true)
expect(isInheritedSeq(events, 3)).toBe(false)
})
it('reports inherited for an event below a later boundary', () => {
// Two pickups in turn: the tail scan must not stop at the nearer boundary.
const events: SessionEvent[] = [
userTurnStart(1, 0),
inheritedAt(1),
userTurnStart(2, 2),
inheritedAt(3),
userTurnStart(3, 4),
]
expect(isInheritedSeq(events, 0)).toBe(true)
expect(isInheritedSeq(events, 2)).toBe(true)
expect(isInheritedSeq(events, 4)).toBe(false)
})
})
/**
* Activity ordering excludes the pickup boundary. A resume picker or session
* list sorting by log tail would otherwise promote every session the user
* merely opened above the ones they actually worked in.
*/
describe('lastActivityTime', () => {
const inheritedAt = (seq: number, time: number): SessionEvent =>
({ type: 'session/inherited', seq, time, data: {} })
it('has no answer for an empty log', () => {
expect(lastActivityTime([])).toBeUndefined()
})
it('reports the log tail when no boundary is present', () => {
const events: SessionEvent[] = [
userTurnStart(1, 0),
{ type: 'turn/end', seq: 1, time: 500, data: { turn: 1, reason: { kind: 'completed' } } },
]
expect(lastActivityTime(events)).toBe(500)
})
it('skips a trailing boundary in favour of the last real work', () => {
const events: SessionEvent[] = [
userTurnStart(1, 0),
{ type: 'turn/end', seq: 1, time: 500, data: { turn: 1, reason: { kind: 'completed' } } },
inheritedAt(2, 9_000),
]
// Resumed long after the work, but never worked in again.
expect(lastActivityTime(events)).toBe(500)
})
it('reports work done above a boundary', () => {
const events: SessionEvent[] = [
userTurnStart(1, 0),
inheritedAt(1, 9_000),
{ type: 'turn/end', seq: 2, time: 9_500, data: { turn: 1, reason: { kind: 'completed' } } },
]
expect(lastActivityTime(events)).toBe(9_500)
})
it('has no answer for a log of nothing but boundaries', () => {
// Unreachable via the constructor, but the projection is a pure function.
expect(lastActivityTime([inheritedAt(0, 1), inheritedAt(1, 2)])).toBeUndefined()
})
})