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:
@@ -2158,7 +2158,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [
|
||||
},
|
||||
{
|
||||
name: 'SessionEventMap',
|
||||
declaration: 'export interface SessionEventMap {\n \'turn/start\': {\n turn: number;\n trigger: TurnTrigger;\n };\n \'turn/end\': {\n turn: number;\n reason: TurnEndReason;\n };\n \'step/start\': {\n turn: number;\n step: number;\n };\n \'step/end\': {\n turn: number;\n step: number;\n };\n \'user/message\': UserMessage;\n \'assistant/chunk\': {\n turn: number;\n step: number;\n chunk: StreamChunk;\n };\n \'assistant/message\': {\n turn: number;\n step: number;\n message: AssistantMessage;\n usage?: TokenUsage;\n };\n \'tool/call\': {\n turn: number;\n step: number;\n callId: CallId;\n name: string;\n arguments: string;\n };\n \'tool/result\': {\n turn: number;\n step: number;\n message: ToolResultMessage;\n error?: {\n name: string;\n code: string;\n };\n meta?: JsonValue;\n };\n \'steering/message\': {\n turn: number;\n message: UserMessage;\n };\n \'todo/write\': {\n todos: TodoItem[];\n };\n \'request/header\': {\n header: EpochHeader;\n reason: RequestHeaderReason;\n };\n}',
|
||||
declaration: 'export interface SessionEventMap {\n \'turn/start\': {\n turn: number;\n trigger: TurnTrigger;\n };\n \'turn/end\': {\n turn: number;\n reason: TurnEndReason;\n };\n \'step/start\': {\n turn: number;\n step: number;\n };\n \'step/end\': {\n turn: number;\n step: number;\n };\n \'user/message\': UserMessage;\n \'assistant/chunk\': {\n turn: number;\n step: number;\n chunk: StreamChunk;\n };\n \'assistant/message\': {\n turn: number;\n step: number;\n message: AssistantMessage;\n usage?: TokenUsage;\n };\n \'tool/call\': {\n turn: number;\n step: number;\n callId: CallId;\n name: string;\n arguments: string;\n };\n \'tool/result\': {\n turn: number;\n step: number;\n message: ToolResultMessage;\n error?: {\n name: string;\n code: string;\n };\n meta?: JsonValue;\n };\n \'steering/message\': {\n turn: number;\n message: UserMessage;\n };\n \'todo/write\': {\n todos: TodoItem[];\n };\n \'request/header\': {\n header: EpochHeader;\n reason: RequestHeaderReason;\n };\n \'session/inherited\': Record<string, never>;\n}',
|
||||
},
|
||||
{
|
||||
name: 'SessionEventMetadataFilter',
|
||||
|
||||
@@ -1207,8 +1207,9 @@ describe('agent loop', () => {
|
||||
|
||||
const replayed = ctx.sessions.create(SessionId('replayed'), { seed: [...agent.session.events] })
|
||||
expect(replayed.deriveMessages()).toEqual(agent.session.deriveMessages())
|
||||
// event-by-event identity of types
|
||||
expect(replayed.events.map(e => e.type)).toEqual(
|
||||
// event-by-event identity of types over the inherited prefix
|
||||
expect(replayed.events.slice(0, agent.session.seq).map(e => e.type)).toEqual(
|
||||
agent.session.events.map(e => e.type))
|
||||
expect(replayed.events.at(-1)?.type).toBe('session/inherited')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -283,7 +283,8 @@ describe('the session-persistence Agent Note: AgentLoop factory create/resume',
|
||||
agentOptions: { provider: 'mock', model: 'mock' },
|
||||
setup: async (agentCtx) => {
|
||||
expect(agentCtx.agent?.id).toBe(sessionId)
|
||||
expect(agentCtx.agent?.session.events).toHaveLength(2)
|
||||
// The two persisted events plus the inherited-history boundary.
|
||||
expect(agentCtx.agent?.session.events).toHaveLength(3)
|
||||
agentCtx.on('session/created', () => void order.push('setup-listener:session/created'))
|
||||
agentCtx.on('agent/created', () => void order.push('setup-listener:agent/created'))
|
||||
order.push('setup:start')
|
||||
@@ -585,7 +586,10 @@ describe('the session-persistence Agent Note: AgentLoop factory create/resume',
|
||||
const a2 = (await ctx2.agents.resume({ resumeSessionId: SessionId('sess-resume') })).agent
|
||||
// The resumed session carries the prior history…
|
||||
expect(a2.session.id).toBe('sess-resume')
|
||||
expect(a2.session.events.length).toBe(events1.length)
|
||||
// …below one boundary marking all of it inherited.
|
||||
expect(a2.session.events.length).toBe(events1.length + 1)
|
||||
expect(a2.session.firstLiveSeq).toBe(events1.length)
|
||||
expect(a2.session.events.at(-1)?.type).toBe('session/inherited')
|
||||
const replay = new Session(SessionId('replay'), events1)
|
||||
expect(a2.session.deriveMessages()).toEqual(replay.deriveMessages())
|
||||
|
||||
|
||||
@@ -23,7 +23,7 @@ export * from './types.ts'
|
||||
export type { AssistantMessage, ToolResultMessage, UserMessage } from '@deepseek-ai/dsh-llm'
|
||||
export { isJsonValue, snapshotJsonValue } from './json.ts'
|
||||
export type { JsonValue } from './json.ts'
|
||||
export { interruptedTurnClosers, TOOL_NOT_STARTED, TOOL_OUTCOME_UNKNOWN } from './repair.ts'
|
||||
export { interruptedTurnClosers, isInheritedSeq, lastActivityTime, TOOL_NOT_STARTED, TOOL_OUTCOME_UNKNOWN } from './repair.ts'
|
||||
export { decodeStorageRecord, packChunkRuns } from './chunk-rows.ts'
|
||||
export type { ChunkRow, StorageRecord } from './chunk-rows.ts'
|
||||
export type { SessionSurface, SurfaceFoldReplacement, SurfaceFoldResult } from './surface.ts'
|
||||
@@ -388,8 +388,12 @@ export class Session {
|
||||
* log as a publication substitute (telemetry adoption) 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
|
||||
* and is deliberately not persisted.
|
||||
* the original fork value — this field is the in-process construction fact.
|
||||
*
|
||||
* Not persisted itself: a nonzero value is projected into the log as the
|
||||
* `session/inherited` event at this seq, which is what a consumer reading
|
||||
* STORED history reads. Prefer this field in-process — it is exact before
|
||||
* the marker's write reaches storage.
|
||||
*/
|
||||
readonly firstLiveSeq: number
|
||||
|
||||
@@ -427,6 +431,13 @@ export class Session {
|
||||
}
|
||||
this.firstLiveSeq = this.log.length
|
||||
this.header = snapshotSessionHeader(id, header)
|
||||
// Appended here so the marker is already in `events` when a backend
|
||||
// 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/inherited') {
|
||||
this.append('session/inherited', {})
|
||||
}
|
||||
}
|
||||
|
||||
/** Cached immutable public snapshot of the private append-only log. */
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
/**
|
||||
* Crash-recovery repair for an interrupted session log. It preserves a fully
|
||||
* written final turn and supplies the missing tool, step, and turn boundaries
|
||||
* needed to resume with a provider-valid transcript.
|
||||
* needed to resume with a provider-valid transcript, plus the inherited-history
|
||||
* boundary a plugin-owned bracket reads to tell dead history from live work.
|
||||
* @module @deepseek-ai/dsh-session/repair
|
||||
*/
|
||||
|
||||
@@ -9,6 +10,50 @@ import { MessageId, freezeMessage, type CallId } from '@deepseek-ai/dsh-llm'
|
||||
import type { ToolResultMessage } from '@deepseek-ai/dsh-llm'
|
||||
import type { SessionEvent } from './types.ts'
|
||||
|
||||
/**
|
||||
* Whether the event at `seq` was inherited rather than written by the lifecycle
|
||||
* that owns `events` — the stored-history reading of `Session.firstLiveSeq`.
|
||||
*
|
||||
* An owner of a standalone open/close bracket calls this on an unmatched
|
||||
* opening marker: `true` means the operation cannot still be running, because
|
||||
* the lifecycle that opened it has ended (a crashed writer, a succeeding
|
||||
* process, or a parent the events were forked out of). `false` means it belongs
|
||||
* to the current lifecycle and must be treated as live.
|
||||
*
|
||||
* Reads the log rather than a `Session`, so it serves a consumer holding only
|
||||
* loaded events; in-process, compare against `session.firstLiveSeq` instead.
|
||||
* @param events - the log to scan, contiguous from seq 0.
|
||||
* @param seq - the event seq to classify.
|
||||
* @returns true when a `session/inherited` boundary sits at or above `seq`.
|
||||
*/
|
||||
export function isInheritedSeq(events: readonly SessionEvent[], seq: number): boolean {
|
||||
// Tail-first: an unmarked log costs no full scan, and bracket queries are
|
||||
// usually about recent events.
|
||||
for (let index = events.length - 1; index >= 0; index -= 1) {
|
||||
const event = events[index]
|
||||
/* v8 ignore next -- a contiguous log has no holes; the guard is for the index type */
|
||||
if (event === undefined) continue
|
||||
if (event.seq < seq) return false
|
||||
if (event.type === 'session/inherited') return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* The `time` of the log's last event that represents actual work, skipping the
|
||||
* `session/inherited` boundary.
|
||||
*
|
||||
* Picking a session up is not activity, and lazy resume means browsing writes a
|
||||
* boundary, so activity ordering (a resume picker, a session list) must skip it
|
||||
* or every opened session sorts as freshly worked in.
|
||||
* @param events - the log to scan, in seq order.
|
||||
* @returns the latest non-boundary event's `time`, or undefined when the log has
|
||||
* no such event (empty, or nothing but boundaries).
|
||||
*/
|
||||
export function lastActivityTime(events: readonly SessionEvent[]): number | undefined {
|
||||
return events.findLast(event => event.type !== 'session/inherited')?.time
|
||||
}
|
||||
|
||||
/** Recovery code for an assistant tool request that never reached a recorded call start. */
|
||||
export const TOOL_NOT_STARTED = 'TOOL_NOT_STARTED'
|
||||
|
||||
|
||||
@@ -250,6 +250,26 @@ export interface SessionEventMap {
|
||||
* It is log-only; the latest snapshot reconstructs the request header.
|
||||
*/
|
||||
'request/header': { header: EpochHeader; reason: RequestHeaderReason }
|
||||
/**
|
||||
* The log-only durable projection of {@link Session.firstLiveSeq}: everything
|
||||
* BELOW it was inherited through a constructor seed — resume, fork, or replay
|
||||
* — and no writer in this session's lifecycle produced it. Appended as the
|
||||
* first live event of every seeded session.
|
||||
*
|
||||
* A plugin owning a standalone open/close bracket (`compact/start` …
|
||||
* `compact/end`) needs it because inherited history and live work are
|
||||
* otherwise byte-identical: an unmatched opening marker below this boundary
|
||||
* belongs to an ended lifecycle, so it is dead whether the writer crashed,
|
||||
* the process succeeded it, or the events were forked out of a parent that is
|
||||
* still running. Read it through `isInheritedSeq`.
|
||||
*
|
||||
* NOT a liveness signal about other writers: a concurrently live session may
|
||||
* hold an open bracket over the same stored history with its own boundary
|
||||
* elsewhere, so tolerating concurrent writers needs a signal beyond the log.
|
||||
*
|
||||
* The payload is empty by design — position and `time` carry the meaning.
|
||||
*/
|
||||
'session/inherited': Record<string, never>
|
||||
}
|
||||
|
||||
/** The appendable event-type keys of {@link SessionEventMap}, plugin-merged extensions included. */
|
||||
|
||||
@@ -50,6 +50,14 @@ function lastSeq(session: Session): number {
|
||||
return event.seq
|
||||
}
|
||||
|
||||
/** A seeded child's inherited prefix: its log minus the constructor's boundary. */
|
||||
function inherited(session: Session): readonly SessionEvent[] {
|
||||
const events = session.events
|
||||
const last = events.at(-1)
|
||||
if (last?.type !== 'session/inherited') throw new Error('seeded child is missing its inherited boundary')
|
||||
return events.slice(0, -1)
|
||||
}
|
||||
|
||||
describe('SessionStore.fork', () => {
|
||||
it('forks an empty live session as an empty child with lineage metadata', async () => {
|
||||
const { ctx, sessions } = await setup()
|
||||
@@ -73,7 +81,7 @@ describe('SessionStore.fork', () => {
|
||||
|
||||
const child = sessions.fork(SessionId('parent'), undefined, SessionId('child'))
|
||||
|
||||
expect(child.events).toEqual(source.events)
|
||||
expect(inherited(child)).toEqual(source.events)
|
||||
expect(child.events).not.toBe(source.events)
|
||||
expect(child.events[1]).not.toBe(source.events[1])
|
||||
expect(() => {
|
||||
@@ -97,8 +105,8 @@ describe('SessionStore.fork', () => {
|
||||
|
||||
const child = sessions.fork(source, undefined, SessionId('log-only-child'))
|
||||
|
||||
expect(child.events).toEqual(source.events)
|
||||
expect(child.events.at(-1)).toMatchObject({
|
||||
expect(inherited(child)).toEqual(source.events)
|
||||
expect(inherited(child).at(-1)).toMatchObject({
|
||||
type: 'test/log-only',
|
||||
data: { value: 'after execution' },
|
||||
})
|
||||
@@ -114,7 +122,7 @@ describe('SessionStore.fork', () => {
|
||||
|
||||
const child = sessions.fork(source, firstBoundary, SessionId('child-from-first'))
|
||||
|
||||
expect(child.events).toEqual(source.events.slice(0, firstBoundary + 1))
|
||||
expect(inherited(child)).toEqual(source.events.slice(0, firstBoundary + 1))
|
||||
expect(child.header.seedLength).toBe(firstBoundary + 1)
|
||||
expect(child.deriveMessages()).toEqual([{
|
||||
id: expect.any(String) as unknown,
|
||||
@@ -141,7 +149,7 @@ describe('SessionStore.fork', () => {
|
||||
|
||||
const child = sessions.fork(source, lastSeq(source), SessionId(`child-${reason.kind}`))
|
||||
|
||||
expect(child.events.at(-1)?.type).toBe('turn/end')
|
||||
expect(inherited(child).at(-1)?.type).toBe('turn/end')
|
||||
expect(child.header.seedLength).toBe(source.events.length)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -112,7 +112,19 @@ describe('Session properties', () => {
|
||||
const original = build(events)
|
||||
const replayed = new Session(SessionId(`replay-${counter++}`), [...original.events])
|
||||
expect(replayed.deriveMessages()).toEqual(original.deriveMessages())
|
||||
expect(replayed.seq).toBe(original.seq)
|
||||
// A non-empty 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)
|
||||
}))
|
||||
})
|
||||
|
||||
it('replaying an already-inherited log adds no further boundary', () => {
|
||||
fc.assert(fc.property(logArb, (events) => {
|
||||
const original = build(events)
|
||||
const once = new Session(SessionId(`idem-a-${counter++}`), [...original.events])
|
||||
const twice = new Session(SessionId(`idem-b-${counter++}`), [...once.events])
|
||||
// Lazy resume makes browsing a pickup, so this must not grow per open.
|
||||
expect(twice.events).toEqual(once.events)
|
||||
}))
|
||||
})
|
||||
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -112,7 +112,7 @@ describe('Session', () => {
|
||||
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
|
||||
session.append('turn/end', { turn: 1, reason: { kind: 'aborted' } })
|
||||
const replayed = new Session(SessionId('aborted-replay'), structuredClone(session.events))
|
||||
expect(replayed.events).toEqual(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).toEqual({ kind: 'aborted' })
|
||||
})
|
||||
@@ -188,7 +188,10 @@ describe('Session', () => {
|
||||
|
||||
const replayed = new Session(SessionId('s3-replay'), [...original.events])
|
||||
expect(replayed.deriveMessages()).toEqual(original.deriveMessages())
|
||||
expect(replayed.seq).toBe(original.seq)
|
||||
// The seed verbatim, plus the boundary the constructor appends over it.
|
||||
expect(replayed.events.slice(0, original.seq)).toEqual(original.events)
|
||||
expect(replayed.seq).toBe(original.seq + 1)
|
||||
expect(replayed.firstLiveSeq).toBe(original.seq)
|
||||
})
|
||||
|
||||
it('rejects pre-provider request headers and assistant messages on seed/load', () => {
|
||||
@@ -217,7 +220,7 @@ describe('Session', () => {
|
||||
const unrelatedPrimitiveData = {
|
||||
type: 'plugin/event', seq: 0, time: 1, data: null,
|
||||
} as unknown as SessionEvent
|
||||
expect(new Session(SessionId('primitive-plugin-data'), [unrelatedPrimitiveData]).events)
|
||||
expect(new Session(SessionId('primitive-plugin-data'), [unrelatedPrimitiveData]).events.slice(0, 1))
|
||||
.toEqual([unrelatedPrimitiveData])
|
||||
})
|
||||
|
||||
@@ -522,7 +525,8 @@ describe('Session', () => {
|
||||
{ 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)
|
||||
expect(session.events).toHaveLength(3)
|
||||
expect(session.events.slice(0, 3)).toEqual(goodSeed)
|
||||
expect(session.firstLiveSeq).toBe(3)
|
||||
})
|
||||
|
||||
it('reads each seed array entry once so validation and storage use the same event', () => {
|
||||
@@ -546,7 +550,7 @@ describe('Session', () => {
|
||||
const session = new Session(SessionId('seed-entry-snapshot'), seed)
|
||||
|
||||
expect(reads).toBe(1)
|
||||
expect(session.events).toEqual([accepted])
|
||||
expect(session.events.slice(0, 1)).toEqual([accepted])
|
||||
})
|
||||
|
||||
it('reads a nested seed-data getter once and stores its first JSON value', () => {
|
||||
@@ -624,7 +628,7 @@ describe('Session', () => {
|
||||
|
||||
const session = new Session(SessionId('seed-null-prototype'), [event])
|
||||
|
||||
expect(session.events).toEqual([{ ...event }])
|
||||
expect(session.events.slice(0, 1)).toEqual([{ ...event }])
|
||||
})
|
||||
|
||||
it('reads a nested seed-metadata getter once and stores its first JSON value', () => {
|
||||
@@ -1647,6 +1651,7 @@ describe('todo/write event', () => {
|
||||
const replayed = new Session(SessionId('t4-replay'), [...original.events])
|
||||
expect(replayed.events.findLast(e => e.type === 'todo/write')!.data.todos)
|
||||
.toEqual([{ content: 'only', status: 'completed' }])
|
||||
expect(replayed.seq).toBe(original.seq)
|
||||
expect(replayed.events.slice(0, original.seq)).toEqual(original.events)
|
||||
expect(replayed.firstLiveSeq).toBe(original.seq)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -14,6 +14,7 @@ import type {
|
||||
import { createUserMessage, ReasoningEffortId } from '@deepseek-ai/dsh-llm'
|
||||
import { errorChain } from '@deepseek-ai/dsh-llm'
|
||||
import type { MessageId, MessageSource } from '@deepseek-ai/dsh-llm'
|
||||
import { lastActivityTime } from '@deepseek-ai/dsh-session'
|
||||
import type { Session, SessionEvent, SessionHeader, SessionId, UserMessage } from '@deepseek-ai/dsh-session'
|
||||
import type { SessionPersistence } from '@deepseek-ai/dsh-session-persistence'
|
||||
import type { Workspace, WorkspaceRecord } from '@deepseek-ai/dsh-workspace'
|
||||
@@ -164,7 +165,9 @@ function sessionBlank(session: Session): boolean {
|
||||
function summarize(session: Session, running: boolean): SessionSummary {
|
||||
return {
|
||||
sessionId: session.id,
|
||||
updatedAt: session.events.at(-1)?.time ?? session.header.createdAt,
|
||||
// Excludes the inherited-history boundary: a resumed-but-untouched session
|
||||
// must not sort as freshly worked in.
|
||||
updatedAt: lastActivityTime(session.events) ?? session.header.createdAt,
|
||||
running,
|
||||
blank: sessionBlank(session),
|
||||
...session.header.parentSession === undefined ? {} : { parentSessionId: session.header.parentSession },
|
||||
|
||||
@@ -78,6 +78,43 @@ describe('sessions.list cold merge', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('attached updatedAt excludes the inherited-history boundary', () => {
|
||||
it('reports the last real work, not the pickup, so a resumed-untouched session does not float', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SessionStore)
|
||||
await ctx.plugin(UserInteractionService)
|
||||
await ctx.plugin(AgentRegistry)
|
||||
const api = createApiProxy(ctx, { provider: 'p', model: 'm', cwd: '/tmp', workspaceRoot: '/tmp' })
|
||||
|
||||
// Old work, resumed just now: the boundary's time is far above the work's,
|
||||
// so reading the log tail would report the pickup as activity.
|
||||
const worked = 1_000_000
|
||||
const resumed = ctx.sessions.create(sid('resumed-untouched'), {
|
||||
seed: [
|
||||
{ type: 'turn/start', seq: 0, time: worked, data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } } },
|
||||
{ type: 'turn/end', seq: 1, time: worked, data: { turn: 1, reason: { kind: 'completed' } } },
|
||||
],
|
||||
meta: { cwd: '/proj', createdAt: 500 },
|
||||
})
|
||||
ctx.agents.register({ id: resumed.id, session: resumed, status: 'idle', ctx } as Agent)
|
||||
const boundary = resumed.events.at(-1)
|
||||
expect(boundary?.type).toBe('session/inherited')
|
||||
expect(boundary?.time).toBeGreaterThan(worked)
|
||||
|
||||
const listed = await api.sessions.list(request({}))
|
||||
if (!listed.result.ok) throw new Error('list failed')
|
||||
const summary = listed.result.value.items.find(item => item.sessionId === 'resumed-untouched')
|
||||
expect(summary?.updatedAt).toBe(worked)
|
||||
|
||||
// Real work above the boundary does move it.
|
||||
resumed.append('turn/start', { turn: 2, trigger: { kind: 'message', source: { kind: 'user' } } })
|
||||
const after = await api.sessions.list(request({}))
|
||||
if (!after.result.ok) throw new Error('list failed')
|
||||
const moved = after.result.value.items.find(item => item.sessionId === 'resumed-untouched')
|
||||
expect(moved?.updatedAt).toBeGreaterThan(worked)
|
||||
})
|
||||
})
|
||||
|
||||
describe('degenerate composition (no persistence, no factory)', () => {
|
||||
it('list skips the cold merge and resume maps a non-not-found failure to internal', async () => {
|
||||
const ctx = new Context()
|
||||
|
||||
@@ -417,7 +417,8 @@ describe('replay anchors and surface folds', () => {
|
||||
expect(after.surfaceDeltaTokens).toBeLessThan(0)
|
||||
expectSurfaceTotal(after)
|
||||
expect(before.nodes).toHaveLength(2)
|
||||
expect(before.logRevision).toBe(original.events.length)
|
||||
// The earlier snapshot still reports the log it measured: seed + boundary.
|
||||
expect(before.logRevision).toBe(original.events.length + 1)
|
||||
expect(before.surfaceDeltaTokens).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
@@ -677,13 +678,15 @@ describe('malformed replay and listener lifecycle', () => {
|
||||
content: [{ type: 'text', text: 'one' }],
|
||||
source: { kind: 'user' },
|
||||
}), { surfaceOp: 'append' })
|
||||
expect(revisions).toEqual([2])
|
||||
expect(activeMeter.measure(session).logRevision).toBe(2)
|
||||
// Seed, constructor boundary, then the append above. Only the last
|
||||
// published: the boundary predates store attachment, like the seed.
|
||||
expect(revisions).toEqual([3])
|
||||
expect(activeMeter.measure(session).logRevision).toBe(3)
|
||||
|
||||
await firstFiber.dispose()
|
||||
const secondFiber = await ctx.plugin(TokenMeterService)
|
||||
activeMeter = ctx.tokenMeter
|
||||
expect(activeMeter.measure(session).logRevision).toBe(2)
|
||||
expect(activeMeter.measure(session).logRevision).toBe(3)
|
||||
await secondFiber.dispose()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -378,7 +378,9 @@ describe('SessionPersistenceJsonl: durability and crash semantics', () => {
|
||||
await ctx.sessions.flush(child)
|
||||
const loaded = await ctx.sessionPersistence.load(child.id)
|
||||
|
||||
expect(loaded.events).toEqual(source.events)
|
||||
// The inherited prefix reaches disk verbatim, then the child's boundary.
|
||||
expect(loaded.events.slice(0, source.events.length)).toEqual(source.events)
|
||||
expect(loaded.events.at(-1)).toMatchObject({ type: 'session/inherited', seq: source.events.length })
|
||||
expect(loaded.meta).toMatchObject({
|
||||
id: SessionId('persist-child'),
|
||||
cwd: '/workspace',
|
||||
|
||||
@@ -218,7 +218,9 @@ export function runCoordinatorContract(name: string, makeFixture: () => Promise<
|
||||
live.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
|
||||
await ctx.sessions.flush(live)
|
||||
const loaded = await ctx.sessionPersistence.load(id)
|
||||
expect(loaded.events.map(event => event.type)).toEqual(['turn/start', 'turn/end'])
|
||||
// The seeded constructor's boundary persisted between the stored
|
||||
// turn/start and the turn/end appended live.
|
||||
expect(loaded.events.map(event => event.type)).toEqual(['turn/start', 'session/inherited', 'turn/end'])
|
||||
expect(loaded.events.at(-1)).toMatchObject({
|
||||
type: 'turn/end',
|
||||
data: { reason: { kind: 'completed' } },
|
||||
@@ -477,11 +479,14 @@ export function runCoordinatorContract(name: string, makeFixture: () => Promise<
|
||||
const forked = ctx.sessions.create(SessionId('forked'), { seed, meta: { cwd: WORK } })
|
||||
await ctx.sessions.flush(forked) // onCreated persisted the seed
|
||||
const loaded = await ctx.sessionPersistence.load(SessionId('forked'))
|
||||
expect(loaded.events).toEqual(seed)
|
||||
// Fork is where the marker earns its keep: the inherited prefix may
|
||||
// carry a bracket the still-running parent owns.
|
||||
expect(loaded.events.slice(0, seed.length)).toEqual(seed)
|
||||
expect(loaded.events.at(-1)).toMatchObject({ type: 'session/inherited', seq: seed.length })
|
||||
// A flush with no NEW events must not double-write.
|
||||
await ctx.sessions.flush(forked)
|
||||
const reloaded = await ctx.sessionPersistence.load(SessionId('forked'))
|
||||
expect(reloaded.events).toEqual(seed)
|
||||
expect(reloaded.events).toEqual(loaded.events)
|
||||
} finally {
|
||||
await fiber.dispose()
|
||||
await fix.cleanup()
|
||||
@@ -510,7 +515,9 @@ export function runCoordinatorContract(name: string, makeFixture: () => Promise<
|
||||
await second.ctx.sessions.flush(s2)
|
||||
|
||||
const reloaded = await second.ctx.sessionPersistence.load(SessionId('resumed'))
|
||||
expect(reloaded.events.map(e => e.seq)).toEqual([0, 1, 2, 3, 4, 5, 6, 7])
|
||||
// 0-5 the resumed seed, 6 the boundary, 7-8 the new turn.
|
||||
expect(reloaded.events.map(e => e.seq)).toEqual([0, 1, 2, 3, 4, 5, 6, 7, 8])
|
||||
expect(reloaded.events[6]).toMatchObject({ type: 'session/inherited' })
|
||||
} finally {
|
||||
await second.fiber.dispose()
|
||||
await fix.cleanup()
|
||||
@@ -791,7 +798,9 @@ export function runCoordinatorContract(name: string, makeFixture: () => Promise<
|
||||
const live = ctx.sessions.create(SessionId('lazy-claim'), { seed: oneTurnLog(), meta: { cwd: WORK } })
|
||||
await expect(ctx.sessions.flush(live)).resolves.toBeUndefined()
|
||||
const loaded = await ctx.sessionPersistence.load(SessionId('lazy-claim'))
|
||||
expect(loaded.events.map(e => e.seq)).toEqual([0, 1, 2, 3, 4, 5])
|
||||
// Seeded 0-5 plus the constructor's boundary at 6.
|
||||
expect(loaded.events.map(e => e.seq)).toEqual([0, 1, 2, 3, 4, 5, 6])
|
||||
expect(loaded.events.at(-1)).toMatchObject({ type: 'session/inherited' })
|
||||
} finally {
|
||||
await fiber.dispose()
|
||||
await fix.cleanup()
|
||||
@@ -844,7 +853,9 @@ export function runCoordinatorContract(name: string, makeFixture: () => Promise<
|
||||
}, { inject: ['sessions'] }))
|
||||
await ctx.sessions.flush(cont)
|
||||
const loaded = await ctx.sessionPersistence.load(SessionId('claim'))
|
||||
expect(loaded.events.map(e => e.seq)).toEqual([0, 1, 2, 3, 4, 5, 6, 7])
|
||||
// 6-7 the claimed suffix; 8 the boundary over the whole seed.
|
||||
expect(loaded.events.map(e => e.seq)).toEqual([0, 1, 2, 3, 4, 5, 6, 7, 8])
|
||||
expect(loaded.events.at(-1)).toMatchObject({ type: 'session/inherited' })
|
||||
expect(loaded.meta).toEqual(durableMeta)
|
||||
expect(loaded.meta.createdAt).toBe(1000)
|
||||
|
||||
|
||||
@@ -1077,8 +1077,9 @@ describe('SQLite reconciliation and source lifecycle', () => {
|
||||
await expect(ctx.sessionQuery.searchEvents({ sessionId: live.id, query: 'needle' }))
|
||||
.rejects.toThrow(expectCode('SESSION_QUERY_INDEX_FAILED'))
|
||||
db.exec('PRAGMA query_only = OFF')
|
||||
// seq 2: one-event seed, its boundary, then the message appended above.
|
||||
await expect(ctx.sessionQuery.searchEvents({ sessionId: live.id, query: 'needle' }))
|
||||
.resolves.toMatchObject({ items: [{ seq: 1 }] })
|
||||
.resolves.toMatchObject({ items: [{ seq: 2 }] })
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -185,6 +185,8 @@ describe('SessionTitleService configuration and refresh boundaries', () => {
|
||||
'turn/start',
|
||||
'user/message',
|
||||
'turn/end',
|
||||
// The seeded constructor's inherited-history boundary.
|
||||
'session/inherited',
|
||||
'session/title',
|
||||
])
|
||||
expect(ctx.sessionTitle.get(session)?.messageSeqs).toEqual([source.seq])
|
||||
|
||||
@@ -126,9 +126,10 @@ describe('in-process policy inheritance', () => {
|
||||
|
||||
expect(child.session.header.seedLength).toBe(1)
|
||||
expect(child.session.firstLiveSeq).toBe(seed.length)
|
||||
// seq 1 is the inherited-history boundary.
|
||||
expect(child.session.events.filter(event => event.type === 'sandbox/mode')).toMatchObject([
|
||||
{ seq: 0, data: { mode: 'workspace-write' } },
|
||||
{ seq: 1, data: { mode: 'read-only', source: 'delegation' } },
|
||||
{ seq: 2, data: { mode: 'read-only', source: 'delegation' } },
|
||||
])
|
||||
await expect(readFile(blocked, 'utf8')).rejects.toMatchObject({ code: 'ENOENT' })
|
||||
expect(ctx.sandboxPolicy.overrideOf(child.session)).toBe('read-only')
|
||||
|
||||
@@ -186,7 +186,9 @@ describe('TelemetryCoordinator adoption', () => {
|
||||
|
||||
const seqs = backend.ledger().map(r => [r.attributes['session.id'], r.attributes['event.seq']])
|
||||
expect(seqs).toEqual(expect.arrayContaining([['seed-parent', 0], ['seed-parent', 1]]))
|
||||
expect(seqs.filter(([id]) => id === 'seeded')).toEqual([['seeded', 2]])
|
||||
// 2 the boundary, 3 the turn/end: both this lifecycle's own writes, while
|
||||
// inherited 0-1 stay with the parent stream.
|
||||
expect(seqs.filter(([id]) => id === 'seeded')).toEqual([['seeded', 2], ['seeded', 3]])
|
||||
})
|
||||
|
||||
it('resume shape: a full-log seed exports nothing yet still rebuilds the chunk projection', async () => {
|
||||
@@ -205,14 +207,16 @@ describe('TelemetryCoordinator adoption', () => {
|
||||
const ofResumed = () => backend.ledger()
|
||||
.filter(r => r.attributes['session.id'] === 'resumed')
|
||||
.map(r => r.attributes['event.seq'])
|
||||
expect(ofResumed()).toEqual([])
|
||||
// Nothing inherited is re-exported; seq 2 is this session's own first
|
||||
// write — the boundary its constructor appended over the seed.
|
||||
expect(ofResumed()).toEqual([2])
|
||||
// The seed fed the projection: the (turn 1, step 1) first chunk already
|
||||
// shipped from the original process, so its continuation is re-dropped…
|
||||
resumed.append('assistant/chunk', { turn: 1, step: 1, chunk: { type: 'text-delta', index: 0, text: 'continuation' } })
|
||||
expect(ofResumed()).toEqual([])
|
||||
expect(ofResumed()).toEqual([2])
|
||||
// …while a new step's first chunk exports normally.
|
||||
resumed.append('assistant/chunk', { turn: 1, step: 2, chunk: { type: 'text-delta', index: 0, text: 'next step' } })
|
||||
expect(ofResumed()).toEqual([3])
|
||||
expect(ofResumed()).toEqual([2, 4])
|
||||
})
|
||||
|
||||
it('stamps session.seed_length from the header so receivers can stitch fork streams', async () => {
|
||||
|
||||
@@ -23,6 +23,7 @@ import {
|
||||
type AgentLlmTarget,
|
||||
} from '@deepseek-ai/dsh-agent'
|
||||
import type { LlmModelInfo, LlmModelReasoningInfo, ReasoningEffortId } from '@deepseek-ai/dsh-llm'
|
||||
import { lastActivityTime } from '@deepseek-ai/dsh-session'
|
||||
import type { SessionId } from '@deepseek-ai/dsh-session'
|
||||
import { foldGoal, type GoalPhase } from '@deepseek-ai/dsh-goal'
|
||||
import { foldSessionTitle } from '@deepseek-ai/dsh-session-title'
|
||||
@@ -512,7 +513,8 @@ export function summarizeResumeCandidate(
|
||||
return {
|
||||
record,
|
||||
title,
|
||||
lastActivityAt: snapshot.events.at(-1)?.time ?? snapshot.session.createdAt,
|
||||
// Excludes a prior pickup's boundary, or every browsed session floats up.
|
||||
lastActivityAt: lastActivityTime(snapshot.events) ?? snapshot.session.createdAt,
|
||||
lastTurn: resumeTurnLabel(snapshot),
|
||||
currentWorkspace: record.header.cwd === cwd,
|
||||
workspaceLabel: formatWorkspace(record.header.cwd),
|
||||
|
||||
@@ -35,6 +35,7 @@ import type { ContentBlock, MessageId } from '@deepseek-ai/dsh-llm'
|
||||
import type {} from '@deepseek-ai/dsh-llm-retry'
|
||||
import { renderPrompt } from '@deepseek-ai/dsh-system-prompt'
|
||||
import {
|
||||
lastActivityTime,
|
||||
SessionId,
|
||||
type SessionEvent,
|
||||
type UserMessage,
|
||||
@@ -985,7 +986,7 @@ export function createTuiChat(
|
||||
const systemPrompt = displayText(renderPrompt(assembly)) || '(empty)'
|
||||
const registeredTools = assembly.tools.map(tool => displayText(tool.name)).join(', ') || '(none)'
|
||||
const events = agent.session.events
|
||||
const latestActivity = events.at(-1)?.time ?? agent.session.header.createdAt
|
||||
const latestActivity = lastActivityTime(events) ?? agent.session.header.createdAt
|
||||
const usedContext = Math.max(0, Math.round(ctx.tokenMeter.measure(agent.session).totalTokens))
|
||||
let context = `${formatDiagnosticNumber(usedContext)} used · capacity unknown`
|
||||
const contextWindow = modelController.contextWindow()
|
||||
|
||||
@@ -837,6 +837,9 @@ describe('TUI terminal-state snapshots', () => {
|
||||
{ type: 'step/end', seq: 5, time: Date.parse(`${day}T00:00:06Z`), data: { turn: 1, step: 1 } },
|
||||
{ type: 'turn/end', seq: 6, time: Date.parse(`${day}T00:00:07Z`), data: { turn: 1, reason: { kind: 'completed' } } },
|
||||
{ type: 'session/title', seq: 7, time: Date.parse(`${day}T00:00:08Z`), data: { title, messageSeqs: [1], source: { kind: 'fallback' } } },
|
||||
// A prior pickup, dated well after the work: the picker must still
|
||||
// show the work's date, not the pickup's.
|
||||
{ type: 'session/inherited', seq: 8, time: Date.parse('2026-07-23T07:59:00.000Z'), data: {} },
|
||||
],
|
||||
})
|
||||
const harness = await setupSnapshot({
|
||||
|
||||
Reference in New Issue
Block a user