feat(web): render durable session titles

This commit is contained in:
Tianyi Cui
2026-07-22 23:43:53 +08:00
parent 690c53dc03
commit a9ea193e31
39 changed files with 481 additions and 57 deletions

View File

@@ -25,6 +25,7 @@ export const askUserQuestionItemSchema = z.object({
export const muxFrameSchema = z.discriminatedUnion('type', [
z.object({ type: z.literal('session/event'), sessionId: sessionIdSchema, event: sessionEventSchema, view: toolEventViewSchema.optional() }),
z.object({ type: z.literal('session/subscribed'), sessionId: sessionIdSchema, lastSeq: z.number().int() }),
z.object({ type: z.literal('session/title'), sessionId: sessionIdSchema, title: z.string().min(1), eventSeq: z.number().int().nonnegative(), updatedAt: z.number() }),
z.object({ type: z.literal('approval/requested'), sessionId: sessionIdSchema, approvalId: approvalRequestIdSchema, toolName: z.string(), callId: z.string().optional(), reason: z.string().optional() }),
z.object({ type: z.literal('approval/resolved'), sessionId: sessionIdSchema, approvalId: approvalRequestIdSchema, outcome: z.union([z.literal('allowed-once'), z.literal('rejected'), z.literal('cancelled'), z.literal('unavailable')]) }),
z.object({ type: z.literal('question/requested'), sessionId: sessionIdSchema, questions: z.array(askUserQuestionItemSchema) }),

View File

@@ -33,8 +33,9 @@ export type ToolEventView =
export interface EventsApi {
/**
* All-session aggregated mux stream. On open, emits a subscribed control frame for every
* attached session and replays each session's still-pending approval/question requested
* frames (rpcId reused verbatim — the refresh-recovery baseline).
* attached session followed by its optional latest title snapshot, then replays each
* session's still-pending approval/question requested frames (rpcId reused verbatim — the
* refresh-recovery baseline).
* since: resume seam, unimplemented in v1 (ignored if passed); reconnection = reopen the
* stream + refetch history.
*/
@@ -54,6 +55,7 @@ export interface EventsApi {
export type MuxFrame =
| { type: 'session/event'; sessionId: SessionId; event: SessionEvent; view?: ToolEventView }
| { type: 'session/subscribed'; sessionId: SessionId; lastSeq: number }
| { type: 'session/title'; sessionId: SessionId; title: string; eventSeq: number; updatedAt: number }
| { type: 'approval/requested'; sessionId: SessionId; approvalId: ApprovalRequestId; toolName: string; callId?: CallId; reason?: string }
| { type: 'approval/resolved'; sessionId: SessionId; approvalId: ApprovalRequestId; outcome: ApprovalOutcome }
| { type: 'question/requested'; sessionId: SessionId; questions: AskUserQuestionItem[] }

View File

@@ -123,6 +123,7 @@ describe('events frame schemas', () => {
const frames = [
{ type: 'session/event', sessionId: 's', event: { type: 't', seq: 0, time: 1, data: null } },
{ type: 'session/subscribed', sessionId: 's', lastSeq: -1 },
{ type: 'session/title', sessionId: 's', title: 'Durable title', eventSeq: 2, updatedAt: 3 },
{ type: 'approval/requested', sessionId: 's', approvalId: 'a', toolName: 'bash', callId: 'c', reason: 'r' },
{ type: 'approval/resolved', sessionId: 's', approvalId: 'a', outcome: 'allowed-once' },
{ type: 'question/requested', sessionId: 's', questions: [{ id: 'q', question: 'Q?', options: [{ label: 'L' }], multiSelect: true }] },
@@ -131,6 +132,13 @@ describe('events frame schemas', () => {
]
for (const frame of frames) expect(muxFrameSchema.parse(frame)).toMatchObject({ type: frame.type })
expect(() => muxFrameSchema.parse({ type: 'unknown/frame' })).toThrow()
for (const invalid of [
{ type: 'session/title', sessionId: 's', title: '', eventSeq: 0, updatedAt: 1 },
{ type: 'session/title', sessionId: 's', title: 'x', eventSeq: -1, updatedAt: 1 },
{ type: 'session/title', sessionId: 's', title: 'x', eventSeq: 0.5, updatedAt: 1 },
{ type: 'session/title', sessionId: 's', title: 'x', eventSeq: 0, updatedAt: 'now' },
{ type: 'session/title', sessionId: 's', title: 'x', eventSeq: 0, updatedAt: Number.NaN },
]) expect(() => muxFrameSchema.parse(invalid)).toThrow()
expect(askUserQuestionItemSchema.parse({ id: 'q', question: 'Q?' }).id).toBe('q')
})

View File

@@ -49,6 +49,7 @@
"@deepseek-ai/dsh-session": "workspace:^",
"@deepseek-ai/dsh-session-persistence": "workspace:^",
"@deepseek-ai/dsh-session-persistence-jsonl": "workspace:^",
"@deepseek-ai/dsh-session-title": "workspace:^",
"@deepseek-ai/dsh-skill": "workspace:^",
"@deepseek-ai/dsh-skill-local": "workspace:^",
"@deepseek-ai/dsh-spill-local": "workspace:^",

View File

@@ -12,6 +12,7 @@ import type { Agent, AgentStatus } from '@deepseek-ai/dsh-agent'
import type { ContentBlock, MessageSource } from '@deepseek-ai/dsh-llm'
import type { Session, SessionEvent, SessionHeader, SessionId } from '@deepseek-ai/dsh-session'
import type { SessionPersistence } from '@deepseek-ai/dsh-session-persistence'
import { foldSessionTitle } from '@deepseek-ai/dsh-session-title'
import type { ApiProxy, HistoryEntry, HostFrame, MuxFrame, SessionSummary, ToolEventView } from '@deepseek-ai/dsh-host-apiproxy/api'
import type { ClientResponse, RpcError, RpcReceipt, RpcRequest, RpcResponse } from '@deepseek-ai/dsh-host-apiproxy/api/rpc'
import { RpcId } from '@deepseek-ai/dsh-host-apiproxy/api/rpc'
@@ -104,6 +105,28 @@ function frame<F>(payload: F): RpcRequest<F> {
return { rpcId: RpcId(randomUUID()), payload }
}
type SessionTitleFrame = Extract<MuxFrame, { type: 'session/title' }>
/** Project the latest durable title without exposing title-generation policy. */
function titleFrame(session: Session): SessionTitleFrame | undefined {
const title = foldSessionTitle(session.events)
if (title === undefined) return undefined
return {
type: 'session/title',
sessionId: session.id,
title: title.title,
eventSeq: title.eventSeq,
updatedAt: title.updatedAt,
}
}
/** Queue the subscription baseline followed by its optional title snapshot. */
function subscribeSession(queue: FrameQueue<RpcRequest<MuxFrame>>, session: Session): void {
queue.push(frame({ type: 'session/subscribed', sessionId: session.id, lastSeq: session.seq - 1 }))
const title = titleFrame(session)
if (title !== undefined) queue.push(frame(title))
}
/** SessionSummary projection for attached (in-memory) sessions. */
function summarize(session: Session, running: boolean): SessionSummary {
return {
@@ -362,7 +385,7 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
mux(_request, signal) {
const queue = new FrameQueue<RpcRequest<MuxFrame>>()
for (const session of ctx.sessions.list()) {
queue.push(frame({ type: 'session/subscribed', sessionId: session.id, lastSeq: session.seq - 1 }))
subscribeSession(queue, session)
}
// Per-session open-call table for result-view pairing. Bounded by the
// per-turn call count: entries clear on turn/end; a table miss (stream
@@ -385,9 +408,13 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
const view = viewFor(ctx, event, callId =>
openCalls.get(session.id)?.get(callId) ?? backscanArgs(session.events, callId))
queue.push(frame({ type: 'session/event', sessionId: session.id, event, ...view === undefined ? {} : { view } }))
if (event.type === 'session/title') {
// The accepted raw event is already in session.events, so the fold must find it.
queue.push(frame(titleFrame(session) as SessionTitleFrame))
}
}),
ctx.on('session/created', (session: Session) => {
queue.push(frame({ type: 'session/subscribed', sessionId: session.id, lastSeq: session.seq - 1 }))
subscribeSession(queue, session)
}),
ctx.on('session/disposed', (session: Session) => {
openCalls.delete(session.id)

View File

@@ -65,6 +65,21 @@ function expectOk<T>(response: RpcResponse<T>): T {
return response.result.value
}
async function nextMux(iterator: AsyncIterator<RpcRequest<MuxFrame>>): Promise<RpcRequest<MuxFrame>> {
const next = await iterator.next()
if (next.done === true) throw new Error('mux ended before the expected frame')
return next.value
}
/** Durably append a title event without mounting title-generation policy. */
function appendTitle(ctx: Context, agent: Agent, title: string) {
return ctx.sessions.appendOutOfBand(agent.session, 'session/title', {
title,
messageSeqs: [1],
source: { kind: 'fallback' },
}, { kind: 'session-title' })
}
let host: RunningHost | undefined
beforeEach(() => {
@@ -203,11 +218,14 @@ describe('sessions.history', () => {
const idle = waitForIdle(first.ctx, agent)
agent.send([{ type: 'text', text: 'save me' }])
await idle
const titleEvent = await appendTitle(first.ctx, agent, 'Persisted title')
await first.dispose()
host = await startHost({ boot: { persistenceRoot, provider: 'scripted', model: 'test-model' } })
host.ctx.llm.registerAdapter(['scripted'], new ScriptedAdapter([]))
expect(host.ctx.agents.get(sessionId)).toBeUndefined()
const abort = new AbortController()
const mux = host.api.events.mux(request({}), abort.signal)[Symbol.asyncIterator]()
const [a, b] = await Promise.all([
host.api.sessions.history(request({ sessionId })),
host.api.sessions.history(request({ sessionId })),
@@ -218,6 +236,11 @@ describe('sessions.history', () => {
}
expect(host.ctx.agents.get(sessionId)).toBeDefined()
expect(host.ctx.agents.list()).toHaveLength(1)
expect((await nextMux(mux)).payload).toMatchObject({ type: 'session/subscribed', sessionId })
expect((await nextMux(mux)).payload).toEqual(expect.objectContaining({
type: 'session/title', sessionId, title: 'Persisted title', eventSeq: titleEvent.seq,
}))
abort.abort()
})
it('errors session-not-found when resume fails, deduplicating concurrent resumes', async () => {
@@ -325,6 +348,43 @@ describe('events streams', () => {
expect((await stream.next()).done).toBe(true)
})
it('mux: projects durable titles after open baselines and immediately after live raw events', async () => {
const running = await boot()
const { api, ctx } = running
const { sessionId } = expectOk(await api.sessions.create(request({})))
const agent = ctx.agents.get(sessionId) as Agent
const initial = await appendTitle(ctx, agent, 'Initial title')
const ac = new AbortController()
const stream = api.events.mux(request({}), ac.signal)[Symbol.asyncIterator]()
expect((await nextMux(stream)).payload).toMatchObject({ type: 'session/subscribed', sessionId })
expect((await nextMux(stream)).payload).toEqual(expect.objectContaining({
type: 'session/title', sessionId, title: 'Initial title', eventSeq: initial.seq, updatedAt: initial.time,
}))
const revised = await appendTitle(ctx, agent, 'Revised title')
let raw: RpcRequest<MuxFrame>
do raw = await nextMux(stream)
while (!(raw.payload.type === 'session/event' && raw.payload.event.type === 'session/title'))
expect(raw.payload).toMatchObject({ type: 'session/event', sessionId, event: { seq: revised.seq } })
expect((await nextMux(stream)).payload).toEqual(expect.objectContaining({
type: 'session/title', sessionId, title: 'Revised title', eventSeq: revised.seq, updatedAt: revised.time,
}))
ac.abort()
})
it('mux: emits no title control for untitled subscriptions', async () => {
const { api } = await boot()
const first = expectOk(await api.sessions.create(request({}))).sessionId
const ac = new AbortController()
const stream = api.events.mux(request({}), ac.signal)[Symbol.asyncIterator]()
expect((await nextMux(stream)).payload).toMatchObject({ type: 'session/subscribed', sessionId: first })
const second = expectOk(await api.sessions.create(request({}))).sessionId
expect((await nextMux(stream)).payload).toMatchObject({ type: 'session/subscribed', sessionId: second })
ac.abort()
})
it('host: session lifecycle, status flips (disposed suppressed), and agent errors', async () => {
const running = await boot([textResponse('x')])
const { api, ctx } = running

View File

@@ -23,6 +23,9 @@
{
"path": "../../core/session"
},
{
"path": "../../session-title/session-title"
},
{
"path": "../../core/system-prompt"
},