fix(session): harden cross-session references

This commit is contained in:
Yichen Jiang
2026-07-21 17:53:30 +08:00
parent 8394898ef5
commit ebb62c482c
19 changed files with 213 additions and 55 deletions

View File

@@ -102,15 +102,22 @@ export class SessionReferenceService extends Service {
* @param agent - target agent; self is excluded and its cwd drives ranking.
* @param query - optional case-insensitive session-id/cwd substring.
* @param limit - optional positive result cap.
* @param signal - optional cancellation boundary for host autocomplete teardown.
* @returns candidate records in stable source creation order within each rank.
*/
async listCandidates(agent: Agent, query = '', limit = this.config.candidateLimit): Promise<SessionReferenceCandidate[]> {
async listCandidates(
agent: Agent,
query = '',
limit = this.config.candidateLimit,
signal?: AbortSignal,
): Promise<SessionReferenceCandidate[]> {
if (!Number.isSafeInteger(limit) || limit <= 0) {
throw new SessionReferenceError('candidate limit must be a positive safe integer', 'SESSION_REFERENCE_INVALID_REFERENCE')
}
const needle = query.toLocaleLowerCase()
const targetCwd = agent.session.header.cwd
const records = (await this.ctx.sessionQuery.listSessions())
assertNotCancelled(signal)
const records = (await settleWithCancellation(this.ctx.sessionQuery.listSessions(), signal))
.filter(record => record.header.id !== agent.id)
.filter((record) => {
if (needle === '') return true
@@ -149,10 +156,13 @@ export class SessionReferenceService extends Service {
assertNotCancelled(signal)
let prepared: PreparedSource[]
try {
prepared = await Promise.all(inputs.map(async input => ({
input,
snapshot: await this.ctx.sessionQuery.readSurface(input.sessionId),
})))
prepared = await settleWithCancellation(
Promise.all(inputs.map(async input => ({
input,
snapshot: await this.ctx.sessionQuery.readSurface(input.sessionId),
}))),
signal,
)
} catch (error: unknown) {
if (signal?.aborted === true) throw cancelled(signal)
throw new SessionReferenceError(
@@ -258,6 +268,25 @@ function assertNotCancelled(signal: AbortSignal | undefined): void {
if (signal?.aborted === true) throw cancelled(signal)
}
function settleWithCancellation<T>(work: Promise<T>, signal: AbortSignal | undefined): Promise<T> {
if (signal === undefined) return work
return new Promise<T>((resolve, reject) => {
const onAbort = (): void => { reject(cancelled(signal)) }
signal.addEventListener('abort', onAbort, { once: true })
void work.then(
(value) => {
signal.removeEventListener('abort', onAbort)
resolve(value)
},
(error: unknown) => {
signal.removeEventListener('abort', onAbort)
reject(error instanceof Error ? error : new Error(String(error)))
},
)
if (signal.aborted) onAbort()
})
}
function cancelled(signal: AbortSignal): SessionReferenceError {
return new SessionReferenceError('session reference preparation was cancelled', 'SESSION_REFERENCE_CANCELLED', { cause: signal.reason })
}

View File

@@ -190,6 +190,21 @@ describe('session reference discovery and preparation', () => {
])
await expect(ctx.sessionReferences.listCandidates(fakeAgent(target), '', 0))
.rejects.toThrow(expectCode('SESSION_REFERENCE_INVALID_REFERENCE'))
let releaseList: (() => void) | undefined
const listSessions = vi.spyOn(ctx.sessionQuery, 'listSessions').mockImplementationOnce(async () => {
await new Promise<void>((resolve) => { releaseList = resolve })
return []
})
const controller = new AbortController()
const pending = ctx.sessionReferences.listCandidates(fakeAgent(target), '', undefined, controller.signal)
await vi.waitFor(() => { expect(releaseList).toBeTypeOf('function') })
const cancelledList = expect(pending).rejects.toThrow(expectCode('SESSION_REFERENCE_CANCELLED'))
controller.abort('autocomplete superseded')
await cancelledList
releaseList?.()
await Promise.resolve()
listSessions.mockRestore()
})
it('projects only the current user/assistant surface and records snapshot metadata', async () => {
@@ -309,6 +324,9 @@ describe('session reference discovery and preparation', () => {
readSurface.mockRejectedValueOnce('non-error read failure')
await expect(ctx.sessionReferences.prepare(agent, content, [{ sessionId: one.id }]))
.rejects.toThrow(/non-error read failure/)
readSurface.mockRejectedValueOnce('non-error signalled read failure')
await expect(ctx.sessionReferences.prepare(agent, content, [{ sessionId: one.id }], new AbortController().signal))
.rejects.toThrow(/non-error signalled read failure/)
const duringRead = new AbortController()
readSurface.mockImplementationOnce(async () => {
@@ -317,6 +335,21 @@ describe('session reference discovery and preparation', () => {
})
await expect(ctx.sessionReferences.prepare(agent, content, [{ sessionId: one.id }], duringRead.signal))
.rejects.toThrow(expectCode('SESSION_REFERENCE_CANCELLED'))
const snapshot = await ctx.sessionQuery.readSurface(one.id)
let releaseRead: (() => void) | undefined
readSurface.mockImplementationOnce(async () => {
await new Promise<void>((resolve) => { releaseRead = resolve })
return snapshot
})
const hangingRead = new AbortController()
const pending = ctx.sessionReferences.prepare(agent, content, [{ sessionId: one.id }], hangingRead.signal)
await vi.waitFor(() => { expect(releaseRead).toBeTypeOf('function') })
const cancelledRead = expect(pending).rejects.toThrow(expectCode('SESSION_REFERENCE_CANCELLED'))
hangingRead.abort('cancelled while storage remained pending')
await cancelledRead
releaseRead?.()
await Promise.resolve()
readSurface.mockRestore()
const abort = new AbortController()