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.
169 lines
8.0 KiB
TypeScript
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'])
|
|
})
|
|
})
|