fix: batch cancellable title reads
This commit is contained in:
@@ -8,14 +8,14 @@
|
||||
- `readSession(sessionId)` returns one complete detached raw log after the same core replay validation used by resume; it never enters the session into the live store.
|
||||
- `filterSessions(filters)` applies provider-independent session metadata and availability predicates to that same cloned logical corpus.
|
||||
- `filterEvents(sessionId, filters)` extracts first-party semantic documents and applies provider-independent metadata and literal-text predicates in ascending seq order.
|
||||
- `readTitleSnapshot(sessionId)` loads one live-preferred or persisted log and returns the cloned source header with its latest folded `session/title` event. `readTitle(sessionId)` is the title-only convenience view; it returns `undefined` when the known session has no title.
|
||||
- `readTitleSnapshots(sessionIds, signal?)` resolves unique ids from one live-preferred corpus observation, passes cancellation through persisted listing and inspection, and returns ordered per-session settlements so one missing or malformed title source does not discard its peers. Each live source is folded directly, and each persisted worker folds to a detached header/title result and releases the full log before dequeuing another id. Cancellation rejects the whole batch. `readTitleSnapshot(sessionId, signal?)` is the one-observation view; `readTitle(sessionId, signal?)` returns only its optional folded `session/title`.
|
||||
- `listEvents(sessionId)` loads the live-preferred raw log and classifies each event as `current`, `shadowed`, or `log-only` with the shared `dsh-session` surface fold.
|
||||
- `readSurface(sessionId)` returns one cloned header, raw-log capture boundary, and the complete folded current surface in model-history order. A live session wins over persistence; compaction is observed before or after its replacement append, never as a synthetic mixture.
|
||||
- `readEvent(request)` returns a cloned header, the full target event, and a bounded raw-seq window. `before` and `after` default to zero and may not exceed `readWindowMax`.
|
||||
- `traceSession(sessionId)` reads the corpus once and returns immediate-to-outward ancestors plus deterministic recursive descendant trees. `complete: false` identifies the first missing parent; a target-connected cycle fails with `SESSION_QUERY_INVALID_LINEAGE`.
|
||||
- `traceEvent(request)` loads the logical log once and returns its cloned source header with direct positional replacements and direct logged provenance. `replacementChain` follows positional replacers to the final replacement; provenance links remain non-transitive.
|
||||
|
||||
Persistence is optional and may mount or unmount dynamically. Cross-corpus listing and lineage tracing fail with `SESSION_QUERY_PERSISTENCE_FAILED` while mounted persistence is unreadable. A title, event read, or trace targeting a known live session does not consult persistence, so durable backend health cannot make current in-memory state unreadable. Persisted title and event operations list before loading and reject a metadata mismatch rather than combining inconsistent observations. `listSessions()` remains lightweight and does not load logs or index titles.
|
||||
Persistence is optional and may mount or unmount dynamically. Cross-corpus listing and lineage tracing fail with `SESSION_QUERY_PERSISTENCE_FAILED` while mounted persistence is unreadable. A title, event read, or trace targeting a known live session does not consult persistence, so durable backend health cannot make current in-memory state unreadable. Persisted title and event operations list before loading and reject a metadata mismatch rather than combining inconsistent observations. A batch title observation performs one metadata listing, inspects its unique persisted ids with at most four workers, and preserves each title's own observed header for downstream authorization. Cancellation starts no queued inspections and rejects only after already-started workers settle. `listSessions()` remains lightweight and does not load logs or index titles.
|
||||
|
||||
## Filtering and extraction
|
||||
|
||||
|
||||
@@ -15,6 +15,22 @@ export interface LogicalSession {
|
||||
events: SessionEvent[]
|
||||
}
|
||||
|
||||
/** Borrowed source visible only during one synchronous batch projection. */
|
||||
export interface LogicalSessionSource {
|
||||
/** Header selected with `events`; callers must clone retained output. */
|
||||
readonly header: SessionHeader
|
||||
/** Raw events selected with `header`; valid only for the projection call. */
|
||||
readonly events: readonly SessionEvent[]
|
||||
}
|
||||
|
||||
/** One source-projection result in a batch logical-corpus observation. */
|
||||
export type LogicalProjectionResult<Value> =
|
||||
| { sessionId: SessionId; status: 'fulfilled'; value: Value }
|
||||
| { sessionId: SessionId; status: 'rejected'; reason: unknown }
|
||||
|
||||
/** Bound persisted observation fan-out for public batch title reads. */
|
||||
const PERSISTED_INSPECT_CONCURRENCY = 4
|
||||
|
||||
/** Resolves a live-preferred corpus against the persistence service mounted now. */
|
||||
export class SessionCorpus {
|
||||
private _persistence: SessionPersistence | undefined
|
||||
@@ -72,16 +88,7 @@ export class SessionCorpus {
|
||||
if (persistence === undefined) throw notFound(sessionId)
|
||||
const listed = (await listPersisted(persistence)).find(header => header.id === sessionId)
|
||||
if (listed === undefined) throw notFound(sessionId)
|
||||
let loaded: Awaited<ReturnType<SessionPersistence['inspect']>>
|
||||
try {
|
||||
loaded = await persistence.inspect(sessionId)
|
||||
} catch (error: unknown) {
|
||||
throw new SessionQueryError(
|
||||
`failed to inspect session "${sessionId}": ${errorMessage(error)}`,
|
||||
'SESSION_QUERY_PERSISTENCE_FAILED',
|
||||
{ cause: error },
|
||||
)
|
||||
}
|
||||
const loaded = await inspectPersisted(persistence, sessionId)
|
||||
const attached = this._ctx.sessions.get(sessionId)
|
||||
if (attached !== undefined) return snapshotLive(attached)
|
||||
assertSessionHeadersCompatible(loaded.meta, listed)
|
||||
@@ -90,11 +97,147 @@ export class SessionCorpus {
|
||||
events: loaded.events.map(event => structuredClone(event)),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Project unique logical sources immediately from one persistence listing.
|
||||
*
|
||||
* The synchronous projector runs before a persisted worker claims its next id.
|
||||
* Full logs are borrowed only for that call and never retained by the batch.
|
||||
* @param sessionIds - sessions to resolve in first-occurrence order.
|
||||
* @param project - synchronous fold that owns/clones every retained value.
|
||||
* @param signal - cancellation shared by listing and every persisted inspection.
|
||||
* @returns one fulfilled or rejected projected result per unique requested id.
|
||||
*/
|
||||
async projectMany<Value>(
|
||||
sessionIds: readonly SessionId[],
|
||||
project: (source: LogicalSessionSource) => Value,
|
||||
signal?: AbortSignal,
|
||||
): Promise<LogicalProjectionResult<Value>[]> {
|
||||
const ids = [...new Set(sessionIds)]
|
||||
signal?.throwIfAborted()
|
||||
const resolved = new Map<SessionId, LogicalProjectionResult<Value>>()
|
||||
const unresolved: SessionId[] = []
|
||||
for (const id of ids) {
|
||||
const session = this._ctx.sessions.get(id)
|
||||
if (session === undefined) {
|
||||
unresolved.push(id)
|
||||
} else {
|
||||
resolved.set(id, projectSource(id, sourceLive(session), project, signal))
|
||||
}
|
||||
}
|
||||
if (unresolved.length === 0) return orderedResults(ids, resolved)
|
||||
|
||||
const persistence = this._persistence
|
||||
if (persistence === undefined) {
|
||||
for (const sessionId of unresolved) {
|
||||
resolved.set(sessionId, { sessionId, status: 'rejected', reason: notFound(sessionId) })
|
||||
}
|
||||
return orderedResults(ids, resolved)
|
||||
}
|
||||
|
||||
let persisted: SessionHeader[]
|
||||
try {
|
||||
persisted = await listPersisted(persistence, signal)
|
||||
signal?.throwIfAborted()
|
||||
} catch (error: unknown) {
|
||||
if (signal?.aborted) signal.throwIfAborted()
|
||||
for (const sessionId of unresolved) {
|
||||
resolved.set(sessionId, { sessionId, status: 'rejected', reason: error })
|
||||
}
|
||||
return orderedResults(ids, resolved)
|
||||
}
|
||||
const persistedById = new Map(persisted.map(header => [header.id, header]))
|
||||
const resolvePersisted = async (sessionId: SessionId): Promise<void> => {
|
||||
const listed = persistedById.get(sessionId)
|
||||
if (listed === undefined) {
|
||||
const attached = this._ctx.sessions.get(sessionId)
|
||||
resolved.set(sessionId, attached === undefined
|
||||
? { sessionId, status: 'rejected', reason: notFound(sessionId) }
|
||||
: projectSource(sessionId, sourceLive(attached), project, signal))
|
||||
return
|
||||
}
|
||||
try {
|
||||
signal?.throwIfAborted()
|
||||
const loaded = await inspectPersisted(persistence, sessionId, signal)
|
||||
signal?.throwIfAborted()
|
||||
const attached = this._ctx.sessions.get(sessionId)
|
||||
if (attached !== undefined) {
|
||||
resolved.set(sessionId, projectSource(sessionId, sourceLive(attached), project, signal))
|
||||
return
|
||||
}
|
||||
assertSessionHeadersCompatible(loaded.meta, listed)
|
||||
resolved.set(sessionId, projectSource(sessionId, {
|
||||
header: loaded.meta,
|
||||
events: loaded.events,
|
||||
}, project, signal))
|
||||
} catch (error: unknown) {
|
||||
if (signal?.aborted) signal.throwIfAborted()
|
||||
resolved.set(sessionId, { sessionId, status: 'rejected', reason: error })
|
||||
}
|
||||
}
|
||||
let cursor = 0
|
||||
const worker = async (): Promise<void> => {
|
||||
for (;;) {
|
||||
signal?.throwIfAborted()
|
||||
const index = cursor
|
||||
if (index >= unresolved.length) return
|
||||
cursor += 1
|
||||
await resolvePersisted(unresolved[index] as SessionId)
|
||||
}
|
||||
}
|
||||
const workerCount = Math.min(PERSISTED_INSPECT_CONCURRENCY, unresolved.length)
|
||||
const settlements = await Promise.allSettled(
|
||||
Array.from({ length: workerCount }, () => worker()),
|
||||
)
|
||||
if (signal?.aborted) signal.throwIfAborted()
|
||||
/* v8 ignore start -- per-id failures settle inside resolvePersisted; workers reject only on abort above */
|
||||
for (const settlement of settlements) {
|
||||
if (settlement.status === 'rejected') {
|
||||
const reason: unknown = settlement.reason
|
||||
throw reason
|
||||
}
|
||||
}
|
||||
/* v8 ignore stop */
|
||||
signal?.throwIfAborted()
|
||||
return orderedResults(ids, resolved)
|
||||
}
|
||||
}
|
||||
|
||||
async function listPersisted(persistence: SessionPersistence): Promise<SessionHeader[]> {
|
||||
function projectSource<Value>(
|
||||
sessionId: SessionId,
|
||||
source: LogicalSessionSource,
|
||||
project: (source: LogicalSessionSource) => Value,
|
||||
signal?: AbortSignal,
|
||||
): LogicalProjectionResult<Value> {
|
||||
try {
|
||||
return await persistence.list()
|
||||
signal?.throwIfAborted()
|
||||
const value = project(source)
|
||||
signal?.throwIfAborted()
|
||||
return { sessionId, status: 'fulfilled', value }
|
||||
} catch (reason: unknown) {
|
||||
/* v8 ignore next -- the synchronous projector has no external cancellation yield */
|
||||
if (signal?.aborted) signal.throwIfAborted()
|
||||
return { sessionId, status: 'rejected', reason }
|
||||
}
|
||||
}
|
||||
|
||||
function sourceLive(session: Session): LogicalSessionSource {
|
||||
return { header: session.header, events: session.events }
|
||||
}
|
||||
|
||||
function orderedResults<Value>(
|
||||
ids: readonly SessionId[],
|
||||
resolved: ReadonlyMap<SessionId, LogicalProjectionResult<Value>>,
|
||||
): LogicalProjectionResult<Value>[] {
|
||||
return ids.map(sessionId => resolved.get(sessionId) as LogicalProjectionResult<Value>)
|
||||
}
|
||||
|
||||
async function listPersisted(
|
||||
persistence: SessionPersistence,
|
||||
signal?: AbortSignal,
|
||||
): Promise<SessionHeader[]> {
|
||||
try {
|
||||
return await persistence.list(signal)
|
||||
} catch (error: unknown) {
|
||||
throw new SessionQueryError(
|
||||
`session persistence listing failed: ${errorMessage(error)}`,
|
||||
@@ -104,6 +247,23 @@ async function listPersisted(persistence: SessionPersistence): Promise<SessionHe
|
||||
}
|
||||
}
|
||||
|
||||
async function inspectPersisted(
|
||||
persistence: SessionPersistence,
|
||||
sessionId: SessionId,
|
||||
signal?: AbortSignal,
|
||||
): Promise<Awaited<ReturnType<SessionPersistence['inspect']>>> {
|
||||
try {
|
||||
return await persistence.inspect(sessionId, signal)
|
||||
} catch (error: unknown) {
|
||||
if (signal?.aborted) signal.throwIfAborted()
|
||||
throw new SessionQueryError(
|
||||
`failed to inspect session "${sessionId}": ${errorMessage(error)}`,
|
||||
'SESSION_QUERY_PERSISTENCE_FAILED',
|
||||
{ cause: error },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
function snapshotLive(session: Session): LogicalSession {
|
||||
return {
|
||||
header: structuredClone(session.header),
|
||||
|
||||
@@ -28,6 +28,7 @@ import type {
|
||||
SessionSearchRequest,
|
||||
SessionSurfaceSnapshot,
|
||||
SessionTitleObservation,
|
||||
SessionTitleObservationResult,
|
||||
} from './types.ts'
|
||||
import {
|
||||
SESSION_QUERY_READ_WINDOW_MAX,
|
||||
@@ -148,24 +149,51 @@ export abstract class SessionQueryService extends Service {
|
||||
/**
|
||||
* Fold the latest log-backed title from one live-preferred logical session.
|
||||
* @param sessionId - live or persisted session id to read.
|
||||
* @param signal - optional cancellation for source resolution and title folding.
|
||||
* @returns latest title snapshot, or `undefined` when the log has no title event.
|
||||
*/
|
||||
async readTitle(sessionId: SessionId): Promise<SessionTitleSnapshot | undefined> {
|
||||
return (await this.readTitleSnapshot(sessionId)).title
|
||||
async readTitle(
|
||||
sessionId: SessionId,
|
||||
signal?: AbortSignal,
|
||||
): Promise<SessionTitleSnapshot | undefined> {
|
||||
return (await this.readTitleSnapshot(sessionId, signal)).title
|
||||
}
|
||||
|
||||
/**
|
||||
* Fold the latest title and return its source header from one corpus observation.
|
||||
* @param sessionId - live or persisted session id to read.
|
||||
* @param signal - optional cancellation for source resolution and title folding.
|
||||
* @returns cloned source header and optional latest title snapshot.
|
||||
*/
|
||||
async readTitleSnapshot(sessionId: SessionId): Promise<SessionTitleObservation> {
|
||||
const loaded = await this._corpus.load(sessionId)
|
||||
const title = foldSessionTitle(loaded.events)
|
||||
return {
|
||||
session: loaded.header,
|
||||
...title === undefined ? {} : { title },
|
||||
}
|
||||
async readTitleSnapshot(
|
||||
sessionId: SessionId,
|
||||
signal?: AbortSignal,
|
||||
): Promise<SessionTitleObservation> {
|
||||
const result = (await this.readTitleSnapshots([sessionId], signal))[0] as SessionTitleObservationResult
|
||||
if (result.status === 'rejected') throw result.reason
|
||||
return result.value
|
||||
}
|
||||
|
||||
/**
|
||||
* Fold titles for unique sessions from one cancellable corpus observation.
|
||||
*
|
||||
* Results preserve first-occurrence input order. Operational failures stay
|
||||
* isolated per session, while cancellation rejects the complete operation.
|
||||
* @param sessionIds - live or persisted session ids to observe.
|
||||
* @param signal - optional cancellation shared by all source reads.
|
||||
* @returns one fulfilled or rejected result per unique requested id.
|
||||
*/
|
||||
async readTitleSnapshots(
|
||||
sessionIds: readonly SessionId[],
|
||||
signal?: AbortSignal,
|
||||
): Promise<SessionTitleObservationResult[]> {
|
||||
return this._corpus.projectMany(sessionIds, (source): SessionTitleObservation => {
|
||||
const title = foldSessionTitle(source.events)
|
||||
return {
|
||||
session: structuredClone(source.header),
|
||||
...title === undefined ? {} : { title },
|
||||
}
|
||||
}, signal)
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -157,6 +157,25 @@ export interface SessionTitleObservation {
|
||||
title?: SessionTitleSnapshot
|
||||
}
|
||||
|
||||
/** One ordered result from a batch title observation. */
|
||||
export type SessionTitleObservationResult =
|
||||
| {
|
||||
/** Requested session id. */
|
||||
sessionId: SessionId
|
||||
/** Successful atomic header/title observation. */
|
||||
status: 'fulfilled'
|
||||
/** Header and optional latest title from one logical source. */
|
||||
value: SessionTitleObservation
|
||||
}
|
||||
| {
|
||||
/** Requested session id. */
|
||||
sessionId: SessionId
|
||||
/** Operational failure isolated to this session. */
|
||||
status: 'rejected'
|
||||
/** Original failure from logical-source resolution or title folding. */
|
||||
reason: unknown
|
||||
}
|
||||
|
||||
/** Inclusive numeric interval used by time and sequence filters. */
|
||||
export interface SessionResultRange {
|
||||
/** Inclusive lower bound. */
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
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'
|
||||
@@ -27,16 +27,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 +74,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 +92,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?.()
|
||||
@@ -192,6 +218,336 @@ 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(4)
|
||||
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 ctx = await liveContext()
|
||||
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(4) })
|
||||
controller.abort(reason)
|
||||
await vi.waitFor(() => { expect(abortsObserved).toBe(4) })
|
||||
|
||||
expect(batchSettled).toBe(false)
|
||||
expect(TestPersistence.inspectCalls).toEqual(entries.slice(0, 4).map(entry => entry.meta.id))
|
||||
for (const release of releases) release()
|
||||
|
||||
await expect(pending).rejects.toBe(reason)
|
||||
expect(inspectionsSettled).toBe(4)
|
||||
expect(TestPersistence.inspectCalls).toEqual(entries.slice(0, 4).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 } })
|
||||
|
||||
@@ -741,8 +741,16 @@ async function readTitles(
|
||||
signal: AbortSignal,
|
||||
): Promise<CompleteTitleMap> {
|
||||
const result = new Map<SessionIdValue, TitleView>()
|
||||
for (const id of new Set(ids)) {
|
||||
result.set(id, await readTitle(ctx, caller, id, signal))
|
||||
signal.throwIfAborted()
|
||||
const observations = await ctx.sessionQuery.readTitleSnapshots(ids, signal)
|
||||
signal.throwIfAborted()
|
||||
for (const observation of observations) {
|
||||
if (observation.status === 'rejected') {
|
||||
result.set(observation.sessionId, unavailableTitle(ctx, observation.sessionId, observation.reason))
|
||||
continue
|
||||
}
|
||||
assertObservedTargetAuthorized(caller, observation.sessionId, observation.value.session)
|
||||
result.set(observation.sessionId, { text: observation.value.title?.title ?? 'untitled' })
|
||||
}
|
||||
return result as CompleteTitleMap
|
||||
}
|
||||
@@ -753,19 +761,18 @@ async function readTitle(
|
||||
id: SessionIdValue,
|
||||
signal: AbortSignal,
|
||||
): Promise<TitleView> {
|
||||
signal.throwIfAborted()
|
||||
try {
|
||||
const observation = await ctx.sessionQuery.readTitleSnapshot(id)
|
||||
signal.throwIfAborted()
|
||||
assertObservedTargetAuthorized(caller, id, observation.session)
|
||||
return { text: observation.title?.title ?? 'untitled' }
|
||||
} catch (error: unknown) {
|
||||
if (signal.aborted) signal.throwIfAborted()
|
||||
if (error instanceof HarnessError && error.code === 'SESSION_QUERY_TOOL_UNAUTHORIZED') throw error
|
||||
const code = error instanceof HarnessError ? error.code : 'UNKNOWN'
|
||||
ctx.logger.warn(`tool-session-query: title read failed for session "${id}": ${fullError(error)}`)
|
||||
return { text: 'untitled', unavailableCode: code }
|
||||
}
|
||||
return (await readTitles(ctx, caller, [id], signal)).get(id)
|
||||
}
|
||||
|
||||
function unavailableTitle(
|
||||
ctx: Context,
|
||||
id: SessionIdValue,
|
||||
error: unknown,
|
||||
): TitleView {
|
||||
if (error instanceof HarnessError && error.code === 'SESSION_QUERY_TOOL_UNAUTHORIZED') throw error
|
||||
const code = error instanceof HarnessError ? error.code : 'UNKNOWN'
|
||||
ctx.logger.warn(`tool-session-query: title read failed for session "${id}": ${fullError(error)}`)
|
||||
return { text: 'untitled', unavailableCode: code }
|
||||
}
|
||||
|
||||
function fullError(error: unknown): string {
|
||||
|
||||
@@ -21,6 +21,7 @@ import SessionQueryService, {
|
||||
type SessionSearchHit,
|
||||
type SessionSearchPage,
|
||||
type SessionSearchRequest,
|
||||
type SessionTitleObservationResult,
|
||||
} from '@deepseek-ai/dsh-session-query'
|
||||
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
|
||||
import ToolRegistry, { type ToolExecutionResult } from '@deepseek-ai/dsh-tools'
|
||||
@@ -155,20 +156,31 @@ class FakeQuery extends SessionQueryService {
|
||||
return FakeQuery.eventSearch(request, exec)
|
||||
}
|
||||
|
||||
override async readTitleSnapshot(sessionId: SessionIdValue) {
|
||||
const value = FakeQuery.titles.get(sessionId)
|
||||
if (value instanceof Error) throw value
|
||||
if (value === undefined) return super.readTitleSnapshot(sessionId)
|
||||
return {
|
||||
session: (await this.readSurface(sessionId)).session,
|
||||
title: {
|
||||
title: value,
|
||||
messageSeqs: [],
|
||||
source: { kind: 'fallback' as const },
|
||||
eventSeq: 0,
|
||||
updatedAt: 1,
|
||||
},
|
||||
}
|
||||
override async readTitleSnapshots(
|
||||
sessionIds: readonly SessionIdValue[],
|
||||
signal?: AbortSignal,
|
||||
): Promise<SessionTitleObservationResult[]> {
|
||||
const observations = await super.readTitleSnapshots(sessionIds, signal)
|
||||
return observations.map((observation): SessionTitleObservationResult => {
|
||||
const value = FakeQuery.titles.get(observation.sessionId)
|
||||
if (value instanceof Error) {
|
||||
return { sessionId: observation.sessionId, status: 'rejected', reason: value }
|
||||
}
|
||||
if (value === undefined || observation.status === 'rejected') return observation
|
||||
return {
|
||||
...observation,
|
||||
value: {
|
||||
...observation.value,
|
||||
title: {
|
||||
title: value,
|
||||
messageSeqs: [],
|
||||
source: { kind: 'fallback' },
|
||||
eventSeq: 0,
|
||||
updatedAt: 1,
|
||||
},
|
||||
},
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -494,9 +506,13 @@ describe('workspace authority and lineage redaction', () => {
|
||||
root: targetRecord,
|
||||
})
|
||||
const titleReads: SessionIdValue[] = []
|
||||
vi.spyOn(mounted.ctx.sessionQuery, 'readTitleSnapshot').mockImplementation((sessionId) => {
|
||||
titleReads.push(sessionId)
|
||||
return Promise.resolve({ session: header(sessionId, '/work') })
|
||||
vi.spyOn(mounted.ctx.sessionQuery, 'readTitleSnapshots').mockImplementation((sessionIds) => {
|
||||
titleReads.push(...sessionIds)
|
||||
return Promise.resolve([...new Set(sessionIds)].map(sessionId => ({
|
||||
sessionId,
|
||||
status: 'fulfilled' as const,
|
||||
value: { session: header(sessionId, '/work') },
|
||||
})))
|
||||
})
|
||||
|
||||
const output = text(await mounted.call('session_trace', { session_id: target.id }))
|
||||
@@ -596,16 +612,20 @@ describe('workspace authority and lineage redaction', () => {
|
||||
FakeQuery.sessionSearch = () => Promise.resolve({
|
||||
items: [sessionHit(target.id, '/work', 'safe hit')],
|
||||
})
|
||||
vi.spyOn(mounted.ctx.sessionQuery, 'readTitleSnapshot').mockResolvedValueOnce({
|
||||
session: movedHeader,
|
||||
title: {
|
||||
title: 'secret moved title',
|
||||
messageSeqs: [],
|
||||
source: { kind: 'fallback' },
|
||||
eventSeq: 0,
|
||||
updatedAt: 1,
|
||||
vi.spyOn(mounted.ctx.sessionQuery, 'readTitleSnapshots').mockResolvedValueOnce([{
|
||||
sessionId: target.id,
|
||||
status: 'fulfilled',
|
||||
value: {
|
||||
session: movedHeader,
|
||||
title: {
|
||||
title: 'secret moved title',
|
||||
messageSeqs: [],
|
||||
source: { kind: 'fallback' },
|
||||
eventSeq: 0,
|
||||
updatedAt: 1,
|
||||
},
|
||||
},
|
||||
})
|
||||
}])
|
||||
const titled = await mounted.call('session_search', { query: 'safe' })
|
||||
expect(errorCode(titled)).toBe('SESSION_QUERY_TOOL_UNAUTHORIZED')
|
||||
expect(text(titled)).not.toContain('secret moved title')
|
||||
@@ -828,9 +848,11 @@ describe('search paging, prior-history bounds, titles, and cancellation', () =>
|
||||
const second = createSession(mounted.ctx, 'stackless-title', '/work')
|
||||
const stackless = new Error('stackless')
|
||||
Object.defineProperty(stackless, 'stack', { value: undefined })
|
||||
const readTitle = vi.spyOn(mounted.ctx.sessionQuery, 'readTitleSnapshot')
|
||||
.mockRejectedValueOnce('string failure')
|
||||
.mockRejectedValueOnce(stackless)
|
||||
const readTitles = vi.spyOn(mounted.ctx.sessionQuery, 'readTitleSnapshots')
|
||||
.mockResolvedValueOnce([
|
||||
{ sessionId: first.id, status: 'rejected', reason: 'string failure' },
|
||||
{ sessionId: second.id, status: 'rejected', reason: stackless },
|
||||
])
|
||||
FakeQuery.sessionSearch = () => Promise.resolve({
|
||||
items: [
|
||||
sessionHit(first.id, '/work'),
|
||||
@@ -840,7 +862,8 @@ describe('search paging, prior-history bounds, titles, and cancellation', () =>
|
||||
const warn = vi.spyOn(mounted.ctx.logger, 'warn').mockImplementation(() => undefined)
|
||||
const result = await mounted.call('session_search', { query: 'needle' })
|
||||
expect(text(result)).toContain('title unavailable: UNKNOWN')
|
||||
expect(readTitle).toHaveBeenCalledTimes(2)
|
||||
expect(readTitles).toHaveBeenCalledTimes(1)
|
||||
expect(readTitles.mock.calls[0]?.[0]).toEqual([first.id, second.id])
|
||||
expect(warn).toHaveBeenCalledWith(expect.stringContaining('string failure'))
|
||||
expect(warn).toHaveBeenCalledWith(expect.stringContaining('Error: stackless'))
|
||||
})
|
||||
@@ -849,14 +872,43 @@ describe('search paging, prior-history bounds, titles, and cancellation', () =>
|
||||
const mounted = await mount()
|
||||
const hit = createSession(mounted.ctx, 'abort-title', '/work')
|
||||
const controller = new AbortController()
|
||||
const cancellation = new Error('cancelled title batch')
|
||||
FakeQuery.sessionSearch = () => Promise.resolve({ items: [sessionHit(hit.id, '/work')] })
|
||||
vi.spyOn(mounted.ctx.sessionQuery, 'readTitleSnapshot').mockImplementation(() => {
|
||||
controller.abort()
|
||||
return Promise.reject(new Error('cancelled title'))
|
||||
let started!: () => void
|
||||
const batchStarted = new Promise<void>((resolve) => { started = resolve })
|
||||
const readTitles = vi.spyOn(mounted.ctx.sessionQuery, 'readTitleSnapshots').mockImplementation((_ids, signal) => {
|
||||
started()
|
||||
return new Promise((_resolve, reject) => {
|
||||
signal?.addEventListener('abort', () => { reject(cancellation) }, { once: true })
|
||||
})
|
||||
})
|
||||
const result = await mounted.call('session_search', { query: 'needle' }, { signal: controller.signal })
|
||||
const pending = mounted.call('session_search', { query: 'needle' }, { signal: controller.signal })
|
||||
await batchStarted
|
||||
controller.abort(cancellation)
|
||||
const result = await pending
|
||||
expect(result.isError).toBe(true)
|
||||
expect(text(result)).not.toContain('title unavailable')
|
||||
expect(readTitles.mock.calls[0]?.[1]).toBe(controller.signal)
|
||||
})
|
||||
|
||||
it('does not downgrade an authorization failure returned by title observation', async () => {
|
||||
const mounted = await mount()
|
||||
const hit = createSession(mounted.ctx, 'unauthorized-title-error', '/work')
|
||||
const failure = new HarnessError(
|
||||
'title observation became unauthorized',
|
||||
'SESSION_QUERY_TOOL_UNAUTHORIZED',
|
||||
)
|
||||
FakeQuery.sessionSearch = () => Promise.resolve({ items: [sessionHit(hit.id, '/work')] })
|
||||
vi.spyOn(mounted.ctx.sessionQuery, 'readTitleSnapshots').mockResolvedValueOnce([{
|
||||
sessionId: hit.id,
|
||||
status: 'rejected',
|
||||
reason: failure,
|
||||
}])
|
||||
|
||||
const result = await mounted.call('session_search', { query: 'needle' })
|
||||
|
||||
expect(errorCode(result)).toBe('SESSION_QUERY_TOOL_UNAUTHORIZED')
|
||||
expect(text(result)).not.toContain('title unavailable')
|
||||
})
|
||||
|
||||
it('passes the exact execution signal to every FTS page and stops on cancellation', async () => {
|
||||
@@ -907,9 +959,13 @@ describe('trace and exact read rendering', () => {
|
||||
complete: true,
|
||||
root: targetRecord,
|
||||
})
|
||||
vi.spyOn(mounted.ctx.sessionQuery, 'readTitleSnapshot').mockImplementation(sessionId => Promise.resolve({
|
||||
session: header(sessionId, '/work'),
|
||||
}))
|
||||
vi.spyOn(mounted.ctx.sessionQuery, 'readTitleSnapshots').mockImplementation(sessionIds => Promise.resolve(
|
||||
[...new Set(sessionIds)].map(sessionId => ({
|
||||
sessionId,
|
||||
status: 'fulfilled' as const,
|
||||
value: { session: header(sessionId, '/work') },
|
||||
})),
|
||||
))
|
||||
|
||||
const output = text(await mounted.call('session_trace', { session_id: target.id }))
|
||||
expect(output).toContain('Descendants:\n- deep-1 —')
|
||||
|
||||
Reference in New Issue
Block a user