diff --git a/packages/client/runtime/src/client/sessions/session.ts b/packages/client/runtime/src/client/sessions/session.ts index 3549422f90..371c74d0a3 100644 --- a/packages/client/runtime/src/client/sessions/session.ts +++ b/packages/client/runtime/src/client/sessions/session.ts @@ -827,7 +827,10 @@ export class Session implements SessionFace { queue: this.queueCache.value, running: this.running, composerPhase: derivePhase( - nodes.length > 0 || partial !== null || this.running || this.pendingCache.value.length > 0, + // Command lifecycle nodes are not conversation: running /permission + // or /plan on a fresh session keeps the hero (the client mirror of + // the host's no-turn sessionBlank predicate). + nodes.some(node => node.kind !== 'command') || partial !== null || this.running || this.pendingCache.value.length > 0, this.promptAttempted, ), removed: this.removed, @@ -848,7 +851,7 @@ export class Session implements SessionFace { * object: `hasContent` only grows within a window and `promptAttempted` is * sticky, so blank → engaging → active never steps back; a failed first * prompt stays engaging (retry semantics — see ComposerPhase). - * @param hasContent - any conversation material exists (nodes, partial, running turn, pending waits). + * @param hasContent - any conversation material exists (non-command nodes, partial, running turn, pending waits; command lifecycle rows alone keep the session blank). * @param promptAttempted - a prompt was initiated on this session object. * @returns the derived phase. */ diff --git a/packages/client/runtime/tests/session.spec.ts b/packages/client/runtime/tests/session.spec.ts index ca9193eda1..c7be330d55 100644 --- a/packages/client/runtime/tests/session.spec.ts +++ b/packages/client/runtime/tests/session.spec.ts @@ -126,6 +126,21 @@ describe('live event path', () => { }) }) + it('command lifecycle rows alone keep the composer blank (hero survives a /permission or /plan switch)', async () => { + // A fresh session whose only window content is a command pair (plus the + // knob events a /permission switch appends — not surface-eligible, so + // they never become nodes) stays phase 'blank': selecting a preset from + // the hero must not enter the conversation view. + const { session } = await opened([]) + expect(session.getSnapshot().composerPhase).toBe('blank') + const feed = (event: SessionEvent) => { session.handleMuxEnvelope('r' as never, { type: 'session/event', sessionId: SID, event }) } + feed(ev.commandRun(0, 'cmd-perm', 'permission', ' danger-full-access')) + feed(ev.commandDone(1, 'cmd-perm', 'success', 'Permission preset: danger-full-access.')) + const snapshot = session.getSnapshot() + expect(snapshot.nodes.at(-1)).toMatchObject({ kind: 'command', name: 'permission' }) + expect(snapshot.composerPhase).toBe('blank') + }) + it('accumulates chunks into partial, then finalize swaps partial out as the node lands', async () => { const { session } = await opened() const feed = (event: SessionEvent) => { session.handleMuxEnvelope('r' as never, { type: 'session/event', sessionId: SID, event }) } diff --git a/packages/host/apiproxy/tests/api-proxy-blank.spec.ts b/packages/host/apiproxy/tests/api-proxy-blank.spec.ts index e5bd8bfbee..36d51a7542 100644 --- a/packages/host/apiproxy/tests/api-proxy-blank.spec.ts +++ b/packages/host/apiproxy/tests/api-proxy-blank.spec.ts @@ -1,7 +1,7 @@ /** * 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 + * plan/mode, permission knob events, 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). @@ -15,6 +15,10 @@ 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' +// Side-effect type imports: the knob-event SessionEventMap merges. +import type {} from '@deepseek-ai/dsh-permission' +import type {} from '@deepseek-ai/dsh-sandbox-policy' +import type {} from '@deepseek-ai/dsh-user-approval' 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' @@ -48,6 +52,10 @@ function appendStandalone(session: Session): void { session.append('session/title', { title: 'standalone title', messageSeqs: [], source: { kind: 'fallback' }, }) + // The three permission knob events (a /permission switch on a fresh session). + session.append('permission/preset', { preset: 'danger-full-access' }) + session.append('sandbox/mode', { mode: 'danger-full-access' }) + session.append('approval/policy', { policy: 'never' }) } async function listBlank(api: ApiProxy, id: string): Promise {