fix(session-title): harden async provider lifecycle

This commit is contained in:
Tianyi Cui
2026-07-21 12:08:00 +08:00
parent bb7db52941
commit a9d518a38e
25 changed files with 491 additions and 102 deletions

View File

@@ -484,8 +484,8 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [
jsDoc: '/**\n * Explicitly retry the registered provider, or materialize the built-in\n * fallback when no provider is registered.\n * @param session - exact live session to refresh.\n * @param signal - optional caller cancellation.\n * @returns latest accepted title, or `undefined` when no eligible text exists.\n */',
},
{
signature: 'register(provider: SessionTitleProvider): () => void',
jsDoc: '/**\n * Register the sole optional title provider. Disposal aborts its pending and\n * active work before another provider may register.\n * @param provider - provider identity, cadence, and generation function.\n * @returns exact Cordis effect disposer for HMR-safe unregistration.\n */',
signature: 'register(provider: SessionTitleProvider): () => Promise<void>',
jsDoc: '/**\n * Register the sole optional title provider. Disposal aborts its pending and\n * active work before another provider may register.\n * @param provider - provider identity, cadence, and generation function.\n * @returns exact Cordis effect disposer, which settles after active calls quiesce.\n */',
},
],
},

View File

@@ -5,7 +5,7 @@ Durable session-title state, one optional asynchronous provider seam, and two op
| Package | Role | ctx key |
|---|---|---|
| [`session-title/`](session-title/README.md) | Log fold, deterministic fallback, provider registry, and refresh API | `ctx.sessionTitle` |
| [`session-title-llm/`](session-title-llm/README.md) | Shared route, prompt, timeout, stream, and validation helper | — |
| [`session-title-llm/`](session-title-llm/README.md) | Shared route, request logging, prompt, timeout, stream, and validation helper | — |
| [`session-title-first-message-llm/`](session-title-first-message-llm/README.md) | Optional provider using the first eligible human message | registers on `ctx.sessionTitle` |
| [`session-title-all-messages-llm/`](session-title-all-messages-llm/README.md) | Optional provider using every eligible human message | registers on `ctx.sessionTitle` |

View File

