fix(web): restrict message forks to completed turn tails

This commit is contained in:
kingwl
2026-08-02 16:25:02 +08:00
parent ab320ba991
commit 76547dfe0c
39 changed files with 283 additions and 73 deletions

View File

@@ -1,7 +1,7 @@
// Web e2e scenario: message IconActions + clocks. Cold-seeds the seeded-history
// fixture (zero model calls) and pins the settled conversation aria after the
// user/assistant footers are focus-revealed — the surface package jsdom tests
// cannot substitute for (docs/testing.md snapshot rule).
// Web e2e scenario: message IconActions + clocks. Cold-seeds a deterministic
// completed-turn-tail fork case (zero model calls) and pins the settled
// conversation aria after the footers are focus-revealed — the surface package
// jsdom tests cannot substitute for (docs/testing.md snapshot rule).
import { mkdir, readFile, writeFile } from 'node:fs/promises'
import { join } from 'node:path'
import { fileURLToPath } from 'node:url'
@@ -25,6 +25,48 @@ const MODE = webSnapshotMode()
const SEED_ID = 'message-actions-web-e2e'
const PROMPT = 'Use the read tool twice in one assistant message: read a.txt and b.txt. Then reply with the single word DONE and stop.'
const MID_TURN_TEXT = 'I will read both files before answering.'
const SECOND_PROMPT = 'Now give the final answer.'
/**
* Adapt the borrowed recording into response -> tools -> interrupted Think,
* followed by one ordinary completed response. The first response keeps
* copy/clock but is not a legal branch point; the second is the real turn tail.
* @param raw - Recorded seeded-history JSONL.
* @returns A contiguous, closed two-turn fixture.
*/
function completedTailFixture(raw: string): string {
const kept: string[] = []
for (const line of raw.trimEnd().split('\n')) {
const row = JSON.parse(line) as {
type: string
seq?: number
seq0?: number
data?: { content?: unknown[] }
}
const firstSeq = row.seq ?? row.seq0
if (firstSeq !== undefined && firstSeq >= 101) break
if (row.type === 'assistant/message' && row.seq === 64) {
const content = row.data?.content
if (!Array.isArray(content)) throw new Error('borrowed step-one assistant message has no content')
content.splice(1, 0, { type: 'text', text: MID_TURN_TEXT })
kept.push(JSON.stringify(row))
} else {
kept.push(line)
}
}
const tail = [
{ type: 'step/end', seq: 101, time: 1784974102749, data: { turn: 1, step: 2 } },
{ type: 'turn/end', seq: 102, time: 1784974102750, data: { turn: 1, reason: { kind: 'aborted' } } },
{ type: 'turn/start', seq: 103, time: 1784974103000, data: { turn: 2, trigger: { kind: 'message', source: { kind: 'user', rpcId: '{{rpcId}}' } } } },
{ type: 'user/message', seq: 104, time: 1784974103001, data: { content: [{ type: 'text', text: SECOND_PROMPT }], source: { kind: 'user', rpcId: '{{rpcId}}' } }, surfaceOp: 'append' },
{ type: 'step/start', seq: 105, time: 1784974103002, data: { turn: 2, step: 1 } },
{ type: 'assistant/message', seq: 106, time: 1784974103003, data: { turn: 2, step: 1, content: [{ type: 'text', text: 'DONE' }], provenance: { provider: 'deepseek-official', model: 'deepseek-v4-flash' } }, sourceEventSeqs: [], surfaceOp: 'append' },
{ type: 'step/end', seq: 107, time: 1784974103004, data: { turn: 2, step: 1 } },
{ type: 'turn/end', seq: 108, time: 1784974103005, data: { turn: 2, reason: { kind: 'completed' } } },
]
return `${[...kept, ...tail.map(row => JSON.stringify(row))].join('\n')}\n`
}
describe('web e2e: message IconActions and clocks on settled history', () => {
let scaffold: WebScaffold
@@ -38,8 +80,8 @@ describe('web e2e: message IconActions and clocks on settled history', () => {
await mkdir(sessionCwd, { recursive: true })
await writeFile(join(sessionCwd, 'a.txt'), 'alpha\n')
await writeFile(join(sessionCwd, 'b.txt'), 'beta\n')
const raw = await readFile(SEED, 'utf8')
expect(fixtureUserPrompts(raw), 'borrowed seed must carry the drive prompt').toEqual([PROMPT])
const raw = completedTailFixture(await readFile(SEED, 'utf8'))
expect(fixtureUserPrompts(raw), 'adapted seed must carry both prompts').toEqual([PROMPT, SECOND_PROMPT])
await seedSession(scaffold, raw, SEED_ID)
browser = await chromium.launch()
page = await newEnglishPage(browser)
@@ -53,7 +95,7 @@ describe('web e2e: message IconActions and clocks on settled history', () => {
await scaffold?.close()
})
it.skipIf(MODE === 'record')('lists the seeded session and reveals user/assistant IconActions', async () => {
it.skipIf(MODE === 'record')('shows branch only on the completed transcript tail', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-message-actions'))
const groupRow = page.locator('[role="treeitem"]').first()
await groupRow.waitFor({ timeout: 15_000 })
@@ -61,16 +103,17 @@ describe('web e2e: message IconActions and clocks on settled history', () => {
const sessionRow = page.locator('[role="treeitem"]').nth(1)
await sessionRow.waitFor({ timeout: 10_000 })
await sessionRow.click()
await expect.poll(() => page.getByText(MID_TURN_TEXT, { exact: true }).count(), { timeout: 15_000 }).toBe(1)
await expect.poll(() => page.getByText('DONE', { exact: true }).count(), { timeout: 15_000 }).toBe(1)
// Focus-reveal the footers (hover:hover keeps them opacity-hidden until
// hover/focus-within). User and each turn's last content assistant both
// have copy + branch.
// hover/focus-within). All message rows keep copy, but only the final
// assistant at a completed transcript tail has branch.
const copyButtons = page.getByRole('button', { name: 'Copy' })
await expect.poll(() => copyButtons.count(), { timeout: 10_000 }).toBeGreaterThanOrEqual(2)
await expect.poll(() => copyButtons.count(), { timeout: 10_000 }).toBeGreaterThanOrEqual(4)
await copyButtons.first().focus()
await expect.poll(() => page.getByRole('button', { name: 'Branch into a new conversation' }).count(), { timeout: 5_000 })
.toBeGreaterThanOrEqual(2)
.toBe(1)
await expect.poll(() => page.getByRole('button', { name: 'Edit' }).count(), { timeout: 5_000 }).toBe(0)
}, 60_000)
@@ -89,8 +132,7 @@ describe('web e2e: message IconActions and clocks on settled history', () => {
it.skipIf(MODE === 'record')('forks through the settled-message and session-row actions', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-message-fork'))
// Exercise the assistant action specifically; package coverage pins the
// user action separately at its own event seq.
// The sole message action belongs to the completed second-turn assistant.
await page.getByRole('button', { name: 'Branch into a new conversation' }).last().click()
await expect.poll(
() => scaffold.ctx.agents.list().find(agent => agent.session.header.parentSession === SessionId(SEED_ID)),

View File

@@ -8,12 +8,14 @@
- button "Copy":
- img
- tooltip "Copy"
- button "Branch into a new conversation":
- img
- button "Think The user wants me to read a.txt and b.txt, then reply with \"DONE\". Let me do both reads in parallel.":
- img
- img
- text: Think The user wants me to read a.txt and b.txt, then reply with "DONE". Let me do both reads in parallel.
- paragraph: I will read both files before answering.
- button "Copy":
- img
- text: 7/25 {{clock}}
- button "Read a.txt":
- img
- img
@@ -28,6 +30,9 @@
- img
- img
- text: Think Both files have been read. a.txt contains "alpha" and b.txt contains "beta". I'll now reply with DONE as instructed.
- text: Stopped Now give the final answer. 7/25 {{clock}}
- button "Copy":
- img
- paragraph: DONE
- button "Copy":
- img
@@ -42,4 +47,4 @@
- text: deepseek-v4-flash
- img
- button "Send message" [disabled]
- text: 1 turns · 2 steps Tool call {{duration}} Cache hit 98% Input 15.8K tok · Output 135 tok
- text: 2 turns · 3 steps Tool call {{duration}} Cache hit 98% Input 7.8K tok · Output 103 tok