skill-user-invoke.e2e.ts 7.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165
  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 = '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. browser = await chromium.launch()
  76. page = await newEnglishPage(browser)
  77. tripwire = watchConsole(page)
  78. await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' })
  79. await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
  80. await connectFreshWorkspace(page, scaffold.workspaceCwd)
  81. }, 120_000)
  82. afterAll(async () => {
  83. const failures: unknown[] = []
  84. await browser?.close().catch((error: unknown) => failures.push(error))
  85. await scaffold?.close().catch((error: unknown) => failures.push(error))
  86. if (replayDir !== undefined) {
  87. await rm(replayDir, { recursive: true, force: true })
  88. .catch((error: unknown) => failures.push(error))
  89. }
  90. if (failures.length === 1) throw failures[0]
  91. if (failures.length > 1) throw new AggregateError(failures, 'skill-user-invoke e2e cleanup failed')
  92. })
  93. it('claims /name args into a gesture bubble, an injection row, and a replayed answer', async () => {
  94. onTestFailed(() => saveFailureShot(page, 'web-e2e-skill-user-invoke'))
  95. const composer = page.locator('[data-composer-input][contenteditable="true"]').last()
  96. await composer.waitFor({ timeout: 15_000 })
  97. // The menu lists the user-only skill (its only entry point) before enter.
  98. await composer.fill(`/${SKILL_NAME}`)
  99. const menu = page.getByRole('listbox', { name: 'Trigger suggestions' })
  100. await expect.poll(
  101. () => menu.getByRole('option', { name: new RegExp(SKILL_NAME) }).count(),
  102. { timeout: 10_000 },
  103. ).toBe(1)
  104. const settled = scaffold.whenTurnSettled()
  105. await composer.fill(`/${SKILL_NAME} ${ARGS_TEXT}`)
  106. await composer.press('Enter')
  107. // The gesture stays an ordinary user bubble (decorated /name token plus
  108. // the trailing text), ahead of the injected context.
  109. const bubble = page.locator('[data-ref-chip="skill"]').first()
  110. await bubble.waitFor({ timeout: 15_000 })
  111. expect(await bubble.textContent()).toBe(`/${SKILL_NAME}`)
  112. // The rendered body arrives as a context-injection row named after the
  113. // skill. Context plus the final answer contributes no summary count, so
  114. // the Turn uses the fallback title while the row's own disclosure remains usable.
  115. const injectionFlow = page.locator('[data-chat-flow-kind="context"]').filter({ hasText: SKILL_NAME })
  116. await injectionFlow.waitFor({ state: 'attached', timeout: 15_000 })
  117. await page.getByText('USER_INVOKE_REPLY', { exact: false }).first().waitFor({ timeout: 20_000 })
  118. await settled
  119. const process = page.getByRole('button', { name: 'Thought for a while', exact: true })
  120. await process.waitFor({ state: 'visible', timeout: 10_000 })
  121. // The chip derives from the step's logged injection, so it must survive
  122. // every later Node rebuild of the Turn (process publication, turn close).
  123. expect(await bubble.count()).toBe(1)
  124. expect(await bubble.textContent()).toBe(`/${SKILL_NAME}`)
  125. await expandOwningTurnProcess(page, injectionFlow)
  126. const injectionRow = page.getByRole('button', { name: `Context injection ${SKILL_NAME}` })
  127. await injectionRow.click()
  128. const injectionBody = page
  129. .locator('[data-context-injection-body]')
  130. .filter({ hasText: `<skill_content name="${SKILL_NAME}">` })
  131. await injectionBody.waitFor({ timeout: 10_000 })
  132. const injected = await injectionBody.textContent()
  133. expect(injected).toContain('Reply with the fixture acknowledgement line.')
  134. expect(injected).not.toContain(ARGS_TEXT)
  135. await injectionRow.click()
  136. await process.click()
  137. const snapshot = await captureStableAria(page, '[class*="centerCol"]', scaffold.workspaceCwd)
  138. await compareOrRefreshGolden(UI_EXPECTED, snapshot, MODE)
  139. const expanded = await captureExpandedTurnProcessAria(
  140. page,
  141. '[class*="centerCol"]',
  142. scaffold.workspaceCwd,
  143. )
  144. await compareOrRefreshGolden(UI_EXPANDED_EXPECTED, expanded, MODE)
  145. expect(tripwire.pageErrors).toEqual([])
  146. expect(tripwire.warnings).toEqual([])
  147. }, 60_000)
  148. it('keeps its snapshot inventory closed', async () => {
  149. await assertFixtureInventory(SNAPSHOT_DIR, ['ui.expected.md', 'ui-expanded.expected.md'])
  150. })
  151. })