skill-user-invoke.e2e.ts 8.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188
  1. // Web e2e scenario: a user invokes a disable-model-invocation skill through
  2. // the composer (issue #1470). The entered `/name args` line claims into
  3. // skill.invoke: the real host forwards the gesture as an ordinary user
  4. // prompt, injects the rendered body as instructions context named after the
  5. // skill, and starts a turn answered by the replay adapter. The transcript shows
  6. // the gesture bubble, the collapsed context-injection row, and the reply.
  7. import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'
  8. import { tmpdir } from 'node:os'
  9. import { fileURLToPath } from 'node:url'
  10. import { join } from 'node:path'
  11. import type { Browser, Page } from 'playwright'
  12. import { chromium } from 'playwright'
  13. import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest'
  14. import type { ReplayOverrideDoc } from '@deepseek-ai/dsh-llm-replay'
  15. import {
  16. assertFixtureInventory,
  17. captureExpandedTurnProcessAria,
  18. captureStableAria,
  19. compareOrRefreshGolden,
  20. launchWebScaffold,
  21. watchConsole,
  22. webSnapshotMode,
  23. type WebScaffold,
  24. } from './scaffold.ts'
  25. import { connectFreshWorkspace, expandOwningTurnProcess, newEnglishPage, saveFailureShot } from './support.ts'
  26. const SNAPSHOT_DIR = fileURLToPath(new URL('./expected/skill-user-invoke', import.meta.url))
  27. const UI_EXPECTED = join(SNAPSHOT_DIR, 'ui.expected.md')
  28. const UI_EXPANDED_EXPECTED = join(SNAPSHOT_DIR, 'ui-expanded.expected.md')
  29. const MODE = webSnapshotMode()
  30. const SKILL_NAME = 'user-invoke-demo'
  31. const ARGS_TEXT = '@"meeting notes.md" and confirm the fixture wiring'
  32. const REPLY = 'USER_INVOKE_REPLY acknowledged; following the injected skill.'
  33. async function seedUserOnlySkill(workspaceCwd: string): Promise<void> {
  34. const directory = join(workspaceCwd, 'workspace', '.agents', 'skills', SKILL_NAME)
  35. await mkdir(directory, { recursive: true })
  36. await writeFile(join(directory, 'SKILL.md'), [
  37. '---',
  38. `name: ${SKILL_NAME}`,
  39. 'description: Prove user-explicit invocation of a model-hidden skill',
  40. 'disable-model-invocation: true',
  41. '---',
  42. '',
  43. 'Reply with the fixture acknowledgement line.',
  44. '',
  45. ].join('\n'))
  46. }
  47. const REPLAY: ReplayOverrideDoc = [{
  48. kind: 'chunks',
  49. chunks: [
  50. { type: 'block-start', index: 0, blockType: 'text' },
  51. { type: 'text-delta', index: 0, text: REPLY },
  52. { type: 'block-end', index: 0, block: { type: 'text', text: REPLY } },
  53. { type: 'usage', usage: { inputTokens: 256, outputTokens: 16 } },
  54. { type: 'finish', reason: { kind: 'stop' } },
  55. ],
  56. }]
  57. describe.skipIf(MODE === 'record')('web e2e: user-explicit skill invocation through the composer', () => {
  58. let scaffold: WebScaffold
  59. let browser: Browser
  60. let page: Page
  61. let replayDir: string
  62. let tripwire: ReturnType<typeof watchConsole>
  63. beforeAll(async () => {
  64. replayDir = await mkdtemp(join(tmpdir(), 'dsh-skill-user-invoke-replay-'))
  65. const replayOverride = join(replayDir, 'replay.override.json')
  66. await writeFile(replayOverride, JSON.stringify(REPLAY))
  67. scaffold = await launchWebScaffold({
  68. replayFixture: join(replayDir, 'override-only.jsonl'),
  69. replayOverride,
  70. // Paced replay keeps the timing-derived chrome (TTFT / tok/s) present
  71. // deterministically; instant playback races it in and out of the golden.
  72. paceMs: 10,
  73. })
  74. await seedUserOnlySkill(scaffold.workspaceCwd)
  75. await writeFile(join(scaffold.workspaceCwd, 'workspace', 'meeting notes.md'), '# Meeting notes\n\nSent reference preview.\n')
  76. browser = await chromium.launch()
  77. page = await newEnglishPage(browser)
  78. tripwire = watchConsole(page)
  79. await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' })
  80. await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
  81. await connectFreshWorkspace(page, scaffold.workspaceCwd)
  82. }, 120_000)
  83. afterAll(async () => {
  84. const failures: unknown[] = []
  85. await browser?.close().catch((error: unknown) => failures.push(error))
  86. await scaffold?.close().catch((error: unknown) => failures.push(error))
  87. if (replayDir !== undefined) {
  88. await rm(replayDir, { recursive: true, force: true })
  89. .catch((error: unknown) => failures.push(error))
  90. }
  91. if (failures.length === 1) throw failures[0]
  92. if (failures.length > 1) throw new AggregateError(failures, 'skill-user-invoke e2e cleanup failed')
  93. })
  94. it('claims /name args into a gesture bubble, an injection row, and a replayed answer', async () => {
  95. onTestFailed(() => saveFailureShot(page, 'web-e2e-skill-user-invoke'))
  96. const composer = page.locator('[data-composer-input][contenteditable="true"]').last()
  97. await composer.waitFor({ timeout: 15_000 })
  98. // The menu lists the user-only skill (its only entry point) before enter.
  99. await composer.fill(`/${SKILL_NAME}`)
  100. const menu = page.getByRole('listbox', { name: 'Trigger suggestions' })
  101. await expect.poll(
  102. () => menu.getByRole('option', { name: new RegExp(SKILL_NAME) }).count(),
  103. { timeout: 10_000 },
  104. ).toBe(1)
  105. const settled = scaffold.whenTurnSettled()
  106. await composer.fill(`/${SKILL_NAME} ${ARGS_TEXT}`)
  107. await composer.press('Enter')
  108. // The gesture stays an ordinary user bubble (decorated /name token plus
  109. // the trailing text), ahead of the injected context.
  110. const bubble = page.locator('[data-ref-chip="skill"]').first()
  111. await bubble.waitFor({ timeout: 15_000 })
  112. expect(await bubble.textContent()).toBe(`/${SKILL_NAME}`)
  113. // The rendered body arrives as a context-injection row named after the
  114. // skill. Context plus the final answer contributes no summary count, so
  115. // the Turn uses the fallback title while the row's own disclosure remains usable.
  116. const injectionFlow = page.locator('[data-chat-flow-kind="context"]').filter({ hasText: SKILL_NAME })
  117. await injectionFlow.waitFor({ state: 'attached', timeout: 15_000 })
  118. await page.getByText('USER_INVOKE_REPLY', { exact: false }).first().waitFor({ timeout: 20_000 })
  119. await settled
  120. const process = page.getByRole('button', { name: 'Thought for a while', exact: true })
  121. await process.waitFor({ state: 'visible', timeout: 10_000 })
  122. // The chip derives from the step's logged injection, so it must survive
  123. // every later Node rebuild of the Turn (process publication, turn close).
  124. expect(await bubble.count()).toBe(1)
  125. expect(await bubble.textContent()).toBe(`/${SKILL_NAME}`)
  126. await expandOwningTurnProcess(page, injectionFlow)
  127. const injectionRow = page.getByRole('button', { name: `Context injection ${SKILL_NAME}` })
  128. await injectionRow.click()
  129. const injectionBody = page
  130. .locator('[data-context-injection-body]')
  131. .filter({ hasText: `<skill_content name="${SKILL_NAME}">` })
  132. await injectionBody.waitFor({ timeout: 10_000 })
  133. const injected = await injectionBody.textContent()
  134. expect(injected).toContain('Reply with the fixture acknowledgement line.')
  135. expect(injected).not.toContain(ARGS_TEXT)
  136. await injectionRow.click()
  137. await process.click()
  138. const snapshot = await captureStableAria(page, '[class*="centerCol"]', scaffold.workspaceCwd)
  139. await compareOrRefreshGolden(UI_EXPECTED, snapshot, MODE)
  140. const expanded = await captureExpandedTurnProcessAria(
  141. page,
  142. '[class*="centerCol"]',
  143. scaffold.workspaceCwd,
  144. )
  145. await compareOrRefreshGolden(UI_EXPANDED_EXPECTED, expanded, MODE)
  146. expect(tripwire.pageErrors).toEqual([])
  147. expect(tripwire.warnings).toEqual([])
  148. }, 60_000)
  149. it('previews sent skill and quoted file references with prose-link hover styling after reloading history', async () => {
  150. onTestFailed(() => saveFailureShot(page, 'web-e2e-sent-reference-preview'))
  151. await page.reload({ waitUntil: 'load' })
  152. const skill = page.locator('[data-chat-flow-kind="user"] [data-ref-chip="skill"]').first()
  153. await skill.waitFor({ timeout: 15_000 })
  154. const preview = page.locator('[data-document-markdown]')
  155. await skill.hover()
  156. await expect.poll(() => skill.evaluate(el => getComputedStyle(el).textDecorationStyle)).toBe('dotted')
  157. await skill.click()
  158. await expect.poll(() => preview.textContent(), { timeout: 10_000 }).toContain('Reply with the fixture acknowledgement line.')
  159. const file = page.locator('[data-chat-flow-kind="user"] [data-ref-chip="file"]').first()
  160. await file.hover()
  161. expect(await file.evaluate(el => getComputedStyle(el).textDecorationStyle)).toBe('dotted')
  162. await file.click()
  163. await expect.poll(() => preview.textContent()).toContain('Sent reference preview.')
  164. await skill.click()
  165. await expect.poll(() => preview.textContent()).toContain('Reply with the fixture acknowledgement line.')
  166. expect(await page.locator('[data-chat-flow-kind="user"]').first().textContent()).toContain('and confirm the fixture wiring')
  167. expect(tripwire.pageErrors).toEqual([])
  168. expect(tripwire.warnings).toEqual([])
  169. })
  170. it('keeps its snapshot inventory closed', async () => {
  171. await assertFixtureInventory(SNAPSHOT_DIR, ['ui.expected.md', 'ui-expanded.expected.md'])
  172. })
  173. })