From fc23a89208f73ec3001527d1bf7d634dfebfa738 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Thu, 23 Jul 2026 00:40:42 +0800 Subject: [PATCH] fix(web): title sessions created by host --- ...-07-21-log-backed-session-titles.i18n.yaml | 4 +- .../2026-07-21-log-backed-session-titles.md | 2 +- ...2026-07-21-log-backed-session-titles.zh.md | 2 +- apps/web/tests/smoke-real.e2e.ts | 13 ++++++ packages/host/runtime/README.md | 4 +- packages/host/runtime/src/boot.ts | 11 +++++ packages/host/runtime/src/start.ts | 7 ++- .../host/runtime/tests/host-runtime.spec.ts | 44 ++++++++++++++++++- 8 files changed, 76 insertions(+), 11 deletions(-) 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 17f4515c1d..f2e0a64585 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: 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 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 494187a73c..6d2aa2049b 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 @@ -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 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 cae51cca92..7f43832f3a 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 @@ -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 会话无需增加模型调用即可持久化标题。两种模型提供方均仍需按需启用。 ### 事件归属与折叠 diff --git a/apps/web/tests/smoke-real.e2e.ts b/apps/web/tests/smoke-real.e2e.ts index a51e0c9e56..4c4519abf4 100644 --- a/apps/web/tests/smoke-real.e2e.ts +++ b/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) diff --git a/packages/host/runtime/README.md b/packages/host/runtime/README.md index 39f9888180..98c860cd95 100644 --- a/packages/host/runtime/README.md +++ b/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 diff --git a/packages/host/runtime/src/boot.ts b/packages/host/runtime/src/boot.ts index ca40b9f8b3..e11aaa3a31 100644 --- a/packages/host/runtime/src/boot.ts +++ b/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 { 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) diff --git a/packages/host/runtime/src/start.ts b/packages/host/runtime/src/start.ts index 44412cf184..94e5f22da1 100644 --- a/packages/host/runtime/src/start.ts +++ b/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 } diff --git a/packages/host/runtime/tests/host-runtime.spec.ts b/packages/host/runtime/tests/host-runtime.spec.ts index 81bc4fc009..3ec7535362 100644 --- a/packages/host/runtime/tests/host-runtime.spec.ts +++ b/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 { +async function boot( + script: (StreamChunk[] | 'hang')[] = [], + sessionTitle?: SessionTitleConfig, +): Promise { 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