fix(session-title): harden async provider lifecycle
This commit is contained in:
@@ -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 */',
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
@@ -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` |
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
Reference in New Issue
Block a user