diff --git a/docs/subsystems/session-title.i18n.yaml b/docs/subsystems/session-title.i18n.yaml index 721bbb0459..e75bf69822 100644 --- a/docs/subsystems/session-title.i18n.yaml +++ b/docs/subsystems/session-title.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/subsystems/session-title.md -session-title.md: d872086500cf477cfa88f8eb756fe9b7be5fc571 -session-title.zh.md: 01aa62a24320e9c9329641603529c1ced9da4360 +session-title.md: 07629a83fa70fa52429e9ca57d75fac6565f5a3e +session-title.zh.md: ea7581f74434204377f7f5e02611e0feb3da144b diff --git a/docs/subsystems/session-title.md b/docs/subsystems/session-title.md index d872086500..07629a83fa 100644 --- a/docs/subsystems/session-title.md +++ b/docs/subsystems/session-title.md @@ -200,5 +200,5 @@ register(provider: SessionTitleProvider): () => Promise Types: [Session](session.md) -Source: [`packages/session/session-title/src/index.ts:282`](../../packages/session/session-title/src/index.ts) +Source: [`packages/session/session-title/src/index.ts:289`](../../packages/session/session-title/src/index.ts) diff --git a/docs/subsystems/session-title.zh.md b/docs/subsystems/session-title.zh.md index 01aa62a243..ea7581f744 100644 --- a/docs/subsystems/session-title.zh.md +++ b/docs/subsystems/session-title.zh.md @@ -200,5 +200,5 @@ register(provider: SessionTitleProvider): () => Promise Types: [Session](session.md) -Source: [`packages/session/session-title/src/index.ts:282`](../../packages/session/session-title/src/index.ts) +Source: [`packages/session/session-title/src/index.ts:289`](../../packages/session/session-title/src/index.ts) diff --git a/packages/session/session-title/README.i18n.yaml b/packages/session/session-title/README.i18n.yaml index c3210ca41a..07d3b10072 100644 --- a/packages/session/session-title/README.i18n.yaml +++ b/packages/session/session-title/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/session/session-title/README.md -README.md: 7949dbdb73d33c6c7ba1cccd469a6b22732aa95d -README.zh.md: 24014485050be9e6aa736ac6c686416c3778ac10 +README.md: f7e18b1163790e65b5f0644909098bf5c55dd6b0 +README.zh.md: 74aee2727eb05a2c57b56f491fdfe598840a696d diff --git a/packages/session/session-title/README.md b/packages/session/session-title/README.md index 7949dbdb73..f7e18b1163 100644 --- a/packages/session/session-title/README.md +++ b/packages/session/session-title/README.md @@ -13,7 +13,7 @@ Only text blocks from human `user/message` events are eligible. The first eligib - `rename(session, title)` accepts an explicit user title synchronously: it normalizes the text, supersedes in-flight automatic work, and appends a `session/title` event with the `user` source. A user-sourced latest title pins the session — later user messages schedule no automatic revision; an explicit `refresh` remains the deliberate unpin. - `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. -The service also registers the host-only `titleInput` projection. It incrementally retains eligible human text and the latest request route, so provider scheduling reads typed state through `stateOf()` instead of rescanning the session log. +The service also registers the host-only `titleInput` projection. It incrementally retains the first and latest eligible messages plus their count, so scheduling and fallback reads are O(1) through `stateOf()`; the full eligible prefix for one provider generation is scanned from the session log at execution time. Automatic work never delays the main agent response. A provider starts only after a marked loop-built request's exact route matches the current logged `request/header`, including when the unchanged header needs no new snapshot. Its late completion appends a standalone log-only event directly through `Session` without opening a turn. Persistence observes that event eagerly and drains on ordinary lifecycle checkpoints; title publication itself does not force a flush. 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 revision before provider work, while overlapping automatic and explicit fallback requests share one session-local in-flight append. The service and bundled model provider each append their own literal event type, so no generic title-write marker, cast, or settlement queue is needed. Service teardown cancels queued work and drains calls that ignore cancellation before unloading completes. diff --git a/packages/session/session-title/README.zh.md b/packages/session/session-title/README.zh.md index 2401448505..74aee2727e 100644 --- a/packages/session/session-title/README.zh.md +++ b/packages/session/session-title/README.zh.md @@ -13,7 +13,7 @@ - `rename(session, title)` 同步接受用户显式标题:规范化文本、取代在途自动工作,并追加一条 `user` 来源的 `session/title` 事件。用户来源的最新标题会钉住该会话——后续用户消息不再安排自动修订;显式 `refresh` 仍是有意的解钉手段。 - `register(provider)` 安装唯一可选提供方,并返回可等待的 Cordis effect disposer。第二次注册会立即抛出;对提供方执行 dispose(资源释放)会中止待处理和活跃调用,等待其结算,之后才允许注册另一个提供方。 -服务还会注册仅供 host 使用的 `titleInput` 投影。该投影增量保留符合条件的人类文本和最近一次请求 route,使提供方调度可以通过 `stateOf()` 读取类型化状态,无需重新扫描会话日志。 +服务还会注册仅供 host 使用的 `titleInput` 投影。该投影增量保留第一条与最新一条合格消息及其计数,使调度与回退读取通过 `stateOf()` 达到 O(1);某次提供方生成所需的完整合格前缀,则会在执行时从会话日志中扫描取得。 自动工作绝不会延迟主 agent(智能体)响应。只有当带标记、由循环构建的请求,其确切路由与当前已记录的 `request/header` 匹配时,提供方才会启动;即使请求头未变而无需新快照,也适用此规则。延迟完成会直接通过 `Session` 追加一个独立的纯日志事件,而不打开轮次。持久化会立即观察到该事件,并在常规生命周期检查点完成刷写;标题发布本身不会强制刷写。自动失败会发出警告并保留最新标题。新的全消息修订、提供方 dispose、会话 dispose 和显式刷新都会中止旧工作,陈旧的完成结果无法追加。并发显式刷新会在提供方工作之前预留修订号;重叠的自动/显式回退请求共享一个会话本地正在进行的追加操作。服务与内置模型提供方各自追加自己的字面事件类型,因此不需要通用标题写入标记、类型断言或结算队列。服务拆卸会取消排队工作,并在卸载完成前等待不响应取消的调用结算完成。 diff --git a/packages/session/session-title/src/index.ts b/packages/session/session-title/src/index.ts index 7f467e6d79..f7d6a553cb 100644 --- a/packages/session/session-title/src/index.ts +++ b/packages/session/session-title/src/index.ts @@ -31,7 +31,6 @@ import type { SessionTitleSnapshot, SessionTitleSource, SessionTitleUserMessage, - TitleInputChunk, TitleInputState, TitleProjection, } from './types.ts' @@ -211,20 +210,28 @@ export function titleSnapshotFromState(state: TitleUnitState): SessionTitleSnaps }) } -const TITLE_INPUT_CHUNK_SIZE = 64 +const EMPTY_TITLE_INPUT: TitleInputState = { first: null, last: null, count: 0 } -const EMPTY_TITLE_INPUT: TitleInputState = { first: null, last: null, count: 0, tail: null } - -function titleInputPrefix(state: TitleInputState, throughSeq: number): SessionTitleUserMessage[] { - const chunks: TitleInputChunk[] = [] - for (let chunk = state.tail; chunk !== null; chunk = chunk.previous) chunks.push(chunk) - const prefix: SessionTitleUserMessage[] = [] - for (const chunk of chunks.reverse()) { - for (const message of chunk.messages) { - if (message.seq <= throughSeq) prefix.push(message) - } +/** + * Collect eligible human text messages from a session log, in seq order. + * The full eligible prefix is only materialized for one provider generation, + * so it is scanned from the log at execution time rather than retained by + * the O(1) `titleInput` projection. + * @param events - the session event log. + * @param throughSeq - optional inclusive upper seq bound. + * @returns eligible messages with exact source seqs. + */ +function collectSessionTitleMessages( + events: readonly SessionEvent[], + throughSeq?: number, +): SessionTitleUserMessage[] { + const messages: SessionTitleUserMessage[] = [] + for (const event of events) { + if (throughSeq !== undefined && event.seq > throughSeq) break + const message = sessionTitleUserMessageOf(event) + if (message !== undefined) messages.push(message) } - return prefix + return messages } // Zod cannot express the branded provider id without a runtime transform. @@ -326,20 +333,16 @@ export class SessionTitleService extends Service { ctx.sessionProjections.register<'titleInput', TitleInputState>({ key: 'titleInput', - stateVersion: 1, + stateVersion: 2, stateSchema: zod.custom(), init: () => EMPTY_TITLE_INPUT, apply: (state, event) => { const message = sessionTitleUserMessageOf(event) if (message === undefined) return state - const tail = state.tail === null || state.tail.messages.length >= TITLE_INPUT_CHUNK_SIZE - ? { messages: [message], previous: state.tail } - : { messages: [...state.tail.messages, message], previous: state.tail.previous } return { first: state.first ?? message, last: message, count: state.count + 1, - tail, } }, }) @@ -586,7 +589,7 @@ export class SessionTitleService extends Service { this.assertCurrent(session, work) await this.ensureFallback(session) this.assertCurrent(session, work) - const messages = titleInputPrefix(this.titleInputOf(session), work.throughSeq) + const messages = collectSessionTitleMessages(session.events, work.throughSeq) const result = await work.registration.provider.generate({ session, messages, diff --git a/packages/session/session-title/src/types.ts b/packages/session/session-title/src/types.ts index 77c6d3e78e..6f4797d262 100644 --- a/packages/session/session-title/src/types.ts +++ b/packages/session/session-title/src/types.ts @@ -65,13 +65,7 @@ export interface SessionTitleUserMessage { readonly text: string } -/** One bounded reverse-linked group of eligible title-input messages. */ -export interface TitleInputChunk { - readonly messages: readonly SessionTitleUserMessage[] - readonly previous: TitleInputChunk | null -} - -/** Eligible title input stored in bounded reverse-linked chunks. */ +/** Eligible title input stored as a bounded aggregate. */ export interface TitleInputState { /** The oldest eligible message, or null before any. */ readonly first: SessionTitleUserMessage | null @@ -79,8 +73,6 @@ export interface TitleInputState { readonly count: number /** Newest eligible message, or null before any. */ readonly last: SessionTitleUserMessage | null - /** Newest chunk in the reverse-linked list. */ - readonly tail: TitleInputChunk | null } declare module '@deepseek-ai/dsh-session-projection/types' { diff --git a/packages/session/session-title/tests/projection.spec.ts b/packages/session/session-title/tests/projection.spec.ts index d518f9f756..74aec3bb70 100644 --- a/packages/session/session-title/tests/projection.spec.ts +++ b/packages/session/session-title/tests/projection.spec.ts @@ -62,7 +62,7 @@ describe('title projection unit', () => { expect('title' in ctx.sessionProjections.snapshot(session).values).toBe(false) }) - it('keeps thousands of title inputs in bounded reverse-linked chunks without persisting them', async () => { + it('keeps thousands of title inputs as a bounded aggregate and checkpoints it', async () => { const { ctx, session } = await harness(false) session.append('turn/start', { turn: 1 }) for (let index = 0; index < 5_000; index++) { @@ -75,9 +75,8 @@ describe('title projection unit', () => { const state = ctx.sessionProjections.stateOf(session, 'titleInput') expect(state?.count).toBe(5_000) - let chunks = 0 - for (let chunk = state?.tail ?? null; chunk !== null; chunk = chunk.previous) chunks += 1 - expect(chunks).toBe(Math.ceil(5_000 / 64)) - expect(ctx.sessionProjections.checkpoint(session).titleInput).toBeUndefined() + expect(state?.first?.text).toBe('message 0') + expect(state?.last?.text).toBe('message 4999') + expect(ctx.sessionProjections.checkpoint(session).titleInput).toBeDefined() }) })