fix(client): initialize draft lexicon after caching input shell

This commit is contained in:
imccyu committed 2026-09-30 14:54:37 +08:00
1 parent a456015082
commit e6783c20fc
2 files changed
+82 -6

No files matched your search

@@ -180,7 +180,6 @@ export class SessionInputShell implements SessionInput {
this.unregister = this.draftEditor.register()
this.state = createSnapshotStore<InputState>(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)
@@ -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: [] })