Răsfoiți Sursa

fix(web): title sessions created by host

Tianyi Cui 2 luni în urmă
părinte
comite
fc23a89208

+ 2 - 2
.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: 494187a73c58fb2313d802825c3ec9f9994d6f2b
-2026-07-21-log-backed-session-titles.zh.md: cae51cca920fad748cb1944d35cc1b80850eb6ee
+2026-07-21-log-backed-session-titles.md: 6d2aa2049b57d82014f1a85555a1bda9b537bece
+2026-07-21-log-backed-session-titles.zh.md: 7f43832f3a0b6a28227b6a862d78be117c7cb398

+ 1 - 1
.agents/notes/implemented/feature/2026-07-21-log-backed-session-titles.md

@@ -12,7 +12,7 @@ Session identity metadata is immutable, the event log is the replay and fork bou
 
 ## Decision
 
-The [`session-title` capability family](../../../../packages/session-title/README.md) owns title state and generation policy. `@deepseek-ai/dsh-session-title` provides `ctx.sessionTitle`, a deterministic first-message fallback, and a registry for at most one optional asynchronous provider. `@deepseek-ai/dsh-session-title-llm` owns the common auxiliary-model request policy; separate first-message and all-user-messages plugins choose input cadence. The shared agent spine mounts only the fallback service with overridable explicit example limits, leaving both model providers opt-in.
+The [`session-title` capability family](../../../../packages/session-title/README.md) owns title state and generation policy. `@deepseek-ai/dsh-session-title` provides `ctx.sessionTitle`, a deterministic first-message fallback, and a registry for at most one optional asynchronous provider. `@deepseek-ai/dsh-session-title-llm` owns the common auxiliary-model request policy; separate first-message and all-user-messages plugins choose input cadence. The shared agent spine and Web host runtime mount the fallback service with explicit overridable limits; neither composition mounts an asynchronous provider, so a fresh Web session persists a title without adding a model call. Either model provider remains opt-in.
 
 ### Event ownership and folding
 

+ 1 - 1
.agents/notes/implemented/feature/2026-07-21-log-backed-session-titles.zh.md

@@ -12,7 +12,7 @@ Status: implemented
 
 ## 决策
 
-[`session-title` 功能包族](../../../../packages/session-title/README.md)负责标题状态和生成策略。`@deepseek-ai/dsh-session-title` 提供 `ctx.sessionTitle`、确定性的首消息回退方案,以及一个至多接受单个可选异步提供方的注册表。`@deepseek-ai/dsh-session-title-llm` 负责通用的辅助模型请求策略;首消息插件和全部用户消息插件分别选择输入调度方式。共享 agent 主干只挂载回退服务,并为其显式设置可覆盖的示例限制;两种模型提供方均需按需启用。
+[`session-title` 功能包族](../../../../packages/session-title/README.md)负责标题状态和生成策略。`@deepseek-ai/dsh-session-title` 提供 `ctx.sessionTitle`、确定性的首消息回退方案,以及一个至多接受单个可选异步提供方的注册表。`@deepseek-ai/dsh-session-title-llm` 负责通用的辅助模型请求策略;首消息插件和全部用户消息插件分别选择输入调度方式。共享 agent 主干与 Web host 运行时都会挂载回退服务,并显式设置可覆盖的限制;两种组合均不挂载异步提供方,因此新建的 Web 会话无需增加模型调用即可持久化标题。两种模型提供方均仍需按需启用。
 
 ### 事件归属与折叠
 

+ 13 - 0
apps/web/tests/smoke-real.e2e.ts

@@ -183,6 +183,19 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY || notReady.length > 0)('web smoke
     // near-empty here means that class of bug is back.
     await page.waitForFunction(() => document.body.innerText.length > 50, undefined, { timeout: 15_000 })
     expect(pageErrors).toEqual([])
+    await page.waitForFunction(
+      () => document.title !== 'DeepSeek Harness' && document.title.endsWith(' — DeepSeek Harness'),
+      undefined,
+      { timeout: 15_000 },
+    )
+    const durableTitle = (await page.title()).replace(/ — DeepSeek Harness$/, '')
+    const sessionTree = page.getByRole('tree', { name: 'Sessions' })
+    const projectRow = sessionTree.getByRole('treeitem').first()
+    if (await projectRow.getAttribute('aria-expanded') === 'false') await projectRow.click()
+    await Promise.all([
+      sessionTree.getByText(durableTitle, { exact: true }).waitFor({ timeout: 10_000 }),
+      page.getByRole('navigation').getByText(durableTitle, { exact: true }).waitFor({ timeout: 10_000 }),
+    ])
     await page.waitForFunction(() => document.body.innerText.includes('介绍完毕'), undefined, { timeout: 120_000 })
     await screen(page, '04-round-complete')
   }, 150_000)

