Bladeren bron

fix(web): blank means the conversation has not started

The summary blank bit switches from log emptiness to the absence of any
turn/start: standalone plugin events — command lifecycle records,
plan/mode, session titles, goal metadata — no longer surface a fresh
session in lists or steal the New Session view. Running /plan (or /goal)
on a blank session keeps it blank and reusable; the first accepted
prompt's turn clears it. Both carriers share one predicate (summarize +
the host/session-added frame); the cold path keeps its constant false
with the index-read rationale; the client mirror already flips only on
prompt acceptance and needed no change.
imccyu 2 maanden geleden
bovenliggende
commit
d804c9c94d

+ 8 - 6
packages/client/runtime/src/client/sessions/session.ts

@@ -216,12 +216,14 @@ export class Session implements ObservableSnapshot<ConversationSnapshot> {
       this.notifier.markDirty()
       return result
     }
-    // Blank flips on ACCEPTANCE, not attempt: an accepted prompt has logged
-    // its user/message on the host (events.length > 0 is fact, not
-    // optimism), while a rejected first prompt must keep the session blank
-    // — the client-side blank mirror only ever lowers, so flipping early on
-    // a failure would surface the session forever and strip its
-    // connectWorkspace reuse eligibility against the host's authority.
+    // Blank flips on ACCEPTANCE, not attempt: an accepted prompt starts the
+    // conversation's first turn on the host (the host criterion — a logged
+    // turn/start — is fact, not optimism; standalone command and projection
+    // events never flip it), while a rejected first prompt must keep the
+    // session blank — the client-side blank mirror only ever lowers, so
+    // flipping early on a failure would surface the session forever and
+    // strip its connectWorkspace reuse eligibility against the host's
+    // authority.
     if (this.blankBit) {
       this.blankBit = false
       this.options.onEngaged?.(this)

+ 17 - 5
packages/host/apiproxy/src/api-proxy.ts

@@ -136,13 +136,24 @@ function subscribeSession(queue: FrameQueue<RpcRequest<MuxFrame>>, session: Sess
   queue.push(frame({ type: 'session/subscribed', sessionId: session.id, lastSeq: session.seq - 1 }))
 }
 
+/**
+ * Whether the session's conversation has started: no turn has run yet (a
+ * turn is one model-loop execution). Standalone plugin events — command
+ * lifecycle records, plan/mode, titles, goals — never open a turn, so
+ * running `/plan` or `/goal` on a fresh session keeps it blank
+ * (list-hidden, reusable).
+ */
+function sessionBlank(session: Session): boolean {
+  return !session.events.some(event => event.type === 'turn/start')
+}
+
 /** SessionSummary projection for attached (in-memory) sessions. */
 function summarize(session: Session, running: boolean): SessionSummary {
   return {
     sessionId: session.id,
     updatedAt: session.events.at(-1)?.time ?? session.header.createdAt,
     running,
-    blank: session.events.length === 0,
+    blank: sessionBlank(session),
     ...session.header.parentSession === undefined ? {} : { parentSessionId: session.header.parentSession },
     ...session.header.cwd === undefined ? {} : { cwd: session.header.cwd },
   }
@@ -167,8 +178,9 @@ async function summarizeCold(persistence: SessionPersistence, meta: SessionHeade
     sessionId: meta.id,
     updatedAt,
     running: false,
-    // Lazy persistence keeps never-appended sessions out of list(): a cold
-    // session necessarily has events, so blank is constantly false here.
+    // Lazy persistence keeps never-appended sessions out of list(); reading
+    // a cold log to check for turns would defeat the index read, so a listed
+    // cold session is served as not-blank (its log holds its conversation).
     blank: false,
     ...meta.parentSession === undefined ? {} : { parentSessionId: meta.parentSession },
     /* v8 ignore next -- the empty arm needs a cwd-less meta, but list()
@@ -1209,8 +1221,8 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
               type: 'host/session-added',
               sessionId: session.id,
               // Derived at frame time like summarize(); a just-created session
-              // has no events yet, so this is constantly true in practice.
-              blank: session.events.length === 0,
+              // has run no turn yet, so this is constantly true in practice.
+              blank: sessionBlank(session),
               ...session.header.parentSession === undefined ? {} : { parentSessionId: session.header.parentSession },
               // cwd rides the frame so the client list needs no refresh to group the new session.
               ...session.header.cwd === undefined ? {} : { cwd: session.header.cwd },

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

@@ -132,11 +132,13 @@ export interface SessionSummary {
   /** Status of the attached agent; always false for cold (unattached) sessions. */
   running: boolean
   /**
-   * Derived emptiness bit: true while the session log holds zero events (no
-   * user message yet). Clients hide blank sessions from lists and reuse them
-   * for New Session on the same workspace. Always false for cold sessions —
-   * lazy persistence keeps a never-appended session out of the store, so a
-   * listed cold session necessarily has events.
+   * Derived conversation-not-started bit: true while no turn has run (no
+   * prompt was accepted yet). Standalone plugin events — command lifecycle
+   * records, plan/mode, titles, goals — do not open a turn and therefore do
+   * not clear it. Clients hide blank sessions from lists and reuse them for
+   * New Session on the same workspace. Always false for cold sessions —
+   * lazy persistence keeps a never-appended session out of the store, and a
+   * listed cold session's log holds its turns.
    */
   blank: boolean
   /** fork/spawn lineage (session.header.parentSession passthrough); absent for root sessions. */

+ 77 - 0
packages/host/apiproxy/tests/api-proxy-blank.spec.ts

@@ -0,0 +1,77 @@
+/**
+ * The summary blank bit means "conversation not started" (no turn has run),
+ * not "log empty": standalone plugin events — command lifecycle records,
+ * plan/mode, session titles — never flip it, so running /plan or /goal on a
+ * fresh session keeps it list-hidden and reusable, while the first accepted
+ * prompt's turn/start clears it. The host/session-added frame shares the
+ * same predicate function (covered by the workspace spec's frame assertion).
+ */
+
+import { describe, expect, it } from 'vitest'
+import { Context } from 'cordis'
+import AgentRegistry from '@deepseek-ai/dsh-agent'
+import type { Agent } from '@deepseek-ai/dsh-agent'
+import SessionStore from '@deepseek-ai/dsh-session'
+import type { Session } from '@deepseek-ai/dsh-session'
+import UserInteractionService from '@deepseek-ai/dsh-user-interaction'
+import { CommandId } from '@deepseek-ai/dsh-commands/brand'
+import type { ApiProxy, RpcRequest } from '@deepseek-ai/dsh-host-apiproxy/api'
+import { RpcId } from '@deepseek-ai/dsh-host-apiproxy/api/rpc'
+import { createApiProxy } from '@deepseek-ai/dsh-host-apiproxy'
+
+let nextRpc = 1
+function request<P>(payload: P): RpcRequest<P> {
+  return { rpcId: RpcId(`blank-${String(nextRpc++)}`), payload }
+}
+
+async function harness(): Promise<{ ctx: Context; api: ApiProxy; attach: (session: Session) => void }> {
+  const ctx = new Context()
+  await ctx.plugin(SessionStore)
+  await ctx.plugin(UserInteractionService)
+  await ctx.plugin(AgentRegistry)
+  return {
+    ctx,
+    api: createApiProxy(ctx, { provider: 'p', model: 'm', cwd: '/tmp', workspaceRoot: '/tmp' }),
+    attach: (session) => {
+      ctx.agents.register({ id: session.id, session, status: 'idle', ctx } as Agent)
+    },
+  }
+}
+
+/** Append the standalone (non-conversation) event family a fresh session can accumulate. */
+function appendStandalone(session: Session): void {
+  session.append('command/run', {
+    commandId: CommandId('blank-cmd-1'), name: 'plan', args: '', source: { kind: 'user' },
+  })
+  session.append('plan/mode', { active: true })
+  session.append('command/done', { commandId: CommandId('blank-cmd-1'), kind: 'success', text: 'Plan mode on.' })
+  session.append('session/title', {
+    title: 'standalone title', messageSeqs: [], source: { kind: 'fallback' },
+  })
+}
+
+async function listBlank(api: ApiProxy, id: string): Promise<boolean | undefined> {
+  const response = await api.sessions.list(request({}))
+  if (!response.result.ok) throw new Error('list failed')
+  return response.result.value.items.find(item => item.sessionId === id)?.blank
+}
+
+describe('summary blank = conversation not started', () => {
+  it('standalone events (command lifecycle, plan/mode, title) keep the session blank', async () => {
+    const { ctx, api, attach } = await harness()
+    const session = ctx.sessions.create()
+    attach(session)
+    expect(await listBlank(api, session.id)).toBe(true)
+    appendStandalone(session)
+    expect(await listBlank(api, session.id)).toBe(true)
+  })
+
+  it('the first turn clears blank', async () => {
+    const { ctx, api, attach } = await harness()
+    const session = ctx.sessions.create()
+    attach(session)
+    appendStandalone(session)
+    session.append('turn/start', { turn: 0, trigger: { kind: 'message', source: { kind: 'user' } } })
+    expect(await listBlank(api, session.id)).toBe(false)
+  })
+})