From b19d1643f79424e4265810479c396350b81d590f Mon Sep 17 00:00:00 2001 From: creatixchu Date: Mon, 17 Aug 2026 18:35:45 +0800 Subject: [PATCH] fix(web): harden the composer submit transaction - wire the directory pick's continue flag through InsertTextRequest to the input shell, which re-tracks at the caret so descent reopens completion (the flag was produced and forwarded but never consumed) - guard the image-only send against a second Enter during the Host round-trip - keep text appended after the sent snapshot when a submit settles ok; only interleaved edits clear with the committed content - drop the dead restoreImages left from the sink rewrite - read recall labels through a shared defensive sessionRecallLabels helper instead of unchecked casts over durable log data, and take the reference summary separator from the locale dictionary - align the composer specs merged from master with the transactional submit contract (sinks resolve SubmitOutcome, settlement is awaited, call assertions carry the AbortSignal), and restore master's forms where the divergence served nothing (queue-mode sink case, single-line filter expectation, component-identity slot lookup) --- apps/web/tests/queue-actions.e2e.ts | 4 +- packages/client/runtime/src/client/index.ts | 2 +- .../src/client/sessions/context-provenance.ts | 13 ++++ .../tests/context-provenance.client.spec.ts | Bin 4540 -> 5161 bytes .../src/client/chat/ChatNodeSeat.tsx | 10 +-- .../src/client/chat/MessageItem.tsx | 2 +- .../src/client/input/facade.ts | 27 ++++---- .../ui-conversation/src/client/input/hub.ts | 2 +- .../src/client/input/machine.ts | 8 ++- .../ui-conversation/src/client/locales.ts | 2 + .../tests/apply-inject.client.spec.tsx | 21 +++--- .../tests/input-bar.client.spec.tsx | 62 ++++++++++++------ .../tests/input-machine.client.spec.ts | 20 +++++- .../tests/input-matrix.client.spec.tsx | 12 ++-- .../input-reference-submit.client.spec.ts | 41 ++++++++++++ .../tests/input-scenarios.client.spec.tsx | 8 +-- .../tests/skeleton.client.spec.tsx | 4 +- packages/client/ui-input-trigger/src/types.ts | 2 + .../tests/service.client.spec.ts | 12 ++++ .../tests/browser-plugin.client.spec.ts | 6 +- 20 files changed, 188 insertions(+), 70 deletions(-) diff --git a/apps/web/tests/queue-actions.e2e.ts b/apps/web/tests/queue-actions.e2e.ts index 9e03e1dbc2..a10ca7188e 100644 --- a/apps/web/tests/queue-actions.e2e.ts +++ b/apps/web/tests/queue-actions.e2e.ts @@ -146,9 +146,7 @@ describe('web e2e: queue row actions', () => { const snapshot = await captureStableAria(page, '[class*="centerCol"]', scaffold.workspaceCwd) await compareOrRefreshGolden(UI_EXPECTED, snapshot, MODE) - expect(sessionEvents.filter( - event => event.type === 'user/message' && event.data.source.kind === 'user', - )).toHaveLength(1) + expect(sessionEvents.filter(event => event.type === 'user/message' && event.data.source.kind === 'user')).toHaveLength(1) expect(tripwire.pageErrors).toEqual([]) expect(tripwire.warnings).toEqual([]) diff --git a/packages/client/runtime/src/client/index.ts b/packages/client/runtime/src/client/index.ts index b5433bb573..f1ab38f830 100644 --- a/packages/client/runtime/src/client/index.ts +++ b/packages/client/runtime/src/client/index.ts @@ -85,7 +85,7 @@ export { } from './sessions/conversation.ts' export { emptyAssistantBlock } from './sessions/partial.ts' export { isTokenDelta } from './sessions/assistant-timing.ts' -export { contextForm, contextProvenance } from './sessions/context-provenance.ts' +export { contextForm, contextProvenance, sessionRecallLabels } from './sessions/context-provenance.ts' export { displayFailureMessage } from './sessions/failure-display.ts' export type { ConversationContext, ConversationContextOriginKind, diff --git a/packages/client/runtime/src/client/sessions/context-provenance.ts b/packages/client/runtime/src/client/sessions/context-provenance.ts index 5912ea388a..dbd3b2dd30 100644 --- a/packages/client/runtime/src/client/sessions/context-provenance.ts +++ b/packages/client/runtime/src/client/sessions/context-provenance.ts @@ -58,6 +58,19 @@ function joined(names: string[]): string | null { return names.length > 0 ? names.join(', ') : null } +/** + * The referenced-session labels of one durable `session-reference` recall + * source, in first-seen order; empty for every other source shape, including + * a foreign or older log whose reference entries carry no readable label. + * @param source - the logged `user/message` source, exactly as recorded. + * @returns distinct non-empty reference labels. + */ +export function sessionRecallLabels(source: unknown): string[] { + const record = asRecord(source) + if (record === null || readString(record, 'kind') !== 'session-reference') return [] + return collect(record, 'references', 'label') +} + /** * Project one durable message source onto its transcript role and producer name. * diff --git a/packages/client/runtime/tests/context-provenance.client.spec.ts b/packages/client/runtime/tests/context-provenance.client.spec.ts index e243660d601d5edbb9d2838bc5eb935102966691..b64902719c604e52f96a5f81c255583dab6a6898 100644 GIT binary patch delta 476 zcmaiwze)o^5XPGbdJqsq8xaO9ZZE9X5>SXBSXkMH6q~&{U08SH{sG}Q@(RLszJaB+ zR=$kjBcyUXi3B|eEDSTV^Zn+Vx5H=lxY*_3(R*dhHAc~+vAPEC>J z9ba+|tbo%qs51Z+ST3#BD1(Pm`Jjvppj$1l6^JF|$YF&JjYRM^Its#+U~ITm<_3t2 zzq;vTqrcMuPEWl-zq0Ob2)S!_POrVeyoFI|ltaUz{()0|{wH7diP?G`LU0kGnQ-y} z&=kdSuow4HXf`G>{;e=6+8#d4$RO+3iPh).data.source as { - kind?: string - form?: string - references?: readonly { label: string }[] - } - if (source.kind !== 'session-reference' || source.form !== 'recall' || source.references === undefined) return '' - return JSON.stringify(source.references.map(reference => reference.label)) + const labels = sessionRecallLabels((previous as ChatNode<'context'>).data.source) + return labels.length === 0 ? '' : JSON.stringify(labels) }) const referenceLabels = useMemo( () => referenceLabelsJson === '' ? [] : JSON.parse(referenceLabelsJson) as string[], diff --git a/packages/client/ui-conversation/src/client/chat/MessageItem.tsx b/packages/client/ui-conversation/src/client/chat/MessageItem.tsx index 712bf5180f..a9c7cd6d31 100644 --- a/packages/client/ui-conversation/src/client/chat/MessageItem.tsx +++ b/packages/client/ui-conversation/src/client/chat/MessageItem.tsx @@ -217,7 +217,7 @@ function UserStyleBubble({ } {referenceLabels.length > 0 && (
- {t('message.referenceSummary', { labels: referenceLabels.join('、') })} + {t('message.referenceSummary', { labels: referenceLabels.join(t('message.referenceSeparator')) })}
)} diff --git a/packages/client/ui-conversation/src/client/input/facade.ts b/packages/client/ui-conversation/src/client/input/facade.ts index f1b6c1b072..02389b7e31 100644 --- a/packages/client/ui-conversation/src/client/input/facade.ts +++ b/packages/client/ui-conversation/src/client/input/facade.ts @@ -91,6 +91,8 @@ export class SessionInputShell implements SessionInput { private noticeSeq = 0 private lastDraft = '' private imageIds: readonly DraftAttachmentId[] = [] + /** One image-only send at a time: Enter during the Host round-trip is a no-op. */ + private imageSendInFlight = false private disposed = false /** Draft persistence mirror (chat store write; receives the clipboard projection, never raw placeholders). */ private mirrorFn: ((text: string) => void) | undefined @@ -141,16 +143,6 @@ export class SessionInputShell implements SessionInput { this.publish() } - /** - * Restore a failed attempt before any images added after its admission. - * @param ids - failed attempt image ids. - */ - restoreImages(ids: readonly DraftAttachmentId[]): void { - const current = new Set(this.imageIds) - this.imageIds = [...ids.filter(id => !current.has(id)), ...this.imageIds] - this.publish() - } - /** * Clear the draft as a successful-send commit: no undo unit is recorded and * the undo history is cut, so Ctrl/Cmd-Z cannot resurrect sent content @@ -202,13 +194,16 @@ export class SessionInputShell implements SessionInput { */ submit(mode: InputSubmitMode = 'queue'): void { if (this.snapshot.draft.trim() === '' && this.imageIds.length > 0) { - if (this.snapshot.phase === 'plain') { + if (this.snapshot.phase === 'plain' && !this.imageSendInFlight) { const imageIds = [...this.imageIds] + this.imageSendInFlight = true void this.deps.defaultSink('', imageIds, mode, new AbortController().signal).then((outcome) => { + this.imageSendInFlight = false if (this.disposed) return if (outcome.kind === 'success') this.commitSend(imageIds) else this.notify('error', outcome.text ?? 'prompt failed') }, (error: unknown) => { + this.imageSendInFlight = false if (!this.disposed) this.notify('error', error instanceof Error ? error.message : String(error)) }) } @@ -341,13 +336,21 @@ export class SessionInputShell implements SessionInput { * a scan-derived decoration, never state. * @param text - the plain reference text to splice in (e.g. `/name `). * @param span - pick-time span snapshot (draftRev CAS). + * @param keepCompleting - re-track at the caret after the splice so an open + * token (a directory pick's trailing slash) reopens the menu. * @returns whether the text was applied. */ - insertText(text: string, span: TokenSpan): boolean { + insertText(text: string, span: TokenSpan, keepCompleting = false): boolean { const snapshot = this.core.state if (span.draftRev !== snapshot.draftRev) return false const draft = snapshot.draft this.setDraft(draft.slice(0, span.start) + text + draft.slice(span.end)) + if (keepCompleting) { + // Machine-driven draft replacement never passes through onChange, so + // re-track at the caret inside the still-open token (see space()). + const next = this.snapshot + this.deps.inputTriggers?.()?.track(next.draft, span.start + text.length, { tier: guardOf(next.phase) }, next.draftRev) + } return true } diff --git a/packages/client/ui-conversation/src/client/input/hub.ts b/packages/client/ui-conversation/src/client/input/hub.ts index 80c0fa9800..1fdd271503 100644 --- a/packages/client/ui-conversation/src/client/input/hub.ts +++ b/packages/client/ui-conversation/src/client/input/hub.ts @@ -91,7 +91,7 @@ export class InputHub implements SessionInputResolver { actx.on('slash/input-consume-token', req => shell.consumeToken(req.guard) ? true : undefined), actx.on('slash/input-insert-text', req => - shell.insertText(req.text, req.span) ? true : undefined), + shell.insertText(req.text, req.span, req.continue === true) ? true : undefined), ] return () => { for (const off of offs) off() diff --git a/packages/client/ui-conversation/src/client/input/machine.ts b/packages/client/ui-conversation/src/client/input/machine.ts index fd883160a1..1b8c71419b 100644 --- a/packages/client/ui-conversation/src/client/input/machine.ts +++ b/packages/client/ui-conversation/src/client/input/machine.ts @@ -523,7 +523,13 @@ export class InputMachine { this.phase = 'plain' this.claim = undefined this.occurrences = [] - this.adopt('') + // Text appended after the sent snapshot during the Host round-trip + // survives the commit; edits interleaved with committed content cannot + // be separated from it, so only a pure suffix is retained. + const snapshot = flight.attempt.draftSnapshot + this.adopt(this.draft !== snapshot && this.draft.startsWith(snapshot) + ? this.draft.slice(snapshot.length) + : '') // Committed content is gone for good: undo must not resurrect a sent draft. this.log = [] this.redoStack = [] diff --git a/packages/client/ui-conversation/src/client/locales.ts b/packages/client/ui-conversation/src/client/locales.ts index 485bd67f63..976edf004c 100644 --- a/packages/client/ui-conversation/src/client/locales.ts +++ b/packages/client/ui-conversation/src/client/locales.ts @@ -95,6 +95,7 @@ export const zh = { 'message.contextInjection': '上下文注入', 'message.contextRecall': '跨会话召回', 'message.referenceSummary': '引用会话 · {labels}', + 'message.referenceSeparator': '、', 'message.context.instructions.loaded': '已载入', 'message.context.instructions.added': '已新增', 'message.context.instructions.updated': '已更新', @@ -265,6 +266,7 @@ export const en = { 'message.contextInjection': 'Context injection', 'message.contextRecall': 'Session recall', 'message.referenceSummary': 'Referenced session · {labels}', + 'message.referenceSeparator': ', ', 'message.context.instructions.loaded': 'loaded', 'message.context.instructions.added': 'added', 'message.context.instructions.updated': 'updated', diff --git a/packages/client/ui-conversation/tests/apply-inject.client.spec.tsx b/packages/client/ui-conversation/tests/apply-inject.client.spec.tsx index 9cec01789a..7b6dc4e51d 100644 --- a/packages/client/ui-conversation/tests/apply-inject.client.spec.tsx +++ b/packages/client/ui-conversation/tests/apply-inject.client.spec.tsx @@ -2,7 +2,7 @@ // apply inject factories exercised end to end against the terminal thin // API: the strict session API (views triple, draft mirror), the // provide-channel input face (machine-sink submit choreography incl. -// optimistic clear + failure restore), the resident API (selectWorkspace +// transactional clear + failure retention), the resident API (selectWorkspace // draft carrying), the composer-bar stop face, openDetails = select action + // layout orchestration, and the closeDetails details API. Complements // chat-apply.spec.tsx (registration) and selection-survival.spec.tsx (store @@ -150,7 +150,7 @@ describe('conversation slot inject API', () => { await b.runtime.dispose() }) - it('the provide-channel input face submits through the machine sink: trim, optimistic clear, failure restore without clobber', async () => { + it('the provide-channel input face submits through the machine sink: trim, transactional clear, failure retains the draft', async () => { const b = await bench() const { injected } = b.conversationApi(ROOT) const { state, actions } = b.inputApi(ROOT) @@ -159,20 +159,23 @@ describe('conversation slot inject API', () => { actions.submit() expect(b.sessionFake.prompt).not.toHaveBeenCalled() expect(state.getSnapshot().draft).toBe(' ') - // Success: cleared and stays cleared. + // Success: the draft clears only after the sink settles. actions.setDraft('hello') actions.submit() - expect(state.getSnapshot().draft).toBe('') - await Promise.resolve() - expect(b.sessionFake.prompt).toHaveBeenCalledWith([{ type: 'text', text: 'hello' }], 'queue') - // Failure: restored (draft still empty when the rejection lands). + await vi.waitFor(() => { + expect(state.getSnapshot().draft).toBe('') + }) + expect(b.sessionFake.prompt).toHaveBeenCalledWith([{ type: 'text', text: 'hello' }], 'queue', expect.any(AbortSignal)) + // Failure: the draft is retained through the round-trip. b.sessionFake.prompt.mockResolvedValueOnce({ ok: false, error: { code: 'agent-busy', message: 'b', details: { reason: 'b' } } }) actions.setDraft('retry me') actions.submit() await vi.waitFor(() => { - expect(state.getSnapshot().draft).toBe('retry me') + expect(b.sessionFake.prompt).toHaveBeenCalledTimes(2) }) - // Failure landing after new typing: no clobber (restore fills empty only). + await new Promise(r => setTimeout(r, 0)) + expect(state.getSnapshot().draft).toBe('retry me') + // Failure landing after new typing: no clobber (the interleaved edit wins). b.sessionFake.prompt.mockResolvedValueOnce({ ok: false, error: { code: 'agent-busy', message: 'b', details: { reason: 'b' } } }) actions.submit() actions.setDraft('typed during flight') diff --git a/packages/client/ui-conversation/tests/input-bar.client.spec.tsx b/packages/client/ui-conversation/tests/input-bar.client.spec.tsx index f7d5e02a7f..c67e901532 100644 --- a/packages/client/ui-conversation/tests/input-bar.client.spec.tsx +++ b/packages/client/ui-conversation/tests/input-bar.client.spec.tsx @@ -13,6 +13,7 @@ import { import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime' import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts' import type { ClientContext, ConversationSnapshot, SessionId } from '@deepseek-ai/dsh-client-runtime/client' +import type { SubmitOutcome } from '@deepseek-ai/dsh-client-ui-input-trigger/client' import { SessionInputShell } from '../src/client/input/facade.ts' import type { ComposerAttachment } from '../src/client/contract/slots.ts' import type { DraftAttachmentId } from '../src/client/input/contract.ts' @@ -101,7 +102,12 @@ function row(id: string): ConversationSnapshot['queue'][number] { /** Real machine behind the bar entry: sink spy, no slash pipeline (plain text goes straight to the sink). */ function bench(over?: BenchOptions) { - const sink = vi.fn() + const sink = vi.fn<( + text: string, + imageIds: readonly DraftAttachmentId[], + mode: 'queue' | 'steer', + signal: AbortSignal, + ) => Promise>(() => Promise.resolve({ kind: 'success' })) const lex = over?.lexicon const session = createSnapshotStore(snapshotOf({ running: over?.running ?? false, @@ -365,15 +371,28 @@ describe('image draft rail', () => { expect(view.queryByRole('status')).toBeNull() }) - it('sends an image-only draft and removes its thumbnail', () => { + it('sends an image-only draft and removes its thumbnail', async () => { const file = new File([Uint8Array.of(1)], 'pixel.png', { type: 'image/png' }) - const attachment = { kind: 'image' as const, id: 'draft-1' as DraftAttachmentId, file, previewUrl: 'blob:draft-1' } - const { view, textarea, sink, removeImage } = bench({ attachments: [attachment] }) + const extra = new File([Uint8Array.of(2)], 'extra.png', { type: 'image/png' }) + const attachments = [ + { kind: 'image' as const, id: 'draft-1' as DraftAttachmentId, file, previewUrl: 'blob:draft-1' }, + { kind: 'image' as const, id: 'draft-2' as DraftAttachmentId, file: extra, previewUrl: 'blob:draft-2' }, + ] + const { view, textarea, sink, removeImage } = bench({ attachments }) expect((view.getByRole('button', { name: '发送消息' }) as HTMLButtonElement).disabled).toBe(false) + fireEvent.click(view.getByRole('button', { name: '移除图片 extra.png' })) + expect(removeImage).toHaveBeenCalledWith('draft-2') + let settle!: (outcome: SubmitOutcome) => void + sink.mockImplementationOnce(() => new Promise((resolve) => { settle = resolve })) fireEvent.keyDown(textarea, { key: 'Enter' }) - expect(sink).toHaveBeenCalledWith('', ['draft-1'], 'queue') - fireEvent.click(view.getByRole('button', { name: '移除图片 pixel.png' })) - expect(removeImage).toHaveBeenCalledWith('draft-1') + expect(sink).toHaveBeenCalledWith('', ['draft-1'], 'queue', expect.any(AbortSignal)) + // The sent thumbnail stays on the rail through the round-trip and leaves + // only after the success settles. + expect(view.getByRole('button', { name: '移除图片 pixel.png' })).toBeTruthy() + settle({ kind: 'success' }) + await vi.waitFor(() => { + expect(view.queryByRole('button', { name: '移除图片 pixel.png' })).toBeNull() + }) }) it('opens the original image on a single click and closes it with Escape', () => { @@ -476,8 +495,11 @@ describe('Enter semantics', () => { it('plain Enter submits queue mode through the machine; repeat and empty are suppressed', () => { const { textarea, sink } = bench({ draft: 'hello' }) fireEvent.keyDown(textarea, { key: 'Enter' }) - expect(sink).toHaveBeenCalledWith('hello', [], 'queue') + expect(sink).toHaveBeenCalledWith('hello', [], 'queue', expect.any(AbortSignal)) + // The submitting-phase lock, not draft emptiness, suppresses the repeat: + // the draft is still uncleared while the sink round-trip is in flight. fireEvent.keyDown(textarea, { key: 'Enter', repeat: true }) + fireEvent.keyDown(textarea, { key: 'Enter' }) expect(sink).toHaveBeenCalledTimes(1) const empty = bench({ draft: ' ' }) fireEvent.keyDown(empty.textarea, { key: 'Enter' }) @@ -501,15 +523,15 @@ describe('Enter semantics', () => { it('Ctrl/Meta+Enter sends normally while idle and steers while running', () => { const idle = bench({ draft: 'hello' }) fireEvent.keyDown(idle.textarea, { key: 'Enter', metaKey: true }) - expect(idle.sink).toHaveBeenCalledWith('hello', [], 'queue') + expect(idle.sink).toHaveBeenCalledWith('hello', [], 'queue', expect.any(AbortSignal)) const busyCtrl = bench({ running: true, draft: 'steer with ctrl' }) fireEvent.keyDown(busyCtrl.textarea, { key: 'Enter', ctrlKey: true }) - expect(busyCtrl.sink).toHaveBeenCalledWith('steer with ctrl', [], 'steer') + expect(busyCtrl.sink).toHaveBeenCalledWith('steer with ctrl', [], 'steer', expect.any(AbortSignal)) const busyMeta = bench({ running: true, draft: 'steer with cmd' }) fireEvent.keyDown(busyMeta.textarea, { key: 'Enter', metaKey: true }) - expect(busyMeta.sink).toHaveBeenCalledWith('steer with cmd', [], 'steer') + expect(busyMeta.sink).toHaveBeenCalledWith('steer with cmd', [], 'steer', expect.any(AbortSignal)) }) it('empty-draft Cmd/Ctrl+Enter steers the whole queue instead of submitting', () => { @@ -574,7 +596,7 @@ describe('Enter semantics', () => { const steerQueue = vi.fn() const { textarea, sink } = bench({ running: true, queue: [row('q-1')], draft: '插话', steerQueue }) fireEvent.keyDown(textarea, { key: 'Enter', ctrlKey: true }) - expect(sink).toHaveBeenCalledWith('插话', [], 'steer') + expect(sink).toHaveBeenCalledWith('插话', [], 'steer', expect.any(AbortSignal)) expect(steerQueue).not.toHaveBeenCalled() }) @@ -622,7 +644,7 @@ describe('running and lock semantics', () => { expect(textarea.disabled).toBe(false) fireEvent.change(textarea, { target: { value: '排队消息2' } }) fireEvent.keyDown(textarea, { key: 'Enter' }) - expect(sink).toHaveBeenCalledWith('排队消息2', [], 'queue') + expect(sink).toHaveBeenCalledWith('排队消息2', [], 'queue', expect.any(AbortSignal)) expect(button.getAttribute('aria-label')).toBe('停止生成') fireEvent.click(button) expect(stop).toHaveBeenCalledTimes(1) @@ -631,17 +653,17 @@ describe('running and lock semantics', () => { it('running plain Enter follows the busy-state Steer preference', () => { const { textarea, sink } = bench({ running: true, busyEnter: 'steer', draft: '直接插话' }) fireEvent.keyDown(textarea, { key: 'Enter' }) - expect(sink).toHaveBeenCalledWith('直接插话', [], 'steer') + expect(sink).toHaveBeenCalledWith('直接插话', [], 'steer', expect.any(AbortSignal)) }) it('running Cmd/Ctrl+Enter uses the opposite of the busy-state Enter preference', () => { const meta = bench({ running: true, busyEnter: 'steer', draft: '排到下一轮' }) fireEvent.keyDown(meta.textarea, { key: 'Enter', metaKey: true }) - expect(meta.sink).toHaveBeenCalledWith('排到下一轮', [], 'queue') + expect(meta.sink).toHaveBeenCalledWith('排到下一轮', [], 'queue', expect.any(AbortSignal)) const ctrl = bench({ running: true, busyEnter: 'steer', draft: 'also queue' }) fireEvent.keyDown(ctrl.textarea, { key: 'Enter', ctrlKey: true }) - expect(ctrl.sink).toHaveBeenCalledWith('also queue', [], 'queue') + expect(ctrl.sink).toHaveBeenCalledWith('also queue', [], 'queue', expect.any(AbortSignal)) }) it('running continuable subagent keeps Send beside an independent Stop', () => { @@ -661,7 +683,7 @@ describe('running and lock semantics', () => { expect(interruptButton).not.toBeNull() expect(textarea.disabled).toBe(false) fireEvent.click(button) - expect(sink).toHaveBeenCalledWith('后续消息', [], 'queue') + expect(sink).toHaveBeenCalledWith('后续消息', [], 'queue', expect.any(AbortSignal)) fireEvent.click(interruptButton!) expect(stop).toHaveBeenCalledTimes(1) }) @@ -718,11 +740,11 @@ describe('running and lock semantics', () => { } const plain = bench({ running: true, busyEnter: 'steer', draft: 'plain', subagent }) fireEvent.keyDown(plain.textarea, { key: 'Enter' }) - expect(plain.sink).toHaveBeenCalledWith('plain', [], 'queue') + expect(plain.sink).toHaveBeenCalledWith('plain', [], 'queue', expect.any(AbortSignal)) const accelerated = bench({ running: true, draft: 'accelerated', subagent }) fireEvent.keyDown(accelerated.textarea, { key: 'Enter', metaKey: true }) - expect(accelerated.sink).toHaveBeenCalledWith('accelerated', [], 'queue') + expect(accelerated.sink).toHaveBeenCalledWith('accelerated', [], 'queue', expect.any(AbortSignal)) }) it('disabled (session removed) locks the textarea and chrome', () => { @@ -735,7 +757,7 @@ describe('running and lock semantics', () => { it('idle primary sends and disables on empty draft', () => { const { button, sink } = bench({ draft: 'go' }) fireEvent.click(button) - expect(sink).toHaveBeenCalledWith('go', [], 'queue') + expect(sink).toHaveBeenCalledWith('go', [], 'queue', expect.any(AbortSignal)) const empty = bench() expect(empty.button.disabled).toBe(true) }) diff --git a/packages/client/ui-conversation/tests/input-machine.client.spec.ts b/packages/client/ui-conversation/tests/input-machine.client.spec.ts index 4bb4a14e7e..192d4c7efa 100644 --- a/packages/client/ui-conversation/tests/input-machine.client.spec.ts +++ b/packages/client/ui-conversation/tests/input-machine.client.spec.ts @@ -72,8 +72,8 @@ describe('input-machine: plain × enter', () => { it('non-command text falls to the default sink', () => { const m = new InputMachine() m.dispatch({ type: 'draft-changed', draft: 'hello world' }) - const effect = effectAt(m.dispatch({ type: 'enter', mode: 'steer' }), 0, 'default-sink') - expect(effect).toMatchObject({ draft: 'hello world', mode: 'steer' }) + const effect = effectAt(m.dispatch({ type: 'enter', mode: 'queue' }), 0, 'default-sink') + expect(effect).toMatchObject({ draft: 'hello world', mode: 'queue' }) expect(effect.attempt.draftSnapshot).toBe('hello world') expect(m.state.phase).toBe('submitting') }) @@ -488,6 +488,22 @@ describe('input-machine: undo / redo', () => { expect(m.dispatch({ type: 'undo' })).toEqual([]) expect(m.state.draft).toBe('') }) + + it('keeps a suffix typed during the round-trip and drops interleaved edits with the commit', () => { + const m = new InputMachine() + m.dispatch({ type: 'draft-changed', draft: 'hello' }) + const effect = effectAt(m.dispatch({ type: 'enter', mode: 'queue' }), 0, 'default-sink') + m.dispatch({ type: 'draft-changed', draft: 'hello world' }) + m.dispatch({ type: 'submit-settled', attempt: effect.attempt, ok: true }) + expect(m.state.draft).toBe(' world') + + const n = new InputMachine() + n.dispatch({ type: 'draft-changed', draft: 'hello' }) + const second = effectAt(n.dispatch({ type: 'enter', mode: 'queue' }), 0, 'default-sink') + n.dispatch({ type: 'draft-changed', draft: 'hXello' }) + n.dispatch({ type: 'submit-settled', attempt: second.attempt, ok: true }) + expect(n.state.draft).toBe('') + }) }) describe('input-machine: paste plane', () => { diff --git a/packages/client/ui-conversation/tests/input-matrix.client.spec.tsx b/packages/client/ui-conversation/tests/input-matrix.client.spec.tsx index 9518318557..eff49c2ca9 100644 --- a/packages/client/ui-conversation/tests/input-matrix.client.spec.tsx +++ b/packages/client/ui-conversation/tests/input-matrix.client.spec.tsx @@ -69,7 +69,7 @@ function mountBar(shell: SessionInputShell, over?: { running?: boolean; disabled } function bench(over?: { running?: boolean; disabled?: boolean; submit?: (args: string) => Promise }) { - const sink = vi.fn() + const sink = vi.fn(() => Promise.resolve({ kind: 'success' })) const shell = new SessionInputShell({ actx: SCTX, defaultSink: sink }) const wiring = shell const view = mountBar(shell, over) @@ -90,13 +90,15 @@ function bench(over?: { running?: boolean; disabled?: boolean; submit?: (args: s } describe('matrix row: plain', () => { - it('enter falls to the default sink; no claim on the currency; edits free', () => { + it('enter falls to the default sink; no claim on the currency; edits free', async () => { const { textarea, shell, sink } = bench() fireEvent.change(textarea, { target: { value: '普通消息' } }) expect(shell.snapshot.claim).toBeUndefined() fireEvent.keyDown(textarea, { key: 'Enter' }) - expect(sink).toHaveBeenCalledWith('普通消息', [], 'queue') - expect(shell.snapshot.phase).toBe('plain') + expect(sink).toHaveBeenCalledWith('普通消息', [], 'queue', expect.any(AbortSignal)) + expect(shell.snapshot.phase).toBe('submitting') + await vi.waitFor(() => { expect(shell.snapshot.phase).toBe('plain') }) + expect(shell.snapshot.claim).toBeUndefined() }) }) @@ -194,7 +196,7 @@ describe('matrix row: locked (session disabled)', () => { expect((textarea).disabled).toBe(false) fireEvent.change(textarea, { target: { value: '排队' } }) fireEvent.keyDown(textarea, { key: 'Enter' }) - expect(sink).toHaveBeenCalledWith('排队', [], 'queue') + expect(sink).toHaveBeenCalledWith('排队', [], 'queue', expect.any(AbortSignal)) }) }) diff --git a/packages/client/ui-conversation/tests/input-reference-submit.client.spec.ts b/packages/client/ui-conversation/tests/input-reference-submit.client.spec.ts index bac940eb18..d41253a299 100644 --- a/packages/client/ui-conversation/tests/input-reference-submit.client.spec.ts +++ b/packages/client/ui-conversation/tests/input-reference-submit.client.spec.ts @@ -120,3 +120,44 @@ describe('reference submission', () => { expect(shell.snapshot.draft).toBe('send this') }) }) + +describe('submit transaction hardening', () => { + it('sends one image-only prompt per settlement, ignoring Enter during the round-trip', async () => { + let settle!: (outcome: SubmitOutcome) => void + const sink = vi.fn(() => new Promise((resolve) => { settle = resolve })) + const shell = new SessionInputShell({ + actx: {} as ClientContext, + defaultSink: sink, + }) + expect(shell.addImages(['img-1' as DraftAttachmentId])).toBe(true) + shell.submit('queue') + shell.submit('queue') + expect(sink).toHaveBeenCalledTimes(1) + settle({ kind: 'success' }) + await vi.waitFor(() => { + expect(shell.snapshot.imageIds).toEqual([]) + }) + + expect(shell.addImages(['img-2' as DraftAttachmentId])).toBe(true) + shell.submit('queue') + expect(sink).toHaveBeenCalledTimes(2) + }) + + it('re-tracks at the caret when a continuing insert-text splice lands (directory descent)', () => { + const track = vi.fn() + const shell = new SessionInputShell({ + actx: {} as ClientContext, + inputTriggers: () => ({ track } as unknown as InputTriggerController), + defaultSink: vi.fn(), + }) + shell.setDraft('@sr') + const applied = shell.insertText('@src/', { start: 0, end: 3, draftRev: shell.snapshot.draftRev }, true) + expect(applied).toBe(true) + expect(shell.snapshot.draft).toBe('@src/') + expect(track).toHaveBeenCalledWith('@src/', 5, { tier: 'plain' }, shell.snapshot.draftRev) + + track.mockClear() + shell.insertText(' plain ', { start: 0, end: 0, draftRev: shell.snapshot.draftRev }) + expect(track).not.toHaveBeenCalled() + }) +}) diff --git a/packages/client/ui-conversation/tests/input-scenarios.client.spec.tsx b/packages/client/ui-conversation/tests/input-scenarios.client.spec.tsx index 2a1c114226..0daadabea4 100644 --- a/packages/client/ui-conversation/tests/input-scenarios.client.spec.tsx +++ b/packages/client/ui-conversation/tests/input-scenarios.client.spec.tsx @@ -106,7 +106,7 @@ async function scopedBench(register?: (inputTriggers: InputTriggerService) => vo register?.(inputTriggers) const actx = sessions.scope(sessionId)! const controller = inputTriggers.sessionOf(actx) - const sink = vi.fn() + const sink = vi.fn(() => Promise.resolve({ kind: 'success' })) const shell = new SessionInputShell({ actx, inputTriggers: () => controller, defaultSink: sink }) // The hub's listener wiring, verbatim. actx.on('slash/input-begin-command', req => shell.beginCommand(req.claim, req.span) ? true : undefined) @@ -243,7 +243,7 @@ describe('scenario D: execute-kind /compact', () => { act(() => { b2.shell.setDraft('/compact 现在') }) fireEvent.keyDown(b2.textarea, { key: 'Enter' }) // execute with trailing → matchEnter answers undefined → default sink. - await vi.waitFor(() => { expect(b2.sink).toHaveBeenCalledWith('/compact 现在', [], 'queue') }) + await vi.waitFor(() => { expect(b2.sink).toHaveBeenCalledWith('/compact 现在', [], 'queue', expect.any(AbortSignal)) }) expect(b2.executed).toHaveLength(0) }) }) @@ -297,8 +297,8 @@ describe('scenario I: unknown /xyz + enter', () => { const b = await bench() act(() => { b.shell.setDraft('/xyz 干点啥') }) fireEvent.keyDown(b.textarea, { key: 'Enter' }) - await vi.waitFor(() => { expect(b.sink).toHaveBeenCalledWith('/xyz 干点啥', [], 'queue') }) - expect(b.shell.snapshot.phase).toBe('plain') + await vi.waitFor(() => { expect(b.sink).toHaveBeenCalledWith('/xyz 干点啥', [], 'queue', expect.any(AbortSignal)) }) + await vi.waitFor(() => { expect(b.shell.snapshot.phase).toBe('plain') }) expect(b.execute).not.toHaveBeenCalled() }) diff --git a/packages/client/ui-conversation/tests/skeleton.client.spec.tsx b/packages/client/ui-conversation/tests/skeleton.client.spec.tsx index acf618fb2b..8f4f938253 100644 --- a/packages/client/ui-conversation/tests/skeleton.client.spec.tsx +++ b/packages/client/ui-conversation/tests/skeleton.client.spec.tsx @@ -31,7 +31,7 @@ import type { ViewTab } from '../src/client/contract/views.ts' /** Machine-backed wiring over a sink spy. */ function fakeWiring() { - const sink = vi.fn() + const sink = vi.fn(() => Promise.resolve({ kind: 'success' as const })) const shell = new SessionInputShell({ actx: {} as ClientContext, defaultSink: sink }) return { wiring: shell, sink, shell } } @@ -309,7 +309,7 @@ describe('ConversationRoot resident composer', () => { fireEvent.change(box, { target: { value: 'ordinary revised' } }) expect(b.chat.store.getSnapshot().draft).toBe('ordinary revised') fireEvent.keyDown(box, { key: 'Enter' }) - expect(b.sink).toHaveBeenCalledWith('ordinary revised', [], 'queue') + expect(b.sink).toHaveBeenCalledWith('ordinary revised', [], 'queue', expect.any(AbortSignal)) expect((b.view.getByRole('button', { name: 'Child' }) as HTMLButtonElement).disabled).toBe(true) expect(b.view.queryByText('Root')).toBeNull() }) diff --git a/packages/client/ui-input-trigger/src/types.ts b/packages/client/ui-input-trigger/src/types.ts index 1e19957191..ba1ba81548 100644 --- a/packages/client/ui-input-trigger/src/types.ts +++ b/packages/client/ui-input-trigger/src/types.ts @@ -224,6 +224,8 @@ export interface InsertTextRequest { /** Literal replacement for the trigger token span (e.g. `/name `). */ readonly text: string readonly span: TokenSpan + /** Keep completion open after the splice (directory descent): the input re-tracks at the caret. */ + readonly continue?: boolean } declare module '@deepseek-ai/cordis' { diff --git a/packages/client/ui-input-trigger/tests/service.client.spec.ts b/packages/client/ui-input-trigger/tests/service.client.spec.ts index 3cbc472c4f..e24e199d72 100644 --- a/packages/client/ui-input-trigger/tests/service.client.spec.ts +++ b/packages/client/ui-input-trigger/tests/service.client.spec.ts @@ -498,6 +498,18 @@ describe('pick / scoped input events', () => { expect(controller.menu.getSnapshot().open).toBe(false) }) + it('forwards a continuing text outcome so a directory pick keeps completion open', async () => { + const { controller, actx } = pickBench(() => ({ text: '@src/', continue: true })) + const texts: Array<{ text: string; continue?: boolean }> = [] + actx.on('slash/input-insert-text', (req) => { + texts.push(req) + return true + }) + await tick() + controller.pick('command', 0) + expect(texts).toEqual([{ text: '@src/', continue: true, span: { start: 0, end: 2, draftRev: 3 } }]) + }) + it('a text outcome the input declines answers false on the space path', async () => { const src: InputTriggerSource = { trigger: '/', diff --git a/packages/client/ui-subagent/tests/browser-plugin.client.spec.ts b/packages/client/ui-subagent/tests/browser-plugin.client.spec.ts index 0f39e47f5a..0758235aec 100644 --- a/packages/client/ui-subagent/tests/browser-plugin.client.spec.ts +++ b/packages/client/ui-subagent/tests/browser-plugin.client.spec.ts @@ -8,7 +8,9 @@ import { } from '@deepseek-ai/dsh-client-runtime/client' import type { ComposerChainProps } from '@deepseek-ai/dsh-client-ui-conversation/client' import { apply as applyLocale, inject as localeInject } from '@deepseek-ai/dsh-client-locale/client' -import type { SubagentCatalogInjected } from '../src/client/SubagentCatalogAction.tsx' +import { + SubagentCatalogAction, type SubagentCatalogInjected, +} from '../src/client/SubagentCatalogAction.tsx' import { SubagentReadOnlyComposer, type SubagentReadOnlyMatch, } from '../src/client/SubagentReadOnlyComposer.tsx' @@ -93,7 +95,7 @@ describe('apply', () => { it('registers catalog actions and selects read-only subagent composers from session facts', async () => { const { ctx, face } = await fullBench(FAMILY) const catalogEntry = ctx.slots.entries('conversation.session.header.actions') - .find(entry => entry.options.id === 'subagent-catalog')! + .find(entry => entry.component === SubagentCatalogAction)! const actions = (catalogEntry.inject as unknown as (id: SessionId) => SubagentCatalogInjected)(sid('parent')) const address: SubagentAddress = { parentSessionId: sid('parent'),