feat(web): render durable session titles
This commit is contained in:
@@ -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) }),
|
||||
|
||||
@@ -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[] }
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
|
||||
|
||||
@@ -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:^",
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -23,6 +23,9 @@
|
||||
{
|
||||
"path": "../../core/session"
|
||||
},
|
||||
{
|
||||
"path": "../../session-title/session-title"
|
||||
},
|
||||
{
|
||||
"path": "../../core/system-prompt"
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user