+ 3 - 1
packages/host/runtime/README.md

@@ -1,6 +1,6 @@
 # @deepseek-ai/dsh-host-runtime
 
-Host runtime assembly for `dsc`: `bootHost` composes the core plugin spine (LLM service + DeepSeek adapter, sessions with JSONL persistence, system prompt, tools, agents, agent loop, local bash), `createApiProxy` implements the [`dsh-host-apiproxy`](../apiproxy/README.md) contract over that composition, and `startHost` is the one-step shell seam returning `{ api, handler, defaults, ctx, dispose }`.
+Host runtime assembly for `dsh`: `bootHost` composes the core plugin spine (LLM service + DeepSeek adapter, sessions with JSONL persistence and deterministic fallback titles, system prompt, tools, agents, agent loop, local bash), `createApiProxy` implements the [`dsh-host-apiproxy`](../apiproxy/README.md) contract over that composition, and `startHost` is the one-step shell seam returning `{ api, handler, defaults, ctx, dispose }`.
 
 Which plugins mount and with what defaults is decided only here — shells must not `ctx.plugin` to alter the assembly. `RunningHost.ctx` is a formal seam with exactly two sanctioned uses: mounting protocol front-door plugins (e.g. a future `dsh acp`) and headless session-event subscription; consuming clients must not bypass `api` through it.
 
@@ -11,6 +11,8 @@ Which plugins mount and with what defaults is decided only here — shells must
 | `persistenceRoot` | (required) | Root directory for JSONL session persistence. |
 | `provider` | `'deepseek'` | Default provider route injected as agentOptions on create/resume and reported by `host.describe`. |
 | `model` | `'deepseek-v4-flash'` | Default model id, same single source as `provider`. |
+| `cwd` | `process.cwd()` | Default project directory for a session whose create request omits `cwd`. |
+| `sessionTitle` | 5 words / 40 fallback bytes / 80 accepted bytes | Deterministic fallback-title limits. The host mounts no asynchronous title provider, so title creation adds no model call. |
 
 ## ApiProxy implementation notes
 

+ 11 - 0
packages/host/runtime/src/boot.ts

@@ -8,6 +8,7 @@ import { Context } from 'cordis'
 import Timer from '@cordisjs/plugin-timer'
 import LlmService from '@deepseek-ai/dsh-llm'
 import SessionStore from '@deepseek-ai/dsh-session'
+import SessionTitleService, { type Config as SessionTitleConfig } from '@deepseek-ai/dsh-session-title'
 import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
 import ToolRegistry from '@deepseek-ai/dsh-tools'
 import AgentRegistry from '@deepseek-ai/dsh-agent'
@@ -38,6 +39,13 @@ import * as timeoutPolicy from '@deepseek-ai/dsh-timeout-policy'
 import SpillLocal from '@deepseek-ai/dsh-spill-local'
 import * as spillPolicy from '@deepseek-ai/dsh-spill-policy'
 
