fix(session-title): honor request lifecycle

This commit is contained in:
Tianyi Cui
2026-07-21 13:37:28 +08:00
parent a9d518a38e
commit 63cb16a540
12 changed files with 169 additions and 15 deletions

View File

@@ -1,5 +1,6 @@
import { Context } from 'cordis'
import { describe, expect, it, vi } from 'vitest'
import LlmService, { deepFreeze } from '@deepseek-ai/dsh-llm'
import SessionStore, { SessionId } from '@deepseek-ai/dsh-session'
import SessionTitleService, {
SessionTitleProviderId,
@@ -265,6 +266,95 @@ describe('SessionTitleService provider lifecycle', () => {
expect(ctx.sessionTitle.get(session)?.title).toBe('Newest complete title')
})
it('runs an all-messages revision when the next main request reuses its logged header', async () => {
const ctx = new Context()
await ctx.plugin(LlmService)
await ctx.plugin(SessionStore)
await ctx.plugin(SessionTitleService, CONFIG)
const requests: SessionTitleProviderRequest[] = []
ctx.sessionTitle.register({
id: SessionTitleProviderId('unchanged-route'),
automatic: 'all-user-messages',
async generate(request) {
requests.push(request)
return {
title: `Revision ${requests.length}`,
messageSeqs: request.messages.map(message => message.seq),
}
},
})
const session = ctx.sessions.create(SessionId('unchanged-route'))
session.append('turn/start', {
turn: 1,
trigger: { kind: 'message', source: { kind: 'user' } },
})
const first = appendHumanPrompt(session, 'First routed prompt')
await settle()
session.append('step/start', { turn: 1, step: 1 })
appendRoute(session)
await settle()
session.append('step/end', { turn: 1, step: 1 })
session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
session.append('turn/start', {
turn: 2,
trigger: { kind: 'message', source: { kind: 'user' } },
})
const second = appendHumanPrompt(session, 'Second prompt on the same route')
await settle()
session.append('step/start', { turn: 2, step: 1 })
void ctx.llm.stream(deepFreeze({
provider: 'main-route',
model: 'chat-model',
messages: session.deriveMessages(),
sessionId: session.id,
}))
await settle()
expect(session.events.filter(event => event.type === 'request/header')).toHaveLength(1)
expect(requests).toHaveLength(2)
expect(requests[1]).toMatchObject({
messages: [
{ seq: first.seq, text: 'First routed prompt' },
{ seq: second.seq, text: 'Second prompt on the same route' },
],
route: { provider: 'main-route', model: 'chat-model' },
})
})
it('ignores model streams that are not a matching loop request', async () => {
const ctx = new Context()
await ctx.plugin(LlmService)
await ctx.plugin(SessionStore)
await ctx.plugin(SessionTitleService, CONFIG)
const generate = vi.fn(async (request: SessionTitleProviderRequest): Promise<SessionTitleProviderResult> => ({
title: 'Unexpected title',
messageSeqs: request.messages.map(message => message.seq),
}))
ctx.sessionTitle.register({
id: SessionTitleProviderId('request-filter'),
automatic: 'all-user-messages',
generate,
})
const options = { provider: 'main-route', model: 'chat-model', messages: [] }
void ctx.llm.stream(deepFreeze(options))
void ctx.llm.stream(deepFreeze({ ...options, sessionId: SessionId('missing') }))
const quiet = ctx.sessions.create(SessionId('quiet'))
void ctx.llm.stream(deepFreeze({ ...options, sessionId: quiet.id }))
const pending = ctx.sessions.create(SessionId('unmatched-boundary'))
pending.append('turn/start', {
turn: 1,
trigger: { kind: 'message', source: { kind: 'user' } },
})
appendHumanPrompt(pending, 'Wait for a matching request boundary')
await settle()
void ctx.llm.stream(deepFreeze({ ...options, sessionId: pending.id }))
await settle()
expect(generate).not.toHaveBeenCalled()
})
it('contains automatic failures but lets explicit refresh reject', async () => {
const ctx = new Context()
await ctx.plugin(SessionStore)

View File

@@ -162,6 +162,37 @@ describe('SessionTitleService configuration and refresh boundaries', () => {
expect(disposeSignal?.aborted).toBe(true)
})
it('rejects fallback refresh cancellation that arrives during durability flush', async () => {
const ctx = await setup()
const seed = new Session(SessionId('fallback-cancel-seed'))
seed.append('turn/start', {
turn: 1,
trigger: { kind: 'message', source: { kind: 'user' } },
})
const source = appendPrompt(seed, 'Persist this fallback despite caller cancellation')
seed.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
const session = ctx.sessions.create(SessionId('fallback-cancel'), { seed: seed.events })
const flushStarted = deferred<undefined>()
const releaseFlush = deferred<undefined>()
ctx.on('session/flush', async (subject) => {
if (subject !== session) return
flushStarted.resolve(undefined)
await releaseFlush.promise
})
const controller = new AbortController()
const refresh = ctx.sessionTitle.refresh(session, controller.signal)
await flushStarted.promise
controller.abort(new Error('cancelled while fallback flushed'))
releaseFlush.resolve(undefined)
await expect(refresh).rejects.toThrow('cancelled while fallback flushed')
expect(ctx.sessionTitle.get(session)).toMatchObject({
messageSeqs: [source.seq],
source: { kind: 'fallback' },
})
})
it('reserves overlapping refresh order before fallback durability settles', async () => {
const ctx = new Context()
await ctx.plugin(SessionStore)