From d804c9c94d3e875802ff68acef5b72b4f10c20f4 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Tue, 28 Jul 2026 23:17:46 +0800 Subject: [PATCH] fix(web): blank means the conversation has not started MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../runtime/src/client/sessions/session.ts | 14 ++-- packages/host/apiproxy/src/api-proxy.ts | 22 ++++-- packages/host/apiproxy/src/api/sessions.ts | 12 +-- .../apiproxy/tests/api-proxy-blank.spec.ts | 77 +++++++++++++++++++ 4 files changed, 109 insertions(+), 16 deletions(-) create mode 100644 packages/host/apiproxy/tests/api-proxy-blank.spec.ts diff --git a/packages/client/runtime/src/client/sessions/session.ts b/packages/client/runtime/src/client/sessions/session.ts index 00398d53af..e762cc571e 100644 --- a/packages/client/runtime/src/client/sessions/session.ts +++ b/packages/client/runtime/src/client/sessions/session.ts @@ -216,12 +216,14 @@ export class Session implements ObservableSnapshot { 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) diff --git a/packages/host/apiproxy/src/api-proxy.ts b/packages/host/apiproxy/src/api-proxy.ts index 56f488857f..ac1be9513a 100644 --- a/packages/host/apiproxy/src/api-proxy.ts +++ b/packages/host/apiproxy/src/api-proxy.ts @@ -136,13 +136,24 @@ function subscribeSession(queue: FrameQueue>, 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 }, diff --git a/packages/host/apiproxy/src/api/sessions.ts b/packages/host/apiproxy/src/api/sessions.ts index 8c7be54440..3f3cb6f1a1 100644 --- a/packages/host/apiproxy/src/api/sessions.ts +++ b/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. */ diff --git a/packages/host/apiproxy/tests/api-proxy-blank.spec.ts b/packages/host/apiproxy/tests/api-proxy-blank.spec.ts new file mode 100644 index 0000000000..e5bd8bfbee --- /dev/null +++ b/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

(payload: P): RpcRequest

{ + 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 { + 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) + }) +})