diff --git a/.agents/notes/implemented/feature/2026-07-21-log-backed-session-titles.i18n.yaml b/.agents/notes/implemented/feature/2026-07-21-log-backed-session-titles.i18n.yaml index 39abb2142d..0c3673b4c3 100644 --- a/.agents/notes/implemented/feature/2026-07-21-log-backed-session-titles.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-21-log-backed-session-titles.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 -2026-07-21-log-backed-session-titles.md: a2cef52bf88f03eef804e3d7f2bf83288ba49b2e -2026-07-21-log-backed-session-titles.zh.md: ea6ecc6e66d1afac772cdd93936c6ed49d5409a2 +2026-07-21-log-backed-session-titles.md: d536e8623652116c1194c303cd50ea44f27bb34d +2026-07-21-log-backed-session-titles.zh.md: b44f7cfaede440291445894ddb73829b1a3404ec diff --git a/.agents/notes/implemented/feature/2026-07-21-log-backed-session-titles.md b/.agents/notes/implemented/feature/2026-07-21-log-backed-session-titles.md index a2cef52bf8..d536e86236 100644 --- a/.agents/notes/implemented/feature/2026-07-21-log-backed-session-titles.md +++ b/.agents/notes/implemented/feature/2026-07-21-log-backed-session-titles.md @@ -16,21 +16,21 @@ The [`session-title` capability family](../../../../packages/session-title/READM ### Event ownership and folding -Every accepted revision is a log-only `session/title` event. Its payload contains normalized non-empty text, the exact eligible human `user/message` seqs used to derive it, and either fallback provenance or the registered provider id plus optional provider/model route. `foldSessionTitle()` selects the latest event and adds that event's seq and timestamp as `SessionTitleSnapshot`. Title events never enter `session.surface` or `deriveMessages()`. +Every accepted revision is a log-only `session/title` event. Its payload contains normalized non-empty text, the exact eligible human `user/message` seqs used to derive it, and either fallback provenance or the registered provider id plus optional provider/model route. Before an auxiliary title-model dispatch, the shared helper appends a log-only `session/title-llm-request` event containing the title-provider id, exact source seqs, route, system prompt, messages, and output-token cap; a later generation failure leaves the request auditable. Validation failures that never reach dispatch create no request event. `foldSessionTitle()` selects the latest title event and adds that event's seq and timestamp as `SessionTitleSnapshot`. Neither event enters `session.surface` or `deriveMessages()`. -The core session package exposes `ctx.sessions.appendOutOfBand()` only for plugin event types whose owners also declaration-merge an `OutOfBandSessionEventMap` marker. An open turn receives the log-only event directly and owns its normal checkpoint. A closed log receives `turn/start → event → turn/end` under the plugin's trigger, followed by an awaited flush. Once the synthetic turn opens, target-append failure still attempts to close and flush it; detach is deferred until the sequence settles. Session titles contribute the `session-title` zero-step trigger and opt `session/title` into this seam. +The core session package exposes `ctx.sessions.appendOutOfBand()` only for plugin event types whose owners also declaration-merge an `OutOfBandSessionEventMap` marker. An open turn receives the log-only event directly and owns its normal checkpoint. A closed log receives `turn/start → event → turn/end` under the plugin's trigger, followed by an awaited flush. Once the synthetic turn opens, target-append failure still attempts to close and flush it; detach is deferred until the sequence settles. Session titles contribute the `session-title` zero-step trigger and opt both title event types into this seam. ### Input and asynchronous timing Only text blocks from human-source `user/message` events are eligible. Empty, control-only, and non-text prompts wait for the next eligible message. The service schedules the first fallback without awaiting it from the prompt path, normalizes whitespace and control sequences, applies the configured word and UTF-8 byte limits without splitting a code point, and records the first message seq. -Automatic provider work waits until the corresponding `request/header` records the main request's exact provider/model route, then runs independently of the agent response. A completion joins whichever turn is open at acceptance time or uses the zero-step append path. Explicit `refresh(session, signal?)` materializes any missing fallback and awaits the registered provider; without a provider it returns the fallback. +Automatic provider work waits until the corresponding `request/header` records the main request's exact provider/model route, then runs independently of the agent response. A completion joins whichever turn is open at acceptance time or uses the zero-step append path. Explicit `refresh(session, signal?)` materializes any missing fallback and awaits the registered provider; without a provider it returns the fallback. Concurrent refreshes reserve their session-local revision before waiting for fallback durability, so a newer call supersedes an older call before either can invert provider completion order. The first-message provider schedules once when a fresh session first creates its fallback. An automatic failure does not reschedule on later prompts; `refresh()` is the retry path. The all-messages provider schedules after every eligible human prompt and passes all eligible messages through that revision, including seeded history. Its newer revision aborts and supersedes older pending or active work. ### Registration, routing, and failure policy -`register(provider)` validates one branded stable id, cadence, and generation function, then returns an effect disposer. A second live registration throws immediately. Provider disposal and session disposal abort pending and active work. Every session-local generation has a monotonic revision and exact registration identity; acceptance rechecks revision, registration, session liveness, and cancellation, so a provider that ignores its abort signal cannot commit stale output. +`register(provider)` validates one branded stable id, cadence, and generation function, then returns an awaitable effect disposer. A second live registration throws immediately. Provider disposal marks the registration closing, aborts its pending and active work, and waits for every call to settle before removing the registration, so replacement cannot overlap a provider that ignores cancellation. Session disposal aborts its active work. Service teardown prevents queued fallback and provider microtasks from starting, aborts active work, and drains tracked promises before unloading completes. Every session-local generation has a monotonic revision and exact registration identity; acceptance rechecks revision, registration, session liveness, service liveness, and cancellation, so stale output cannot commit. Model providers require explicit word, CJK-character, input-byte, output-token, and timeout limits. Optional `provider` and `model` overrides are a pair; without them the helper uses the exact route from the logged main request header. Selected messages are framed as JSON under one fixed language-aware instruction. Oversized input is rejected rather than truncated because truncation would make the recorded source seqs falsely imply complete use. @@ -55,6 +55,6 @@ A fork inherits seed title events unchanged, like the rest of its source log. Th - Titles survive JSONL and SQLite persistence, replay through ACP, and follow fork inheritance without a separate mutable record. - A fallback appears without an auxiliary call; deployments choose whether better titles justify model cost and whether later prompts should retitle a session. -- Late accepted titles consume event seqs and may create a balanced zero-step turn, so transcript and persistence fixtures expose the update even though model history and KV-cache identity do not change. +- Auxiliary request records and late accepted titles consume event seqs and may create balanced zero-step turns, so persistence exposes both attempted dispatches and accepted updates even though model history and KV-cache identity do not change. - One provider and monotonic per-session revisions make disposal, supersession, and stale-result rejection explicit, at the cost of leaving multi-strategy precedence to a composite provider. - Manual rename, deletion, generated-versus-user precedence, search, and list indexing remain outside the capability. diff --git a/.agents/notes/implemented/feature/2026-07-21-log-backed-session-titles.zh.md b/.agents/notes/implemented/feature/2026-07-21-log-backed-session-titles.zh.md index ea6ecc6e66..b44f7cfaed 100644 --- a/.agents/notes/implemented/feature/2026-07-21-log-backed-session-titles.zh.md +++ b/.agents/notes/implemented/feature/2026-07-21-log-backed-session-titles.zh.md @@ -16,21 +16,21 @@ Status: implemented ### 事件归属与折叠 -每个已接受的修订都是纯日志 `session/title` 事件。其载荷包含规范化后的非空文本、用于派生标题的所有合格且来源为人类的 `user/message` 的准确 seq,以及回退来源信息,或已注册的提供方 id 加可选的提供方和模型路由。`foldSessionTitle()` 选择最新事件,并将该事件的 seq 和时间戳加入 `SessionTitleSnapshot`。标题事件永远不会进入 `session.surface` 或 `deriveMessages()`。 +每个已接受的修订都是纯日志 `session/title` 事件。其载荷包含规范化后的非空文本、用于派生标题的所有合格且来源为人类的 `user/message` 的准确 seq,以及回退来源信息,或已注册的提供方 id 加可选的提供方和模型路由。辅助标题模型发起调用前,共享辅助组件会追加一个纯日志 `session/title-llm-request` 事件,其载荷包含标题提供方 id、准确的源 seq、路由、系统提示词、消息和输出 token 上限;即使后续生成失败,这次请求仍可审计。未进入调用阶段的验证失败不会创建请求事件。`foldSessionTitle()` 选择最新的标题事件,并将该事件的 seq 和时间戳加入 `SessionTitleSnapshot`。这两类事件都不会进入 `session.surface` 或 `deriveMessages()`。 -核心会话包通过 `ctx.sessions.appendOutOfBand()` 暴露这一接口,但只允许所属插件同时通过声明合并向 `OutOfBandSessionEventMap` 添加标记的插件事件类型使用。开放轮次会直接接收纯日志事件,并负责其常规检查点。已关闭的日志会在该插件的触发器下接收 `turn/start → event → turn/end`,随后等待刷写完成。合成轮次一旦开启,即使目标追加失败,系统仍会尝试将其关闭并刷写;整个序列完成前会延迟 detach。会话标题提供 `session-title` 零步骤触发器,并让 `session/title` 使用这一服务边界。 +核心会话包通过 `ctx.sessions.appendOutOfBand()` 暴露这一接口,但只允许所属插件同时通过声明合并向 `OutOfBandSessionEventMap` 添加标记的插件事件类型使用。开放轮次会直接接收纯日志事件,并负责其常规检查点。已关闭的日志会在该插件的触发器下接收 `turn/start → event → turn/end`,随后等待刷写完成。合成轮次一旦开启,即使目标追加失败,系统仍会尝试将其关闭并刷写;整个序列完成前会延迟 detach。会话标题提供 `session-title` 零步骤触发器,并让这两类标题事件都使用这一服务边界。 ### 输入与异步时序 只有人类来源的 `user/message` 事件中的文本块才符合条件。空提示词、仅含控制字符的提示词和非文本提示词会等待下一条合格消息。服务从提示词路径调度首个回退标题而不等待其完成,随后规范化空白和控制序列,应用已配置的单词数和 UTF-8 字节限制且不拆分代码点,并记录第一条消息的 seq。 -自动提供方工作会等待相应的 `request/header` 记录主请求的准确提供方和模型路由,然后独立于 agent 响应运行。完成结果在被接受时加入当时开放的轮次,否则使用零步骤追加路径。显式调用 `refresh(session, signal?)` 会生成尚缺的回退标题并等待已注册的提供方;没有提供方时则返回回退标题。 +自动提供方工作会等待相应的 `request/header` 记录主请求的准确提供方和模型路由,然后独立于 agent 响应运行。完成结果在被接受时加入当时开放的轮次,否则使用零步骤追加路径。显式调用 `refresh(session, signal?)` 会生成尚缺的回退标题并等待已注册的提供方;没有提供方时则返回回退标题。并发刷新会在等待回退标题持久化完成前预留会话本地修订号,因此在任何调用有机会造成提供方完成顺序倒置之前,较新的调用就会取代较早的调用。 首消息提供方仅在新会话首次创建回退标题时调度一次。自动执行失败后,后续提示词不会重新调度;`refresh()` 是重试路径。全部消息提供方会在每条合格且由人类发出的提示词后调度,并传入截至该修订的所有合格消息,包括预置历史记录。较新的修订会中止并取代更早的待执行或活跃工作。 ### 注册、路由与失败策略 -`register(provider)` 会验证一个带品牌类型的稳定 id、执行时机和生成函数,然后返回 effect 资源释放函数。第二个活跃注册会立即抛出错误。提供方和会话执行 dispose(资源释放)时,都会中止待执行和活跃工作。每项会话本地生成都有单调递增的修订号和对应的注册身份;接受结果时会重新检查修订号、注册、会话活跃状态和取消状态,因此即使提供方忽略中止信号,也无法提交陈旧输出。 +`register(provider)` 会验证一个带品牌类型的稳定 id、执行时机和生成函数,然后返回一个可等待完成的 effect 资源释放函数。第二个活跃注册会立即抛出错误。提供方执行资源释放时,会将注册标记为正在关闭,中止其待执行和活跃工作,并等待所有调用结束后才移除注册,因此替代提供方不会与忽略取消的旧提供方重叠运行。会话资源释放会中止其活跃工作。服务卸载时,会阻止排队中的回退和提供方微任务启动,中止活跃工作,并且卸载完成前会等待所有已跟踪的 promise 结算。每项会话本地生成都有单调递增的修订号和对应的注册身份;接受结果时会重新检查修订号、注册、会话活跃状态、服务活跃状态和取消状态,因此陈旧输出无法提交。 模型提供方必须显式配置单词数、CJK 字符数、输入字节数、输出 token 数和超时限制。可选的 `provider` 和 `model` 覆盖项必须成对提供;两者均未提供时,辅助组件会使用主请求已记录请求头中的准确路由。系统在一条固定且能区分语言的指令下,将选中的消息封装为 JSON。过大输入会被拒绝而不是截断,因为截断会让记录的源消息 seq 错误地表示这些消息已被完整使用。 @@ -55,6 +55,6 @@ Status: implemented - 标题可以在 JSONL 和 SQLite 持久化中存续,通过 ACP 回放,并遵循 fork 继承语义,而无需单独的可变记录。 - 回退标题无需辅助调用即可出现;部署方可以自行决定更优标题是否值得模型成本,以及后续提示词是否需要重新生成会话标题。 -- 延迟接受的标题会占用事件 seq,并可能创建平衡的零步骤轮次,因此 transcript(文本记录)和持久化 fixture(测试前置数据)会呈现该更新,尽管模型历史和 KV 缓存标识保持不变。 +- 辅助请求记录和延迟接受的标题会占用事件 seq,并可能创建平衡的零步骤轮次,因此持久化会同时呈现尝试发起的调用与已接受的更新,尽管模型历史和 KV 缓存标识保持不变。 - 单个提供方和每会话单调递增的修订号让释放、取代和陈旧结果拒绝行为明确可见,但多策略优先级必须由复合提供方负责。 - 手动重命名、删除、生成标题与用户标题的优先级、搜索和列表索引不在此功能范围内。 diff --git a/docs/config-catalog.md b/docs/config-catalog.md index c6a3889cbe..10549c12ee 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -881,7 +881,7 @@ Source: [`packages/session-title/session-title/src/index.ts:63`](../packages/ses ## `@deepseek-ai/dsh-session-title-all-messages-llm` -Requires: `sessionTitle` · `llm` +Requires: `sessionTitle` · `llm` · `sessions` ```ts config-catalog /** Required LLM policy; this plugin adds no defaults. */ @@ -894,7 +894,7 @@ Source: [`packages/session-title/session-title-all-messages-llm/src/index.ts:15` ## `@deepseek-ai/dsh-session-title-first-message-llm` -Requires: `sessionTitle` · `llm` +Requires: `sessionTitle` · `llm` · `sessions` ```ts config-catalog /** Required LLM policy; this plugin adds no defaults. */ diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index f98218f7d3..60f10133e1 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -1016,14 +1016,14 @@ async refresh(session: Session, signal?: AbortSignal): Promise void +register(provider: SessionTitleProvider): () => Promise ``` Types: [Session](../core-data-structures/session.md) · [SessionTitleProvider](../core-data-structures/session-title.md) · [SessionTitleSnapshot](../core-data-structures/session-title.md) -Source: [`packages/session-title/session-title/src/index.ts:233`](../../packages/session-title/session-title/src/index.ts) +Source: [`packages/session-title/session-title/src/index.ts:235`](../../packages/session-title/session-title/src/index.ts) ## `ctx.skills` — `SkillService` diff --git a/docs/core-data-structures/session-title.md b/docs/core-data-structures/session-title.md index 3e118368d3..39e2b00ae5 100644 --- a/docs/core-data-structures/session-title.md +++ b/docs/core-data-structures/session-title.md @@ -1,8 +1,8 @@ # Session Titles -Durable latest-wins title state and the optional asynchronous provider vocabulary owned by [`@deepseek-ai/dsh-session-title`](../../packages/session-title/session-title). The package README owns timing, fallback, failure, and fork behavior; the generated [persistence catalog](../persistence-catalog.md) owns the complete `session/title` event declaration. +Durable latest-wins title state and the optional asynchronous provider vocabulary owned by [`@deepseek-ai/dsh-session-title`](../../packages/session-title/session-title). The shared LLM helper owns the exact auxiliary request record. Package READMEs own timing, fallback, failure, and fork behavior; the generated [persistence catalog](../persistence-catalog.md) owns the complete event declarations. -Source: [`packages/session-title/session-title/src/index.ts`](../../packages/session-title/session-title/src/index.ts) +Sources: [`packages/session-title/session-title/src/index.ts`](../../packages/session-title/session-title/src/index.ts), [`packages/session-title/session-title-llm/src/index.ts`](../../packages/session-title/session-title-llm/src/index.ts) ## Durable title state @@ -56,6 +56,28 @@ interface SessionTitleSnapshot extends SessionTitleEventData { } ``` +## Auxiliary request record + +The shared LLM helper records each validated, dispatchable title request before calling the model. The payload reproduces the model-visible system and message input, routing, output limit, provider ownership, and source-message attribution even when generation later fails. + +```ts type-equiv +/** Exact model-visible request recorded before one auxiliary title dispatch. */ +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 +} +``` + ## Provider input and output The service snapshots eligible messages through one revision. A provider returns only seqs from that request; service-owned acceptance verifies ordering, normalizes the title, enforces the byte limit, and appends provenance. diff --git a/docs/module-graph.md b/docs/module-graph.md index 953fe46018..1ec486ba17 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -251,6 +251,7 @@ flowchart TD pkg_session_query --> pkg_session_persistence pkg_session_query --> pkg_session_title pkg_session_title_llm --> pkg_llm + pkg_session_title_llm --> pkg_session pkg_session_title_llm --> pkg_session_title pkg_session_title_llm --> pkg_timeout pkg_invariants --> pkg_agent @@ -298,9 +299,11 @@ flowchart TD pkg_fs_sandbox --> pkg_sandbox pkg_fs_sandbox --> pkg_sandbox_policy pkg_session_title_all_messages_llm --> pkg_llm + pkg_session_title_all_messages_llm --> pkg_session pkg_session_title_all_messages_llm --> pkg_session_title pkg_session_title_all_messages_llm --> pkg_session_title_llm pkg_session_title_first_message_llm --> pkg_llm + pkg_session_title_first_message_llm --> pkg_session pkg_session_title_first_message_llm --> pkg_session_title pkg_session_title_first_message_llm --> pkg_session_title_llm pkg_permission --> pkg_bash @@ -585,7 +588,7 @@ flowchart TD | [`session-persistence-jsonl`](../packages/session-persistence/session-persistence-jsonl) | `session-persistence` | [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence) | | [`session-persistence-sqlite`](../packages/session-persistence/session-persistence-sqlite) | `session-persistence` | [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence) | | [`session-query`](../packages/session-query/session-query) | `session-query` | [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`session-title`](../packages/session-title/session-title) | -| [`session-title-llm`](../packages/session-title/session-title-llm) | `session-title` | [`llm`](../packages/llm/llm), [`session-title`](../packages/session-title/session-title), [`timeout`](../packages/util/timeout) | +| [`session-title-llm`](../packages/session-title/session-title-llm) | `session-title` | [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-title`](../packages/session-title/session-title), [`timeout`](../packages/util/timeout) | | [`invariants`](../packages/support/invariants) | `support` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session) | | [`commands`](../packages/ui/commands) | `ui` | [`agent`](../packages/core/agent), [`scope`](../packages/core/scope) | | [`user-approval`](../packages/ui/user-approval) | `ui` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt) | @@ -598,8 +601,8 @@ flowchart TD | [`goal-session`](../packages/goal/goal-session) | `goal` | [`agent`](../packages/core/agent), [`goal`](../packages/goal/goal), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | | [`bash-sandbox`](../packages/bash/bash-sandbox) | `bash` | [`bash`](../packages/bash/bash), [`bash-local`](../packages/bash/bash-local), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy) | | [`fs-sandbox`](../packages/fs/fs-sandbox) | `fs` | [`fs`](../packages/fs/fs), [`fs-local`](../packages/fs/fs-local), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy) | -| [`session-title-all-messages-llm`](../packages/session-title/session-title-all-messages-llm) | `session-title` | [`llm`](../packages/llm/llm), [`session-title`](../packages/session-title/session-title), [`session-title-llm`](../packages/session-title/session-title-llm) | -| [`session-title-first-message-llm`](../packages/session-title/session-title-first-message-llm) | `session-title` | [`llm`](../packages/llm/llm), [`session-title`](../packages/session-title/session-title), [`session-title-llm`](../packages/session-title/session-title-llm) | +| [`session-title-all-messages-llm`](../packages/session-title/session-title-all-messages-llm) | `session-title` | [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-title`](../packages/session-title/session-title), [`session-title-llm`](../packages/session-title/session-title-llm) | +| [`session-title-first-message-llm`](../packages/session-title/session-title-first-message-llm) | `session-title` | [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-title`](../packages/session-title/session-title), [`session-title-llm`](../packages/session-title/session-title-llm) | | [`permission`](../packages/ui/permission) | `ui` | [`bash`](../packages/bash/bash), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`session`](../packages/core/session), [`user-approval`](../packages/ui/user-approval) | | [`agent-loop`](../packages/core/agent-loop) | `core` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | | [`tool-goal`](../packages/goal/tool-goal) | `goal` | [`agent`](../packages/core/agent), [`goal`](../packages/goal/goal), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | diff --git a/docs/persistence-catalog.md b/docs/persistence-catalog.md index 10020c5d78..7ba4a3be2b 100644 --- a/docs/persistence-catalog.md +++ b/docs/persistence-catalog.md @@ -392,6 +392,17 @@ Types: [SessionTitleEventData](core-data-structures/session-title.md) Source: [`packages/session-title/session-title/src/index.ts:89`](../packages/session-title/session-title/src/index.ts) +#### `session/title-llm-request` — log-only + +```ts persistence-catalog +/** Log-only pre-dispatch record of one session-title model request. */ +'session/title-llm-request': SessionTitleLlmRequestEventData +``` + +Types: [SessionTitleLlmRequestEventData](core-data-structures/session-title.md) + +Source: [`packages/session-title/session-title-llm/src/index.ts:40`](../packages/session-title/session-title-llm/src/index.ts) + ### `steering/*` #### `steering/message` — surface diff --git a/packages/cordis/tool-cordis/src/api-catalog.ts b/packages/cordis/tool-cordis/src/api-catalog.ts index a223da88ba..c02c5777b9 100644 --- a/packages/cordis/tool-cordis/src/api-catalog.ts +++ b/packages/cordis/tool-cordis/src/api-catalog.ts @@ -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', + 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 */', }, ], }, diff --git a/packages/session-title/README.md b/packages/session-title/README.md index d17acc2d60..8c26cf1785 100644 --- a/packages/session-title/README.md +++ b/packages/session-title/README.md @@ -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` | diff --git a/packages/session-title/session-title-all-messages-llm/package.json b/packages/session-title/session-title-all-messages-llm/package.json index 0d87033b38..1d0b941e0b 100644 --- a/packages/session-title/session-title-all-messages-llm/package.json +++ b/packages/session-title/session-title-all-messages-llm/package.json @@ -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" diff --git a/packages/session-title/session-title-all-messages-llm/src/index.ts b/packages/session-title/session-title-all-messages-llm/src/index.ts index 3e174bf8c3..96bd424434 100644 --- a/packages/session-title/session-title-all-messages-llm/src/index.ts +++ b/packages/session-title/session-title-all-messages-llm/src/index.ts @@ -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 = 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 { diff --git a/packages/session-title/session-title-first-message-llm/package.json b/packages/session-title/session-title-first-message-llm/package.json index 7d3c4a70fa..eb3c23289b 100644 --- a/packages/session-title/session-title-first-message-llm/package.json +++ b/packages/session-title/session-title-first-message-llm/package.json @@ -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" diff --git a/packages/session-title/session-title-first-message-llm/src/index.ts b/packages/session-title/session-title-first-message-llm/src/index.ts index c38c3aa291..51cc8eab44 100644 --- a/packages/session-title/session-title-first-message-llm/src/index.ts +++ b/packages/session-title/session-title-first-message-llm/src/index.ts @@ -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 = 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 { diff --git a/packages/session-title/session-title-first-message-llm/tests/provider.spec.ts b/packages/session-title/session-title-first-message-llm/tests/provider.spec.ts index c42b24ad13..ed749bd3e5 100644 --- a/packages/session-title/session-title-first-message-llm/tests/provider.spec.ts +++ b/packages/session-title/session-title-first-message-llm/tests/provider.spec.ts @@ -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) diff --git a/packages/session-title/session-title-llm/README.md b/packages/session-title/session-title-llm/README.md index 441d2f9215..85ca837a75 100644 --- a/packages/session-title/session-title-llm/README.md +++ b/packages/session-title/session-title-llm/README.md @@ -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. diff --git a/packages/session-title/session-title-llm/package.json b/packages/session-title/session-title-llm/package.json index 1d81378a00..28be809b48 100644 --- a/packages/session-title/session-title-llm/package.json +++ b/packages/session-title/session-title-llm/package.json @@ -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" diff --git a/packages/session-title/session-title-llm/src/index.ts b/packages/session-title/session-title-llm/src/index.ts index f09732555a..c7ffc1eb1c 100644 --- a/packages/session-title/session-title-llm/src/index.ts +++ b/packages/session-title/session-title-llm/src/index.ts @@ -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 { 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) diff --git a/packages/session-title/session-title-llm/tests/llm.spec.ts b/packages/session-title/session-title-llm/tests/llm.spec.ts index f5195ede79..2c1a701356 100644 --- a/packages/session-title/session-title-llm/tests/llm.spec.ts +++ b/packages/session-title/session-title-llm/tests/llm.spec.ts @@ -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 { + 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, diff --git a/packages/session-title/session-title/README.md b/packages/session-title/session-title/README.md index 96607bb1e9..ba3c898ca9 100644 --- a/packages/session-title/session-title/README.md +++ b/packages/session-title/session-title/README.md @@ -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. diff --git a/packages/session-title/session-title/src/index.ts b/packages/session-title/session-title/src/index.ts index bae2f4df94..757dea356d 100644 --- a/packages/session-title/session-title/src/index.ts +++ b/packages/session-title/session-title/src/index.ts @@ -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> + 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() + private readonly lifetime = new AbortController() + private readonly inFlight = new Set>() 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 { 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 { 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): 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): 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 { + 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 { 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 { + 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(run: Promise, registration?: ProviderRegistration): Promise { + 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 { + 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 { + this.assertServiceActive() const current = this.get(session) if (current !== undefined) return current const [first] = collectSessionTitleMessages(session.events) diff --git a/packages/session-title/session-title/tests/provider.spec.ts b/packages/session-title/session-title/tests/provider.spec.ts index cb298461da..b29139f1e8 100644 --- a/packages/session-title/session-title/tests/provider.spec.ts +++ b/packages/session-title/session-title/tests/provider.spec.ts @@ -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 () => { diff --git a/packages/session-title/session-title/tests/service-contracts.spec.ts b/packages/session-title/session-title/tests/service-contracts.spec.ts index adf1358c97..b9a9851935 100644 --- a/packages/session-title/session-title/tests/service-contracts.spec.ts +++ b/packages/session-title/session-title/tests/service-contracts.spec.ts @@ -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() + const releaseFlush = deferred() + let flushCount = 0 + ctx.on('session/flush', async (subject) => { + if (subject !== session || ++flushCount !== 1) return + flushStarted.resolve(undefined) + await releaseFlush.promise + }) + const result = deferred() + 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 } = {} + 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() + 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() + const releaseFlush = deferred() + 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 () => { diff --git a/scripts/gen-persistence-catalog.ts b/scripts/gen-persistence-catalog.ts index 1fe5292716..853b134d26 100644 --- a/scripts/gen-persistence-catalog.ts +++ b/scripts/gen-persistence-catalog.ts @@ -42,6 +42,7 @@ const LINK_MAP: Record = { TurnTrigger: 'session.md', TurnEndReason: 'session.md', SessionTitleEventData: 'session-title.md', + SessionTitleLlmRequestEventData: 'session-title.md', SessionTitleModelProvenance: 'session-title.md', SessionTitleProviderId: 'session-title.md', SessionTitleSource: 'session-title.md', diff --git a/scripts/type-equiv.manifest.json b/scripts/type-equiv.manifest.json index 9b24a39bfd..40bf159862 100644 --- a/scripts/type-equiv.manifest.json +++ b/scripts/type-equiv.manifest.json @@ -96,6 +96,7 @@ { "doc": "docs/core-data-structures/session-title.md", "symbol": "SessionTitleSource", "source": "packages/session-title/session-title/src/index.ts" }, { "doc": "docs/core-data-structures/session-title.md", "symbol": "SessionTitleEventData", "source": "packages/session-title/session-title/src/index.ts" }, { "doc": "docs/core-data-structures/session-title.md", "symbol": "SessionTitleSnapshot", "source": "packages/session-title/session-title/src/index.ts" }, + { "doc": "docs/core-data-structures/session-title.md", "symbol": "SessionTitleLlmRequestEventData", "source": "packages/session-title/session-title-llm/src/index.ts" }, { "doc": "docs/core-data-structures/session-title.md", "symbol": "SessionTitleUserMessage", "source": "packages/session-title/session-title/src/index.ts" }, { "doc": "docs/core-data-structures/session-title.md", "symbol": "SessionTitleAutomaticMode", "source": "packages/session-title/session-title/src/index.ts" }, { "doc": "docs/core-data-structures/session-title.md", "symbol": "SessionTitleProviderRequest", "source": "packages/session-title/session-title/src/index.ts" },