@@ -17,6 +17,7 @@
"license": "BSD-3-Clause",
"peerDependencies": {
"@deepseek-ai/dsh-llm": "^0.0.1",
"@deepseek-ai/dsh-session": "^0.0.1",
"@deepseek-ai/dsh-session-title": "^0.0.1",
"@deepseek-ai/dsh-session-title-llm": "^0.0.1",
"cordis": "^4.0.0-rc.7"

View File

@@ -9,7 +9,7 @@ import {
import type { SessionTitleLlmConfig } from '@deepseek-ai/dsh-session-title-llm'
export const name = 'session-title-all-messages-llm'
export const inject = ['sessionTitle', 'llm']
export const inject = ['sessionTitle', 'llm', 'sessions']
/** Required LLM policy; this plugin adds no defaults. */
export type Config = SessionTitleLlmConfig
@@ -28,7 +28,7 @@ export const Config: z<Config> = z.object({
/**
* Register the all-user-messages model provider.
* @param ctx - context exposing session-title and LLM services.
* @param ctx - context exposing session-title, LLM, and session services.
* @param config - required route, target, byte, token, and timeout policy.
*/
export function apply(ctx: Context, config: Config): void {

View File

@@ -17,6 +17,7 @@
"license": "BSD-3-Clause",
"peerDependencies": {
"@deepseek-ai/dsh-llm": "^0.0.1",
"@deepseek-ai/dsh-session": "^0.0.1",
"@deepseek-ai/dsh-session-title": "^0.0.1",
"@deepseek-ai/dsh-session-title-llm": "^0.0.1",
"cordis": "^4.0.0-rc.7"

View File

@@ -9,7 +9,7 @@ import {
import type { SessionTitleLlmConfig } from '@deepseek-ai/dsh-session-title-llm'
export const name = 'session-title-first-message-llm'
export const inject = ['sessionTitle', 'llm']
export const inject = ['sessionTitle', 'llm', 'sessions']
/** Required LLM policy; this plugin adds no defaults. */
export type Config = SessionTitleLlmConfig
@@ -28,7 +28,7 @@ export const Config: z<Config> = z.object({
/**
* Register the first-message model provider.
* @param ctx - context exposing session-title and LLM services.
* @param ctx - context exposing session-title, LLM, and session services.
* @param config - required route, target, byte, token, and timeout policy.
*/
export function apply(ctx: Context, config: Config): void {

View File

@@ -40,7 +40,7 @@ describe('first-message LLM title provider', () => {
let registered: SessionTitleProvider | undefined
vi.spyOn(ctx.sessionTitle, 'register').mockImplementation((provider) => {
registered = provider
return () => undefined
return async () => undefined
})
providerPlugin.apply(ctx, LLM_CONFIG)

View File

@@ -1,6 +1,6 @@
# @deepseek-ai/dsh-session-title-llm
Shared implementation policy for model-backed session-title providers. It resolves the auxiliary route, frames exact selected human messages as JSON, applies a language-aware title instruction, enforces input and output budgets, composes timeout and caller cancellation, assembles the stream, and returns normalized text with exact source seqs and model provenance.
Shared implementation policy for model-backed session-title providers. It resolves the auxiliary route, frames exact selected human messages as JSON, records the exact dispatchable request, applies a language-aware title instruction, enforces input and output budgets, composes timeout and caller cancellation, assembles the stream, and returns normalized text with exact source seqs and model provenance.
This package is a library, not a Cordis plugin. The provider plugins call `registerSessionTitleLlmProvider()` with their cadence and message selector; it validates shared config and delegates each revision to `generateSessionTitleWithLlm()`, so registration, route, prompt, cancellation, and validation behavior cannot drift between them.
@@ -8,6 +8,8 @@ This package is a library, not a Cordis plugin. The provider plugins call `regis
`provider` and `model` overrides are optional but must be supplied together as non-empty strings. Without that pair, the helper uses the exact provider/model route captured from the current session's logged `request/header`; an explicit refresh before any route exists therefore needs overrides. Input exceeding `maxInputBytes` rejects instead of being truncated. Timeout, cancellation, malformed or empty output, tool calls, and non-stop finish reasons also reject; the session-title service decides whether that rejection is an automatic warning or an explicit caller failure.
After route and input validation, the helper appends a log-only `session/title-llm-request` event before model dispatch. It contains the title-provider id, exact source seqs, route, system prompt, message list, and output-token cap used by the call. A later model failure leaves that request record intact; validation failures that never become dispatchable requests do not create one. The event stays outside derived model history.
## Configuration
Every field is required except the paired route override; there are no library defaults.

View File

@@ -23,6 +23,7 @@
"license": "BSD-3-Clause",
"peerDependencies": {
"@deepseek-ai/dsh-llm": "^0.0.1",
"@deepseek-ai/dsh-session": "^0.0.1",
"@deepseek-ai/dsh-session-title": "^0.0.1",
"@deepseek-ai/dsh-timeout": "^0.0.1",
"cordis": "^4.0.0-rc.7"

View File

@@ -7,7 +7,7 @@
import type { Context } from 'cordis'
import z from 'schemastery'
import { BlockAssembler, deepFreeze } from '@deepseek-ai/dsh-llm'
import type { FinishReason, GenerateOptions } from '@deepseek-ai/dsh-llm'
import type { FinishReason, GenerateOptions, Message } from '@deepseek-ai/dsh-llm'
import { deadline, MAX_TIMER_DELAY_MS } from '@deepseek-ai/dsh-timeout'
import { normalizeSessionTitle, SessionTitleProviderId } from '@deepseek-ai/dsh-session-title'
import type {
@@ -18,6 +18,33 @@ import type {
SessionTitleUserMessage,
} from '@deepseek-ai/dsh-session-title'
/** Exact model-visible request recorded before one auxiliary title dispatch. */
export interface SessionTitleLlmRequestEventData {
/** Registered title-provider identity responsible for the request. */
readonly titleProvider: SessionTitleProviderId
/** Exact human `user/message` seqs represented in `messages`. */
readonly messageSeqs: number[]
/** Exact auxiliary LLM route. */
readonly route: SessionTitleModelProvenance
/** Exact auxiliary system prompt. */
readonly system: string
/** Exact auxiliary message list. */
readonly messages: Message[]
/** Exact auxiliary output-token cap. */
readonly maxTokens: number
}
declare module '@deepseek-ai/dsh-session' {
interface SessionEventMap {
/** Log-only pre-dispatch record of one session-title model request. */
'session/title-llm-request': SessionTitleLlmRequestEventData
}
interface OutOfBandSessionEventMap {
'session/title-llm-request': true
}
}
/** Capability-owned timeout reason code for auxiliary title requests. */
export const SESSION_TITLE_TIMEOUT_CODE = 'SESSION_TITLE_TIMEOUT'
@@ -132,11 +159,12 @@ export function registerSessionTitleLlmProvider(
selectMessages: SessionTitleLlmMessageSelector,
): void {
const resolved = resolveSessionTitleLlmConfig(config)
const titleProvider = SessionTitleProviderId(id)
ctx.sessionTitle.register({
id: SessionTitleProviderId(id),
id: titleProvider,
automatic,
async generate(request) {
return generateSessionTitleWithLlm(ctx, resolved, request, selectMessages(request.messages))
return generateSessionTitleWithLlm(ctx, resolved, request, selectMessages(request.messages), titleProvider)
},
})
}
@@ -196,6 +224,7 @@ function finishError(finish: FinishReason): Error | undefined {
* @param config - validated model-provider policy.
* @param request - service-owned session, route, message snapshot, and cancellation.
* @param selectedMessages - exact provider-selected subset to frame and attribute.
* @param titleProvider - registered title-provider identity recorded with the request.
* @returns normalized non-empty title, exact source seqs, and used model route.
*/
export async function generateSessionTitleWithLlm(
@@ -203,6 +232,7 @@ export async function generateSessionTitleWithLlm(
config: ResolvedSessionTitleLlmConfig,
request: SessionTitleProviderRequest,
selectedMessages: readonly SessionTitleUserMessage[],
titleProvider: SessionTitleProviderId,
): Promise<SessionTitleProviderResult> {
request.signal.throwIfAborted()
if (selectedMessages.length === 0) {
@@ -213,16 +243,30 @@ export async function generateSessionTitleWithLlm(
throw new Error(`session-title-llm: input is ${inputBytes} bytes, exceeding maxInputBytes ${config.maxInputBytes}`)
}
const route = resolveRoute(config, request)
const messages: Message[] = [{
role: 'user',
content: [{ type: 'text', text: frameMessages(selectedMessages) }],
}]
const system = systemPrompt(config)
using callDeadline = deadline(request.signal, config.timeoutMs, SESSION_TITLE_TIMEOUT_CODE)
const options: GenerateOptions = {
const options: GenerateOptions = deepFreeze({
provider: route.provider,
model: route.model,
messages: [{ role: 'user', content: [{ type: 'text', text: frameMessages(selectedMessages) }] }],
system: systemPrompt(config),
messages,
system,
maxTokens: config.maxOutputTokens,
sessionId: request.session.id,
signal: callDeadline.signal,
}
})
await ctx.sessions.appendOutOfBand(request.session, 'session/title-llm-request', {
titleProvider,
messageSeqs: selectedMessages.map(message => message.seq),
route,
system,
messages,
maxTokens: config.maxOutputTokens,
}, { kind: 'session-title' })
callDeadline.signal.throwIfAborted()
const assembler = new BlockAssembler()
for await (const chunk of ctx.llm.stream(options)) assembler.push(chunk)
const terminalError = finishError(assembler.finish)

View File

@@ -2,7 +2,8 @@ import { Context } from 'cordis'
import { describe, expect, it, vi } from 'vitest'
import LlmService, { CallId, LlmAdapter } from '@deepseek-ai/dsh-llm'
import type { FinishReason, GenerateOptions, StreamChunk } from '@deepseek-ai/dsh-llm'
import { Session, SessionId } from '@deepseek-ai/dsh-session'
import SessionStore, { SessionId } from '@deepseek-ai/dsh-session'
import { SessionTitleProviderId } from '@deepseek-ai/dsh-session-title'
import type { SessionTitleProviderRequest } from '@deepseek-ai/dsh-session-title'
import { MAX_TIMER_DELAY_MS } from '@deepseek-ai/dsh-timeout'
import {
@@ -15,11 +16,15 @@ import type { SessionTitleLlmConfig } from '@deepseek-ai/dsh-session-title-llm'
class RecordingAdapter extends LlmAdapter {
readonly requests: GenerateOptions[] = []
constructor(private readonly script: readonly StreamChunk[]) {
constructor(
private readonly script: readonly StreamChunk[],
private readonly onDispatch?: () => void,
) {
super()
}
override async * stream(options: GenerateOptions): AsyncIterable<StreamChunk> {
this.onDispatch?.()
this.requests.push(options)
yield * this.script
}
@@ -57,24 +62,38 @@ const CONFIG = {
timeoutMs: 1_000,
} as const
function request(signal = new AbortController().signal): SessionTitleProviderRequest {
const TITLE_PROVIDER = SessionTitleProviderId('test-title-provider')
let nextSession = 0
function request(ctx: Context, signal = new AbortController().signal): SessionTitleProviderRequest {
const session = ctx.sessions.create(SessionId(`title-call-${++nextSession}`))
session.append('turn/start', {
turn: 1,
trigger: { kind: 'message', source: { kind: 'user' } },
})
const first = session.append('user/message', {
content: [{ type: 'text', text: 'first prompt' }],
source: { kind: 'user' },
}, { surfaceOp: 'append' })
const second = session.append('user/message', {
content: [{ type: 'text', text: '第二个问题' }],
source: { kind: 'user' },
}, { surfaceOp: 'append' })
session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
return {
session: new Session(SessionId('title-call')),
session,
messages: [
{ seq: 2, text: 'first prompt' },
{ seq: 9, text: '第二个问题' },
{ seq: first.seq, text: 'first prompt' },
{ seq: second.seq, text: '第二个问题' },
],
route: { provider: 'current-route', model: 'current-model' },
signal,
}
}
function requestWithoutRoute(signal = new AbortController().signal): SessionTitleProviderRequest {
return {
session: new Session(SessionId('title-call-no-route')),
messages: [{ seq: 2, text: 'first prompt' }],
signal,
}
function requestWithoutRoute(ctx: Context, signal = new AbortController().signal): SessionTitleProviderRequest {
const routed = request(ctx, signal)
return { session: routed.session, messages: routed.messages, signal }
}
async function withScript(script: readonly StreamChunk[]): Promise<{
@@ -82,6 +101,7 @@ async function withScript(script: readonly StreamChunk[]): Promise<{
adapter: RecordingAdapter
}> {
const ctx = new Context()
await ctx.plugin(SessionStore)
await ctx.plugin(LlmService)
const adapter = new RecordingAdapter(script)
ctx.llm.registerAdapter(['current-route'], adapter)
@@ -91,39 +111,59 @@ async function withScript(script: readonly StreamChunk[]): Promise<{
describe('generateSessionTitleWithLlm', () => {
it('uses the exact logged route, language targets, full framed input, and output token cap', async () => {
const ctx = new Context()
await ctx.plugin(SessionStore)
await ctx.plugin(LlmService)
const adapter = new RecordingAdapter(SCRIPT)
const providerRequest = request(ctx)
let requestWasLoggedAtDispatch = false
const adapter = new RecordingAdapter(SCRIPT, () => {
requestWasLoggedAtDispatch = providerRequest.session.events
.some(event => event.type === 'session/title-llm-request')
})
ctx.llm.registerAdapter(['current-route'], adapter)
const result = await generateSessionTitleWithLlm(
ctx,
resolveSessionTitleLlmConfig(CONFIG),
request(),
request().messages,
providerRequest,
providerRequest.messages,
TITLE_PROVIDER,
)
expect(result).toEqual({
title: '五个字标题',
messageSeqs: [2, 9],
messageSeqs: providerRequest.messages.map(message => message.seq),
model: { provider: 'current-route', model: 'current-model' },
})
expect(requestWasLoggedAtDispatch).toBe(true)
expect(adapter.requests).toHaveLength(1)
const options = adapter.requests[0]!
expect(Object.isFrozen(options)).toBe(true)
expect(Object.isFrozen(options.messages)).toBe(true)
expect(options).toMatchObject({
provider: 'current-route',
model: 'current-model',
maxTokens: 32,
sessionId: SessionId('title-call'),
sessionId: providerRequest.session.id,
})
expect(options.system).toContain('5 words')
expect(options.system).toContain('10 CJK characters')
const prompt = options.messages[0]?.content[0]
expect(prompt?.type === 'text' && prompt.text).toContain('first prompt')
expect(prompt?.type === 'text' && prompt.text).toContain('第二个问题')
expect(providerRequest.session.events.findLast(event => event.type === 'session/title-llm-request')?.data)
.toEqual({
titleProvider: TITLE_PROVIDER,
messageSeqs: providerRequest.messages.map(message => message.seq),
route: { provider: 'current-route', model: 'current-model' },
system: options.system,
messages: options.messages,
maxTokens: 32,
})
})
it('uses paired explicit overrides and rejects an oversized input without calling the model', async () => {
const ctx = new Context()
await ctx.plugin(SessionStore)
await ctx.plugin(LlmService)
const adapter = new RecordingAdapter(SCRIPT)
ctx.llm.registerAdapter(['explicit-route'], adapter)
@@ -134,12 +174,15 @@ describe('generateSessionTitleWithLlm', () => {
maxInputBytes: 4,
})
await expect(generateSessionTitleWithLlm(ctx, config, request(), request().messages))
const oversized = request(ctx)
await expect(generateSessionTitleWithLlm(ctx, config, oversized, oversized.messages, TITLE_PROVIDER))
.rejects.toThrow(/input.*bytes.*maxInputBytes/i)
expect(adapter.requests).toEqual([])
expect(oversized.session.events.some(event => event.type === 'session/title-llm-request')).toBe(false)
const withinLimit = resolveSessionTitleLlmConfig({ ...config, maxInputBytes: 1_000 })
await generateSessionTitleWithLlm(ctx, withinLimit, request(), [request().messages[0]!])
const within = request(ctx)
await generateSessionTitleWithLlm(ctx, withinLimit, within, [within.messages[0]!], TITLE_PROVIDER)
expect(adapter.requests[0]).toMatchObject({
provider: 'explicit-route',
model: 'explicit-model',
@@ -176,13 +219,16 @@ describe('generateSessionTitleWithLlm', () => {
it('rejects an absent route, empty selection, and pre-aborted caller before model dispatch', async () => {
const { ctx, adapter } = await withScript(SCRIPT)
const config = resolveSessionTitleLlmConfig(CONFIG)
await expect(generateSessionTitleWithLlm(ctx, config, requestWithoutRoute(), requestWithoutRoute().messages))
const unrouted = requestWithoutRoute(ctx)
await expect(generateSessionTitleWithLlm(ctx, config, unrouted, unrouted.messages, TITLE_PROVIDER))
.rejects.toThrow(/no logged request route/)
await expect(generateSessionTitleWithLlm(ctx, config, request(), []))
const empty = request(ctx)
await expect(generateSessionTitleWithLlm(ctx, config, empty, [], TITLE_PROVIDER))
.rejects.toThrow(/at least one source message/)
const controller = new AbortController()
controller.abort(new Error('caller stopped'))
await expect(generateSessionTitleWithLlm(ctx, config, request(controller.signal), request().messages))
const aborted = request(ctx, controller.signal)
await expect(generateSessionTitleWithLlm(ctx, config, aborted, aborted.messages, TITLE_PROVIDER))
.rejects.toThrow('caller stopped')
expect(adapter.requests).toEqual([])
})
@@ -192,12 +238,15 @@ describe('generateSessionTitleWithLlm', () => {
[{ kind: 'aborted', failure: { message: 'provider aborted', code: 'ABORTED' } }, 'provider aborted', 'ABORTED'],
] satisfies Array<[FinishReason, string, string]>)('preserves %s terminal failure details', async (reason, message, code) => {
const { ctx } = await withScript([{ type: 'finish', reason }])
const providerRequest = request(ctx)
await expect(generateSessionTitleWithLlm(
ctx,
resolveSessionTitleLlmConfig(CONFIG),
request(),
request().messages,
providerRequest,
providerRequest.messages,
TITLE_PROVIDER,
)).rejects.toMatchObject({ message, code })
expect(providerRequest.session.events.some(event => event.type === 'session/title-llm-request')).toBe(true)
})
it.each([
@@ -206,11 +255,13 @@ describe('generateSessionTitleWithLlm', () => {
[{ kind: 'future-finish' } as never, /unsupported finish reason "future-finish"/],
] satisfies Array<[FinishReason, RegExp]>)('rejects the terminal finish reason %s', async (reason, error) => {
const { ctx } = await withScript([{ type: 'finish', reason }])
const providerRequest = request(ctx)
await expect(generateSessionTitleWithLlm(
ctx,
resolveSessionTitleLlmConfig(CONFIG),
request(),
request().messages,
providerRequest,
providerRequest.messages,
TITLE_PROVIDER,
)).rejects.toThrow(error)
})
@@ -221,11 +272,13 @@ describe('generateSessionTitleWithLlm', () => {
{ type: 'finish', reason: { kind: 'stop' } },
]
const tool = await withScript(toolScript)
const toolRequest = request(tool.ctx)
await expect(generateSessionTitleWithLlm(
tool.ctx,
resolveSessionTitleLlmConfig(CONFIG),
request(),
request().messages,
toolRequest,
toolRequest.messages,
TITLE_PROVIDER,
)).rejects.toThrow(/output must contain text only/)
const reasoning = await withScript([
@@ -233,11 +286,13 @@ describe('generateSessionTitleWithLlm', () => {
{ type: 'reasoning-delta', index: 0, text: 'no final title' },
{ type: 'finish', reason: { kind: 'stop' } },
])
const reasoningRequest = request(reasoning.ctx)
await expect(generateSessionTitleWithLlm(
reasoning.ctx,
resolveSessionTitleLlmConfig(CONFIG),
request(),
request().messages,
reasoningRequest,
reasoningRequest.messages,
TITLE_PROVIDER,
)).rejects.toThrow(/produced no text/)
})
@@ -245,13 +300,16 @@ describe('generateSessionTitleWithLlm', () => {
vi.useFakeTimers()
try {
const ctx = new Context()
await ctx.plugin(SessionStore)
await ctx.plugin(LlmService)
ctx.llm.registerAdapter(['current-route'], new CooperativeAdapter())
const providerRequest = request(ctx)
const pending = generateSessionTitleWithLlm(
ctx,
resolveSessionTitleLlmConfig({ ...CONFIG, timeoutMs: 10 }),
request(),
request().messages,
providerRequest,
providerRequest.messages,
TITLE_PROVIDER,
)
const rejected = expect(pending).rejects.toMatchObject({
code: SESSION_TITLE_TIMEOUT_CODE,

View File

@@ -8,9 +8,9 @@ Only text blocks from human `user/message` events are eligible. The first eligib
- `get(session)` folds the latest accepted title from a live or replayed log.
- `refresh(session, signal?)` materializes the fallback when needed, then explicitly runs the registered provider over the current eligible messages. Provider errors and caller cancellation reject.
- `register(provider)` installs the sole optional provider and returns its Cordis effect disposer. A second registration throws immediately; disposal aborts pending and active calls before another provider can register.
- `register(provider)` installs the sole optional provider and returns its awaitable Cordis effect disposer. A second registration throws immediately; disposal aborts pending and active calls, waits for their settlement, and only then permits another provider to register.
Automatic work never delays the main agent response. A provider starts after the matching `request/header` records the main request's exact route; its late completion joins an open turn or uses a flushed zero-step `session-title` turn through `ctx.sessions.appendOutOfBand()`. Automatic failures warn and retain the latest title. New all-message revisions, provider disposal, session disposal, and explicit refresh abort older work, and a stale completion cannot append.
Automatic work never delays the main agent response. A provider starts after the matching `request/header` records the main request's exact route; its late completion joins an open turn or uses a flushed zero-step `session-title` turn through `ctx.sessions.appendOutOfBand()`. Automatic failures warn and retain the latest title. New all-message revisions, provider disposal, session disposal, and explicit refresh abort older work, and a stale completion cannot append. Concurrent explicit refreshes reserve their order before fallback durability waits, so only the newest call may reach the provider. Service teardown cancels queued work and drains calls that ignore cancellation before unloading completes.
Forks inherit title events in their seed unchanged. The first-message cadence does not automatically retitle a child; the all-messages cadence may append a new revision after the child receives a later human prompt.

View File

@@ -3,7 +3,7 @@
* @module @deepseek-ai/dsh-session-title
*/
import { Context, Service } from 'cordis'
import { Context, FiberState, Service, type Fiber } from 'cordis'
import z from 'schemastery'
import type { Branded } from '@deepseek-ai/dsh-brand'
import { deepFreeze } from '@deepseek-ai/dsh-llm'
@@ -200,6 +200,8 @@ interface ResolvedConfig {
/** One exact provider registration generation. */
interface ProviderRegistration {
readonly provider: SessionTitleProvider
readonly active: Set<Promise<unknown>>
closing: boolean
}
/** Automatic work waiting for the matching main-request header. */
@@ -239,11 +241,15 @@ export class SessionTitleService extends Service {
})
private readonly config: ResolvedConfig
private readonly ownerFiber: Fiber
private registration: ProviderRegistration | undefined
private readonly work = new Map<Session, SessionTitleWorkState>()
private readonly lifetime = new AbortController()
private readonly inFlight = new Set<Promise<unknown>>()
constructor(ctx: Context, config: Config) {
super(ctx, 'sessionTitle')
this.ownerFiber = ctx.fiber
const candidate: unknown = config
if (candidate === null || typeof candidate !== 'object') {
throw new Error('session-title: configuration is required')
@@ -257,6 +263,18 @@ export class SessionTitleService extends Service {
}
this.config = deepFreeze({ ...value })
ctx.effect(() => async () => {
this.lifetime.abort(new Error('session-title service disposed'))
if (this.registration !== undefined) this.registration.closing = true
this.registration = undefined
for (const state of this.work.values()) {
delete state.pending
state.active?.controller.abort(new Error('session-title service disposed'))
}
await this.drain(this.inFlight)
this.work.clear()
}, 'sessionTitle lifecycle')
ctx.on('session/event', (session, event) => {
switch (event.type) {
case 'user/message':
@@ -295,15 +313,16 @@ export class SessionTitleService extends Service {
*/
async refresh(session: Session, signal?: AbortSignal): Promise<SessionTitleSnapshot | undefined> {
signal?.throwIfAborted()
this.assertServiceActive()
if (this.ctx.sessions.get(session.id) !== session) {
throw new Error(`session "${session.id}" is not live in this store`)
}
const fallback = await this.ensureFallback(session)
const registration = this.registration
if (registration === undefined) return fallback
const messages = collectSessionTitleMessages(session.events)
const latest = messages.at(-1)
if (latest === undefined) return fallback
if (registration === undefined || registration.closing || latest === undefined) {
return this.ensureFallback(session)
}
const state = this.stateFor(session)
const revision = this.supersede(state, 'explicit title refresh superseded older generation')
const work = this.activate({
@@ -313,42 +332,48 @@ export class SessionTitleService extends Service {
}, state, signal)
const config = session.requestHeader()?.config
const route = config === undefined ? undefined : { provider: config.provider, model: config.model }
return this.runProvider(session, work, route)
return this.startProvider(session, work, route)
}
/**
* Register the sole optional title provider. Disposal aborts its pending and
* active work before another provider may register.
* @param provider - provider identity, cadence, and generation function.
* @returns exact Cordis effect disposer for HMR-safe unregistration.
* @returns exact Cordis effect disposer, which settles after active calls quiesce.
*/
register(provider: SessionTitleProvider): () => void {
register(provider: SessionTitleProvider): () => Promise<void> {
this.validateProvider(provider)
if (this.registration !== undefined) {
throw new Error(`session-title provider "${this.registration.provider.id}" is already registered`)
}
const registration: ProviderRegistration = {
provider,
active: new Set(),
closing: false,
}
const dispose = this.ctx.effect(function* (this: SessionTitleService) {
this.registration = registration
yield () => {
this.registration = undefined
yield async () => {
registration.closing = true
for (const state of this.work.values()) {
delete state.pending
state.active?.controller.abort(new Error(`session-title provider "${provider.id}" was disposed`))
if (state.pending?.registration === registration) delete state.pending
if (state.active?.registration === registration) {
state.active.controller.abort(new Error(`session-title provider "${provider.id}" was disposed`))
}
}
await this.drain(registration.active)
if (this.registration === registration) this.registration = undefined
}
}.bind(this), 'sessionTitle.register()')
// eslint-disable-next-line @typescript-eslint/no-misused-promises -- exact effect disposer preserves owner teardown ordering
return dispose
}
/** Schedule fallback creation and any provider cadence for one eligible event. */
private onUserMessage(session: Session, event: Extract<SessionEvent, { type: 'user/message' }>): void {
if (!this.serviceActive()) return
if (event.data.source.kind !== 'user' || collectSessionTitleMessages([event]).length === 0) return
const registration = this.registration
if (registration !== undefined) {
if (registration !== undefined && !registration.closing) {
const messages = collectSessionTitleMessages(session.events, event.seq)
const shouldSchedule = registration.provider.automatic === 'all-user-messages'
|| (session.header.parentSession === undefined && messages.length === 1 && this.get(session) === undefined)
@@ -358,15 +383,19 @@ export class SessionTitleService extends Service {
state.pending = { registration, revision, throughSeq: event.seq }
}
}
queueMicrotask(() => {
void this.ensureFallback(session).catch((error: unknown) => {
this.defer(async () => {
try {
await this.ensureFallback(session)
} catch (error: unknown) {
if (!this.serviceActive()) return
this.ctx.logger.warn(`session "${session.id}": fallback title update failed: ${String(error)}`)
})
}
})
}
/** Start pending automatic work only after its exact main-request route is logged. */
private onRequestHeader(session: Session, event: Extract<SessionEvent, { type: 'request/header' }>): void {
if (!this.serviceActive()) return
const state = this.work.get(session)
const pending = state?.pending
if (state === undefined || pending === undefined || pending.throughSeq >= event.seq) return
@@ -375,16 +404,31 @@ export class SessionTitleService extends Service {
provider: event.data.header.config.provider,
model: event.data.header.config.model,
}
queueMicrotask(() => {
if (this.registration !== pending.registration || state.revision !== pending.revision) return
this.defer(async () => {
if (this.registration !== pending.registration
|| pending.registration.closing
|| this.work.get(session) !== state
|| state.revision !== pending.revision) return
const work = this.activate(pending, state)
void this.runProvider(session, work, route).catch((error: unknown) => {
if (work.signal.aborted) return
try {
await this.startProvider(session, work, route)
} catch (error: unknown) {
if (work.signal.aborted || !this.serviceActive()) return
this.ctx.logger.warn(`session "${session.id}": automatic title generation failed: ${String(error)}`)
})
}
})
}
/** Start one tracked provider call after publishing its active revision. */
private startProvider(
session: Session,
work: ActiveProviderWork,
route?: SessionTitleModelProvenance,
): Promise<SessionTitleSnapshot | undefined> {
const run = Promise.resolve().then(() => this.runProvider(session, work, route))
return this.track(run, work.registration)
}
/** Execute and durably accept one current provider revision. */
private async runProvider(
session: Session,
@@ -392,6 +436,7 @@ export class SessionTitleService extends Service {
route?: SessionTitleModelProvenance,
): Promise<SessionTitleSnapshot | undefined> {
try {
this.assertCurrent(session, work)
await this.ensureFallback(session)
this.assertCurrent(session, work)
const messages = collectSessionTitleMessages(session.events, work.throughSeq)
@@ -470,6 +515,7 @@ export class SessionTitleService extends Service {
/** Fail a completion whose provider, revision, session, or signal is stale. */
private assertCurrent(session: Session, work: ActiveProviderWork): void {
this.assertServiceActive()
work.signal.throwIfAborted()
const state = this.work.get(session)
/* v8 ignore next -- every supported supersession, provider disposal, and session disposal aborts
@@ -490,8 +536,8 @@ export class SessionTitleService extends Service {
): ActiveProviderWork {
const controller = new AbortController()
const signal = upstream === undefined
? controller.signal
: AbortSignal.any([controller.signal, upstream])
? AbortSignal.any([controller.signal, this.lifetime.signal])
: AbortSignal.any([controller.signal, this.lifetime.signal, upstream])
const work: ActiveProviderWork = { ...pending, controller, signal }
state.active = work
return work
@@ -515,6 +561,44 @@ export class SessionTitleService extends Service {
return state
}
/** Queue detached service work and retain it through service disposal. */
private defer(task: () => Promise<void>): void {
const run = Promise.resolve().then(async () => {
if (!this.serviceActive()) return
await task()
})
void this.track(run)
}
/** Retain one promise until settlement for service and optional provider teardown. */
private track<T>(run: Promise<T>, registration?: ProviderRegistration): Promise<T> {
this.inFlight.add(run)
registration?.active.add(run)
const settled = (): void => {
this.inFlight.delete(run)
registration?.active.delete(run)
}
void run.then(settled, settled)
return run
}
/** Await every current and settling promise in one lifecycle registry. */
private async drain(active: Set<Promise<unknown>>): Promise<void> {
while (active.size > 0) await Promise.allSettled([...active])
}
/** Whether the owning plugin fiber can still start or commit title work. */
private serviceActive(): boolean {
return !this.lifetime.signal.aborted
&& this.ownerFiber.uid !== null
&& this.ownerFiber.state === FiberState.ACTIVE
}
/** Reject work once the owning plugin fiber has begun unloading. */
private assertServiceActive(): void {
if (!this.serviceActive()) throw new Error('session-title service disposed')
}
/** Reject malformed provider registrations before publishing an effect. */
private validateProvider(provider: unknown): asserts provider is SessionTitleProvider {
if (provider === null || typeof provider !== 'object') {
@@ -534,6 +618,7 @@ export class SessionTitleService extends Service {
/** Create the first deterministic fallback if the session still lacks a title. */
private async ensureFallback(session: Session): Promise<SessionTitleSnapshot | undefined> {
this.assertServiceActive()
const current = this.get(session)
if (current !== undefined) return current
const [first] = collectSessionTitleMessages(session.events)

View File

@@ -84,7 +84,7 @@ describe('SessionTitleService provider lifecycle', () => {
await settle()
child.append('turn/end', { turn: 2, reason: { kind: 'completed' } })
expect(firstGenerate).not.toHaveBeenCalled()
disposeFirst()
await disposeFirst()
const allGenerate = vi.fn(async (request: SessionTitleProviderRequest) => ({
title: 'Fork all prompts',
@@ -170,7 +170,7 @@ describe('SessionTitleService provider lifecycle', () => {
expect(requests[1]?.messages.map(message => message.seq)).toEqual([first.seq, second.seq])
})
it('rejects a second provider and aborts stale work when the winner is disposed', async () => {
it('rejects a second provider and drains stale work when the winner is disposed', async () => {
const ctx = new Context()
await ctx.plugin(SessionStore)
await ctx.plugin(SessionTitleService, CONFIG)
@@ -202,10 +202,15 @@ describe('SessionTitleService provider lifecycle', () => {
await settle()
expect(observedSignal?.aborted).toBe(false)
dispose()
const disposal = dispose()
expect(observedSignal?.aborted).toBe(true)
pending.resolve({ title: 'stale provider result', messageSeqs: [message.seq] })
let disposed = false
void disposal.then(() => { disposed = true })
await settle()
expect(disposed).toBe(false)
pending.resolve({ title: 'stale provider result', messageSeqs: [message.seq] })
await disposal
expect(disposed).toBe(true)
expect(ctx.sessionTitle.get(session)?.source.kind).toBe('fallback')
const replacement: SessionTitleProvider = {
@@ -214,7 +219,7 @@ describe('SessionTitleService provider lifecycle', () => {
generate: async () => ({ title: 'replacement', messageSeqs: [message.seq] }),
}
const disposeReplacement = ctx.sessionTitle.register(replacement)
disposeReplacement()
await disposeReplacement()
})
it('supersedes an older all-messages revision and cannot commit an ignored abort', async () => {

View File

@@ -1,4 +1,4 @@
import { Context } from 'cordis'
import { Context, type Fiber } from 'cordis'
import { describe, expect, it, vi } from 'vitest'
import SessionStore, { Session, SessionId } from '@deepseek-ai/dsh-session'
import SessionTitleService, {
@@ -162,6 +162,157 @@ describe('SessionTitleService configuration and refresh boundaries', () => {
expect(disposeSignal?.aborted).toBe(true)
})
it('reserves overlapping refresh order before fallback durability settles', async () => {
const ctx = new Context()
await ctx.plugin(SessionStore)
await ctx.plugin(SessionTitleService, CONFIG)
const seed = new Session(SessionId('refresh-order-seed'))
seed.append('turn/start', {
turn: 1,
trigger: { kind: 'message', source: { kind: 'user' } },
})
const source = appendPrompt(seed, 'Keep the newest explicit refresh')
seed.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
const session = ctx.sessions.create(SessionId('refresh-order'), { seed: seed.events })
const flushStarted = deferred<undefined>()
const releaseFlush = deferred<undefined>()
let flushCount = 0
ctx.on('session/flush', async (subject) => {
if (subject !== session || ++flushCount !== 1) return
flushStarted.resolve(undefined)
await releaseFlush.promise
})
const result = deferred<SessionTitleProviderResult>()
const requests: SessionTitleProviderRequest[] = []
ctx.sessionTitle.register({
id: SessionTitleProviderId('refresh-order'),
automatic: 'first-message',
generate(request) {
requests.push(request)
return result.promise
},
})
const older = ctx.sessionTitle.refresh(session)
const olderOutcome = older.then(
() => undefined,
(error: unknown) => error,
)
await flushStarted.promise
const newer = ctx.sessionTitle.refresh(session)
await settle()
expect(requests).toHaveLength(1)
expect(requests[0]?.signal.aborted).toBe(false)
releaseFlush.resolve(undefined)
await settle()
expect(requests).toHaveLength(1)
expect(requests[0]?.signal.aborted).toBe(false)
result.resolve({ title: 'Newest explicit title', messageSeqs: [source.seq] })
await expect(newer).resolves.toMatchObject({ title: 'Newest explicit title' })
const olderError = await olderOutcome
expect(olderError).toBeInstanceOf(Error)
if (!(olderError instanceof Error)) throw new Error('expected older refresh to reject')
expect(olderError.message).toMatch(/superseded/)
})
it('cancels a queued fallback when the session-title service unloads', async () => {
const ctx = new Context()
await ctx.plugin(SessionStore)
const lifecycle: { fiber?: Fiber; session?: Session; inactiveRefresh?: Promise<unknown> } = {}
ctx.on('internal/plugin', (subject) => {
if (subject !== lifecycle.fiber || subject.uid !== null || lifecycle.session === undefined) return
appendPrompt(lifecycle.session, 'Ignore reentrant disposal prompt')
lifecycle.session.append('request/header', {
header: { config: { provider: 'main', model: 'main' } },
reason: 'initial',
})
lifecycle.inactiveRefresh = ctx.sessionTitle.refresh(lifecycle.session).then(
() => undefined,
(error: unknown) => error,
)
})
const fiber = await ctx.plugin(SessionTitleService, CONFIG)
lifecycle.fiber = fiber
const session = startSession(ctx, 'service-dispose-fallback')
lifecycle.session = session
appendPrompt(session, 'Do not publish after service disposal')
await fiber.dispose()
await settle()
expect(session.events.some(event => event.type === 'session/title')).toBe(false)
const inactiveError = await lifecycle.inactiveRefresh
expect(inactiveError).toBeInstanceOf(Error)
if (!(inactiveError instanceof Error)) throw new Error('expected inactive refresh to reject')
expect(inactiveError.message).toBe('session-title service disposed')
})
it('aborts pending and active provider work and drains ignored cancellation during service unload', async () => {
const ctx = new Context()
await ctx.plugin(SessionStore)
const fiber = await ctx.plugin(SessionTitleService, CONFIG)
const result = deferred<SessionTitleProviderResult>()
const requests: SessionTitleProviderRequest[] = []
ctx.sessionTitle.register({
id: SessionTitleProviderId('service-unload'),
automatic: 'all-user-messages',
generate(request) {
requests.push(request)
return result.promise
},
})
const active = startSession(ctx, 'service-unload-active')
const activeMessage = appendPrompt(active, 'Active provider work')
await settle()
const refresh = ctx.sessionTitle.refresh(active)
const refreshOutcome = refresh.then(
() => undefined,
(error: unknown) => error,
)
await settle()
expect(requests).toHaveLength(1)
const pending = startSession(ctx, 'service-unload-pending')
appendPrompt(pending, 'Pending provider work')
const disposal = fiber.dispose()
let disposed = false
void disposal.then(() => { disposed = true })
await settle()
expect(requests[0]?.signal.aborted).toBe(true)
expect(disposed).toBe(false)
result.resolve({ title: 'Ignored service abort', messageSeqs: [activeMessage.seq] })
await disposal
expect(disposed).toBe(true)
await expect(refreshOutcome).resolves.toEqual(expect.objectContaining({ message: 'session-title service disposed' }))
})
it('suppresses a queued fallback failure after service unload begins', async () => {
const ctx = new Context()
await ctx.plugin(SessionStore)
const fiber = await ctx.plugin(SessionTitleService, CONFIG)
const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => undefined)
const session = startSession(ctx, 'service-unload-flush')
appendPrompt(session, 'Fallback whose flush outlives the service')
session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
const flushStarted = deferred<undefined>()
const releaseFlush = deferred<undefined>()
ctx.on('session/flush', async (subject) => {
if (subject !== session) return
flushStarted.resolve(undefined)
await releaseFlush.promise
throw new Error('flush failed during service unload')
})
await flushStarted.promise
const disposal = fiber.dispose()
releaseFlush.resolve(undefined)
await disposal
expect(warn).not.toHaveBeenCalled()
})
it('warns when a detached session prevents queued fallback publication', async () => {
const ctx = await setup()
const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => undefined)
@@ -238,10 +389,13 @@ describe('SessionTitleService provider validation and stale scheduling', () => {
header: { config: { provider: 'main', model: 'main' } },
reason: 'initial',
})
dispose()
const pending = startSession(ctx, 'pending-provider-dispose')
appendPrompt(pending, 'Drop pending provider work')
await dispose()
await settle()
expect(generate).not.toHaveBeenCalled()
expect(ctx.sessionTitle.get(session)?.source.kind).toBe('fallback')
expect(ctx.sessionTitle.get(pending)?.source.kind).toBe('fallback')
})
it('rejects malformed provider results without replacing the fallback', async () => {