input-scenarios.spec.tsx 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264
  1. // @vitest-environment jsdom
  2. /**
  3. * Scenario-chain integration (design §8 A/C/D/H/I): the real per-session
  4. * SlashController pipeline over a real session scope (SessionsService over
  5. * a listed host session) + a command source implementing the decision
  6. * table's relevant cells + the real SessionInput machine (scoped-event
  7. * listeners wired the way the hub does) + the real InputBar. ui-command
  8. * itself is not a dependency of this package; the source below is the
  9. * decision-table contract at the SlashSource seam.
  10. */
  11. import { Context } from 'cordis'
  12. import { afterEach, describe, expect, it, vi } from 'vitest'
  13. import { act, cleanup, fireEvent, render } from '@testing-library/react'
  14. import { SessionsService } from '@deepseek-ai/dsh-client-runtime/client'
  15. import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client'
  16. import { SlashService } from '@deepseek-ai/dsh-client-ui-slash/client'
  17. import type { ClientSessionContext, CommandClaim, PickOutcome, SubmitOutcome } from '@deepseek-ai/dsh-client-ui-slash/client'
  18. import { FakeApiClient, ok } from '../../runtime/tests/fake-api.ts'
  19. import { SessionInputShell } from '../src/client/input/facade.ts'
  20. import { InputBar } from '../src/client/skeleton/InputBar.tsx'
  21. import type { InputBarProps } from '../src/client/skeleton/InputBar.tsx'
  22. import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react'
  23. import { createSnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
  24. import type { ConversationSnapshot } from '@deepseek-ai/dsh-client-runtime/client'
  25. afterEach(cleanup)
  26. /** Directory row driving kind derivation (input? = leadingInput, else execute). */
  27. interface FakeCommand {
  28. name: string
  29. description: string
  30. input?: { hint: string }
  31. }
  32. /** T6 decision-table source over an in-memory directory (menu/space/enter columns for leadingInput + execute). */
  33. function commandSource(commands: FakeCommand[], execute: (line: string) => Promise<SubmitOutcome>) {
  34. const resolve = (name: string): FakeCommand | undefined => commands.find(c => c.name === name)
  35. const leadingClaim = (desc: FakeCommand): CommandClaim => ({
  36. token: `/${desc.name} `,
  37. ...(desc.input !== undefined ? { hint: desc.input.hint } : {}),
  38. submit: args => execute(`/${desc.name} ${args}`),
  39. })
  40. const executed: string[] = []
  41. return {
  42. executed,
  43. source: {
  44. trigger: '/' as const,
  45. name: 'command',
  46. candidates: (_session: ClientSessionContext, req: { query: string; position: string }) =>
  47. Promise.resolve(commands
  48. .filter(c => c.name.startsWith(req.query))
  49. .filter(c => req.position === 'leading' || c.input === undefined)
  50. .map(c => ({ name: c.name, description: c.description, ...(c.input !== undefined ? { hint: c.input.hint } : {}) }))),
  51. onPick: (pick: { candidate: { name: string } }): PickOutcome => {
  52. const desc = resolve(pick.candidate.name)
  53. if (desc === undefined) return undefined
  54. if (desc.input !== undefined) return { claim: leadingClaim(desc) }
  55. executed.push(`/${desc.name}`)
  56. void execute(`/${desc.name}`)
  57. return 'handled'
  58. },
  59. matchSpace: (_session: ClientSessionContext, token: string): PickOutcome => {
  60. const desc = resolve(token.slice(1))
  61. if (desc?.input === undefined) return undefined
  62. return { claim: leadingClaim(desc) }
  63. },
  64. matchEnter: (_session: ClientSessionContext, line: string): Promise<PickOutcome> => {
  65. const trimmed = line.trim()
  66. const ws = trimmed.search(/\s/)
  67. const token = ws === -1 ? trimmed : trimmed.slice(0, ws)
  68. const desc = resolve(token.slice(1))
  69. if (desc === undefined) return Promise.resolve(undefined)
  70. if (desc.input !== undefined) return Promise.resolve({ claim: leadingClaim(desc) })
  71. if (ws !== -1) return Promise.resolve(undefined) // execute with trailing → default sink
  72. executed.push(trimmed)
  73. void execute(trimmed)
  74. return Promise.resolve('handled')
  75. },
  76. },
  77. }
  78. }
  79. const COMMANDS: FakeCommand[] = [
  80. { name: 'goal', description: '设定目标', input: { hint: '目标内容' } },
  81. { name: 'compact', description: '压缩上下文' },
  82. ]
  83. /** Real scope bench: SessionsService over one listed session + SlashController + shell listeners (the hub wiring shape). */
  84. async function scopedBench(register?: (slash: SlashService) => void) {
  85. const ctx = new Context()
  86. const api = new FakeApiClient()
  87. api.onWorkspaceList = () => Promise.resolve(ok({ items: [] }))
  88. const sessionId = 'scenario-s1' as Parameters<SessionsService['open']>[0]
  89. api.onList = () => Promise.resolve(ok({
  90. items: [{ sessionId, updatedAt: 1, running: false, blank: false, cwd: '/w/a' }],
  91. }) as never)
  92. const sessions = new SessionsService(ctx, api) // provides 'sessions' itself
  93. await sessions.refresh()
  94. await Promise.resolve() // manager notifier flush
  95. await ctx.plugin(SlashService).await()
  96. const slash = ctx.get('slash') as SlashService
  97. register?.(slash)
  98. const actx = sessions.scope(sessionId)! as ClientContext
  99. const controller = slash.sessionOf(actx)
  100. const sink = vi.fn()
  101. const shell = new SessionInputShell({ actx, slash: () => controller, defaultSink: sink })
  102. // The hub's listener wiring, verbatim.
  103. actx.on('slash/input-begin-command', req => shell.beginCommand(req.claim, req.span) ? true : undefined)
  104. actx.on('slash/input-insert-reference', req => shell.insertReference(req.reference, req.span) ? true : undefined)
  105. actx.on('slash/input-consume-token', req => shell.consumeToken(req.guard) ? true : undefined)
  106. const wiring = shell
  107. const sessionStore = createSnapshotStore<ConversationSnapshot>({
  108. sessionId, nodes: [], foldDegraded: false, partial: null, runningCalls: [], codeDispatches: new Map(),
  109. pending: [], queue: [], todos: [], running: false, composerPhase: 'active', removed: false,
  110. openState: 'open', openError: null, hasMore: false, loadingOlder: false,
  111. promptError: null, blank: false, lastAgentError: null,
  112. })
  113. const barProps: InputBarProps = {
  114. sessionId,
  115. SessionProvider: ({ children }) => children(sessionId),
  116. useSession: bindSnapshotSelector(sessionStore),
  117. useSessions: bindSnapshotSelector(createSnapshotStore({
  118. ids: [], byId: {}, current: undefined, phase: 'ready',
  119. })) as InputBarProps['useSessions'],
  120. useWorkspaces: bindSnapshotSelector(createSnapshotStore({
  121. items: [], state: 'idle', phase: 'ready', error: null,
  122. baselinesReady: true, recentWorkspaceId: undefined,
  123. })) as InputBarProps['useWorkspaces'],
  124. useInput: bindSnapshotSelector(shell.state),
  125. inputActions: shell.actions,
  126. keyboard: shell,
  127. renderSlot: (() => null) as InputBarProps['renderSlot'],
  128. stop: vi.fn(),
  129. variant: 'composer',
  130. }
  131. const view = render(<InputBar {...barProps} />)
  132. const textarea = view.container.querySelector('textarea')! as HTMLTextAreaElement
  133. const type = (text: string): void => {
  134. fireEvent.change(textarea, { target: { value: text } })
  135. }
  136. return { ctx, slash, controller, shell, wiring, view, textarea, type, sink }
  137. }
  138. async function bench(executeImpl?: (line: string) => Promise<SubmitOutcome>) {
  139. const execute = vi.fn(executeImpl ?? ((line: string) =>
  140. Promise.resolve({ kind: 'success' as const, text: `已执行 ${line}` })))
  141. const { source, executed } = commandSource(COMMANDS, execute)
  142. const base = await scopedBench((slash) => { slash.registerSource(source as never) })
  143. return { ...base, execute, executed }
  144. }
  145. describe('scenario A: menu-pick /goal, type args, enter submits', () => {
  146. it('runs the whole claim chain through the real pipeline', async () => {
  147. const b = await bench()
  148. b.type('/go')
  149. // Candidates land async; the menu opens with the goal row.
  150. await vi.waitFor(() => {
  151. const menu = b.controller.menu.getSnapshot()
  152. expect(menu.open).toBe(true)
  153. expect(menu.groups[0]?.items.map(i => i.name)).toContain('goal')
  154. })
  155. // Pointer pick (menu path executes through the bound target inside the pipeline).
  156. act(() => { b.controller.pick('command', 0) })
  157. expect(b.shell.snapshot.phase).toBe('claimed')
  158. expect(b.textarea.value).toBe('/goal ')
  159. expect(b.view.container.querySelector('[data-decoration="token"]')?.textContent).toBe('/goal ')
  160. expect(b.view.container.querySelector('[data-decoration="hint"]')?.textContent).toBe('目标内容')
  161. // Continue typing args; hint drops; claim holds.
  162. b.type('/goal 发布 v1')
  163. expect(b.shell.snapshot.phase).toBe('claimed')
  164. // Enter: submitting → command execute → commit clears.
  165. fireEvent.keyDown(b.textarea, { key: 'Enter' })
  166. await vi.waitFor(() => { expect(b.execute).toHaveBeenCalledWith('/goal 发布 v1') })
  167. await vi.waitFor(() => { expect(b.textarea.value).toBe('') })
  168. expect(b.shell.snapshot.phase).toBe('plain')
  169. expect(b.view.getByText('已执行 /goal 发布 v1')).toBeTruthy()
  170. expect(b.sink).not.toHaveBeenCalled()
  171. })
  172. })
  173. describe('scenario C: pasted /goal xxx + enter (menu never opened)', () => {
  174. it('adjudicates on enter, claims and submits in one stroke', async () => {
  175. const b = await bench()
  176. // Paste lands whole; caret at end means detectTrigger sees no token under
  177. // the caret mid-whitespace — menu stays closed; enter runs adjudication.
  178. act(() => { b.shell.setDraft('/goal 尽快发布') })
  179. fireEvent.keyDown(b.textarea, { key: 'Enter' })
  180. await vi.waitFor(() => { expect(b.execute).toHaveBeenCalledWith('/goal 尽快发布') })
  181. await vi.waitFor(() => { expect(b.shell.snapshot.phase).toBe('plain') })
  182. expect(b.textarea.value).toBe('')
  183. expect(b.sink).not.toHaveBeenCalled()
  184. })
  185. })
  186. describe('scenario D: execute-kind /compact', () => {
  187. it('menu pick executes immediately without touching the draft machine phase', async () => {
  188. const b = await bench()
  189. b.type('/comp')
  190. await vi.waitFor(() => { expect(b.controller.menu.getSnapshot().open).toBe(true) })
  191. act(() => { b.controller.pick('command', 0) })
  192. // 'handled': no claim, machine still plain; the source ran the detached execute.
  193. expect(b.shell.snapshot.phase).toBe('plain')
  194. expect(b.executed).toContain('/compact')
  195. })
  196. it('bare /compact + enter executes; trailing text falls to the default sink (scenario I twin)', async () => {
  197. const b = await bench()
  198. act(() => { b.shell.setDraft('/compact') })
  199. fireEvent.keyDown(b.textarea, { key: 'Enter' })
  200. await vi.waitFor(() => { expect(b.executed).toContain('/compact') })
  201. // 'handled' flows back as the adjudicated event one microtask later.
  202. await vi.waitFor(() => { expect(b.shell.snapshot.phase).toBe('plain') })
  203. cleanup()
  204. const b2 = await bench()
  205. act(() => { b2.shell.setDraft('/compact 现在') })
  206. fireEvent.keyDown(b2.textarea, { key: 'Enter' })
  207. // execute with trailing → matchEnter answers undefined → default sink.
  208. await vi.waitFor(() => { expect(b2.sink).toHaveBeenCalledWith('/compact 现在', 'queue') })
  209. expect(b2.executed).toHaveLength(0)
  210. })
  211. })
  212. describe('scenario H: backspace breaks the token', () => {
  213. it('claim releases automatically; the enter after that goes through adjudication again', async () => {
  214. const b = await bench()
  215. b.type('/goal')
  216. await vi.waitFor(() => { expect(b.controller.menu.getSnapshot().open).toBe(true) })
  217. // Space adjudication claims (space column, leadingInput).
  218. fireEvent.keyDown(b.textarea, { key: ' ' })
  219. expect(b.shell.snapshot.phase).toBe('claimed')
  220. // Backspace into the token: watch break → plain, visuals gone.
  221. b.type('/goa ')
  222. expect(b.shell.snapshot.phase).toBe('plain')
  223. expect(b.view.container.querySelector('[data-decoration="token"]')).toBeNull()
  224. })
  225. })
  226. describe('scenario I: unknown /xyz + enter', () => {
  227. it('adjudication misses in one hop and the whole line rides the default sink', async () => {
  228. const b = await bench()
  229. act(() => { b.shell.setDraft('/xyz 干点啥') })
  230. fireEvent.keyDown(b.textarea, { key: 'Enter' })
  231. await vi.waitFor(() => { expect(b.sink).toHaveBeenCalledWith('/xyz 干点啥', 'queue') })
  232. expect(b.shell.snapshot.phase).toBe('plain')
  233. expect(b.execute).not.toHaveBeenCalled()
  234. })
  235. it('adjudication failure (source warmup throw) notices and keeps the draft', async () => {
  236. const b = await scopedBench((slash) => {
  237. slash.registerSource({
  238. trigger: '/', name: 'command',
  239. candidates: () => Promise.resolve([]),
  240. onPick: () => undefined,
  241. matchEnter: () => Promise.reject(new Error('目录预热失败')),
  242. } as never)
  243. })
  244. act(() => { b.shell.setDraft('/plan 上线') })
  245. fireEvent.keyDown(b.textarea, { key: 'Enter' })
  246. await vi.waitFor(() => { expect(b.view.getByText('目录预热失败')).toBeTruthy() })
  247. // Never a silent downgrade: draft retained, sink untouched.
  248. expect(b.textarea.value).toBe('/plan 上线')
  249. expect(b.sink).not.toHaveBeenCalled()
  250. })
  251. })