Merge PR #941 dependency into manual compaction stack

# Conflicts:
#	docs/cordis-catalog/services.md
#	docs/core-data-structures/session.i18n.yaml
#	packages/host/apiproxy/README.i18n.yaml
#	packages/host/apiproxy/src/api-proxy.ts
#	packages/ui/tui/src/index.ts
This commit is contained in:
Hypatia May
2026-07-30 16:42:24 +08:00
50 changed files with 889 additions and 147 deletions

View File

@@ -7,6 +7,8 @@ import type { SessionEvent, TurnEndReason } from '@deepseek-ai/dsh-session'
declare module '@deepseek-ai/dsh-session' {
interface SessionEventMap {
'test/log-only': { value: string }
/** Stands in for a plugin's open/close bracket (`compact/start`). */
'test/bracket-open': { id: string }
}
}
@@ -50,6 +52,14 @@ function lastSeq(session: Session): number {
return event.seq
}
/** A seeded child's constructor seed: its log minus the end-seed marker. */
function inherited(session: Session): readonly SessionEvent[] {
const events = session.events
const last = events.at(-1)
if (last?.type !== 'session/end-seed') throw new Error('seeded child is missing its end-seed marker')
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 +83,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 +107,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 +124,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,11 +151,32 @@ 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)
}
})
it('marks a bracket the child inherited from a still-running parent', async () => {
// The constructor placement's central claim, unreachable from the
// persistence load path.
const { ctx, sessions } = await setup()
const parent = ctx.sessions.create(SessionId('bracket-parent'), { meta: { cwd: '/workspace' } })
appendClosedTurn(parent, 1, 'work')
const open = parent.append('test/bracket-open', { id: 'op-1' })
const child = sessions.fork(parent, undefined, SessionId('bracket-child'))
// Parent: no end-seed event follows the bracket, so its owner treats it as live.
expect(parent.events.at(-1)).toBe(open)
expect(parent.events.some(event => event.type === 'session/end-seed')).toBe(false)
// Child: the same bracket is before end-seed, so it belongs to the seed.
const boundary = child.events.at(-1)
expect(boundary).toMatchObject({ type: 'session/end-seed' })
expect(boundary!.seq).toBeGreaterThan(open.seq)
expect(child.firstLiveSeq).toBe(open.seq + 1)
expect(inherited(child).at(-1)).toMatchObject({ type: 'test/bracket-open', data: { id: 'op-1' } })
})
it('rejects invalid boundaries before creating a child', async () => {
const { ctx, sessions } = await setup()
const empty = ctx.sessions.create(SessionId('empty'))

View File

@@ -382,6 +382,24 @@ describe('session-log invariants', () => {
.toThrow(/turn 1 is still open/)
})
it('accepts end-seed whether or not a turn is open', async () => {
const { ctx } = await setup()
// Balanced seed: between turns.
expect(() => ctx.sessions.create(SessionId('inherited-between-turns'), { seed: [
{ type: 'turn/start', seq: 0, time: 1, data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } } },
{ type: 'turn/end', seq: 1, time: 2, data: { turn: 1, reason: { kind: 'completed' } } },
] })).not.toThrow()
// Unbalanced seed: inside the open turn, which the relation permits.
const open = ctx.sessions.create(SessionId('inherited-inside-open-turn'), { seed: [
{ type: 'turn/start', seq: 0, time: 1, data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } } },
] })
expect(open.events.map(event => event.type)).toEqual(['turn/start', 'session/end-seed'])
// Still open afterwards: the boundary moves no cursor.
expect(() => open.append('turn/start', { turn: 2, trigger: { kind: 'message', source: { kind: 'user' } } }))
.toThrow(/turn 1 is still open/)
expect(() => open.append('turn/end', { turn: 1, reason: { kind: 'completed' } })).not.toThrow()
})
it('removes all listeners when the companion is disposed', async () => {
const { ctx, fiber } = await setup()
const session = ctx.sessions.create()

View File

@@ -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 a log that already ends in end-seed adds no further marker', () => {
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)
}))
})

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, lastActivityTime, TOOL_NOT_STARTED, TOOL_OUTCOME_UNKNOWN } from '../src/index.ts'
import type { SessionEvent, SurfaceEvent } from '../src/index.ts'
/**
@@ -273,3 +273,44 @@ describe('interruptedTurnClosers', () => {
expect(closers.map(e => e.type)).toEqual(['step/end', 'turn/end'])
})
})
describe('lastActivityTime', () => {
const endSeedAt = (seq: number, time: number): SessionEvent =>
({ type: 'session/end-seed', 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' } } },
endSeedAt(2, 9_000),
]
// Resumed long after the work, but never worked in again.
expect(lastActivityTime(events)).toBe(500)
})
it('reports work appended after end-seed', () => {
const events: SessionEvent[] = [
userTurnStart(1, 0),
endSeedAt(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([endSeedAt(0, 1), endSeedAt(1, 2)])).toBeUndefined()
})
})

View File

@@ -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 end-seed event the constructor appends.
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)
})
})