fix(session-title): enforce framed input and deadline

This commit is contained in:
Tianyi Cui
2026-07-21 15:00:15 +08:00
parent d9557fa5e0
commit 9b048354b8
7 changed files with 61 additions and 16 deletions
@@ -2,7 +2,7 @@
Optional `ctx.sessionTitle` provider that summarizes every eligible human message through `ctx.llm`. It registers the `all-user-messages` cadence and starts a new revision after each new human prompt, using seeded history as well as child-session prompts. A newer revision aborts and supersedes older work; even a provider that ignores cancellation cannot commit stale output.
The plugin uses the complete required [shared LLM configuration](../session-title-llm/README.md#configuration). Omit both `provider` and `model` to inherit the exact route from each current logged main request, or set both to route title generation independently. If aggregate input exceeds `maxInputBytes`, the request fails instead of truncating history; automatic use warns and keeps the prior title.
The plugin uses the complete required [shared LLM configuration](../session-title-llm/README.md#configuration). Omit both `provider` and `model` to inherit the exact route from each current logged main request, or set both to route title generation independently. If the final framed aggregate prompt exceeds `maxInputBytes`, the request fails instead of truncating history; automatic use warns and keeps the prior title.
## Model Experience
@@ -6,7 +6,7 @@ This package is a library, not a Cordis plugin. The provider plugins call `regis
## Route and failure contract
`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.
`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. The helper measures the final JSON-framed user prompt, including seq fields, wrappers, and JSON escaping, against `maxInputBytes` before logging or dispatch instead of truncating it. Timeout and caller cancellation are rechecked while consuming the stream and after it completes, so a late successful result cannot be accepted even if an interceptor or adapter ignores abort. 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. The append shares the title capability's per-session settlement queue, so a superseding request cannot collide with an earlier fallback, request record, or accepted-title flush. The dispatched envelope is deep-frozen to keep interceptors aligned with that record but deliberately lacks dsh-agent-loop's process-local request identity, so loop-only reconstruction observers do not compare it with the conversation header. 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.
@@ -18,7 +18,7 @@ Every field is required except the paired route override; there are no library d
|---|---|
| `targetWords` | Positive target word count for non-CJK titles. |
| `targetCjkCharacters` | Positive target character count for Chinese, Japanese, or Korean titles. |
| `maxInputBytes` | Positive aggregate UTF-8 byte ceiling across selected messages. |
| `maxInputBytes` | Positive UTF-8 byte ceiling for the final JSON-framed user prompt. |
| `maxOutputTokens` | Positive auxiliary generation token cap. |
| `timeoutMs` | Positive end-to-end deadline within the runtime timer limit. |
| `provider`, `model` | Optional explicit route; both or neither. |
@@ -42,4 +42,4 @@ No main-request invalidation. Auxiliary cache reuse is provider-specific; the fi
## Known Limitations and Deferred Work
- The helper accepts text output only and rejects tool calls; structured-output adapters and provider-specific prompt variants are not exposed.
- It enforces a byte ceiling for the whole selected input rather than clipping individual messages or applying a retention policy.
- It enforces a byte ceiling for the whole framed user prompt rather than clipping individual messages or applying a retention policy.
@@ -58,7 +58,7 @@ export interface SessionTitleLlmConfig {
readonly targetWords: number
/** Target character count for Chinese, Japanese, or Korean titles. */
readonly targetCjkCharacters: number
/** Maximum total UTF-8 bytes across selected source-message text. */
/** Maximum UTF-8 bytes in the final JSON-framed user prompt. */
readonly maxInputBytes: number
/** Auxiliary generation output-token cap. */
readonly maxOutputTokens: number
@@ -242,14 +242,15 @@ export async function generateSessionTitleWithLlm(
if (selectedMessages.length === 0) {
throw new Error('session-title-llm: at least one source message is required')
}
const inputBytes = selectedMessages.reduce((total, message) => total + Buffer.byteLength(message.text, 'utf8'), 0)
const framedInput = frameMessages(selectedMessages)
const inputBytes = Buffer.byteLength(framedInput, 'utf8')
if (inputBytes > config.maxInputBytes) {
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) }],
content: [{ type: 'text', text: framedInput }],
}]
const system = systemPrompt(config)
using callDeadline = deadline(request.signal, config.timeoutMs, SESSION_TITLE_TIMEOUT_CODE)
@@ -272,7 +273,11 @@ export async function generateSessionTitleWithLlm(
}, callDeadline.signal)
callDeadline.signal.throwIfAborted()
const assembler = new BlockAssembler()
for await (const chunk of ctx.llm.stream(options)) assembler.push(chunk)
for await (const chunk of ctx.llm.stream(options)) {
callDeadline.signal.throwIfAborted()
assembler.push(chunk)
}
callDeadline.signal.throwIfAborted()
const terminalError = finishError(assembler.finish)
if (terminalError !== undefined) throw terminalError
const blocks = assembler.message().content
@@ -48,6 +48,17 @@ class CooperativeAdapter extends LlmAdapter {
}
}
class DelayedSuccessAdapter extends LlmAdapter {
constructor(private readonly delayMs: number) {
super()
}
override async * stream(): AsyncIterable<StreamChunk> {
await new Promise<void>(resolve => setTimeout(resolve, this.delayMs))
yield * SCRIPT
}
}
const SCRIPT: StreamChunk[] = [
{ type: 'block-start', index: 0, blockType: 'text' },
{ type: 'text-delta', index: 0, text: ' 五个字标题 ' },
@@ -162,21 +173,24 @@ describe('generateSessionTitleWithLlm', () => {
})
})
it('uses paired explicit overrides and rejects an oversized input without calling the model', async () => {
it('uses paired explicit overrides and bounds the final framed input before model dispatch', async () => {
const ctx = new Context()
await ctx.plugin(SessionStore)
await ctx.plugin(LlmService)
const adapter = new RecordingAdapter(SCRIPT)
ctx.llm.registerAdapter(['explicit-route'], adapter)
const oversized = request(ctx)
const [selected] = oversized.messages
if (selected === undefined) throw new Error('expected one selected message')
const rawInputBytes = Buffer.byteLength(selected.text, 'utf8')
const config = resolveSessionTitleLlmConfig({
...CONFIG,
provider: 'explicit-route',
model: 'explicit-model',
maxInputBytes: 4,
maxInputBytes: rawInputBytes,
})
const oversized = request(ctx)
await expect(generateSessionTitleWithLlm(ctx, config, oversized, oversized.messages, TITLE_PROVIDER))
await expect(generateSessionTitleWithLlm(ctx, config, oversized, [selected], 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)
@@ -322,4 +336,30 @@ describe('generateSessionTitleWithLlm', () => {
vi.useRealTimers()
}
})
it('rejects a successful stream that completes after the configured deadline', async () => {
vi.useFakeTimers()
try {
const ctx = new Context()
await ctx.plugin(SessionStore)
await ctx.plugin(LlmService)
ctx.llm.registerAdapter(['current-route'], new DelayedSuccessAdapter(20))
const providerRequest = request(ctx)
const pending = generateSessionTitleWithLlm(
ctx,
resolveSessionTitleLlmConfig({ ...CONFIG, timeoutMs: 10 }),
providerRequest,
providerRequest.messages,
TITLE_PROVIDER,
)
const rejected = expect(pending).rejects.toMatchObject({
code: SESSION_TITLE_TIMEOUT_CODE,
timeoutMs: 10,
})
await vi.advanceTimersByTimeAsync(20)
await rejected
} finally {
vi.useRealTimers()
}
})
})