|
|
@@ -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)),
|