Files
deepseek-harness/apps/web/tests/goal-multi-turn-actions.e2e.ts
T
Chinesezjc b462d5fd69 Merge remote-tracking branch 'origin/master' into feat/web-message-feedback-ui
Adapt to two contract changes master introduced:

- The generated Remote face now wraps every business result in
  RemoteResult, folding carrier failures into an ok:false branch instead
  of rejecting. The controller reads that envelope at its three call
  sites and maps a carrier failure onto the same settled shape the
  controls already render; three specs cover the new branch.
- Client packages split their tsconfig into host and client halves, and
  the host aggregate now compiles any test not named *.client.spec.*.
  Rename this package's specs to the client convention and drop the
  ../connection project reference, which pointed at a solution file that
  no longer carries the client sources.

Keep master's mount loop with its rollback-on-failure in api-remotes and
add messageFeedbackRemote to it.
2026-08-12 10:43:23 +08:00

169 lines
8.0 KiB
TypeScript

// 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.client.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'])
})
})