Files
deepseek-harness/apps/web/tests/goal-multi-turn-actions.e2e.ts
2026-08-07 11:51:18 +08:00

169 lines
8.0 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Keyless replay of a real two-round Goal run. Each autonomous round ends as
// its own turn, so the first answer must keep its IconActions when Goal opens
// round two and the final answer must own a second, distinct action row.
import { mkdir, readFile, writeFile } from 'node:fs/promises'
import { dirname, join } from 'node:path'
import { fileURLToPath } from 'node:url'
import type { Browser, Page } from 'playwright'
import { chromium } from 'playwright'
import { afterEach, describe, expect, it, onTestFailed } from 'vitest'
import { parseSessionLog } from '@deepseek-ai/dsh-llm-replay'
import type { SessionEvent, SessionId } from '@deepseek-ai/dsh-session'
import type {} from '@deepseek-ai/dsh-goal'
import {
assertFixtureInventory, captureStableAria, compareOrRefreshGolden,
launchWebScaffold, recordFixture, watchConsole, webSnapshotMode, type WebScaffold,
} from './scaffold.ts'
import { connectFreshWorkspace, newEnglishPage, saveFailureShot } from './support.ts'
const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/goal-multi-turn-actions', import.meta.url))
const FIXTURE = join(SNAPSHOT_DIR, 'session.jsonl')
const OVERRIDE = join(SNAPSHOT_DIR, 'replay.override.json')
const UI_EXPECTED = join(SNAPSHOT_DIR, 'ui.expected.md')
const MODE = webSnapshotMode()
const PROMPT = '做两个turn每个turn输出随机一个包的文件结构。注意你做完一个turn之后直接输出内容停止我们的系统会帮你再开一个turn你看着做一个类似的'
const COMMAND = `/goal ${PROMPT}`
const PACKAGE_FILES: Readonly<Record<string, string>> = {
'packages/client/ui-conversation/README.md': '# UI conversation\n',
'packages/client/ui-conversation/package.json': '{"name":"@deepseek-ai/dsh-client-ui-conversation"}\n',
'packages/client/ui-conversation/src/client.ts': 'export {}\n',
'packages/client/ui-conversation/tests/chat-view.spec.tsx': 'export {}\n',
'packages/context/session-reference/README.md': '# Session reference\n',
'packages/context/session-reference/package.json': '{"name":"@deepseek-ai/dsh-session-reference"}\n',
'packages/context/session-reference/src/index.ts': 'export {}\n',
'packages/context/session-reference/src/uri.ts': 'export {}\n',
'packages/context/session-reference/tests/session-reference.spec.ts': 'export {}\n',
'packages/llm/token-meter/README.md': '# Token meter\n',
'packages/llm/token-meter/package.json': '{"name":"@deepseek-ai/dsh-token-meter"}\n',
'packages/llm/token-meter/src/index.ts': 'export {}\n',
'packages/llm/token-meter/tests/token-meter.spec.ts': 'export {}\n',
'packages/skill/skill-local/README.md': '# Local skill provider\n',
'packages/skill/skill-local/package.json': '{"name":"@deepseek-ai/dsh-skill-local"}\n',
'packages/skill/skill-local/src/index.ts': 'export {}\n',
'packages/skill/skill-local/src/invariant.ts': 'export {}\n',
'packages/skill/skill-local/tests/skill-local.spec.ts': 'export {}\n',
}
/** Materialize a stable package inventory inside the isolated session workspace. */
async function seedPackageInventory(workspaceRoot: string): Promise<void> {
for (const [relativePath, content] of Object.entries(PACKAGE_FILES)) {
const path = join(workspaceRoot, 'workspace', relativePath)
await mkdir(dirname(path), { recursive: true })
await writeFile(path, content)
}
}
/** Await exactly the requested number of durable turn ends, then flush the session. */
function whenTurnsSettled(scaffold: WebScaffold, count: number, timeoutMs: number): Promise<SessionId> {
return new Promise<SessionId>((resolve, reject) => {
let completed = 0
const timer = setTimeout(() => {
off()
reject(new Error(`only ${completed}/${count} Goal turns ended within ${timeoutMs}ms`))
}, timeoutMs)
const off = scaffold.ctx.on('session/event', (session, event: SessionEvent) => {
if (event.type !== 'turn/end') return
completed += 1
if (completed !== count) return
clearTimeout(timer)
off()
scaffold.ctx.sessions.flush(session).then(() => { resolve(session.id) }, reject)
})
})
}
/** Goal-owned round numbers in durable user-message order. */
function goalRounds(events: readonly SessionEvent[]): number[] {
return events.flatMap(event => event.type === 'user/message' && event.data.source.kind === 'goal'
? [event.data.source.round]
: [])
}
/** Objective written by each durable Goal creation. */
function createdObjectives(events: readonly SessionEvent[]): string[] {
return events.flatMap(event => event.type === 'goal/change' && event.data.operation === 'create'
? [event.data.goal.objective]
: [])
}
describe('web e2e: Goal keeps one assistant action row per completed turn', () => {
let scaffold: WebScaffold | undefined
let browser: Browser | undefined
let page: Page
let tripwire: ReturnType<typeof watchConsole>
let sessionEvents: SessionEvent[]
afterEach(async () => {
const failures: unknown[] = []
await browser?.close().catch((error: unknown) => failures.push(error))
browser = undefined
const closing = scaffold
scaffold = undefined
await closing?.close().catch((error: unknown) => failures.push(error))
if (failures.length === 1) throw failures[0]
if (failures.length > 1) throw new AggregateError(failures, 'goal-multi-turn-actions teardown failed')
})
/** Boot the real Web composition and connect a fresh package fixture workspace. */
async function launch(): Promise<void> {
sessionEvents = []
scaffold = await launchWebScaffold(
MODE === 'record' ? {} : { replayFixture: FIXTURE, replayOverride: OVERRIDE },
)
await seedPackageInventory(scaffold.workspaceCwd)
scaffold.ctx.on('session/event', (_session, event: SessionEvent) => { sessionEvents.push(event) })
browser = await chromium.launch()
page = await newEnglishPage(browser)
tripwire = watchConsole(page)
await page.goto(scaffold.baseUrl, { waitUntil: 'load' })
await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
await connectFreshWorkspace(page, scaffold.workspaceCwd)
}
/** Submit the Goal command after arming the two-turn barrier. */
async function runGoal(timeoutMs: number): Promise<SessionId> {
const input = page.locator('textarea').first()
await input.waitFor({ timeout: 10_000 })
const settled = whenTurnsSettled(scaffold!, 2, timeoutMs)
await input.fill(COMMAND)
await input.press('Enter')
return settled
}
it.skipIf(MODE !== 'record')('records the two-round Goal through the real model', async () => {
await launch()
onTestFailed(() => saveFailureShot(page, 'web-e2e-goal-multi-turn-actions-record'))
const sessionId = await runGoal(360_000)
await recordFixture(scaffold!, sessionId, FIXTURE)
}, 380_000)
it.skipIf(MODE === 'record')('keeps actions on both completed Goal turn tails', async () => {
const fixtureEvents = parseSessionLog(await readFile(FIXTURE, 'utf8'))
expect(createdObjectives(fixtureEvents)).toEqual([PROMPT])
expect(goalRounds(fixtureEvents)).toEqual([1, 2])
await launch()
onTestFailed(() => saveFailureShot(page, 'web-e2e-goal-multi-turn-actions'))
await runGoal(120_000)
expect(sessionEvents.flatMap(event => event.type === 'turn/end' ? [event.data.turn] : []))
.toEqual([1, 2])
expect(goalRounds(sessionEvents)).toEqual([1, 2])
const branchButtons = page.getByRole('button', { name: 'Branch into a new conversation' })
await expect.poll(() => branchButtons.count(), { timeout: 15_000 }).toBe(2)
expect(await branchButtons.evaluateAll(buttons => buttons.map(button => button.getAttribute('aria-disabled'))))
.toEqual([null, null])
await branchButtons.last().focus()
const snapshot = await captureStableAria(page, '[class*="centerCol"]', scaffold!.workspaceCwd)
await compareOrRefreshGolden(UI_EXPECTED, snapshot, MODE)
expect(tripwire.pageErrors).toEqual([])
expect(tripwire.warnings).toEqual([])
}, 140_000)
it.skipIf(MODE === 'record')('keeps a closed fixture inventory', async () => {
await assertFixtureInventory(SNAPSHOT_DIR, ['replay.override.json', 'session.jsonl', 'ui.expected.md'])
})
})