Просмотр исходного кода

refactor: retire the session/title frame and the todos history rider from the wire

imccyu 1 месяц назад
Родитель
Сommit
e78edd6ae4

+ 1 - 1
packages/host/apiproxy/README.md

@@ -12,7 +12,7 @@ The layering/protocol decisions are recorded in the [GUI layering and RPC protoc
 
 `session.history`'s tail page (`beforeSeq` absent) additionally carries an optional `projections` block — the watermark snapshot of every unit registered on `ctx.sessionProjections` (`@deepseek-ai/dsh-session-projection`), with `asOfSeq` = the last event seq the values reflect (`-1` on an empty log). The gateway also subscribes to the registry's change feed and mints a `session/projection` mux frame per changed unit (`{sessionId, key, value, seq}` — live push state, never logged; clients hold one generic per-session value store under higher-seq-wins). The carrier holds zero domain knowledge (each value passed its unit's own schema inside the registry; the wire schemas keep `values`/`value` wide); loadOlder pages never carry the block, and a composition without the registry serves histories without either surface.
 
-The mux stream projects the latest log-backed title as a validated `session/title` control frame after each attached-session subscription baseline and immediately after the corresponding live raw title event. This projection does not add titles to `session.list`; cold sessions remain metadata-only there until opening or resuming attaches their logs.
+Session titles ride the generic projection pair like every other domain — the history-tail `projections` block plus `session/projection` frames under the `title` key (the bespoke `session/title` frame is retired). Titles do not join `session.list`; cold sessions remain metadata-only there until opening or resuming attaches their logs.
 
 Workspace and Session lists are separate reconnect baselines. `workspace.create` creates a unique name or adopts an existing directory, `workspace.delete` removes only the Workspace registration, `session.create` accepts an optional preallocated Session id, and `host/workspace-changed`, `host/workspace-removed`, plus `host/session-added` carry committed increments in either arrival order. Registration deletion preserves the directory and session logs; its Sessions remain in `session.list` and become Ungrouped. `SessionSummary.blank` and the `host/session-added` frame carry the derived zero-events bit: clients hide blank sessions and reuse them per workspace, flip blank on the first `host/session-status(running:true)`, and treat `session.list` as the reconnect authority; cold summaries are never blank because lazy persistence keeps never-appended sessions out of `list()`.
 

+ 2 - 39
packages/host/apiproxy/src/api-proxy.ts

@@ -10,9 +10,8 @@ import type { Context } from 'cordis'
 import type { Agent, AgentMessage, AgentMessageId, AgentStatus } from '@deepseek-ai/dsh-agent'
 import { errorChain } from '@deepseek-ai/dsh-llm'
 import type { ContentBlock, MessageSource } from '@deepseek-ai/dsh-llm'
-import type { JsonValue, Session, SessionEvent, SessionHeader, SessionId, TodoItem } from '@deepseek-ai/dsh-session'
+import type { JsonValue, 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 { Workspace, WorkspaceRecord } from '@deepseek-ai/dsh-workspace'
 import {
   workspaceDomainState, workspaceRecord, WorkspaceId as brandWorkspaceId,
@@ -126,26 +125,9 @@ 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. */
+/** Queue the subscription baseline frame. */
 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. */
@@ -289,15 +271,6 @@ function backscanArgs(events: readonly SessionEvent[], callId: string): { name:
   return undefined
 }
 
-/** Current todo projection: the latest `todo/write` over the full log (whole-list replace ⇒ last write wins); undefined when none. */
-function backscanTodos(events: readonly SessionEvent[]): TodoItem[] | undefined {
-  for (let i = events.length - 1; i >= 0; i--) {
-    const event = events[i]
-    if (event !== undefined && event.type === 'todo/write') return event.data.todos
-  }
-  return undefined
-}
-
 /**
  * The projection baseline for one history tail page: the registry's
  * watermark-cache snapshot — one fully synchronous read (no await between the
@@ -694,18 +667,12 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
           const view = viewFor(ctx, event, callId => backscanArgs(page.events, callId))
           return { event, ...view === undefined ? {} : { view } }
         })
-        // Tail page carries the session-level todo projection over the FULL
-        // log (the page window may not contain the last todo/write; a paged
-        // client cannot reconstruct session-level state from it).
-        // TODO(gui): retire this rider onto the generic projections block.
-        const todos = beforeSeq === undefined ? backscanTodos(found.agent.session.events) : undefined
         // Baseline rider: tail page only — loadOlder (beforeSeq present) is
         // the one path that never needs a fresh projection baseline.
         const projections = beforeSeq === undefined ? projectionsFor(ctx, found.agent) : undefined
         return ok(request, {
           events: entries,
           hasMore: page.hasMore,
-          ...todos === undefined ? {} : { todos },
           ...projections === undefined ? {} : { projections },
         })
       },
@@ -1033,10 +1000,6 @@ 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) => {
             subscribeSession(queue, session)

+ 0 - 1
packages/host/apiproxy/src/api/events.schema.ts

@@ -27,7 +27,6 @@ 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')]) }),
   // Non-empty by wire contract: the user-interaction service rejects empty

+ 3 - 4
packages/host/apiproxy/src/api/events.ts

@@ -35,9 +35,9 @@ export type ToolEventView =
 export interface EventsApi {
   /**
    * All-session aggregated mux stream. On open, emits a subscribed control frame for every
-   * 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).
+   * attached session, then replays each session's still-pending approval/question requested
+   * frames (rpcId reused verbatim — the refresh-recovery baseline). Session titles ride the
+   * generic projection pair (history-tail projections block + session/projection frames).
    * since: resume seam, unimplemented in v1 (ignored if passed); reconnection = reopen the
    * stream + refetch history.
    */
@@ -57,7 +57,6 @@ 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[] }

+ 1 - 8
packages/host/apiproxy/src/api/sessions.schema.ts

@@ -93,12 +93,6 @@ export const historyEntrySchema = z.object({
   view: toolEventViewSchema.optional(),
 }) satisfies z.ZodType<Wire<HistoryEntry>>
 
-/** One todo item of the tail page's session-level projection (the todo/write payload shape). */
-export const todoItemSchema = z.object({
-  content: z.string(),
-  status: z.union([z.literal('pending'), z.literal('in_progress'), z.literal('completed')]),
-})
-
 /**
  * Projection baseline passthrough: `values` stays a wide record — each value
  * was already parsed by its provider's own schema on the host side, and
@@ -110,11 +104,10 @@ export const sessionProjectionsBlockSchema = z.object({
   values: z.record(z.string(), z.unknown()),
 }) as unknown as z.ZodType<SessionProjectionsBlock>
 
-/** session.history response value (todos and projections ride the tail page only). */
+/** session.history response value (projections rides the tail page only). */
 export const sessionHistoryValueSchema = z.object({
   events: z.array(historyEntrySchema),
   hasMore: z.boolean(),
-  todos: z.array(todoItemSchema).optional(),
   projections: sessionProjectionsBlockSchema.optional(),
 }) satisfies z.ZodType<Wire<ResponseValue<'session.history'>>>
 

+ 2 - 7
packages/host/apiproxy/src/api/sessions.ts

@@ -5,7 +5,7 @@
  */
 
 import type { ContentBlock } from '@deepseek-ai/dsh-llm/types'
-import type { SessionEvent, SessionId, TodoItem } from '@deepseek-ai/dsh-session/types'
+import type { SessionEvent, SessionId } from '@deepseek-ai/dsh-session/types'
 // The pure-type outlet: api/ is browser-importable, and the package root's
 // cordis Context merge (via dsh-agent) must not enter client aggregates.
 import type { SessionProjectionMap } from '@deepseek-ai/dsh-session-projection/types'
@@ -97,11 +97,6 @@ export interface SessionsApi {
    * Each entry pairs the raw SessionEvent with the host-computed view (tool events whose
    * presenter produced one, evaluated against the registry at pagination time); the client
    * rebuilds the surface from the events with the shared fold.
-   * The tail page (beforeSeq absent) also carries `todos` — the session's current todo
-   * projection (latest `todo/write` over the FULL log, independent of the page window) —
-   * so a paged client restores the plan without walking history; absent when the session
-   * never wrote one. Older pages omit it (the projection is session-level, not per-page).
-   * TODO(gui): the todos rider retires onto the generic projections block below.
    * The tail page — and only the tail page — additionally carries `projections`
    * when the deployment mounts the session-projection registry: every moment
    * the client needs a fresh baseline already pulls the tail page, and
@@ -109,7 +104,7 @@ export interface SessionsApi {
    * A deployment without the registry serves histories without the block.
    */
   history(request: RpcRequest<{ sessionId: SessionId; beforeSeq?: number; maxMessages?: number }>):
-  Promise<RpcResponse<{ events: HistoryEntry[]; hasMore: boolean; todos?: TodoItem[]; projections?: SessionProjectionsBlock }>>
+  Promise<RpcResponse<{ events: HistoryEntry[]; hasMore: boolean; projections?: SessionProjectionsBlock }>>
 
   /** Sends a message. content is core's ContentBlock[] verbatim; mode maps 1:1 — queue→send, steer→steer. */
   prompt(request: RpcRequest<{ sessionId: SessionId; mode: 'queue' | 'steer'; content: ContentBlock[] }>):

+ 0 - 33
packages/host/apiproxy/tests/api-proxy-view.spec.ts

@@ -154,39 +154,6 @@ describe('mux live view computation', () => {
     expect('view' in (byKey.get('tool/result:h-plain') ?? {})).toBe(false)
   })
 
-  it('tail page carries the full-log todo projection; older pages and todo-less sessions omit it', async () => {
-    const { ctx } = await harness()
-    const api = createApiProxy(ctx, { provider: 'p', model: 'm', cwd: '/tmp', workspaceRoot: '/tmp' })
-    const session = ctx.sessions.create()
-    ctx.agents.register({ id: session.id, session, status: 'idle', ctx } as Agent)
-    // Superseded write early in the log, latest write later; enough messages to page.
-    session.append('todo/write', { todos: [{ content: 'old', status: 'pending' }] })
-    for (let turn = 0; turn < 6; turn++) {
-      session.append('turn/start', { turn, trigger: { kind: 'message', source: { kind: 'user' } } })
-      session.append('user/message', { content: [{ type: 'text', text: `q${turn}` }], source: { kind: 'user' } }, { surfaceOp: 'append' })
-      session.append('assistant/message', { turn, step: 0, content: [{ type: 'text', text: `a${turn}` }], provenance: { provider: 'p', model: 'm' } }, { surfaceOp: 'append' })
-      session.append('turn/end', { turn, reason: { kind: 'completed' } })
-    }
-    session.append('todo/write', { todos: [{ content: 'current', status: 'in_progress' }] })
-
-    // Tail page limited to 2 messages: the latest todo/write may or may not sit
-    // in the window — the projection must come from the FULL log either way.
-    const tail = await api.sessions.history({ rpcId: RpcId('t-todos'), payload: { sessionId: session.id, maxMessages: 2 } })
-    if (!tail.result.ok) throw new Error('history failed')
-    expect(tail.result.value.todos).toEqual([{ content: 'current', status: 'in_progress' }])
-    // An older page omits the projection (session-level, tail-page-only).
-    const boundary = tail.result.value.events[0]?.event.seq ?? 0
-    const older = await api.sessions.history({ rpcId: RpcId('t-todos-2'), payload: { sessionId: session.id, beforeSeq: boundary, maxMessages: 2 } })
-    if (!older.result.ok) throw new Error('older failed')
-    expect('todos' in older.result.value).toBe(false)
-    // A session with no todo/write anywhere omits the field.
-    const bare = ctx.sessions.create()
-    ctx.agents.register({ id: bare.id, session: bare, status: 'idle', ctx } as Agent)
-    const bareTail = await api.sessions.history({ rpcId: RpcId('t-todos-3'), payload: { sessionId: bare.id } })
-    if (!bareTail.result.ok) throw new Error('bare failed')
-    expect('todos' in bareTail.result.value).toBe(false)
-  })
-
   it('drops a disposed session from the live open-call table (result after dispose gets no view)', async () => {
     const { ctx } = await harness()
     const api = createApiProxy(ctx, { provider: 'p', model: 'm', cwd: '/tmp', workspaceRoot: '/tmp' })

+ 9 - 5
packages/host/apiproxy/tests/fetch-carrier.spec.ts

@@ -25,10 +25,10 @@ function fakeApi(overrides: Partial<{ muxFrames: MuxFrame[]; hostFrames: HostFra
         return { rpcId: request.rpcId, result: { ok: true, value: { sessionId: 's-new' as never } } }
       },
       async history(request) {
-        if (request.payload.sessionId === ('with-todos' as never)) {
+        if (request.payload.sessionId === ('with-projections' as never)) {
           return {
             rpcId: request.rpcId,
-            result: { ok: true, value: { events: [], hasMore: false, todos: [{ content: 'current', status: 'in_progress' as const }] } },
+            result: { ok: true, value: { events: [], hasMore: false, projections: { asOfSeq: 9, values: { todos: [{ content: 'current', status: 'in_progress' as const }] } } } },
           }
         }
         return {
@@ -128,10 +128,14 @@ describe('unary round trip (handler ⇄ client, no network)', () => {
     expect(response.rpcId).toMatch(/[0-9a-f-]{36}/)
   })
 
-  it('carries the tail-page todos projection through the wire schema (Zod must not strip it)', async () => {
-    const response = await client().sessions.history({ sessionId: 'with-todos' as never })
+  it('carries the tail-page projections block through the wire schema (Zod must not strip it)', async () => {
+    const response = await client().sessions.history({ sessionId: 'with-projections' as never })
     expect(response.result.ok).toBe(true)
-    if (response.result.ok) expect(response.result.value.todos).toEqual([{ content: 'current', status: 'in_progress' }])
+    if (response.result.ok) {
+      expect(response.result.value.projections).toEqual(
+        { asOfSeq: 9, values: { todos: [{ content: 'current', status: 'in_progress' }] } },
+      )
+    }
   })
 
   it('carries a business error as 200 + error result', async () => {

+ 4 - 6
packages/host/apiproxy/tests/rpc-schemas.spec.ts

@@ -246,23 +246,21 @@ 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 }] },
       { type: 'question/resolved', sessionId: 's', questionRpcId: 'r', outcome: 'answered' },
       { type: 'session/queued', sessionId: 's', content: [{ type: 'text', text: 'queued prompt' }], source: { kind: 'user', rpcId: 'r9' }, steering: false },
       { type: 'session/queued', sessionId: 's', content: [{ type: 'text', text: 'steer' }], source: { kind: 'user' }, steering: true },
+      { type: 'session/projection', sessionId: 's', key: 'todos', value: [{ content: 'x', status: 'pending' }], seq: 7 },
       { type: 'stream/error', error: { code: 'internal', message: 'm', details: {} } },
     ]
     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 },
+      { type: 'session/projection', sessionId: 's', key: '', value: null, seq: 0 },
+      { type: 'session/projection', sessionId: 's', key: 'todos', value: null, seq: -1 },
+      { type: 'session/projection', sessionId: 's', key: 'todos', value: null, seq: 0.5 },
     ]) expect(() => muxFrameSchema.parse(invalid)).toThrow()
     expect(askUserQuestionItemSchema.parse({ id: 'q', question: 'Q?' }).id).toBe('q')
   })