fix(session-title): typed rename rejection, provenance invariant, contract docs
SessionTitleInvalidError narrows the one rename failure that blames the input; the fallback-unpin append extracts to appendFallback beside ensureFallback's guarded twin; a deferred-provider test proves rename supersedes ACTIVE generation; the invariant companion enforces messageSeqs-empty iff user-source on every appended session/title event (tsconfig gains the session-title invariant path); SessionTitleEventData field docs state the third source kind and the empty-seqs rule, mirrored into the bilingual core-data-structures page; the note qualifies the refresh unpin as conditional on a derivable replacement.
This commit is contained in:
44
packages/session-title/session-title/tests/invariant.spec.ts
Normal file
44
packages/session-title/session-title/tests/invariant.spec.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
// Title-provenance invariant: messageSeqs is empty iff source.kind is 'user'
|
||||
// — the durable relationship every appended session/title event must keep.
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import * as SessionTitleInvariantCompanion from '@deepseek-ai/dsh-session-title/invariant'
|
||||
import InvariantService, { InvariantError } from '@deepseek-ai/dsh-invariants'
|
||||
import SessionStore, { SessionId } from '@deepseek-ai/dsh-session'
|
||||
|
||||
async function setup(): Promise<Context> {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SessionStore)
|
||||
await ctx.plugin(InvariantService, { enabled: true })
|
||||
await ctx.plugin(SessionTitleInvariantCompanion)
|
||||
return ctx
|
||||
}
|
||||
|
||||
describe('session-title provenance invariant', () => {
|
||||
it('accepts cited automatic titles and citation-free user renames', async () => {
|
||||
const ctx = await setup()
|
||||
const session = ctx.sessions.create(SessionId('title-invariant-valid'))
|
||||
expect(() => {
|
||||
session.append('session/title', { title: 'auto', messageSeqs: [1], source: { kind: 'fallback' } })
|
||||
session.append('session/title', { title: 'named', messageSeqs: [], source: { kind: 'user' } })
|
||||
}).not.toThrow()
|
||||
})
|
||||
|
||||
it('rejects a citation-free automatic title and a user rename that cites messages', async () => {
|
||||
const ctx = await setup()
|
||||
const session = ctx.sessions.create(SessionId('title-invariant-invalid'))
|
||||
expect(() => {
|
||||
session.append('session/title', { title: 'auto', messageSeqs: [], source: { kind: 'fallback' } })
|
||||
}).toThrow(expect.objectContaining<Partial<InvariantError>>({
|
||||
code: 'INVARIANT',
|
||||
packageName: '@deepseek-ai/dsh-session-title',
|
||||
}))
|
||||
expect(() => {
|
||||
session.append('session/title', { title: 'named', messageSeqs: [1], source: { kind: 'user' } })
|
||||
}).toThrow(expect.objectContaining<Partial<InvariantError>>({
|
||||
code: 'INVARIANT',
|
||||
packageName: '@deepseek-ai/dsh-session-title',
|
||||
}))
|
||||
expect(session.seq).toBe(0)
|
||||
})
|
||||
})
|
||||
@@ -29,7 +29,7 @@ function appendHumanPrompt(session: ReturnType<Context['sessions']['create']>, t
|
||||
}
|
||||
|
||||
describe('SessionTitleService.rename', () => {
|
||||
it('appends a normalized user-source title and supersedes automatic work', async () => {
|
||||
it('appends a normalized user-source title', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SessionStore)
|
||||
await ctx.plugin(SessionTitleService, CONFIG)
|
||||
@@ -118,11 +118,50 @@ describe('SessionTitleService.rename', () => {
|
||||
title: 'Derivable prompt words',
|
||||
source: { kind: 'fallback' },
|
||||
})
|
||||
// The pin is gone: the next user message schedules automatic work again
|
||||
// (observable as a fresh fallback-source title remaining latest).
|
||||
// The pin is gone: the latest title is fallback-sourced, so the
|
||||
// onUserMessage pin check no longer skips scheduling.
|
||||
expect(ctx.sessionTitle.get(session)?.source.kind).toBe('fallback')
|
||||
})
|
||||
|
||||
it('supersedes in-flight automatic generation: a late provider result cannot override the user title', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SessionStore)
|
||||
await ctx.plugin(SessionTitleService, CONFIG)
|
||||
// The provider parks on a test-held deferred so rename lands while its
|
||||
// generation is ACTIVE (not merely scheduled).
|
||||
let releaseProvider: (() => void) | undefined
|
||||
const gate = new Promise<void>((resolve) => { releaseProvider = resolve })
|
||||
let aborted = false
|
||||
const generate = vi.fn(async (request: SessionTitleProviderRequest) => {
|
||||
request.signal.addEventListener('abort', () => { aborted = true })
|
||||
await gate
|
||||
return { title: 'Late provider title', messageSeqs: request.messages.map(message => message.seq) }
|
||||
})
|
||||
ctx.sessionTitle.register({
|
||||
id: SessionTitleProviderId('deferred-provider'),
|
||||
automatic: 'all-user-messages',
|
||||
generate,
|
||||
})
|
||||
const session = ctx.sessions.create(SessionId('rename-supersede'))
|
||||
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
|
||||
appendHumanPrompt(session, 'Prompt that triggers generation')
|
||||
session.append('request/header', {
|
||||
header: { config: { provider: 'main-route', model: 'chat-model' } },
|
||||
reason: 'change',
|
||||
})
|
||||
await settle()
|
||||
expect(generate).toHaveBeenCalledOnce()
|
||||
|
||||
ctx.sessionTitle.rename(session, 'User wins')
|
||||
expect(aborted).toBe(true)
|
||||
releaseProvider?.()
|
||||
await settle()
|
||||
// The released provider result must not append over the user title, and
|
||||
// the swallowed abort must not surface as an unhandled rejection.
|
||||
const latest = session.events.findLast(item => item.type === 'session/title')
|
||||
expect(latest?.data).toMatchObject({ title: 'User wins', source: { kind: 'user' } })
|
||||
})
|
||||
|
||||
it('fallback-only refresh keeps the user title when no fallback is derivable', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SessionStore)
|
||||
|
||||
Reference in New Issue
Block a user