From e6783c20fc66e79c8a12174e054c27545f73babb Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Wed, 30 Sep 2026 09:24:01 +0800 Subject: [PATCH] fix(client): initialize draft lexicon after caching input shell --- .../src/client/input/facade.ts | 3 +- .../tests/draft-hub.client.spec.ts | 85 +++++++++++++++++-- 2 files changed, 82 insertions(+), 6 deletions(-) diff --git a/packages/client/ui-conversation/src/client/input/facade.ts b/packages/client/ui-conversation/src/client/input/facade.ts index 0434c726d5..5dcb925ac9 100644 --- a/packages/client/ui-conversation/src/client/input/facade.ts +++ b/packages/client/ui-conversation/src/client/input/facade.ts @@ -180,7 +180,6 @@ export class SessionInputShell implements SessionInput { this.unregister = this.draftEditor.register() this.state = createSnapshotStore(this.compose()) this.unsubscribeInbox = deps.inbox?.subscribe(() => { this.publish() }) - this.draftEditor.refreshLexiconSubscription() } // ---- editor plumbing ---- @@ -217,6 +216,8 @@ export class SessionInputShell implements SessionInput { * @param text - plain text or the complete semantic document. */ setDraft(text: DraftInput): void { + // Catalog resolution can reenter InputHub, which caches this shell before importing its draft. + this.refreshLexiconSubscription() const draft = resolveDraftInput(text) if (draft.references.length === 0) this.draftEditor.setDraft(draft.text) else this.draftEditor.restoreDraft(draft.text, draft.references) diff --git a/packages/client/ui-conversation/tests/draft-hub.client.spec.ts b/packages/client/ui-conversation/tests/draft-hub.client.spec.ts index ee1aae3bd4..46e62c7607 100644 --- a/packages/client/ui-conversation/tests/draft-hub.client.spec.ts +++ b/packages/client/ui-conversation/tests/draft-hub.client.spec.ts @@ -2,6 +2,7 @@ /** InputHub restores drafts before views and follows Session-owned lexicon subscriptions. */ import { randomUUID } from 'node:crypto' import { Context, Service } from '@deepseek-ai/cordis' +import type { SessionReference } from '@deepseek-ai/dsh-api-session-controller/client' import { createSnapshotStore } from '@deepseek-ai/dsh-client-store' import { makeTranslate, TestSessions } from '@deepseek-ai/dsh-client-test-runtime' import { SessionId } from '@deepseek-ai/dsh-session/types' @@ -11,7 +12,7 @@ import type { DraftSnapshot } from '../src/client/contract/draft-editor.ts' import type { InputTriggerController } from '../src/client/contract/input.ts' import { ReferenceChipNode } from '../src/client/input/editor/chip-node.tsx' import { TextRefNode } from '../src/client/input/editor/text-ref.ts' -import type { SessionInputShell } from '../src/client/input/facade.ts' +import { SessionInputShell } from '../src/client/input/facade.ts' import { InputHub } from '../src/client/input/hub.ts' import { zh } from '../src/client/locales.ts' @@ -24,7 +25,7 @@ const saved: DraftSnapshot = { }], } -async function fixture(draft: unknown) { +async function storedSession(draft: unknown) { const id = SessionId(`draft-hub-${randomUUID()}`) const key = `dsh.conversation.${id}` const previous = localStorage.getItem(key) @@ -47,8 +48,12 @@ async function fixture(draft: unknown) { await reference.ready const binding = reference.binding const hub = new InputHub(ctx, makeTranslate(zh, {})) - const shell = hub.shellFor(binding) - return { ctx, sessions, id, key, raw, reference, binding, hub, shell } + return { ctx, sessions, id, key, raw, reference, binding, hub } +} + +async function fixture(draft: unknown) { + const b = await storedSession(draft) + return { ...b, shell: b.hub.shellFor(b.binding) } } function textReferences(shell: SessionInputShell): string[] { @@ -97,15 +102,85 @@ function controller(lexicon: () => InputTriggerController['lexicon']): InputTrig } class TriggerProvider extends Service { - constructor(ctx: Context, private readonly config: { readonly controller: InputTriggerController }) { + constructor(ctx: Context, private readonly config: { + readonly controller: InputTriggerController + readonly initialize?: () => void + }) { super(ctx, 'inputTriggers') } sessionOf(): InputTriggerController { + this.config.initialize?.() return this.config.controller } } +it('does not resolve input triggers until the first draft initialization', async () => { + const b = await storedSession(saved) + const source = catalog(['saved']) + const triggers = controller(() => source.source) + const resolve = vi.fn(() => triggers) + const shell = new SessionInputShell({ + actx: b.binding.ctx, + inputTriggers: resolve, + defaultSink: () => Promise.resolve({ kind: 'success' }), + commandAttachments: { + serialize: () => Promise.resolve([]), + release: () => {}, + unsupportedNotice: token => token, + }, + }) + onTestFinished(() => { shell.dispose() }) + + expect(resolve).not.toHaveBeenCalled() + shell.setDraft(saved) + expect(resolve).toHaveBeenCalled() + expect(source.subscribers).toBe(1) + expect(shell.draftSnapshot).toEqual(saved) +}) + +it('reuses one shell during synchronous catalog retention and restores its initial draft before returning', async () => { + const b = await storedSession(saved) + const source = catalog(['saved']) + const retained: SessionReference[] = [] + onTestFinished(() => { for (const reference of retained) reference.release() }) + const reentered: SessionInputShell[] = [] + let warming = false + let initialized = false + let observed = false + const stop = b.sessions.retainInfo(b.id).subscribe(() => { + // One reentry exposes duplicate shells without exhausting the stack on a regression. + if (!warming || observed) return + observed = true + reentered.push(b.hub.shellFor(b.binding)) + }) + onTestFinished(stop) + const provider = b.ctx.plugin(TriggerProvider, { + controller: controller(() => source.source), + initialize: () => { + if (initialized) return + initialized = true + warming = true + try { + retained.push(b.sessions.retain(b.id)) + } finally { warming = false } + }, + }) + await provider.await() + + const first = b.hub.shellFor(b.binding) + expect(observed).toBe(true) + expect(reentered).toHaveLength(1) + expect(retained).toHaveLength(1) + expect(retained[0]?.binding).toBe(b.binding) + expect(new Set([first, ...reentered, b.hub.shellFor(b.binding)]).size).toBe(1) + expect(source.subscribers).toBe(1) + expect(first.draftSnapshot).toEqual(saved) + expect(reentered[0]?.draftSnapshot).toBe(first.draftSnapshot) + expect(first.editor.getEditorState().read(() => $nodesOfType(ReferenceChipNode).length)).toBe(1) + expect(localStorage.getItem(b.key)).toBe(b.raw) +}) + it('restores legacy text before a view and reuses the live draft on later shell lookups', async () => { const b = await fixture('/saved legacy\n🙂 中文') expect(b.shell.draftSnapshot).toEqual({ text: '/saved legacy\n🙂 中文', references: [] })