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:
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user