Merge remote-tracking branch 'origin/master' into mergebot/pr1667
This commit is contained in:
@@ -20,7 +20,7 @@ import type {
|
||||
SessionStartSource,
|
||||
} from '@deepseek-ai/dsh-agent'
|
||||
import { errorChain } from '@deepseek-ai/dsh-llm'
|
||||
import { SessionId } from '@deepseek-ai/dsh-session'
|
||||
import { SessionId, SessionPreparation } from '@deepseek-ai/dsh-session'
|
||||
import type { Session, SessionHeader } from '@deepseek-ai/dsh-session'
|
||||
import type {} from '@deepseek-ai/dsh-system-prompt'
|
||||
import type {} from '@deepseek-ai/dsh-tools'
|
||||
@@ -104,6 +104,30 @@ async function raceAbort<T>(operation: PromiseLike<T> | T, signal: AbortSignal,
|
||||
}
|
||||
}
|
||||
|
||||
/** Start an abortable operation and release a value that arrives after cancellation. */
|
||||
async function raceAbortCall<T>(
|
||||
operation: () => PromiseLike<T> | T,
|
||||
signal: AbortSignal,
|
||||
id: SessionId,
|
||||
releaseAbandoned?: (value: T) => void,
|
||||
): Promise<T> {
|
||||
if (signal.aborted) {
|
||||
throw signal.reason instanceof Error
|
||||
? signal.reason
|
||||
: new Error(`agent "${id}" creation aborted`, { cause: signal.reason })
|
||||
}
|
||||
const pending = Promise.resolve().then(operation)
|
||||
try {
|
||||
return await raceAbort(pending, signal, id)
|
||||
} catch (error: unknown) {
|
||||
// oxlint-disable-next-line typescript/no-unnecessary-condition -- the signal can abort while the operation is awaited.
|
||||
if (signal.aborted && releaseAbandoned !== undefined) {
|
||||
void pending.then(releaseAbandoned, () => undefined)
|
||||
}
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
/** Resolve the deployment-wide scheduler cap at the owning config boundary. */
|
||||
function resolveMaxParallelToolCalls(value: number | undefined): number {
|
||||
const maxParallelToolCalls = value ?? DEFAULT_MAX_PARALLEL_TOOL_CALLS
|
||||
@@ -524,8 +548,8 @@ export class AgentLoop extends Service implements AgentFactory {
|
||||
* @returns the published running agent.
|
||||
*/
|
||||
create(id: SessionId, options: AgentOptions = {}, meta: Pick<SessionHeader, 'cwd'> = {}): Agent {
|
||||
const session = this.runtime.ctx.sessions.prepare(id, { meta })
|
||||
const prepared = this.prepare(this.ctx, id, options, session)
|
||||
using preparation = SessionPreparation.create(this.runtime.ctx.sessions.prepare(id, { meta }))
|
||||
const prepared = this.prepare(this.ctx, id, options, preparation.session)
|
||||
try {
|
||||
return prepared.publish('startup').agent
|
||||
} catch (error: unknown) {
|
||||
@@ -541,14 +565,14 @@ export class AgentLoop extends Service implements AgentFactory {
|
||||
* @returns the published handle.
|
||||
*/
|
||||
async createAgent(ownerCtx: Context, options: CreateAgentOptions): Promise<AgentHandle> {
|
||||
const session = this.runtime.ctx.sessions.prepare(options.sessionId, {
|
||||
const preparation = SessionPreparation.create(this.runtime.ctx.sessions.prepare(options.sessionId, {
|
||||
...options.seed === undefined ? {} : { seed: options.seed },
|
||||
...options.meta === undefined ? {} : { meta: options.meta },
|
||||
})
|
||||
}))
|
||||
const published = this.setupAndPublish(
|
||||
ownerCtx,
|
||||
options.sessionId,
|
||||
session,
|
||||
preparation,
|
||||
options.agentOptions ?? {},
|
||||
options.setup,
|
||||
options.signal,
|
||||
@@ -562,12 +586,14 @@ export class AgentLoop extends Service implements AgentFactory {
|
||||
private async setupAndPublish(
|
||||
ownerCtx: Context,
|
||||
id: SessionId,
|
||||
session: Session,
|
||||
preparation: SessionPreparation,
|
||||
agentOptions: AgentOptions,
|
||||
setup: AgentSetup | undefined,
|
||||
signal: AbortSignal | undefined,
|
||||
source: SessionStartSource,
|
||||
): Promise<AgentHandle> {
|
||||
using ownedPreparation = preparation
|
||||
const session = ownedPreparation.session
|
||||
const prepared = this.prepare(ownerCtx, id, agentOptions, session, signal)
|
||||
try {
|
||||
const setupCommit = await raceAbort(setup?.(prepared.agent.ctx), prepared.signal, id)
|
||||
@@ -613,26 +639,31 @@ export class AgentLoop extends Service implements AgentFactory {
|
||||
ownerAbort.signal,
|
||||
this.ownership.signal,
|
||||
])
|
||||
let loaded: Awaited<ReturnType<SessionPersistence['load']>>
|
||||
let preparation: SessionPreparation | undefined
|
||||
try {
|
||||
loaded = await raceAbort(persistence.load(id), fused, id)
|
||||
try {
|
||||
preparation = await raceAbortCall(
|
||||
() => persistence.prepare(id, fused),
|
||||
fused,
|
||||
id,
|
||||
(abandoned) => { abandoned[Symbol.dispose]() },
|
||||
)
|
||||
} finally {
|
||||
await unfollowOwner()
|
||||
}
|
||||
ownerCtx.fiber.assertActive()
|
||||
if (!this.ownership.isActive()) throw new Error('agent loop is not active')
|
||||
return await this.setupAndPublish(
|
||||
ownerCtx,
|
||||
id,
|
||||
preparation,
|
||||
options.agentOptions ?? {},
|
||||
options.setup,
|
||||
options.signal,
|
||||
'resume',
|
||||
)
|
||||
} finally {
|
||||
await unfollowOwner()
|
||||
}
|
||||
ownerCtx.fiber.assertActive()
|
||||
if (!this.ownership.isActive()) throw new Error('agent loop is not active')
|
||||
const session = this.runtime.ctx.sessions.prepare(id, {
|
||||
seed: loaded.events,
|
||||
meta: loaded.meta,
|
||||
})
|
||||
const prepared = this.prepare(ownerCtx, id, options.agentOptions ?? {}, session, options.signal)
|
||||
try {
|
||||
const setupCommit = await raceAbort(options.setup?.(prepared.agent.ctx), prepared.signal, id)
|
||||
setupCommit?.commit()
|
||||
return prepared.publish('resume')
|
||||
} catch (error: unknown) {
|
||||
await prepared.dispose()
|
||||
throw error
|
||||
preparation?.[Symbol.dispose]()
|
||||
}
|
||||
})()
|
||||
this.ownership.trackWrapper(published)
|
||||
|
||||
@@ -5,7 +5,7 @@ import { mkdtemp, rm } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import LlmService from '@deepseek-ai/dsh-llm'
|
||||
import SessionStore, { SessionId } from '@deepseek-ai/dsh-session'
|
||||
import SessionStore, { SessionId, SessionPreparation } from '@deepseek-ai/dsh-session'
|
||||
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
|
||||
import ToolRegistry from '@deepseek-ai/dsh-tools'
|
||||
import AgentRegistry, { type Agent } from '@deepseek-ai/dsh-agent'
|
||||
@@ -296,14 +296,15 @@ describe('config-driven session id', () => {
|
||||
})
|
||||
|
||||
it.each(['resolve', 'reject'] as const)(
|
||||
'abandons an exact-id persistence lookup that later %s when AgentLoop disposal starts',
|
||||
'abandons an exact-id preparation that later %s when AgentLoop disposal starts',
|
||||
async (outcome) => {
|
||||
const root = await mkdtemp(join(tmpdir(), 'dsh-cfg-exact-dispose-'))
|
||||
dirs.push(root)
|
||||
const ctx = await makeCoreContext()
|
||||
await ctx.plugin(SessionPersistenceJsonl, { root })
|
||||
const loading = Promise.withResolvers<Awaited<ReturnType<typeof ctx.sessionPersistence.load>>>()
|
||||
vi.spyOn(ctx.sessionPersistence, 'load').mockReturnValue(loading.promise)
|
||||
const preparing = Promise.withResolvers<SessionPreparation>()
|
||||
vi.spyOn(ctx.sessionPersistence, 'prepare').mockReturnValue(preparing.promise)
|
||||
const released = vi.fn()
|
||||
const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => undefined)
|
||||
const failures: unknown[] = []
|
||||
ctx.on('agent-loop/config-start-failed', (_sessionId, error) => { failures.push(error) })
|
||||
@@ -313,18 +314,15 @@ describe('config-driven session id', () => {
|
||||
})
|
||||
await loop.dispose()
|
||||
if (outcome === 'resolve') {
|
||||
loading.resolve({
|
||||
meta: {
|
||||
id: SessionId('config-exact-dispose'),
|
||||
version: 0,
|
||||
createdAt: Date.now(),
|
||||
},
|
||||
events: [],
|
||||
})
|
||||
preparing.resolve(SessionPreparation.create(
|
||||
ctx.sessions.prepare(SessionId('config-exact-dispose')),
|
||||
{ release: released },
|
||||
))
|
||||
} else {
|
||||
loading.reject(new Error('startup cancelled by teardown'))
|
||||
preparing.reject(new Error('startup cancelled by teardown'))
|
||||
}
|
||||
await Promise.resolve()
|
||||
if (outcome === 'resolve') await expect.poll(() => released).toHaveBeenCalledOnce()
|
||||
expect(ctx.agents.get(SessionId('config-exact-dispose'))).toBeUndefined()
|
||||
expect(failures).toEqual([])
|
||||
expect(warn).not.toHaveBeenCalled()
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import { createUserMessage } from '@deepseek-ai/dsh-llm'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import { mkdtemp, rm } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import LlmService from '@deepseek-ai/dsh-llm'
|
||||
import SessionStore, { SESSION_FORMAT_VERSION, Session, SessionId } from '@deepseek-ai/dsh-session'
|
||||
import type { SessionEvent } from '@deepseek-ai/dsh-session'
|
||||
import SessionStore, { SESSION_FORMAT_VERSION, Session, SessionId, SessionPreparation } from '@deepseek-ai/dsh-session'
|
||||
import type { SessionEvent, SessionHeader } from '@deepseek-ai/dsh-session'
|
||||
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
|
||||
import ToolRegistry from '@deepseek-ai/dsh-tools'
|
||||
import AgentRegistry, { type Agent } from '@deepseek-ai/dsh-agent'
|
||||
@@ -52,6 +52,18 @@ async function persistSession(sessionId: SessionId): Promise<string> {
|
||||
return root
|
||||
}
|
||||
|
||||
/** Build a detached preparation for lifecycle-race test doubles. */
|
||||
function preparationFromSnapshot(
|
||||
ctx: Context,
|
||||
snapshot: { meta: SessionHeader; events: readonly SessionEvent[] },
|
||||
): SessionPreparation {
|
||||
return SessionPreparation.create(ctx.sessions.prepare(snapshot.meta.id, {
|
||||
seed: structuredClone(snapshot.events) as SessionEvent[],
|
||||
meta: structuredClone(snapshot.meta),
|
||||
seedSource: 'persistence',
|
||||
}))
|
||||
}
|
||||
|
||||
function waitForIdle(ctx: Context, agent: Agent): Promise<void> {
|
||||
return new Promise((resolve) => {
|
||||
const dispose = ctx.on('agent/status', (subject, status) => {
|
||||
@@ -196,7 +208,7 @@ describe('the session-persistence Agent Note: AgentLoop factory create/resume',
|
||||
await ctx.sessions.flush(first.session)
|
||||
|
||||
await expect(ctx.agents.resume({ resumeSessionId: sessionId }))
|
||||
.rejects.toThrow(/live turn is open/)
|
||||
.rejects.toThrow(/while it is live/)
|
||||
|
||||
first.session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
|
||||
await ctx.sessions.flush(first.session)
|
||||
@@ -446,22 +458,24 @@ describe('the session-persistence Agent Note: AgentLoop factory create/resume',
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('owner unload aborts a never-settling persistence load, releases the identity, and blocks late publication', async () => {
|
||||
it('owner unload aborts a never-settling persistence preparation, releases the identity, and blocks late publication', async () => {
|
||||
const sessionId = SessionId('resume-load-owner-unload')
|
||||
const root = await persistSession(sessionId)
|
||||
const ctx = await mountPersistentHarness(root, new MockAdapter([textResponse('next')]))
|
||||
const snapshot = await ctx.sessionPersistence.load(sessionId)
|
||||
const lateLoad = Promise.withResolvers<typeof snapshot>()
|
||||
const loadStarted = Promise.withResolvers<undefined>()
|
||||
let loads = 0
|
||||
ctx.sessionPersistence.load = (id) => {
|
||||
const abandoned = preparationFromSnapshot(ctx, snapshot)
|
||||
const latePreparation = Promise.withResolvers<SessionPreparation>()
|
||||
const preparationStarted = Promise.withResolvers<undefined>()
|
||||
const originalPrepare = ctx.sessionPersistence.prepare.bind(ctx.sessionPersistence)
|
||||
let preparations = 0
|
||||
ctx.sessionPersistence.prepare = (id, signal) => {
|
||||
expect(id).toBe(sessionId)
|
||||
loads += 1
|
||||
if (loads === 1) {
|
||||
loadStarted.resolve(undefined)
|
||||
return lateLoad.promise
|
||||
preparations += 1
|
||||
if (preparations === 1) {
|
||||
preparationStarted.resolve(undefined)
|
||||
return latePreparation.promise
|
||||
}
|
||||
return Promise.resolve(structuredClone(snapshot))
|
||||
return originalPrepare(id, signal)
|
||||
}
|
||||
|
||||
const published: string[] = []
|
||||
@@ -473,7 +487,7 @@ describe('the session-persistence Agent Note: AgentLoop factory create/resume',
|
||||
const owner = await ctx.plugin(Object.assign((inner: Context) => {
|
||||
resuming = inner.agents.resume({ resumeSessionId: sessionId, agentOptions: { provider: 'mock', model: 'mock' } })
|
||||
}, { inject: ['agents'] }))
|
||||
await loadStarted.promise
|
||||
await preparationStarted.promise
|
||||
|
||||
const rejection = expect(promptly(resuming)).rejects.toThrow(/owner disposed during setup/)
|
||||
await promptly(owner.dispose())
|
||||
@@ -485,23 +499,24 @@ describe('the session-persistence Agent Note: AgentLoop factory create/resume',
|
||||
// can be reused before awaiting the public rejection.
|
||||
const retry = await promptly(ctx.agents.resume({ resumeSessionId: sessionId, agentOptions: { provider: 'mock', model: 'mock' } }))
|
||||
await rejection
|
||||
expect(loads).toBe(2)
|
||||
expect(preparations).toBe(2)
|
||||
expect(published).toEqual(['session/created', 'agent/created', 'agent/session-start'])
|
||||
|
||||
// Settlement of the abandoned backend promise cannot resume the old
|
||||
// transaction or emit a second publication after the retry owns the ids.
|
||||
lateLoad.resolve(structuredClone(snapshot))
|
||||
latePreparation.resolve(abandoned)
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
expect(ctx.agents.get(sessionId)).toBe(retry.agent)
|
||||
expect(ctx.sessions.get(sessionId)).toBe(retry.agent.session)
|
||||
expect(published).toEqual(['session/created', 'agent/created', 'agent/session-start'])
|
||||
|
||||
abandoned[Symbol.dispose]()
|
||||
await retry.dispose()
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('AgentLoop unload aborts persistence load and awaits wrapper settlement', async () => {
|
||||
it('AgentLoop unload aborts persistence preparation and awaits wrapper settlement', async () => {
|
||||
const sessionId = SessionId('resume-load-factory-unload')
|
||||
const root = await persistSession(sessionId)
|
||||
const ctx = new Context()
|
||||
@@ -515,19 +530,20 @@ describe('the session-persistence Agent Note: AgentLoop factory create/resume',
|
||||
ctx.llm.registerAdapter(['mock'], new MockAdapter([textResponse('next')]))
|
||||
|
||||
const snapshot = await ctx.sessionPersistence.load(sessionId)
|
||||
const lateLoad = Promise.withResolvers<typeof snapshot>()
|
||||
const loadStarted = Promise.withResolvers<undefined>()
|
||||
ctx.sessionPersistence.load = (id) => {
|
||||
const abandoned = preparationFromSnapshot(ctx, snapshot)
|
||||
const latePreparation = Promise.withResolvers<SessionPreparation>()
|
||||
const preparationStarted = Promise.withResolvers<undefined>()
|
||||
ctx.sessionPersistence.prepare = (id) => {
|
||||
expect(id).toBe(sessionId)
|
||||
loadStarted.resolve(undefined)
|
||||
return lateLoad.promise
|
||||
preparationStarted.resolve(undefined)
|
||||
return latePreparation.promise
|
||||
}
|
||||
const published: string[] = []
|
||||
ctx.on('session/created', () => void published.push('session/created'))
|
||||
ctx.on('agent/created', () => void published.push('agent/created'))
|
||||
|
||||
const resuming = ctx.agents.resume({ resumeSessionId: sessionId, agentOptions: { provider: 'mock', model: 'mock' } })
|
||||
await loadStarted.promise
|
||||
await preparationStarted.promise
|
||||
const rejection = expect(promptly(resuming)).rejects.toThrow(/agent loop is not active/)
|
||||
await promptly(loopFiber.dispose())
|
||||
await rejection
|
||||
@@ -535,10 +551,11 @@ describe('the session-persistence Agent Note: AgentLoop factory create/resume',
|
||||
expect(published).toEqual([])
|
||||
expect(ctx.agents.get(sessionId)).toBeUndefined()
|
||||
expect(ctx.sessions.get(sessionId)).toBeUndefined()
|
||||
lateLoad.resolve(structuredClone(snapshot))
|
||||
latePreparation.resolve(abandoned)
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
expect(published).toEqual([])
|
||||
abandoned[Symbol.dispose]()
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
@@ -730,6 +747,24 @@ describe('creation and resume cancellation edges', () => {
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('rejects when setup synchronously aborts its caller signal', async () => {
|
||||
const { ctx } = await persistentHarness(new MockAdapter([]))
|
||||
const controller = new AbortController()
|
||||
|
||||
const creating = ctx.agents.create({
|
||||
sessionId: SessionId('setup-synchronous-abort'),
|
||||
agentOptions: { provider: 'mock', model: 'mock' },
|
||||
signal: controller.signal,
|
||||
setup() {
|
||||
controller.abort(new Error('setup synchronously cancelled'))
|
||||
},
|
||||
})
|
||||
|
||||
await expect(promptly(creating)).rejects.toThrow('setup synchronously cancelled')
|
||||
expect(ctx.agents.get(SessionId('setup-synchronous-abort'))).toBeUndefined()
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('resume with a pre-aborted caller signal rejects out of the load race', async () => {
|
||||
const sessionId = SessionId('resume-pre-aborted')
|
||||
const root = await persistSession(sessionId)
|
||||
@@ -743,19 +778,45 @@ describe('creation and resume cancellation edges', () => {
|
||||
signal: controller.signal,
|
||||
}))).rejects.toThrow('resume abandoned')
|
||||
|
||||
const stringReason = new AbortController()
|
||||
stringReason.abort('resume string reason')
|
||||
await expect(promptly(ctx.agents.resume({
|
||||
resumeSessionId: sessionId,
|
||||
agentOptions: { provider: 'mock', model: 'mock' },
|
||||
signal: stringReason.signal,
|
||||
}))).rejects.toThrow(/creation aborted/)
|
||||
|
||||
expect(ctx.agents.get(sessionId)).toBeUndefined()
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('factory teardown during a hung resume load rejects with loop-inactive', async () => {
|
||||
it('releases a restored preparation if the loop becomes inactive before setup', async () => {
|
||||
const sessionId = SessionId('resume-loop-inactive-after-prepare')
|
||||
const root = await persistSession(sessionId)
|
||||
const ctx = await mountPersistentHarness(root, new MockAdapter([]))
|
||||
const loop = ctx.agentLoop as unknown as {
|
||||
ownership: { isActive: () => boolean }
|
||||
}
|
||||
vi.spyOn(loop.ownership, 'isActive').mockReturnValueOnce(false)
|
||||
|
||||
await expect(ctx.agents.resume({
|
||||
resumeSessionId: sessionId,
|
||||
agentOptions: { provider: 'mock', model: 'mock' },
|
||||
})).rejects.toThrow('agent loop is not active')
|
||||
expect(ctx.agents.get(sessionId)).toBeUndefined()
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('factory teardown during a hung resume preparation rejects with loop-inactive', async () => {
|
||||
const sessionId = SessionId('resume-loop-teardown')
|
||||
const root = await persistSession(sessionId)
|
||||
const ctx = await mountPersistentHarness(root, new MockAdapter([]))
|
||||
const snapshot = await ctx.sessionPersistence.load(sessionId)
|
||||
const gate = Promise.withResolvers<typeof snapshot>()
|
||||
const loadStarted = Promise.withResolvers<undefined>()
|
||||
ctx.sessionPersistence.load = () => {
|
||||
loadStarted.resolve(undefined)
|
||||
const abandoned = preparationFromSnapshot(ctx, snapshot)
|
||||
const gate = Promise.withResolvers<SessionPreparation>()
|
||||
const preparationStarted = Promise.withResolvers<undefined>()
|
||||
ctx.sessionPersistence.prepare = () => {
|
||||
preparationStarted.resolve(undefined)
|
||||
return gate.promise
|
||||
}
|
||||
|
||||
@@ -763,27 +824,28 @@ describe('creation and resume cancellation edges', () => {
|
||||
resumeSessionId: sessionId,
|
||||
agentOptions: { provider: 'mock', model: 'mock' },
|
||||
})
|
||||
await loadStarted.promise
|
||||
// Resolve the load only after teardown began: the post-load ownership
|
||||
await preparationStarted.promise
|
||||
// Resolve the preparation only after teardown began: the post-prepare ownership
|
||||
// check, not the abort race, must reject the wrapper.
|
||||
const rejection = expect(promptly(resuming)).rejects.toThrow()
|
||||
const disposal = ctx.fiber.dispose()
|
||||
gate.resolve(structuredClone(snapshot))
|
||||
gate.resolve(abandoned)
|
||||
await rejection
|
||||
await disposal
|
||||
abandoned[Symbol.dispose]()
|
||||
})
|
||||
})
|
||||
|
||||
describe('configured-start failure edges', () => {
|
||||
it('a non-Error mid-load abort reason is wrapped for the resume caller', async () => {
|
||||
it('a non-Error mid-prepare abort reason is wrapped for the resume caller', async () => {
|
||||
const sessionId = SessionId('resume-string-mid-abort')
|
||||
const root = await persistSession(sessionId)
|
||||
const ctx = await mountPersistentHarness(root, new MockAdapter([]))
|
||||
const gate = Promise.withResolvers<never>()
|
||||
gate.promise.catch(() => undefined)
|
||||
const loadStarted = Promise.withResolvers<undefined>()
|
||||
ctx.sessionPersistence.load = () => {
|
||||
loadStarted.resolve(undefined)
|
||||
const preparationStarted = Promise.withResolvers<undefined>()
|
||||
ctx.sessionPersistence.prepare = () => {
|
||||
preparationStarted.resolve(undefined)
|
||||
return gate.promise
|
||||
}
|
||||
const controller = new AbortController()
|
||||
@@ -793,7 +855,7 @@ describe('configured-start failure edges', () => {
|
||||
agentOptions: { provider: 'mock', model: 'mock' },
|
||||
signal: controller.signal,
|
||||
})
|
||||
await loadStarted.promise
|
||||
await preparationStarted.promise
|
||||
controller.abort('operator string reason')
|
||||
|
||||
await expect(promptly(resuming)).rejects.toThrow(/creation aborted/)
|
||||
@@ -808,7 +870,7 @@ describe('configured-start failure edges', () => {
|
||||
// The artifact exists (list reports it) but its load fails: this is
|
||||
// corruption, not first creation — the failure must be reported, and no
|
||||
// fresh same-id session may shadow the broken one.
|
||||
ctx.sessionPersistence.load = () => Promise.reject(new Error('artifact corrupt'))
|
||||
ctx.sessionPersistence.prepare = () => Promise.reject(new Error('artifact corrupt'))
|
||||
|
||||
const configured = new Context()
|
||||
await configured.plugin(LlmService)
|
||||
@@ -818,7 +880,7 @@ describe('configured-start failure edges', () => {
|
||||
await configured.plugin(AgentRegistry)
|
||||
await configured.plugin(SessionPersistenceJsonl, { root })
|
||||
configured.llm.registerAdapter(['mock'], new MockAdapter([]))
|
||||
configured.sessionPersistence.load = id => ctx.sessionPersistence.load(id)
|
||||
configured.sessionPersistence.prepare = (id, signal) => ctx.sessionPersistence.prepare(id, signal)
|
||||
const configFailures: unknown[] = []
|
||||
configured.on('agent-loop/config-start-failed', (_id, error) => { configFailures.push(error) })
|
||||
const configWarnings: string[] = []
|
||||
@@ -847,9 +909,9 @@ describe('configured-start failure edges', () => {
|
||||
const ctx = await mountPersistentHarness(root, new MockAdapter([]))
|
||||
const gate = Promise.withResolvers<never>()
|
||||
gate.promise.catch(() => undefined)
|
||||
const loadStarted = Promise.withResolvers<undefined>()
|
||||
ctx.sessionPersistence.load = () => {
|
||||
loadStarted.resolve(undefined)
|
||||
const preparationStarted = Promise.withResolvers<undefined>()
|
||||
ctx.sessionPersistence.prepare = () => {
|
||||
preparationStarted.resolve(undefined)
|
||||
return gate.promise
|
||||
}
|
||||
const failures: unknown[] = []
|
||||
@@ -863,12 +925,12 @@ describe('configured-start failure edges', () => {
|
||||
await configured.plugin(AgentRegistry)
|
||||
await configured.plugin(SessionPersistenceJsonl, { root })
|
||||
configured.llm.registerAdapter(['mock'], new MockAdapter([]))
|
||||
configured.sessionPersistence.load = id => ctx.sessionPersistence.load(id)
|
||||
configured.sessionPersistence.prepare = (id, signal) => ctx.sessionPersistence.prepare(id, signal)
|
||||
configured.on('agent-loop/config-start-failed', (_id, error) => { failures.push(error) })
|
||||
const loop = await configured.plugin(AgentLoop, {
|
||||
agents: [{ id: 'main', resumeSessionId: sessionId, provider: 'mock', model: 'mock' }],
|
||||
})
|
||||
await loadStarted.promise
|
||||
await preparationStarted.promise
|
||||
const disposal = loop.dispose()
|
||||
gate.reject(new Error('late backend failure'))
|
||||
await disposal
|
||||
|
||||
@@ -187,8 +187,8 @@ export interface AgentFactory {
|
||||
*/
|
||||
createAgent(ownerCtx: Context, options: CreateAgentOptions): Promise<AgentHandle>
|
||||
/**
|
||||
* Load a persisted session and resume an agent on it. Async because it awaits
|
||||
* both `ctx.sessionPersistence.load` and the optional unpublished setup
|
||||
* Prepare a persisted session and resume an agent on it. Async because it awaits
|
||||
* both `ctx.sessionPersistence.prepare` and the optional unpublished setup
|
||||
* transaction; must be called after that service exists (consumers inject
|
||||
* `sessionPersistence`). Publication follows the same setup-commit and
|
||||
* ordered boundary as {@link createAgent}.
|
||||
|
||||
@@ -13,13 +13,15 @@ import { scopeOf, scopeTarget } from '@deepseek-ai/dsh-scope'
|
||||
import type { Scoped } from '@deepseek-ai/dsh-scope'
|
||||
import type { Message } from '@deepseek-ai/dsh-llm'
|
||||
import { SESSION_FORMAT_VERSION, SessionId } from './types.ts'
|
||||
import type { CreateSessionOptions, EpochHeader, RequestContext, SessionEvent, SessionEventMap, SessionEventType, SessionHeader, SurfaceIntent, SurfaceEventType } from './types.ts'
|
||||
import type { CreateSessionOptions, EpochHeader, PrepareSessionOptions, RequestContext, SessionEvent, SessionEventMap, SessionEventType, SessionHeader, SurfaceIntent, SurfaceEventType } from './types.ts'
|
||||
import { snapshotJsonValue } from './json.ts'
|
||||
import { deriveEventMessage, SurfaceManager } from './surface.ts'
|
||||
import type { SessionSurface } from './surface.ts'
|
||||
import { foldRequestHeader } from './request-header.ts'
|
||||
|
||||
export * from './types.ts'
|
||||
export { SessionPreparation } from './preparation.ts'
|
||||
export type { SessionPreparationOptions } from './preparation.ts'
|
||||
export type { AssistantMessage, ToolResultMessage, UserMessage } from '@deepseek-ai/dsh-llm'
|
||||
export { isJsonValue, snapshotJsonValue } from './json.ts'
|
||||
export type { JsonValue } from './json.ts'
|
||||
@@ -143,6 +145,17 @@ function validateSessionHeader(id: SessionId, input: unknown): SessionHeader {
|
||||
return deepFreeze(record as unknown as SessionHeader)
|
||||
}
|
||||
|
||||
/** Validate and freeze one exclusively owned persistence header in place. */
|
||||
function validateRestoredSessionHeader(id: SessionId, input: unknown): SessionHeader {
|
||||
if (input !== null && typeof input === 'object' && !Array.isArray(input)) {
|
||||
const prototype = Reflect.getPrototypeOf(input)
|
||||
if (prototype !== Object.prototype && prototype !== null) {
|
||||
throw new Error('session header is not a plain JSON record')
|
||||
}
|
||||
}
|
||||
return validateSessionHeader(id, input)
|
||||
}
|
||||
|
||||
/** Detach, validate, and freeze the creation metadata published by a session. */
|
||||
function snapshotSessionHeader(id: SessionId, source?: SessionHeader): SessionHeader {
|
||||
const input: unknown = source === undefined
|
||||
@@ -190,23 +203,58 @@ export function snapshotSessionEvent<T extends SessionEvent>(event: T): T {
|
||||
return adoptSessionEvent(structuredClone(event))
|
||||
}
|
||||
|
||||
/** Deep-freeze one acyclic JSON tree without consuming the JavaScript call stack. */
|
||||
function freezeRestoredObject<T extends object>(value: T): T {
|
||||
const pending: object[] = [value]
|
||||
while (pending.length > 0) {
|
||||
// The non-empty check proves an object remains to visit.
|
||||
// oxlint-disable-next-line typescript/no-non-null-assertion
|
||||
const current = pending.pop()!
|
||||
Object.freeze(current)
|
||||
for (const key in current) {
|
||||
const child = (current as Record<string, unknown>)[key]
|
||||
if (child !== null && typeof child === 'object') pending.push(child)
|
||||
}
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
/** Validate the fixed event envelope after one-pass JSON materialization. */
|
||||
function assertSessionEventEnvelope(value: Record<string, unknown>, index: number): asserts value is SessionEvent {
|
||||
const event = value
|
||||
if (event['type'] === 'request/header-delta') {
|
||||
throw new Error(`seed event at index ${index} uses unsupported legacy request/header-delta format`)
|
||||
}
|
||||
const allowed = new Set(['type', 'seq', 'time', 'data', 'surfaceOp', 'sourceEventSeqs'])
|
||||
if (Object.keys(event).some(key => !allowed.has(key))
|
||||
|| !Object.hasOwn(event, 'type') || typeof event['type'] !== 'string'
|
||||
|| !Object.hasOwn(event, 'seq') || typeof event['seq'] !== 'number'
|
||||
|| !Number.isSafeInteger(event['seq']) || event['seq'] < 0
|
||||
|| !Object.hasOwn(event, 'time') || typeof event['time'] !== 'number'
|
||||
|| !Number.isSafeInteger(event['time']) || event['time'] < 0
|
||||
|| !Object.hasOwn(event, 'data')) {
|
||||
for (const key in event) {
|
||||
switch (key) {
|
||||
case 'type':
|
||||
case 'seq':
|
||||
case 'time':
|
||||
case 'data':
|
||||
case 'surfaceOp':
|
||||
case 'sourceEventSeqs':
|
||||
break
|
||||
default:
|
||||
throw new Error(`seed event at index ${index} has an invalid event envelope`)
|
||||
}
|
||||
}
|
||||
const type = event['type']
|
||||
const seq = event['seq']
|
||||
const time = event['time']
|
||||
if (typeof type !== 'string'
|
||||
|| typeof seq !== 'number' || !Number.isSafeInteger(seq) || seq < 0
|
||||
|| typeof time !== 'number' || !Number.isSafeInteger(time)
|
||||
|| event['data'] === undefined) {
|
||||
throw new Error(`seed event at index ${index} has an invalid event envelope`)
|
||||
}
|
||||
assertCurrentLlmShape(event, index)
|
||||
switch (type) {
|
||||
case 'request/header':
|
||||
case 'user/message':
|
||||
case 'assistant/message':
|
||||
case 'tool/result':
|
||||
assertCurrentLlmShape(event, index)
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
/** Reject obsolete request headers and malformed messages at the seed/load boundary. */
|
||||
@@ -236,6 +284,8 @@ function assertCurrentLlmShape(event: Record<string, unknown>, index: number): v
|
||||
assertMessageEventShape(event, `seed ${type} at index ${index}`)
|
||||
}
|
||||
|
||||
const allowedAdapterKeys = new Set(['reasoningEffort', 'maxTokens'])
|
||||
|
||||
/** Validate adapter-default provenance imported from a durable request header. */
|
||||
function assertAdapterDefaults(
|
||||
value: unknown,
|
||||
@@ -247,8 +297,7 @@ function assertAdapterDefaults(
|
||||
throw new Error(`seed request/header at index ${index} has invalid adapterDefaults`)
|
||||
}
|
||||
const defaults = value as Record<string, unknown>
|
||||
const allowed = new Set(['reasoningEffort', 'maxTokens'])
|
||||
if (Object.keys(defaults).some(key => !allowed.has(key))
|
||||
if (Object.keys(defaults).some(key => !allowedAdapterKeys.has(key))
|
||||
|| Object.values(defaults).some(marker => marker !== true)
|
||||
|| defaults['reasoningEffort'] === true && config['reasoningEffort'] === undefined
|
||||
|| defaults['maxTokens'] === true && config['maxTokens'] === undefined) {
|
||||
@@ -442,7 +491,28 @@ export class Session {
|
||||
return new Session(id, seed, header)
|
||||
}
|
||||
|
||||
private constructor(id: SessionId, seed?: readonly SessionEvent[], header?: SessionHeader) {
|
||||
/**
|
||||
* Restore a detached session by taking ownership of fresh persistence values.
|
||||
* Storage shape, event envelopes, sequence continuity, surface transitions,
|
||||
* and header fields are validated before the graphs are frozen in place.
|
||||
* @param id - restored session identity.
|
||||
* @param seed - fresh detached events whose ownership is transferred.
|
||||
* @param header - fresh detached metadata whose ownership is transferred.
|
||||
* @returns a restored detached session.
|
||||
*/
|
||||
static fromRestore(id: SessionId, seed: readonly SessionEvent[], header: SessionHeader): Session {
|
||||
return new Session(id, seed, header, 'restore')
|
||||
}
|
||||
|
||||
private constructor(
|
||||
id: SessionId,
|
||||
seed?: readonly SessionEvent[],
|
||||
header?: SessionHeader,
|
||||
mode: 'snapshot' | 'restore' = 'snapshot',
|
||||
) {
|
||||
const restoredHeader = mode === 'restore'
|
||||
? validateRestoredSessionHeader(id, header)
|
||||
: undefined
|
||||
if (seed !== undefined) {
|
||||
// Validate the seed to the SAME invariants `append` enforces, so a
|
||||
// replay/fork (`ctx.sessions.create(id, { seed })`) cannot construct a
|
||||
@@ -454,7 +524,7 @@ export class Session {
|
||||
for (const [index, source] of seed.entries()) {
|
||||
// The seed is a persistence/replay boundary: validate and detach the
|
||||
// complete event in one lossless-JSON pass.
|
||||
const snapshot = snapshotJsonValue(source)
|
||||
const snapshot = mode === 'restore' ? source : snapshotJsonValue(source)
|
||||
if (snapshot === undefined) {
|
||||
throw new Error(`seed event at index ${index} is not losslessly JSON-serializable`)
|
||||
}
|
||||
@@ -471,11 +541,11 @@ export class Session {
|
||||
} catch (error: unknown) {
|
||||
throw new Error(`invalid seed event at index ${index}: ${error instanceof Error ? error.message : 'invalid surface metadata'}`)
|
||||
}
|
||||
this.log.push(deepFreeze(snapshot))
|
||||
this.log.push(mode === 'restore' ? freezeRestoredObject(snapshot) : deepFreeze(snapshot))
|
||||
}
|
||||
}
|
||||
this.firstLiveSeq = this.log.length
|
||||
this.header = snapshotSessionHeader(id, header)
|
||||
this.header = restoredHeader ?? 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
|
||||
@@ -779,13 +849,17 @@ export class SessionStore extends Service {
|
||||
* before the driver's closing events commit, dropping them.
|
||||
*
|
||||
* @param id - the session id; omitted, the store mints `session-<n>`.
|
||||
* @param options - seed events and/or creation metadata for the header.
|
||||
* @param options - seed events and/or creation metadata for the header. With
|
||||
* `seedSource: 'persistence'`, metadata and events must be fresh detached
|
||||
* graphs whose ownership transfers to this call: they are validated and
|
||||
* frozen in place through {@link Session.fromRestore}, so the caller must
|
||||
* retain no mutable aliases.
|
||||
* @returns the constructed session, NOT yet in the store.
|
||||
* @throws if a session with `id` already exists, metadata is not a plain
|
||||
* lossless-JSON record with valid scalar fields, or `meta.cwd` is a
|
||||
* non-absolute path.
|
||||
*/
|
||||
prepare(id?: SessionId, options?: CreateSessionOptions): Session {
|
||||
prepare(id?: SessionId, options?: PrepareSessionOptions): Session {
|
||||
let sessionId: SessionId
|
||||
if (id === undefined) {
|
||||
do sessionId = SessionId(`session-${++this.counter}`)
|
||||
@@ -794,6 +868,9 @@ export class SessionStore extends Service {
|
||||
sessionId = SessionId(id)
|
||||
}
|
||||
if (this.store.has(sessionId)) throw new Error(`session "${sessionId}" already exists`)
|
||||
if (options?.seedSource === 'persistence') {
|
||||
return Session.fromRestore(sessionId, options.seed, options.meta)
|
||||
}
|
||||
const seed = options?.seed
|
||||
const meta = options?.meta
|
||||
const header: SessionHeader = {
|
||||
|
||||
49
packages/core/session/src/preparation.ts
Normal file
49
packages/core/session/src/preparation.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
/**
|
||||
* Ownership of one unpublished Session before registry publication.
|
||||
* @module @deepseek-ai/dsh-session/preparation
|
||||
*/
|
||||
|
||||
import type { Session } from './index.ts'
|
||||
|
||||
/** Options for a preparation whose provider retains unpublished state. */
|
||||
export interface SessionPreparationOptions {
|
||||
/** Release provider-owned state when the Session was not published. */
|
||||
readonly release?: () => void
|
||||
}
|
||||
|
||||
/**
|
||||
* One exact unpublished Session and the provider state that keeps it usable.
|
||||
* Disposal is synchronous and idempotent. Providers decide whether release
|
||||
* returns the Session to a cache or discards it; publication may consume that
|
||||
* state before disposal, making the callback a no-op.
|
||||
*/
|
||||
export class SessionPreparation implements Disposable {
|
||||
private released = false
|
||||
|
||||
/** The exact Session to use for setup and publication. */
|
||||
readonly session: Session
|
||||
|
||||
private constructor(
|
||||
session: Session,
|
||||
private readonly options: SessionPreparationOptions,
|
||||
) {
|
||||
this.session = session
|
||||
}
|
||||
|
||||
/**
|
||||
* Wrap an unpublished Session in one preparation lifetime.
|
||||
* @param session - exact unpublished Session.
|
||||
* @param options - optional provider release behavior.
|
||||
* @returns a preparation disposed after publication or rollback.
|
||||
*/
|
||||
static create(session: Session, options?: SessionPreparationOptions): SessionPreparation {
|
||||
return new SessionPreparation(session, options ?? {})
|
||||
}
|
||||
|
||||
/** Release provider state once when this preparation leaves its caller. */
|
||||
[Symbol.dispose](): void {
|
||||
if (this.released) return
|
||||
this.released = true
|
||||
this.options.release?.()
|
||||
}
|
||||
}
|
||||
@@ -355,6 +355,14 @@ function applySurfaceEvent(
|
||||
baseSeq: number,
|
||||
): SurfaceFoldReplacement | undefined {
|
||||
const plan = planSurfaceEvent(state, event, expectedSeq, events, baseSeq)
|
||||
return applySurfacePlan(state, plan)
|
||||
}
|
||||
|
||||
/** Commit one previously validated surface transition. */
|
||||
function applySurfacePlan(
|
||||
state: SurfaceFoldState,
|
||||
plan: SurfacePlan | undefined,
|
||||
): SurfaceFoldReplacement | undefined {
|
||||
if (plan?.kind === 'append') {
|
||||
state.nodes.push(plan.seq)
|
||||
} else if (plan?.kind === 'replace') {
|
||||
@@ -392,6 +400,8 @@ export class SurfaceManager implements SessionSurface {
|
||||
private _state = createFoldState()
|
||||
/** Last processed absolute seq. */
|
||||
private _lastProcessedSeq: number
|
||||
/** Candidate already validated by `validateNext`, pending exact log admission. */
|
||||
private _pendingPlan: { event: SessionEvent; expectedSeq: number; plan: SurfacePlan | undefined } | undefined
|
||||
|
||||
/**
|
||||
* @param log - Contiguous complete log or loaded event window.
|
||||
@@ -410,13 +420,12 @@ export class SurfaceManager implements SessionSurface {
|
||||
*/
|
||||
validateNext(event: SessionEvent): void {
|
||||
if (this._lastProcessedSeq < this.baseSeq + this.log.length - 1) this._processDelta()
|
||||
planSurfaceEvent(
|
||||
this._state,
|
||||
const expectedSeq = this.baseSeq + this.log.length
|
||||
this._pendingPlan = {
|
||||
event,
|
||||
this.baseSeq + this.log.length,
|
||||
this.log,
|
||||
this.baseSeq,
|
||||
)
|
||||
expectedSeq,
|
||||
plan: planSurfaceEvent(this._state, event, expectedSeq, this.log, this.baseSeq),
|
||||
}
|
||||
}
|
||||
|
||||
/** Monotonic count of folded positional replacements. */
|
||||
@@ -437,7 +446,14 @@ export class SurfaceManager implements SessionSurface {
|
||||
for (let seq = this._lastProcessedSeq + 1; seq <= tailSeq; seq++) {
|
||||
const index = seq - this.baseSeq
|
||||
// oxlint-disable-next-line typescript/no-non-null-assertion -- bounded by the loop condition
|
||||
applySurfaceEvent(this._state, this.log[index]!, seq, this.log, this.baseSeq)
|
||||
const event = this.log[index]!
|
||||
const pending = this._pendingPlan
|
||||
if (pending?.event === event && pending.expectedSeq === seq) {
|
||||
applySurfacePlan(this._state, pending.plan)
|
||||
} else {
|
||||
applySurfaceEvent(this._state, event, seq, this.log, this.baseSeq)
|
||||
}
|
||||
if (pending !== undefined && pending.expectedSeq <= seq) this._pendingPlan = undefined
|
||||
this._lastProcessedSeq = seq
|
||||
}
|
||||
}
|
||||
|
||||
@@ -93,6 +93,24 @@ export interface CreateSessionOptions {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fresh storage values transferred to {@link SessionStore.prepare} without a
|
||||
* second serialization copy. Callers retain no mutable aliases.
|
||||
*/
|
||||
export interface RestoredSessionOptions {
|
||||
/** Fresh detached storage events to validate and freeze in place. */
|
||||
readonly seed: SessionEvent[]
|
||||
/** Fresh detached storage metadata to validate and freeze in place. */
|
||||
readonly meta: SessionHeader
|
||||
/** Select the persistence ownership-transfer path. */
|
||||
readonly seedSource: 'persistence'
|
||||
}
|
||||
|
||||
/** Inputs accepted while constructing an unpublished Session. */
|
||||
export type PrepareSessionOptions =
|
||||
| (CreateSessionOptions & { readonly seedSource?: undefined })
|
||||
| RestoredSessionOptions
|
||||
|
||||
/** Why an active agent driver was cancelled. */
|
||||
export type AgentCancelCause =
|
||||
| { readonly kind: 'user' }
|
||||
|
||||
@@ -941,6 +941,36 @@ describe('Session', () => {
|
||||
expect(() => { appendedEvent.data.todos[0]!.content = 'mutated' }).toThrow(TypeError)
|
||||
})
|
||||
|
||||
it('iteratively freezes deeply nested restored event data', () => {
|
||||
const depth = 20_000
|
||||
const data: Record<string, unknown> = {}
|
||||
let tail = data
|
||||
for (let index = 0; index < depth; index += 1) {
|
||||
const child: Record<string, unknown> = {}
|
||||
tail['child'] = child
|
||||
tail = child
|
||||
}
|
||||
const event = {
|
||||
type: 'test/deep-restore', seq: 0, time: 1, data,
|
||||
} as unknown as SessionEvent
|
||||
|
||||
expect(() => Session.fromRestore(SessionId('deep-restore'), [event], {
|
||||
version: SESSION_FORMAT_VERSION,
|
||||
id: SessionId('deep-restore'),
|
||||
createdAt: 1,
|
||||
})).not.toThrow()
|
||||
|
||||
let current: unknown = event
|
||||
let frozenNodes = 0
|
||||
for (let index = 0; index <= depth + 1; index += 1) {
|
||||
if (!Object.isFrozen(current)) break
|
||||
frozenNodes += 1
|
||||
current = (current as Record<string, unknown>)['data']
|
||||
?? (current as Record<string, unknown>)['child']
|
||||
}
|
||||
expect(frozenNodes).toBe(depth + 2)
|
||||
})
|
||||
|
||||
it('returns cached frozen event-array snapshots that do not grow after append', () => {
|
||||
const session = Session.create(SessionId('events-snapshot'))
|
||||
session.append('turn/start', { turn: 1 })
|
||||
@@ -998,6 +1028,15 @@ describe('Session', () => {
|
||||
|
||||
expect(() => Session.create(SessionId('header-invalid'), undefined, new ExoticHeader()))
|
||||
.toThrow(/not losslessly JSON-serializable/)
|
||||
expect(() => Session.fromRestore(SessionId('header-invalid'), [], new ExoticHeader()))
|
||||
.toThrow(/not a plain JSON record/)
|
||||
for (const header of [null, 1, []]) {
|
||||
expect(() => Session.fromRestore(
|
||||
SessionId('header-invalid'),
|
||||
[],
|
||||
header as unknown as SessionHeader,
|
||||
)).toThrow(/not a plain JSON record/)
|
||||
}
|
||||
expect(() => Session.create(SessionId('header-invalid'), undefined, {
|
||||
version: SESSION_FORMAT_VERSION,
|
||||
id: SessionId('header-invalid'),
|
||||
@@ -1050,7 +1089,6 @@ describe('Session', () => {
|
||||
{ ...base, seq: -1 },
|
||||
{ ...base, time: '1' },
|
||||
{ ...base, time: 0.5 },
|
||||
{ ...base, time: -1 },
|
||||
{ type: base.type, seq: base.seq, time: base.time },
|
||||
]
|
||||
|
||||
|
||||
Reference in New Issue
Block a user