+/** Default deterministic title policy for sessions created through the host. */
+const DEFAULT_SESSION_TITLE_CONFIG: SessionTitleConfig = {
+  fallbackMaxWords: 5,
+  fallbackMaxBytes: 40,
+  maxTitleBytes: 80,
+}
+
 /** Options for bootHost — the assembly-layer composition knobs. */
 export interface BootHostOptions {
   /** Root directory for JSONL session persistence. */
@@ -46,6 +54,8 @@ export interface BootHostOptions {
   provider?: string
   /** Default model id (defaults to 'deepseek-v4-flash', matching the demos). */
   model?: string
+  /** Deterministic fallback-title limits; no asynchronous title provider is mounted by the host. */
+  sessionTitle?: SessionTitleConfig
   /**
    * Default project directory for sessions created without an explicit cwd
    * (defaults to the host process working directory). A session's cwd is its
@@ -89,6 +99,7 @@ export async function bootHost(options: BootHostOptions): Promise<HostHandle> {
   await ctx.plugin(Timer)
   await ctx.plugin(LlmService)
   await ctx.plugin(SessionStore)
+  await ctx.plugin(SessionTitleService, options.sessionTitle ?? DEFAULT_SESSION_TITLE_CONFIG)
   await ctx.plugin(SystemPrompt, { persona: '' })
   await ctx.plugin(ToolRegistry)
   await ctx.plugin(AgentRegistry)

+ 3 - 4
packages/host/runtime/src/start.ts

@@ -16,10 +16,9 @@ import { createApiProxy } from './api-proxy.ts'
 /** Options for startHost. */
 export interface StartHostOptions {
   /**
-   * Passed through to bootHost verbatim (persistenceRoot required +
-   * provider?/model?). Future host-level knobs (profile, log sink — any
-   * output added to the assembly MUST be switchable off here) land as
-   * additive fields.
+   * Passed through to bootHost verbatim. Future host-level knobs (profile,
+   * log sink — any output added to the assembly MUST be switchable off here)
+   * land as additive fields.
    */
   boot: BootHostOptions
 }

+ 42 - 2
packages/host/runtime/tests/host-runtime.spec.ts

@@ -8,6 +8,7 @@ import { agentEvents } from '@deepseek-ai/dsh-agent'
 import type { GenerateOptions, StreamChunk } from '@deepseek-ai/dsh-llm'
 import { LlmAdapter } from '@deepseek-ai/dsh-llm'
 import type { SessionId } from '@deepseek-ai/dsh-session'
+import type { Config as SessionTitleConfig } from '@deepseek-ai/dsh-session-title'
 import type { HostFrame, MuxFrame } from '@deepseek-ai/dsh-host-apiproxy/api'
 import type { RpcRequest, RpcResponse } from '@deepseek-ai/dsh-host-apiproxy/api/rpc'
 import { RpcId } from '@deepseek-ai/dsh-host-apiproxy/api/rpc'
@@ -92,9 +93,17 @@ afterEach(async () => {
   vi.unstubAllEnvs()
 })
 
-async function boot(script: (StreamChunk[] | 'hang')[] = []): Promise<RunningHost> {
+async function boot(
+  script: (StreamChunk[] | 'hang')[] = [],
+  sessionTitle?: SessionTitleConfig,
+): Promise<RunningHost> {
   host = await startHost({
-    boot: { persistenceRoot: mkdtempSync(join(tmpdir(), 'dsh-host-runtime-')), provider: 'scripted', model: 'test-model' },
+    boot: {
+      persistenceRoot: mkdtempSync(join(tmpdir(), 'dsh-host-runtime-')),
+      provider: 'scripted',
+      model: 'test-model',
+      ...(sessionTitle === undefined ? {} : { sessionTitle }),
+    },
   })
   host.ctx.llm.registerAdapter(['scripted'], new ScriptedAdapter(script))
   return host
@@ -149,6 +158,37 @@ describe('sessions.create / list', () => {
 })
 
 describe('sessions.prompt / cancel', () => {
+  it.each([
+    { name: 'host default', config: undefined, expected: 'Show the Web UI durable' },
+    {
+      name: 'configured limit',
+      config: { fallbackMaxWords: 2, fallbackMaxBytes: 40, maxTitleBytes: 80 },
+      expected: 'Show the',
+    },
+  ] satisfies { name: string; config: SessionTitleConfig | undefined; expected: string }[])(
+    'logs a durable fallback title with the $name',
+    async ({ config, expected }) => {
+      const running = await boot([textResponse('pong')], config)
+      const { api, ctx } = running
+      const { sessionId } = expectOk(await api.sessions.create(request({})))
+      const agent = ctx.agents.get(sessionId) as Agent
+      const idle = waitForIdle(ctx, agent)
+      expectOk(await api.sessions.prompt(request({
+        sessionId,
+        mode: 'queue' as const,
+        content: [{ type: 'text' as const, text: 'Show the Web UI durable session title' }],
+      })))
+      await idle
+
+      const title = agent.session.events.find(event => event.type === 'session/title')
+      expect(title?.data).toEqual({
+        title: expected,
+        messageSeqs: [1],
+        source: { kind: 'fallback' },
+      })
+    },
+  )
+
   it('queues a prompt whose rpcId rides into user/message, then the reply lands', async () => {
     const running = await boot([textResponse('pong')])
     const { api, ctx } = running