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)
This commit is contained in:
creatixchu 2026-08-17 18:35:45 +08:00
parent 6bb79911ba
commit b19d1643f7
20 changed files with 188 additions and 70 deletions

View file

@ -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([])

View file

@ -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,

View file

@ -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.
*

View file

@ -1,4 +1,5 @@
import { memo, useMemo } from 'react'
import { sessionRecallLabels } from '@deepseek-ai/dsh-client-runtime/client'
import { JsonBlock } from '@deepseek-ai/dsh-client-ui-primitives'
import type { ChatNodeOwnerProps, ChatViewSlotProps } from '../contract/slots.ts'
import type { ChatNode } from '../contract/chat-nodes.ts'
@ -27,13 +28,8 @@ export const ChatNodeSeat = memo(function ChatNodeSeat({
const previousKey = snapshot.chat.order[index - 1]
const previous = previousKey === undefined ? undefined : snapshot.chat.nodes.get(previousKey)
if (previous?.kind !== 'context') return ''
const source = (previous as ChatNode<'context'>).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<readonly string[]>(
() => referenceLabelsJson === '' ? [] : JSON.parse(referenceLabelsJson) as string[],

View file

@ -217,7 +217,7 @@ function UserStyleBubble({
</div>}
{referenceLabels.length > 0 && (
<div className={css.referenceSummary}>
{t('message.referenceSummary', { labels: referenceLabels.join('、') })}
{t('message.referenceSummary', { labels: referenceLabels.join(t('message.referenceSeparator')) })}
</div>
)}
</div>

View file

@ -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
}

View file

@ -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()

View file

@ -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 = []

View file

@ -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',

View file

@ -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')

View file

@ -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<SubmitOutcome>>(() => Promise.resolve({ kind: 'success' }))
const lex = over?.lexicon
const session = createSnapshotStore<ConversationSnapshot>(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<SubmitOutcome>((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)
})

View file

@ -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', () => {

View file

@ -69,7 +69,7 @@ function mountBar(shell: SessionInputShell, over?: { running?: boolean; disabled
}
function bench(over?: { running?: boolean; disabled?: boolean; submit?: (args: string) => Promise<SubmitOutcome> }) {
const sink = vi.fn()
const sink = vi.fn(() => Promise.resolve<SubmitOutcome>({ 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))
})
})

View file

@ -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<SubmitOutcome>((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()
})
})

View file

@ -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<SubmitOutcome>({ 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()
})

View file

@ -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()
})

View file

@ -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' {

View file

@ -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: '/',

View file

@ -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'),