test: cover multi-turn workspace rollback behavior

The session workspace and current-turn undo surfaces now have regression coverage for multi-turn rollback semantics, stale current-turn cards, and dark theme workspace token usage.

Constraint: Workspace rollback must behave consistently for git and non-git session review flows.
Rejected: Rely on manual browser-only checks | they do not guard future regressions.
Confidence: high
Scope-risk: narrow
Directive: Keep rollback tests focused on target user message identity, not only visible index position.
Tested: bun test src/server/__tests__/sessions.test.ts
Tested: cd desktop && bun run test -- src/components/chat/MessageList.test.tsx src/components/workspace/WorkspacePanel.test.tsx src/stores/workspacePanelStore.test.ts
Tested: cd desktop && bun run lint
Tested: cd desktop && bun run build
Not-tested: Full live multi-turn assistant generation E2E across a fresh temporary project.
This commit is contained in:
程序员阿江(Relakkes) 2026-04-30 17:54:13 +08:00
parent aaf25159af
commit bcfe042fc8
3 changed files with 319 additions and 0 deletions

View File

@ -911,6 +911,180 @@ describe('MessageList nested tool calls', () => {
})
})
it('undoes only the latest completed turn when earlier turns also changed files', async () => {
vi.spyOn(sessionsApi, 'rewind')
.mockResolvedValueOnce({
target: {
targetUserMessageId: 'user-2',
userMessageIndex: 1,
userMessageCount: 2,
},
conversation: {
messagesRemoved: 2,
},
code: {
available: true,
filesChanged: ['src/second.ts'],
insertions: 7,
deletions: 2,
},
})
.mockResolvedValueOnce({
target: {
targetUserMessageId: 'user-2',
userMessageIndex: 1,
userMessageCount: 2,
},
conversation: {
messagesRemoved: 2,
removedMessageIds: ['user-2', 'assistant-2'],
},
code: {
available: true,
filesChanged: ['src/second.ts'],
insertions: 7,
deletions: 2,
},
})
vi.spyOn(sessionsApi, 'getWorkspaceStatus').mockResolvedValue({
state: 'ok',
workDir: '/tmp/example-project',
repoName: 'example-project',
branch: null,
isGitRepo: false,
changedFiles: [],
})
const reloadHistory = vi.fn().mockResolvedValue(undefined)
const queueComposerPrefill = vi.fn()
useChatStore.setState({
reloadHistory,
queueComposerPrefill,
sessions: {
[ACTIVE_TAB]: makeSessionState({
messages: [
{
id: 'user-1',
type: 'user_text',
content: '第一轮需求',
timestamp: 1,
},
{
id: 'assistant-1',
type: 'assistant_text',
content: 'first done',
timestamp: 2,
},
{
id: 'user-2',
type: 'user_text',
content: '第二轮需求',
timestamp: 3,
},
{
id: 'assistant-2',
type: 'assistant_text',
content: 'second done',
timestamp: 4,
},
],
}),
},
})
render(<MessageList />)
expect(await screen.findByText('1 files changed')).toBeTruthy()
expect(screen.getByText('src/second.ts')).toBeTruthy()
expect(sessionsApi.rewind).toHaveBeenCalledWith(ACTIVE_TAB, {
targetUserMessageId: 'user-2',
userMessageIndex: 1,
expectedContent: '第二轮需求',
dryRun: true,
})
fireEvent.click(screen.getByRole('button', { name: 'Undo current turn changes' }))
const dialog = await screen.findByRole('dialog', { name: 'Undo current turn?' })
fireEvent.click(within(dialog).getByRole('button', { name: 'Undo current turn' }))
await waitFor(() => {
expect(sessionsApi.rewind).toHaveBeenLastCalledWith(ACTIVE_TAB, {
targetUserMessageId: 'user-2',
userMessageIndex: 1,
expectedContent: '第二轮需求',
})
})
expect(reloadHistory).toHaveBeenCalledWith(ACTIVE_TAB)
expect(queueComposerPrefill).toHaveBeenCalledWith(ACTIVE_TAB, {
text: '第二轮需求',
attachments: undefined,
})
})
it('does not show a stale current-turn change card when the latest completed turn has no files', async () => {
vi.spyOn(sessionsApi, 'rewind').mockResolvedValue({
target: {
targetUserMessageId: 'user-2',
userMessageIndex: 1,
userMessageCount: 2,
},
conversation: {
messagesRemoved: 2,
},
code: {
available: true,
filesChanged: [],
insertions: 0,
deletions: 0,
},
})
useChatStore.setState({
sessions: {
[ACTIVE_TAB]: makeSessionState({
messages: [
{
id: 'user-1',
type: 'user_text',
content: '第一轮改文件',
timestamp: 1,
},
{
id: 'assistant-1',
type: 'assistant_text',
content: 'first done',
timestamp: 2,
},
{
id: 'user-2',
type: 'user_text',
content: '第二轮只解释',
timestamp: 3,
},
{
id: 'assistant-2',
type: 'assistant_text',
content: 'second done',
timestamp: 4,
},
],
}),
},
})
render(<MessageList />)
await waitFor(() => {
expect(sessionsApi.rewind).toHaveBeenCalledWith(ACTIVE_TAB, {
targetUserMessageId: 'user-2',
userMessageIndex: 1,
expectedContent: '第二轮只解释',
dryRun: true,
})
})
expect(screen.queryByLabelText('Current turn changed files')).toBeNull()
})
it('shows raw startup details under translated CLI startup errors', () => {
useChatStore.setState({
sessions: {

View File

@ -88,6 +88,17 @@ async function clickElement(element: Element) {
})
}
function classNameContains(element: Element | null, needle: string) {
let current = element
while (current) {
if (typeof current.className === 'string' && current.className.includes(needle)) {
return true
}
current = current.parentElement
}
return false
}
vi.mock('../../api/sessions', () => ({
sessionsApi: (() => {
if (!mocks) {
@ -450,6 +461,60 @@ describe('WorkspacePanel', () => {
expect(view.getAllByText('b.ts').length).toBeGreaterThanOrEqual(1)
})
it('uses theme tokens for the panel, preview tabs, and code surface in dark mode', async () => {
await setSettingsState({ ...settingsInitialState, locale: 'en', theme: 'dark' })
await setWorkspaceState((state) => ({
...state,
panelBySession: {
...state.panelBySession,
'session-dark-theme': {
isOpen: true,
activeView: 'changed',
},
},
statusBySession: {
...state.statusBySession,
'session-dark-theme': {
state: 'ok',
workDir: '/repo',
repoName: 'repo',
branch: 'main',
isGitRepo: true,
changedFiles: [],
},
},
previewTabsBySession: {
...state.previewTabsBySession,
'session-dark-theme': [{
id: 'file:src/theme.ts',
path: 'src/theme.ts',
kind: 'file',
title: 'theme.ts',
language: 'typescript',
content: 'export const theme = "dark"',
state: 'ok',
size: 27,
}],
},
activePreviewTabIdBySession: {
...state.activePreviewTabIdBySession,
'session-dark-theme': 'file:src/theme.ts',
},
}))
const view = await renderPanel('session-dark-theme')
const panel = view.getByTestId('workspace-panel')
const tabList = view.getByRole('tablist', { name: 'Preview tabs' })
const codeSurface = view.getByTestId('workspace-code')
expect(panel.className).toContain('bg-[var(--color-surface)]')
expect(panel.className).not.toContain('bg-white')
expect(tabList.className).toContain('bg-[var(--color-surface-container-lowest)]')
expect(tabList.className).not.toContain('bg-white')
expect(classNameContains(codeSurface, 'bg-[var(--color-code-bg)]')).toBe(true)
expect(classNameContains(codeSurface, 'bg-white')).toBe(false)
})
it('caps rendered preview lines to keep large diffs responsive', async () => {
const longDiff = Array.from({ length: 650 }, (_, index) => `+line ${index + 1}`).join('\n')

View File

@ -1726,6 +1726,86 @@ describe('Sessions API', () => {
expect(remainingMessages[0]?.id).toBe(firstUserId)
})
it('POST /api/sessions/:id/rewind should keep first-turn file state when undoing only the latest turn', async () => {
const sessionId = 'dddddddd-bbbb-cccc-dddd-ffffffffffff'
const workDir = path.join(tmpDir, 'two-turns-separate-files')
const firstTurnFile = path.join(workDir, 'src', 'first.js')
const secondTurnFile = path.join(workDir, 'src', 'second.js')
const firstUserId = crypto.randomUUID()
const secondUserId = crypto.randomUUID()
const firstBaseBackup = 'separate-first@v1'
const firstAfterTurnBackup = 'separate-first@v2'
const secondBaseBackup = 'separate-second@v1'
await fs.mkdir(path.dirname(firstTurnFile), { recursive: true })
await fs.writeFile(firstTurnFile, "export const FIRST = 'v1'\n", 'utf-8')
await fs.writeFile(secondTurnFile, "export const SECOND = 'v2'\n", 'utf-8')
await writeFileHistoryBackup(sessionId, firstBaseBackup, "export const FIRST = 'base'\n")
await writeFileHistoryBackup(sessionId, firstAfterTurnBackup, "export const FIRST = 'v1'\n")
await writeFileHistoryBackup(sessionId, secondBaseBackup, "export const SECOND = 'base'\n")
await writeSessionFile('-tmp-api-two-turns-separate-files', sessionId, [
makeSessionMetaEntry(workDir),
makeFileHistorySnapshotEntry(firstUserId, {
'src/first.js': {
backupFileName: firstBaseBackup,
version: 1,
backupTime: '2026-01-01T00:00:00.000Z',
},
}),
{
...makeUserEntry('make first file v1', firstUserId),
cwd: workDir,
sessionId,
},
makeAssistantEntry('DONE first', firstUserId),
makeFileHistorySnapshotEntry(secondUserId, {
'src/first.js': {
backupFileName: firstAfterTurnBackup,
version: 2,
backupTime: '2026-01-01T00:00:00.000Z',
},
'src/second.js': {
backupFileName: secondBaseBackup,
version: 1,
backupTime: '2026-01-01T00:00:00.000Z',
},
}),
{
...makeUserEntry('make second file v2', secondUserId),
cwd: workDir,
sessionId,
},
makeAssistantEntry('DONE second', secondUserId),
])
const previewRes = await fetch(`${baseUrl}/api/sessions/${sessionId}/rewind`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ userMessageIndex: 1, dryRun: true }),
})
expect(previewRes.status).toBe(200)
const preview = await previewRes.json() as {
code: { available: boolean; filesChanged: string[] }
}
expect(preview.code.available).toBe(true)
expect(preview.code.filesChanged).toEqual([secondTurnFile])
const executeRes = await fetch(`${baseUrl}/api/sessions/${sessionId}/rewind`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ userMessageIndex: 1 }),
})
expect(executeRes.status).toBe(200)
expect(await fs.readFile(firstTurnFile, 'utf-8')).toBe("export const FIRST = 'v1'\n")
expect(await fs.readFile(secondTurnFile, 'utf-8')).toBe("export const SECOND = 'base'\n")
const remainingMessages = await service.getSessionMessages(sessionId)
expect(remainingMessages).toHaveLength(2)
expect(remainingMessages[0]?.id).toBe(firstUserId)
})
it('POST /api/sessions/:id/rewind should include files created after the first turn', async () => {
const sessionId = 'eeeeeeee-bbbb-cccc-dddd-eeeeeeeeeeee'
const workDir = path.join(tmpDir, 'created-on-second-turn')