Merge remote-tracking branch 'origin/master' into xtr/agent-loop-message-machine
# Conflicts: # .agents/notes/implemented/feature/2026-06-14-acp-agent-client-protocol.md # .agents/notes/implemented/feature/2026-06-14-acp-agent-client-protocol.zh.md # .agents/notes/implemented/feature/2026-06-30-hook-bridges.i18n.yaml # .agents/notes/implemented/feature/2026-06-30-interception-seams.i18n.yaml # .agents/notes/implemented/feature/2026-07-06-sandbox.i18n.yaml # .agents/notes/implemented/feature/2026-07-06-sandbox.md # .agents/notes/implemented/feature/2026-07-06-sandbox.zh.md # .agents/notes/implemented/feature/2026-07-19-model-facing-goal-tools.i18n.yaml # .agents/notes/implemented/feature/2026-07-19-same-session-goal-round-driver.i18n.yaml # .agents/notes/implemented/feature/2026-07-25-workspace-ui-product-flow.i18n.yaml # .agents/notes/implemented/simplification/2026-07-02-remove-stream-chunk-mirror.i18n.yaml # docs/architecture.i18n.yaml # docs/cookbook/adding-a-tool.i18n.yaml # docs/cookbook/extension-cookbook.i18n.yaml # docs/core-data-structures/llm-streaming.i18n.yaml # docs/core-data-structures/session.i18n.yaml # docs/core-data-structures/tools.i18n.yaml # docs/event-producer-consumer.md # docs/persistence-catalog.md # examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/stdout.expected.jsonl # examples/acp-agent/tests/snapshots/escalation-approved/session.jsonl # examples/acp-agent/tests/snapshots/escalation-rejected/session.jsonl # examples/acp-agent/tests/snapshots/fs-escalation-approved/session.jsonl # examples/acp-agent/tests/snapshots/hook-cc-pretool-ask/session.jsonl # examples/acp-agent/tests/snapshots/permission-switching/session.jsonl # examples/acp-agent/tests/snapshots/plan-mode-reject/session.jsonl # examples/acp-agent/tests/snapshots/plan-mode/session.jsonl # examples/acp-agent/tests/snapshots/session-sandbox-root/session.jsonl # packages/context/session-reference/README.md # packages/core/agent-loop/tests/agent.spec.ts # packages/hooks/hooks-claude/tests/coverage-cases.ts # packages/host/runtime/tests/host-runtime.spec.ts # packages/llm/llm-retry/tests/retry.spec.ts # packages/session-persistence/session-persistence/src/coordinator.ts # packages/support/acp-snapshot/README.md # packages/support/acp-snapshot/src/normalize.ts # packages/ui/acp/acp-feature-support.md # packages/ui/acp/src/codec.ts # packages/ui/acp/src/index.ts # packages/ui/acp/tests/bridge.spec.ts # packages/ui/acp/tests/codec.spec.ts # packages/ui/acp/tests/config-options.spec.ts # packages/ui/acp/tests/dispose.spec.ts # packages/ui/acp/tests/edges.spec.ts # packages/ui/acp/tests/stream-update.spec.ts # packages/ui/acp/tests/turns.spec.ts
This commit is contained in:
@@ -210,8 +210,10 @@ it('registers exact and abstract search behavior under one ctx key', async () =>
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SessionStore)
|
||||
const fiber = await ctx.plugin(TestSessionQueryService)
|
||||
const session = ctx.sessions.create(id)
|
||||
await expect(ctx.sessionQuery.searchSessions({ query: 'AI' })).resolves.toEqual({ items: [] })
|
||||
await expect(ctx.sessionQuery.searchEvents({ sessionId: id, query: 'AI' })).resolves.toEqual({ items: [] })
|
||||
await expect(ctx.sessionQuery.searchEvents({ sessionId: id, query: 'AI' }))
|
||||
.resolves.toEqual({ session: session.header, items: [] })
|
||||
await fiber.dispose()
|
||||
expect(ctx.sessionQuery).toBeUndefined()
|
||||
})
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { Context, type Fiber } from 'cordis'
|
||||
import SessionStore, { SESSION_FORMAT_VERSION, SessionId } from '@deepseek-ai/dsh-session'
|
||||
import type { SessionEvent, SessionHeader, SessionId as SessionIdType } from '@deepseek-ai/dsh-session'
|
||||
import SessionPersistence, { SessionPersistenceRevision } from '@deepseek-ai/dsh-session-persistence'
|
||||
import SessionQueryService, {
|
||||
SESSION_QUERY_DEFAULT_PERSISTED_INSPECT_CONCURRENCY,
|
||||
type SessionEventSurface,
|
||||
type SessionQueryErrorCode,
|
||||
} from '@deepseek-ai/dsh-session-query'
|
||||
@@ -27,16 +28,31 @@ function eventLog(text = 'hello'): SessionEvent[] {
|
||||
class TestPersistence extends SessionPersistence {
|
||||
static entries = new Map<SessionIdType, { meta: SessionHeader; events: SessionEvent[] }>()
|
||||
static listFailure: unknown
|
||||
static listOverride: ((signal?: AbortSignal) => Promise<SessionHeader[]>) | undefined
|
||||
static inspectFailure: unknown
|
||||
static inspectEffect: (() => void) | undefined
|
||||
static inspectOverride: ((
|
||||
id: SessionIdType,
|
||||
signal?: AbortSignal,
|
||||
) => Promise<{ meta: SessionHeader; events: SessionEvent[] }>) | undefined
|
||||
static afterList: (() => void) | undefined
|
||||
static listCalls = 0
|
||||
static inspectCalls: SessionIdType[] = []
|
||||
static listSignals: Array<AbortSignal | undefined> = []
|
||||
static inspectSignals: Array<AbortSignal | undefined> = []
|
||||
|
||||
static reset(entries: readonly { meta: SessionHeader; events: SessionEvent[] }[] = []): void {
|
||||
this.entries = new Map(entries.map(entry => [entry.meta.id, structuredClone(entry)]))
|
||||
this.listFailure = undefined
|
||||
this.listOverride = undefined
|
||||
this.inspectFailure = undefined
|
||||
this.inspectEffect = undefined
|
||||
this.inspectOverride = undefined
|
||||
this.afterList = undefined
|
||||
this.listCalls = 0
|
||||
this.inspectCalls = []
|
||||
this.listSignals = []
|
||||
this.inspectSignals = []
|
||||
}
|
||||
|
||||
locate(_meta: SessionHeader): undefined {
|
||||
@@ -59,7 +75,15 @@ class TestPersistence extends SessionPersistence {
|
||||
return this.inspect(id)
|
||||
}
|
||||
|
||||
inspect(id: SessionIdType): Promise<{ meta: SessionHeader; events: SessionEvent[] }> {
|
||||
inspect(
|
||||
id: SessionIdType,
|
||||
signal?: AbortSignal,
|
||||
): Promise<{ meta: SessionHeader; events: SessionEvent[] }> {
|
||||
TestPersistence.inspectCalls.push(id)
|
||||
TestPersistence.inspectSignals.push(signal)
|
||||
if (TestPersistence.inspectOverride !== undefined) {
|
||||
return TestPersistence.inspectOverride(id, signal)
|
||||
}
|
||||
if (TestPersistence.inspectFailure !== undefined) return rejectUnknown(TestPersistence.inspectFailure)
|
||||
const entry = TestPersistence.entries.get(id)
|
||||
if (entry === undefined) return Promise.reject(new Error('missing test session'))
|
||||
@@ -69,7 +93,10 @@ class TestPersistence extends SessionPersistence {
|
||||
return Promise.resolve(result)
|
||||
}
|
||||
|
||||
list(): Promise<SessionHeader[]> {
|
||||
list(signal?: AbortSignal): Promise<SessionHeader[]> {
|
||||
TestPersistence.listCalls += 1
|
||||
TestPersistence.listSignals.push(signal)
|
||||
if (TestPersistence.listOverride !== undefined) return TestPersistence.listOverride(signal)
|
||||
if (TestPersistence.listFailure !== undefined) return rejectUnknown(TestPersistence.listFailure)
|
||||
const headers = [...TestPersistence.entries.values()].map(entry => structuredClone(entry.meta))
|
||||
TestPersistence.afterList?.()
|
||||
@@ -104,6 +131,287 @@ function rejectUnknown<T>(reason: unknown): Promise<T> {
|
||||
})
|
||||
}
|
||||
|
||||
const cancellableSessionListings = [
|
||||
{
|
||||
name: 'listSessions',
|
||||
run: (ctx: Context, signal: AbortSignal) => ctx.sessionQuery.listSessions(signal),
|
||||
},
|
||||
{
|
||||
name: 'filterSessions',
|
||||
run: (ctx: Context, signal: AbortSignal) => ctx.sessionQuery.filterSessions([], signal),
|
||||
},
|
||||
] as const
|
||||
|
||||
interface CancellableExactRead {
|
||||
readonly name: 'traceSession' | 'traceEvent' | 'readEvent'
|
||||
readonly inspects: boolean
|
||||
readonly run: (
|
||||
ctx: Context,
|
||||
sessionId: SessionIdType,
|
||||
signal: AbortSignal,
|
||||
) => Promise<unknown>
|
||||
}
|
||||
|
||||
const cancellableExactReads: readonly CancellableExactRead[] = [
|
||||
{
|
||||
name: 'traceSession',
|
||||
inspects: false,
|
||||
run: (ctx, sessionId, signal) => ctx.sessionQuery.traceSession(sessionId, signal),
|
||||
},
|
||||
{
|
||||
name: 'traceEvent',
|
||||
inspects: true,
|
||||
run: (ctx, sessionId, signal) => ctx.sessionQuery.traceEvent({ sessionId, seq: 0 }, signal),
|
||||
},
|
||||
{
|
||||
name: 'readEvent',
|
||||
inspects: true,
|
||||
run: (ctx, sessionId, signal) => ctx.sessionQuery.readEvent({ sessionId, seq: 0 }, signal),
|
||||
},
|
||||
] as const
|
||||
|
||||
describe.each(cancellableSessionListings)('$name cancellation', ({ run }) => {
|
||||
it('preserves an exact pre-abort reason without entering persistence', async () => {
|
||||
TestPersistence.reset()
|
||||
const ctx = await liveContext()
|
||||
await ctx.plugin(TestPersistence)
|
||||
const controller = new AbortController()
|
||||
const reason = new Error('session listing cancelled before start')
|
||||
controller.abort(reason)
|
||||
|
||||
await expect(run(ctx, controller.signal)).rejects.toBe(reason)
|
||||
expect(TestPersistence.listCalls).toBe(0)
|
||||
expect(TestPersistence.listSignals).toEqual([])
|
||||
})
|
||||
|
||||
it('forwards in-flight cancellation and waits for persistence cleanup before rejecting', async () => {
|
||||
TestPersistence.reset()
|
||||
const ctx = await liveContext()
|
||||
await ctx.plugin(TestPersistence)
|
||||
const controller = new AbortController()
|
||||
const reason = new Error('session listing cancelled in flight')
|
||||
const started = Promise.withResolvers<undefined>()
|
||||
const abortObserved = Promise.withResolvers<undefined>()
|
||||
const cleanup = Promise.withResolvers<undefined>()
|
||||
let active = false
|
||||
TestPersistence.listOverride = async (signal) => {
|
||||
if (signal === undefined) throw new Error('expected persistence listing signal')
|
||||
active = true
|
||||
const aborted = new Promise<void>((resolve) => {
|
||||
signal.addEventListener('abort', () => { resolve() }, { once: true })
|
||||
})
|
||||
started.resolve(undefined)
|
||||
await aborted
|
||||
abortObserved.resolve(undefined)
|
||||
await cleanup.promise
|
||||
active = false
|
||||
signal.throwIfAborted()
|
||||
return []
|
||||
}
|
||||
|
||||
const pending = run(ctx, controller.signal)
|
||||
let settled = false
|
||||
void pending.then(
|
||||
() => { settled = true },
|
||||
() => { settled = true },
|
||||
)
|
||||
await started.promise
|
||||
controller.abort(reason)
|
||||
await abortObserved.promise
|
||||
|
||||
expect(settled).toBe(false)
|
||||
expect(active).toBe(true)
|
||||
expect(TestPersistence.listSignals).toEqual([controller.signal])
|
||||
|
||||
cleanup.resolve(undefined)
|
||||
await expect(pending).rejects.toBe(reason)
|
||||
expect(active).toBe(false)
|
||||
})
|
||||
|
||||
it('preserves cancellation after a persistence implementation ignores the signal', async () => {
|
||||
TestPersistence.reset()
|
||||
const ctx = await liveContext()
|
||||
await ctx.plugin(TestPersistence)
|
||||
const controller = new AbortController()
|
||||
const reason = new Error('session listing cancelled before persistence returned')
|
||||
const started = Promise.withResolvers<undefined>()
|
||||
const listing = Promise.withResolvers<SessionHeader[]>()
|
||||
TestPersistence.listOverride = (_signal) => {
|
||||
started.resolve(undefined)
|
||||
return listing.promise
|
||||
}
|
||||
|
||||
const pending = run(ctx, controller.signal)
|
||||
await started.promise
|
||||
controller.abort(reason)
|
||||
listing.resolve([])
|
||||
|
||||
await expect(pending).rejects.toBe(reason)
|
||||
expect(TestPersistence.listSignals).toEqual([controller.signal])
|
||||
})
|
||||
})
|
||||
|
||||
describe.each(cancellableExactReads)('$name cancellation', ({ inspects, run }) => {
|
||||
it('preserves an exact pre-abort reason without entering persistence', async () => {
|
||||
const persisted = header('pre-aborted-exact-read')
|
||||
TestPersistence.reset([{ meta: persisted, events: eventLog() }])
|
||||
const ctx = await liveContext()
|
||||
await ctx.plugin(TestPersistence)
|
||||
const controller = new AbortController()
|
||||
const reason = new Error('exact read cancelled before start')
|
||||
controller.abort(reason)
|
||||
|
||||
await expect(run(ctx, persisted.id, controller.signal)).rejects.toBe(reason)
|
||||
expect(TestPersistence.listCalls).toBe(0)
|
||||
expect(TestPersistence.inspectCalls).toEqual([])
|
||||
})
|
||||
|
||||
it('forwards in-flight list cancellation and waits for cleanup before rejecting', async () => {
|
||||
const persisted = header('cancelled-exact-list')
|
||||
TestPersistence.reset([{ meta: persisted, events: eventLog() }])
|
||||
const ctx = await liveContext()
|
||||
await ctx.plugin(TestPersistence)
|
||||
const controller = new AbortController()
|
||||
const reason = new Error('exact read list cancelled in flight')
|
||||
const started = Promise.withResolvers<undefined>()
|
||||
const abortObserved = Promise.withResolvers<undefined>()
|
||||
const cleanup = Promise.withResolvers<undefined>()
|
||||
let active = false
|
||||
TestPersistence.listOverride = async (signal) => {
|
||||
if (signal === undefined) throw new Error('expected exact-read listing signal')
|
||||
active = true
|
||||
const aborted = new Promise<void>((resolve) => {
|
||||
signal.addEventListener('abort', () => { resolve() }, { once: true })
|
||||
})
|
||||
started.resolve(undefined)
|
||||
await aborted
|
||||
abortObserved.resolve(undefined)
|
||||
await cleanup.promise
|
||||
active = false
|
||||
signal.throwIfAborted()
|
||||
return []
|
||||
}
|
||||
|
||||
const pending = run(ctx, persisted.id, controller.signal)
|
||||
let settled = false
|
||||
void pending.then(
|
||||
() => { settled = true },
|
||||
() => { settled = true },
|
||||
)
|
||||
await started.promise
|
||||
controller.abort(reason)
|
||||
await abortObserved.promise
|
||||
|
||||
expect(settled).toBe(false)
|
||||
expect(active).toBe(true)
|
||||
expect(TestPersistence.listSignals).toEqual([controller.signal])
|
||||
expect(TestPersistence.inspectCalls).toEqual([])
|
||||
|
||||
cleanup.resolve(undefined)
|
||||
await expect(pending).rejects.toBe(reason)
|
||||
expect(active).toBe(false)
|
||||
})
|
||||
|
||||
it('waits for an ignoring backend to return before preserving the abort reason', async () => {
|
||||
const persisted = header('ignored-exact-signal')
|
||||
const entry = { meta: persisted, events: eventLog() }
|
||||
TestPersistence.reset([entry])
|
||||
const ctx = await liveContext()
|
||||
await ctx.plugin(TestPersistence)
|
||||
const controller = new AbortController()
|
||||
const reason = new Error('exact read cancelled while backend ignored signal')
|
||||
const started = Promise.withResolvers<undefined>()
|
||||
const release = Promise.withResolvers<undefined>()
|
||||
let active = false
|
||||
if (inspects) {
|
||||
TestPersistence.inspectOverride = async () => {
|
||||
active = true
|
||||
started.resolve(undefined)
|
||||
await release.promise
|
||||
active = false
|
||||
return structuredClone(entry)
|
||||
}
|
||||
} else {
|
||||
TestPersistence.listOverride = async () => {
|
||||
active = true
|
||||
started.resolve(undefined)
|
||||
await release.promise
|
||||
active = false
|
||||
return [structuredClone(persisted)]
|
||||
}
|
||||
}
|
||||
|
||||
const pending = run(ctx, persisted.id, controller.signal)
|
||||
let settled = false
|
||||
void pending.then(
|
||||
() => { settled = true },
|
||||
() => { settled = true },
|
||||
)
|
||||
await started.promise
|
||||
controller.abort(reason)
|
||||
|
||||
expect(settled).toBe(false)
|
||||
expect(active).toBe(true)
|
||||
expect(TestPersistence.listSignals).toEqual([controller.signal])
|
||||
expect(TestPersistence.inspectSignals).toEqual(inspects ? [controller.signal] : [])
|
||||
|
||||
release.resolve(undefined)
|
||||
await expect(pending).rejects.toBe(reason)
|
||||
expect(active).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe.each(cancellableExactReads.filter(read => read.inspects))(
|
||||
'$name persisted inspection cancellation',
|
||||
({ run }) => {
|
||||
it('forwards cancellation and waits for inspection cleanup before rejecting', async () => {
|
||||
const persisted = header('cancelled-exact-inspect')
|
||||
TestPersistence.reset([{ meta: persisted, events: eventLog() }])
|
||||
const ctx = await liveContext()
|
||||
await ctx.plugin(TestPersistence)
|
||||
const controller = new AbortController()
|
||||
const reason = new Error('exact read inspection cancelled in flight')
|
||||
const started = Promise.withResolvers<undefined>()
|
||||
const abortObserved = Promise.withResolvers<undefined>()
|
||||
const cleanup = Promise.withResolvers<undefined>()
|
||||
let active = false
|
||||
TestPersistence.inspectOverride = async (_sessionId, signal) => {
|
||||
if (signal === undefined) throw new Error('expected exact-read inspection signal')
|
||||
active = true
|
||||
const aborted = new Promise<void>((resolve) => {
|
||||
signal.addEventListener('abort', () => { resolve() }, { once: true })
|
||||
})
|
||||
started.resolve(undefined)
|
||||
await aborted
|
||||
abortObserved.resolve(undefined)
|
||||
await cleanup.promise
|
||||
active = false
|
||||
signal.throwIfAborted()
|
||||
throw new Error('unreachable after exact-read cancellation')
|
||||
}
|
||||
|
||||
const pending = run(ctx, persisted.id, controller.signal)
|
||||
let settled = false
|
||||
void pending.then(
|
||||
() => { settled = true },
|
||||
() => { settled = true },
|
||||
)
|
||||
await started.promise
|
||||
controller.abort(reason)
|
||||
await abortObserved.promise
|
||||
|
||||
expect(settled).toBe(false)
|
||||
expect(active).toBe(true)
|
||||
expect(TestPersistence.listSignals).toEqual([controller.signal])
|
||||
expect(TestPersistence.inspectSignals).toEqual([controller.signal])
|
||||
|
||||
cleanup.resolve(undefined)
|
||||
await expect(pending).rejects.toBe(reason)
|
||||
expect(active).toBe(false)
|
||||
})
|
||||
},
|
||||
)
|
||||
|
||||
describe('session-query exact reads', () => {
|
||||
it('returns a detached replay-valid full log and rejects a corrupt persisted seed', async () => {
|
||||
const valid = header('valid-log', 2)
|
||||
@@ -192,6 +500,341 @@ describe('session-query exact reads', () => {
|
||||
expect(Object.keys((await ctx.sessionQuery.listSessions())[0]!)).toEqual(['header', 'live', 'persisted'])
|
||||
})
|
||||
|
||||
it('batches unique persisted title observations through one cancellable corpus scan', async () => {
|
||||
const first = header('batch-title-first', 1)
|
||||
const second = header('batch-title-second', 2)
|
||||
const titleEvent = (title: string, time: number): SessionEvent => ({
|
||||
type: 'session/title',
|
||||
seq: 0,
|
||||
time,
|
||||
data: {
|
||||
title,
|
||||
messageSeqs: [],
|
||||
source: { kind: 'fallback' },
|
||||
},
|
||||
})
|
||||
TestPersistence.reset([
|
||||
{ meta: first, events: [titleEvent('First title', 10)] },
|
||||
{ meta: second, events: [titleEvent('Second title', 20)] },
|
||||
])
|
||||
const ctx = await liveContext()
|
||||
await ctx.plugin(TestPersistence)
|
||||
const signal = new AbortController().signal
|
||||
const missing = SessionId('batch-title-missing')
|
||||
|
||||
const results = await ctx.sessionQuery.readTitleSnapshots(
|
||||
[second.id, first.id, second.id, missing],
|
||||
signal,
|
||||
)
|
||||
|
||||
expect(results.map(result => [result.sessionId, result.status])).toEqual([
|
||||
[second.id, 'fulfilled'],
|
||||
[first.id, 'fulfilled'],
|
||||
[missing, 'rejected'],
|
||||
])
|
||||
expect(results[0]).toMatchObject({ value: { session: second, title: { title: 'Second title' } } })
|
||||
expect(results[1]).toMatchObject({ value: { session: first, title: { title: 'First title' } } })
|
||||
expect(TestPersistence.listCalls).toBe(1)
|
||||
expect(TestPersistence.inspectCalls).toEqual([second.id, first.id])
|
||||
expect(TestPersistence.listSignals).toEqual([signal])
|
||||
expect(TestPersistence.inspectSignals).toEqual([signal, signal])
|
||||
})
|
||||
|
||||
it('bounds persisted title inspection concurrency while preserving ordered results', async () => {
|
||||
const entries = Array.from({ length: 12 }, (_, index) => {
|
||||
const meta = header(`bounded-title-${index}`, index)
|
||||
return { meta, events: eventLog(`title-${index}`) }
|
||||
})
|
||||
TestPersistence.reset(entries)
|
||||
const ctx = await liveContext()
|
||||
await ctx.plugin(TestPersistence)
|
||||
let active = 0
|
||||
let maximum = 0
|
||||
TestPersistence.inspectOverride = async (id) => {
|
||||
active += 1
|
||||
maximum = Math.max(maximum, active)
|
||||
await new Promise<void>(resolve => setImmediate(resolve))
|
||||
active -= 1
|
||||
const entry = TestPersistence.entries.get(id)
|
||||
if (entry === undefined) throw new Error('missing bounded test session')
|
||||
return structuredClone(entry)
|
||||
}
|
||||
|
||||
const results = await ctx.sessionQuery.readTitleSnapshots(entries.map(entry => entry.meta.id))
|
||||
|
||||
expect(maximum).toBe(SESSION_QUERY_DEFAULT_PERSISTED_INSPECT_CONCURRENCY)
|
||||
expect(TestPersistence.listCalls).toBe(1)
|
||||
expect(TestPersistence.inspectCalls).toEqual(entries.map(entry => entry.meta.id))
|
||||
expect(results.map(result => result.sessionId)).toEqual(entries.map(entry => entry.meta.id))
|
||||
expect(results.every(result => result.status === 'fulfilled')).toBe(true)
|
||||
})
|
||||
|
||||
it('folds and discards each completed log before its worker dequeues another inspection', async () => {
|
||||
const entries = Array.from({ length: 5 }, (_, index) => ({
|
||||
meta: header(`project-title-${index}`, index),
|
||||
events: [],
|
||||
}))
|
||||
TestPersistence.reset(entries)
|
||||
const ctx = await liveContext()
|
||||
await ctx.plugin(TestPersistence)
|
||||
const timeline: string[] = []
|
||||
const releases = new Map<SessionIdType, () => void>()
|
||||
TestPersistence.inspectOverride = id => new Promise((resolve) => {
|
||||
timeline.push(`inspect:${id}`)
|
||||
releases.set(id, () => {
|
||||
const marker = `full-log-marker:${id}`
|
||||
const titleEvent = {
|
||||
type: 'session/title',
|
||||
seq: 1,
|
||||
time: 20,
|
||||
data: {
|
||||
title: `Projected ${id}`,
|
||||
get messageSeqs() {
|
||||
timeline.push(`project:${id}`)
|
||||
return []
|
||||
},
|
||||
source: { kind: 'fallback' },
|
||||
},
|
||||
} as unknown as SessionEvent
|
||||
resolve({
|
||||
meta: entries.find(entry => entry.meta.id === id)!.meta,
|
||||
events: [...eventLog(marker), titleEvent],
|
||||
})
|
||||
})
|
||||
})
|
||||
const release = (id: SessionIdType): void => {
|
||||
const settle = releases.get(id)
|
||||
if (settle === undefined) throw new Error(`inspection ${id} has not started`)
|
||||
settle()
|
||||
}
|
||||
const ids = entries.map(entry => entry.meta.id)
|
||||
|
||||
const pending = ctx.sessionQuery.readTitleSnapshots(ids)
|
||||
await vi.waitFor(() => { expect(TestPersistence.inspectCalls).toHaveLength(4) })
|
||||
release(ids[0]!)
|
||||
await vi.waitFor(() => { expect(TestPersistence.inspectCalls).toHaveLength(5) })
|
||||
|
||||
// Heap-retention assertions would depend on nondeterministic GC. This ordering
|
||||
// is the deterministic guard: a retain-all implementation cannot touch the
|
||||
// observable title getter until every inspection has completed.
|
||||
expect(timeline.indexOf(`project:${ids[0]}`))
|
||||
.toBeLessThan(timeline.indexOf(`inspect:${ids[4]}`))
|
||||
for (const id of ids.slice(1)) release(id)
|
||||
const results = await pending
|
||||
|
||||
expect(results.map(result => result.sessionId)).toEqual(ids)
|
||||
expect(JSON.stringify(results)).not.toContain('full-log-marker:')
|
||||
expect(results.every(result => result.status === 'fulfilled')).toBe(true)
|
||||
})
|
||||
|
||||
it('passes cancellation into a stalled persisted title batch and rejects with its reason', async () => {
|
||||
const persisted = header('stalled-title', 1)
|
||||
TestPersistence.reset([{ meta: persisted, events: [] }])
|
||||
const ctx = await liveContext()
|
||||
await ctx.plugin(TestPersistence)
|
||||
const controller = new AbortController()
|
||||
const reason = new Error('title deadline')
|
||||
let started!: () => void
|
||||
const inspectStarted = new Promise<void>((resolve) => { started = resolve })
|
||||
TestPersistence.inspectOverride = (_id, signal) => new Promise((_resolve, reject) => {
|
||||
started()
|
||||
signal?.addEventListener('abort', () => { reject(reason) }, { once: true })
|
||||
})
|
||||
|
||||
const pending = ctx.sessionQuery.readTitleSnapshots([persisted.id], controller.signal)
|
||||
await inspectStarted
|
||||
controller.abort(reason)
|
||||
|
||||
await expect(pending).rejects.toBe(reason)
|
||||
expect(TestPersistence.listSignals).toEqual([controller.signal])
|
||||
expect(TestPersistence.inspectSignals).toEqual([controller.signal])
|
||||
})
|
||||
|
||||
it('drains started title inspections after cancellation without starting queued ids', async () => {
|
||||
const entries = Array.from({ length: 8 }, (_, index) => ({
|
||||
meta: header(`cancel-queued-title-${index}`, index),
|
||||
events: eventLog(`queued-${index}`),
|
||||
}))
|
||||
TestPersistence.reset(entries)
|
||||
const persistedInspectConcurrency = 2
|
||||
const ctx = await liveContext({ persistedInspectConcurrency })
|
||||
await ctx.plugin(TestPersistence)
|
||||
const controller = new AbortController()
|
||||
const reason = new Error('cancel queued title batch')
|
||||
const releases: Array<() => void> = []
|
||||
let abortsObserved = 0
|
||||
let inspectionsSettled = 0
|
||||
TestPersistence.inspectOverride = (_id, signal) => new Promise((_resolve, reject) => {
|
||||
signal?.addEventListener('abort', () => { abortsObserved += 1 }, { once: true })
|
||||
releases.push(() => {
|
||||
inspectionsSettled += 1
|
||||
reject(reason)
|
||||
})
|
||||
})
|
||||
|
||||
const pending = ctx.sessionQuery.readTitleSnapshots(
|
||||
entries.map(entry => entry.meta.id),
|
||||
controller.signal,
|
||||
)
|
||||
let batchSettled = false
|
||||
void pending.then(
|
||||
() => { batchSettled = true },
|
||||
() => { batchSettled = true },
|
||||
)
|
||||
await vi.waitFor(() => {
|
||||
expect(TestPersistence.inspectCalls).toHaveLength(persistedInspectConcurrency)
|
||||
})
|
||||
controller.abort(reason)
|
||||
await vi.waitFor(() => { expect(abortsObserved).toBe(persistedInspectConcurrency) })
|
||||
|
||||
expect(batchSettled).toBe(false)
|
||||
expect(TestPersistence.inspectCalls)
|
||||
.toEqual(entries.slice(0, persistedInspectConcurrency).map(entry => entry.meta.id))
|
||||
for (const release of releases) release()
|
||||
|
||||
await expect(pending).rejects.toBe(reason)
|
||||
expect(inspectionsSettled).toBe(persistedInspectConcurrency)
|
||||
expect(TestPersistence.inspectCalls)
|
||||
.toEqual(entries.slice(0, persistedInspectConcurrency).map(entry => entry.meta.id))
|
||||
})
|
||||
|
||||
it('passes cancellation into a stalled persisted title listing and rejects with its reason', async () => {
|
||||
const persisted = header('stalled-title-list', 1)
|
||||
TestPersistence.reset([{ meta: persisted, events: [] }])
|
||||
const ctx = await liveContext()
|
||||
await ctx.plugin(TestPersistence)
|
||||
const controller = new AbortController()
|
||||
const reason = new Error('title listing deadline')
|
||||
let started!: () => void
|
||||
const listStarted = new Promise<void>((resolve) => { started = resolve })
|
||||
TestPersistence.listOverride = signal => new Promise((_resolve, reject) => {
|
||||
started()
|
||||
signal?.addEventListener('abort', () => { reject(reason) }, { once: true })
|
||||
})
|
||||
|
||||
const pending = ctx.sessionQuery.readTitleSnapshots([persisted.id], controller.signal)
|
||||
await listStarted
|
||||
controller.abort(reason)
|
||||
|
||||
await expect(pending).rejects.toBe(reason)
|
||||
expect(TestPersistence.listSignals).toEqual([controller.signal])
|
||||
expect(TestPersistence.inspectCalls).toEqual([])
|
||||
})
|
||||
|
||||
it('isolates title read and fold failures while preferring a live owner attached during inspection', async () => {
|
||||
const attached = header('batch-title-attached', 1)
|
||||
const failed = header('batch-title-failed', 2)
|
||||
const malformed = header('batch-title-malformed', 3)
|
||||
const inspectFailure = new Error('one title inspect failed')
|
||||
const malformedTitle = {
|
||||
type: 'session/title',
|
||||
seq: 0,
|
||||
time: 30,
|
||||
data: {
|
||||
title: 'malformed',
|
||||
source: { kind: 'fallback' },
|
||||
},
|
||||
} as unknown as SessionEvent
|
||||
TestPersistence.reset([
|
||||
{ meta: attached, events: eventLog('stale persisted') },
|
||||
{ meta: failed, events: [] },
|
||||
{ meta: malformed, events: [malformedTitle] },
|
||||
])
|
||||
const ctx = await liveContext()
|
||||
await ctx.plugin(TestPersistence)
|
||||
TestPersistence.inspectOverride = (id) => {
|
||||
if (id === failed.id) return Promise.reject(inspectFailure)
|
||||
const entry = TestPersistence.entries.get(id)
|
||||
if (entry === undefined) return Promise.reject(new Error('missing test session'))
|
||||
if (id === attached.id) {
|
||||
const session = ctx.sessions.create(attached.id, { meta: { createdAt: attached.createdAt } })
|
||||
session.append('session/title', {
|
||||
title: 'Attached live title',
|
||||
messageSeqs: [],
|
||||
source: { kind: 'fallback' },
|
||||
})
|
||||
}
|
||||
return Promise.resolve(structuredClone(entry))
|
||||
}
|
||||
|
||||
const results = await ctx.sessionQuery.readTitleSnapshots([
|
||||
attached.id,
|
||||
failed.id,
|
||||
malformed.id,
|
||||
])
|
||||
|
||||
expect(results[0]).toMatchObject({
|
||||
status: 'fulfilled',
|
||||
value: { session: attached, title: { title: 'Attached live title' } },
|
||||
})
|
||||
expect(results[1]).toMatchObject({
|
||||
sessionId: failed.id,
|
||||
status: 'rejected',
|
||||
reason: {
|
||||
code: 'SESSION_QUERY_PERSISTENCE_FAILED',
|
||||
cause: inspectFailure,
|
||||
},
|
||||
})
|
||||
expect(results[2]).toMatchObject({ sessionId: malformed.id, status: 'rejected' })
|
||||
if (results[2]?.status !== 'rejected') throw new Error('expected malformed title rejection')
|
||||
expect(results[2].reason).toBeInstanceOf(TypeError)
|
||||
})
|
||||
|
||||
it('preserves live batch results across missing persistence, listing failure, and late attachment', async () => {
|
||||
const liveOnly = await liveContext()
|
||||
const live = liveOnly.sessions.create(SessionId('batch-title-live'))
|
||||
const missing = SessionId('batch-title-no-persistence')
|
||||
|
||||
await expect(liveOnly.sessionQuery.readTitleSnapshots([live.id, live.id])).resolves.toEqual([{
|
||||
sessionId: live.id,
|
||||
status: 'fulfilled',
|
||||
value: { session: live.header },
|
||||
}])
|
||||
await expect(liveOnly.sessionQuery.readTitleSnapshots([live.id, missing])).resolves.toMatchObject([
|
||||
{ sessionId: live.id, status: 'fulfilled' },
|
||||
{ sessionId: missing, status: 'rejected' },
|
||||
])
|
||||
await expect(liveOnly.sessionQuery.readTitleSnapshot(missing))
|
||||
.rejects.toThrow(expectCode('SESSION_QUERY_SESSION_NOT_FOUND'))
|
||||
|
||||
const persisted = header('batch-title-persisted', 1)
|
||||
const late = header('batch-title-late', 2)
|
||||
TestPersistence.reset([{ meta: persisted, events: [] }])
|
||||
const mixed = await liveContext()
|
||||
const mixedLive = mixed.sessions.create(SessionId('batch-title-mixed-live'))
|
||||
await mixed.plugin(TestPersistence)
|
||||
TestPersistence.afterList = () => {
|
||||
mixed.sessions.create(late.id, { meta: { createdAt: late.createdAt } })
|
||||
TestPersistence.afterList = undefined
|
||||
}
|
||||
|
||||
await expect(mixed.sessionQuery.readTitleSnapshots([
|
||||
mixedLive.id,
|
||||
persisted.id,
|
||||
late.id,
|
||||
])).resolves.toMatchObject([
|
||||
{ sessionId: mixedLive.id, status: 'fulfilled' },
|
||||
{ sessionId: persisted.id, status: 'fulfilled' },
|
||||
{ sessionId: late.id, status: 'fulfilled' },
|
||||
])
|
||||
|
||||
TestPersistence.reset()
|
||||
TestPersistence.listFailure = new Error('title listing failed')
|
||||
const failedList = await liveContext()
|
||||
const survivingLive = failedList.sessions.create(SessionId('batch-title-list-live'))
|
||||
await failedList.plugin(TestPersistence)
|
||||
|
||||
await expect(failedList.sessionQuery.readTitleSnapshots([survivingLive.id, missing]))
|
||||
.resolves.toMatchObject([
|
||||
{ sessionId: survivingLive.id, status: 'fulfilled' },
|
||||
{
|
||||
sessionId: missing,
|
||||
status: 'rejected',
|
||||
reason: expectCode('SESSION_QUERY_PERSISTENCE_FAILED'),
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
it('lists live sessions deterministically and returns detached headers', async () => {
|
||||
const ctx = await liveContext()
|
||||
const older = ctx.sessions.create(SessionId('older'), { meta: { createdAt: 1 } })
|
||||
@@ -408,9 +1051,15 @@ describe('session-query exact reads', () => {
|
||||
await ctx.plugin(TestPersistence)
|
||||
TestPersistence.listFailure = new Error('list unavailable')
|
||||
TestPersistence.inspectFailure = new Error('inspect unavailable')
|
||||
const signal = new AbortController().signal
|
||||
|
||||
await expect(ctx.sessionQuery.listEvents(live.id)).resolves.toHaveLength(2)
|
||||
await expect(ctx.sessionQuery.readEvent({ sessionId: live.id, seq: 1 })).resolves.toMatchObject({ target: { seq: 1 } })
|
||||
await expect(ctx.sessionQuery.traceEvent({ sessionId: live.id, seq: 1 }, signal))
|
||||
.resolves.toMatchObject({ session: { id: live.id }, target: { seq: 1 } })
|
||||
await expect(ctx.sessionQuery.readEvent({ sessionId: live.id, seq: 1 }, signal))
|
||||
.resolves.toMatchObject({ target: { seq: 1 } })
|
||||
expect(TestPersistence.listSignals).toEqual([])
|
||||
expect(TestPersistence.inspectSignals).toEqual([])
|
||||
await expect(ctx.sessionQuery.listSessions()).rejects.toThrow(expectCode('SESSION_QUERY_PERSISTENCE_FAILED'))
|
||||
await expect(ctx.sessionQuery.listEvents(SessionId('durable'))).rejects.toThrow(expectCode('SESSION_QUERY_PERSISTENCE_FAILED'))
|
||||
})
|
||||
@@ -459,10 +1108,16 @@ describe('session-query exact reads', () => {
|
||||
const direct = new Context()
|
||||
await direct.plugin(SessionStore)
|
||||
expect(new TestSessionQueryService(direct)).toBeInstanceOf(SessionQueryService)
|
||||
const invalid = new Context()
|
||||
await invalid.plugin(SessionStore)
|
||||
expect(() => new TestSessionQueryService(invalid, { readWindowMax: -1 }))
|
||||
.toThrow(expectCode('SESSION_QUERY_INVALID_CONFIG'))
|
||||
for (const config of [
|
||||
{ readWindowMax: -1 },
|
||||
{ persistedInspectConcurrency: 0 },
|
||||
{ persistedInspectConcurrency: Number.MAX_SAFE_INTEGER + 1 },
|
||||
]) {
|
||||
const invalid = new Context()
|
||||
await invalid.plugin(SessionStore)
|
||||
expect(() => new TestSessionQueryService(invalid, config))
|
||||
.toThrow(expectCode('SESSION_QUERY_INVALID_CONFIG'))
|
||||
}
|
||||
})
|
||||
|
||||
it('leaves the optional persistence dependency optional', async () => {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import SessionQueryService from '@deepseek-ai/dsh-session-query'
|
||||
import type {
|
||||
SessionEventSearchHit,
|
||||
SessionEventSearchPage,
|
||||
SessionEventSearchRequest,
|
||||
SessionSearchExecContext,
|
||||
SessionSearchHit,
|
||||
@@ -17,10 +17,13 @@ export class TestSessionQueryService extends SessionQueryService {
|
||||
return Promise.resolve({ items: [] })
|
||||
}
|
||||
|
||||
override searchEvents(
|
||||
_request: SessionEventSearchRequest,
|
||||
override async searchEvents(
|
||||
request: SessionEventSearchRequest,
|
||||
_exec?: SessionSearchExecContext,
|
||||
): Promise<SessionSearchPage<SessionEventSearchHit>> {
|
||||
return Promise.resolve({ items: [] })
|
||||
): Promise<SessionEventSearchPage> {
|
||||
return {
|
||||
session: (await this.readSurface(request.sessionId)).session,
|
||||
items: [],
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user