diff --git a/apps/web/tests/message-actions.e2e.ts b/apps/web/tests/message-actions.e2e.ts index d3c4f45fd7..679fdfbc65 100644 --- a/apps/web/tests/message-actions.e2e.ts +++ b/apps/web/tests/message-actions.e2e.ts @@ -87,13 +87,41 @@ describe('web e2e: message IconActions and clocks on settled history', () => { await compareOrRefreshGolden(UI_EXPECTED, snapshot, MODE) }) - it.skipIf(MODE === 'record')('forks the session through the settled user-message action', async () => { + it.skipIf(MODE === 'record')('forks through the settled-message and session-row actions', async () => { onTestFailed(() => saveFailureShot(page, 'web-e2e-message-fork')) await page.getByRole('button', { name: '在新对话中分支' }).first().click() await expect.poll( () => scaffold.ctx.agents.list().find(agent => agent.session.header.parentSession === SessionId(SEED_ID)), { timeout: 15_000 }, ).toBeDefined() + await expect.poll( + () => page.locator('[role="treeitem"]').count(), + { timeout: 10_000 }, + ).toBe(3) + await expect.poll( + () => page.locator('[role="treeitem"][aria-selected="true"]').count(), + { timeout: 10_000 }, + ).toBe(1) + // The row action owns a distinct ui-workspace injection from the message + // action above, so exercise both through the loaded app before capture. + const sourceRow = page.locator('[role="treeitem"][aria-expanded="true"]').last() + const rowBox = await sourceRow.boundingBox() + if (rowBox === null) throw new Error('fork source row has no layout box') + const actionButton = sourceRow.locator('button[aria-label^="Session actions for "]') + await sourceRow.hover({ position: { x: rowBox.width - 16, y: rowBox.height / 2 } }) + await expect.poll(() => actionButton.isVisible(), { timeout: 2_000 }).toBe(true) + const buttonBox = await actionButton.boundingBox() + if (buttonBox === null) throw new Error('fork source row action has no layout box') + await page.mouse.click(buttonBox.x + buttonBox.width / 2, buttonBox.y + buttonBox.height / 2) + await page.getByRole('menuitem', { name: 'Fork session' }).click() + await expect.poll( + () => scaffold.ctx.agents.list().filter(agent => agent.session.header.parentSession !== undefined).length, + { timeout: 15_000 }, + ).toBe(2) + await expect.poll( + () => page.locator('[role="treeitem"]').count(), + { timeout: 10_000 }, + ).toBe(4) await expect.poll( () => page.locator('[role="treeitem"][aria-selected="true"]').count(), { timeout: 10_000 }, diff --git a/apps/web/tests/snapshots/message-actions/fork.expected.md b/apps/web/tests/snapshots/message-actions/fork.expected.md index 2afb7d4d23..5255632c3a 100644 --- a/apps/web/tests/snapshots/message-actions/fork.expected.md +++ b/apps/web/tests/snapshots/message-actions/fork.expected.md @@ -1,9 +1,10 @@ - tree "Sessions": - - treeitem "Ungrouped 2 sessions" [expanded]: + - treeitem "Ungrouped 3 sessions" [expanded]: - img - - text: Ungrouped 2 sessions + - text: Ungrouped 3 sessions - treeitem "Collapse Use the read tool twice 1min" [expanded]: - button "Collapse": - img - text: Use the read tool twice 1min - treeitem "Use the read tool twice now" [selected] + - treeitem "Use the read tool twice now" diff --git a/apps/web/tests/snapshots/message-actions/ui.expected.md b/apps/web/tests/snapshots/message-actions/ui.expected.md index e21b3782a8..19ba02d99d 100644 --- a/apps/web/tests/snapshots/message-actions/ui.expected.md +++ b/apps/web/tests/snapshots/message-actions/ui.expected.md @@ -31,7 +31,7 @@ - img - button "在新对话中分支": - img -- text: {{clock}} cache hit 98% · 15,962 tokens · 1 turns · 2 steps +- text: {{clock}} - textbox "Message the agent" - button "Add attachment": - img @@ -41,3 +41,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 diff --git a/packages/host/apiproxy/tests/api-proxy-fork.spec.ts b/packages/host/apiproxy/tests/api-proxy-fork.spec.ts index 64cb1188f5..797bca29f2 100644 --- a/packages/host/apiproxy/tests/api-proxy-fork.spec.ts +++ b/packages/host/apiproxy/tests/api-proxy-fork.spec.ts @@ -82,7 +82,9 @@ describe('sessions.fork', () => { expect(response.result.ok).toBe(true) if (!response.result.ok) return const child = ctx.sessions.get(response.result.value.sessionId) - expect(child?.events.length).toBe(3) + expect(child?.events.map(event => event.type)).toEqual([ + 'turn/start', 'user/message', 'turn/end', 'session/end-seed', + ]) expect(child?.header.parentSession).toBe(source.id) expect(child?.header.cwd).toBe('/proj') await ctx.fiber.dispose() @@ -92,13 +94,23 @@ describe('sessions.fork', () => { const ctx = await composed() const source = liveAgent(ctx, 'session-tail', 2, true) const proxy = api(ctx) + const expectedTypes = [ + 'turn/start', 'user/message', 'turn/end', + 'turn/start', 'user/message', 'turn/end', + 'session/end-seed', + ] const omitted = await proxy.sessions.fork(request({ sessionId: source.id })) expect(omitted.result.ok).toBe(true) if (omitted.result.ok) { - expect(ctx.sessions.get(omitted.result.value.sessionId)?.events.length).toBe(6) + expect(ctx.sessions.get(omitted.result.value.sessionId)?.events.map(event => event.type)) + .toEqual(expectedTypes) } const pastEnd = await proxy.sessions.fork(request({ sessionId: source.id, atSeq: 999 })) expect(pastEnd.result.ok).toBe(true) + if (pastEnd.result.ok) { + expect(ctx.sessions.get(pastEnd.result.value.sessionId)?.events.map(event => event.type)) + .toEqual(expectedTypes) + } await ctx.fiber.dispose() })