fix(web): title sessions created by host
This commit is contained in:
@@ -2,5 +2,5 @@
|
|||||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
# 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:
|
# after editing either side, bring the other along and re-record with:
|
||||||
# pnpm run verify-translation-pairing --write
|
# pnpm run verify-translation-pairing --write
|
||||||
2026-07-21-log-backed-session-titles.md: 494187a73c58fb2313d802825c3ec9f9994d6f2b
|
2026-07-21-log-backed-session-titles.md: 6d2aa2049b57d82014f1a85555a1bda9b537bece
|
||||||
2026-07-21-log-backed-session-titles.zh.md: cae51cca920fad748cb1944d35cc1b80850eb6ee
|
2026-07-21-log-backed-session-titles.zh.md: 7f43832f3a0b6a28227b6a862d78be117c7cb398
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ Session identity metadata is immutable, the event log is the replay and fork bou
|
|||||||
|
|
||||||
## Decision
|
## 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
|
### Event ownership and folding
|
||||||
|
|
||||||
|
|||||||
@@ -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 会话无需增加模型调用即可持久化标题。两种模型提供方均仍需按需启用。
|
||||||
|
|
||||||
### 事件归属与折叠
|
### 事件归属与折叠
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
// near-empty here means that class of bug is back.
|
||||||
await page.waitForFunction(() => document.body.innerText.length > 50, undefined, { timeout: 15_000 })
|
await page.waitForFunction(() => document.body.innerText.length > 50, undefined, { timeout: 15_000 })
|
||||||
expect(pageErrors).toEqual([])
|
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 page.waitForFunction(() => document.body.innerText.includes('介绍完毕'), undefined, { timeout: 120_000 })
|
||||||
await screen(page, '04-round-complete')
|
await screen(page, '04-round-complete')
|
||||||
}, 150_000)
|
}, 150_000)
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# @deepseek-ai/dsh-host-runtime
|
# @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.
|
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. |
|
| `persistenceRoot` | (required) | Root directory for JSONL session persistence. |
|
||||||
| `provider` | `'deepseek'` | Default provider route injected as agentOptions on create/resume and reported by `host.describe`. |
|
| `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`. |
|
| `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
|
## ApiProxy implementation notes
|
||||||
|
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { Context } from 'cordis'
|
|||||||
import Timer from '@cordisjs/plugin-timer'
|
import Timer from '@cordisjs/plugin-timer'
|
||||||
import LlmService from '@deepseek-ai/dsh-llm'
|
import LlmService from '@deepseek-ai/dsh-llm'
|
||||||
import SessionStore from '@deepseek-ai/dsh-session'
|
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 SystemPrompt from '@deepseek-ai/dsh-system-prompt'
|
||||||
import ToolRegistry from '@deepseek-ai/dsh-tools'
|
import ToolRegistry from '@deepseek-ai/dsh-tools'
|
||||||
import AgentRegistry from '@deepseek-ai/dsh-agent'
|
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 SpillLocal from '@deepseek-ai/dsh-spill-local'
|
||||||
import * as spillPolicy from '@deepseek-ai/dsh-spill-policy'
|
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. */
|
/** Options for bootHost — the assembly-layer composition knobs. */
|
||||||
export interface BootHostOptions {
|
export interface BootHostOptions {
|
||||||
/** Root directory for JSONL session persistence. */
|
/** Root directory for JSONL session persistence. */
|
||||||
@@ -46,6 +54,8 @@ export interface BootHostOptions {
|
|||||||
provider?: string
|
provider?: string
|
||||||
/** Default model id (defaults to 'deepseek-v4-flash', matching the demos). */
|
/** Default model id (defaults to 'deepseek-v4-flash', matching the demos). */
|
||||||
model?: string
|
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
|
* Default project directory for sessions created without an explicit cwd
|
||||||
* (defaults to the host process working directory). A session's cwd is its
|
* (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(Timer)
|
||||||
await ctx.plugin(LlmService)
|
await ctx.plugin(LlmService)
|
||||||
await ctx.plugin(SessionStore)
|
await ctx.plugin(SessionStore)
|
||||||
|
await ctx.plugin(SessionTitleService, options.sessionTitle ?? DEFAULT_SESSION_TITLE_CONFIG)
|
||||||
await ctx.plugin(SystemPrompt, { persona: '' })
|
await ctx.plugin(SystemPrompt, { persona: '' })
|
||||||
await ctx.plugin(ToolRegistry)
|
await ctx.plugin(ToolRegistry)
|
||||||
await ctx.plugin(AgentRegistry)
|
await ctx.plugin(AgentRegistry)
|
||||||
|
|||||||
@@ -16,10 +16,9 @@ import { createApiProxy } from './api-proxy.ts'
|
|||||||
/** Options for startHost. */
|
/** Options for startHost. */
|
||||||
export interface StartHostOptions {
|
export interface StartHostOptions {
|
||||||
/**
|
/**
|
||||||
* Passed through to bootHost verbatim (persistenceRoot required +
|
* Passed through to bootHost verbatim. Future host-level knobs (profile,
|
||||||
* provider?/model?). Future host-level knobs (profile, log sink — any
|
* log sink — any output added to the assembly MUST be switchable off here)
|
||||||
* output added to the assembly MUST be switchable off here) land as
|
* land as additive fields.
|
||||||
* additive fields.
|
|
||||||
*/
|
*/
|
||||||
boot: BootHostOptions
|
boot: BootHostOptions
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { agentEvents } from '@deepseek-ai/dsh-agent'
|
|||||||
import type { GenerateOptions, StreamChunk } from '@deepseek-ai/dsh-llm'
|
import type { GenerateOptions, StreamChunk } from '@deepseek-ai/dsh-llm'
|
||||||
import { LlmAdapter } from '@deepseek-ai/dsh-llm'
|
import { LlmAdapter } from '@deepseek-ai/dsh-llm'
|
||||||
import type { SessionId } from '@deepseek-ai/dsh-session'
|
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 { HostFrame, MuxFrame } from '@deepseek-ai/dsh-host-apiproxy/api'
|
||||||
import type { RpcRequest, RpcResponse } from '@deepseek-ai/dsh-host-apiproxy/api/rpc'
|
import type { RpcRequest, RpcResponse } from '@deepseek-ai/dsh-host-apiproxy/api/rpc'
|
||||||
import { RpcId } 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()
|
vi.unstubAllEnvs()
|
||||||
})
|
})
|
||||||
|
|
||||||
async function boot(script: (StreamChunk[] | 'hang')[] = []): Promise<RunningHost> {
|
async function boot(
|
||||||
|
script: (StreamChunk[] | 'hang')[] = [],
|
||||||
|
sessionTitle?: SessionTitleConfig,
|
||||||
|
): Promise<RunningHost> {
|
||||||
host = await startHost({
|
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))
|
host.ctx.llm.registerAdapter(['scripted'], new ScriptedAdapter(script))
|
||||||
return host
|
return host
|
||||||
@@ -149,6 +158,37 @@ describe('sessions.create / list', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
describe('sessions.prompt / cancel', () => {
|
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 () => {
|
it('queues a prompt whose rpcId rides into user/message, then the reply lands', async () => {
|
||||||
const running = await boot([textResponse('pong')])
|
const running = await boot([textResponse('pong')])
|
||||||
const { api, ctx } = running
|
const { api, ctx } = running
|
||||||
|
|||||||
Reference in New Issue
Block a user