fix: harden controlled AI session consistency

This commit is contained in:
2026-07-21 19:54:25 +08:00
parent 82829645c2
commit b7de3c28b6
16 changed files with 497 additions and 69 deletions

View File

@@ -117,12 +117,17 @@ const aiApiSource = existsSync('src/api/ai.ts') ? readFileSync('src/api/ai.ts',
for (const required of ['/api/v1/projects/', '/ai-sessions', 'listAISessions', 'createAISession']) {
if (!aiApiSource.includes(required)) failures.push(`AI API must include ${required}`)
}
if (!aiApiSource.includes('signal?: AbortSignal')) failures.push('AI API requests must accept an AbortSignal')
if (!aiApiSource.includes('signal,')) failures.push('AI API requests must pass the AbortSignal to apiRequest')
if (/\b(?:task|note|source)Id\b/.test(aiApiSource)) failures.push('AI session responses must not expose automatic formal object IDs')
const aiPageSource = existsSync('src/pages/projects/project-ai.tsx') ? readFileSync('src/pages/projects/project-ai.tsx', 'utf8') : ''
for (const required of ['AI 助手', '创建会话', 'loading', 'error']) {
if (!aiPageSource.includes(required)) failures.push(`project AI page must include ${required}`)
}
for (const required of ['AbortController', 'generationRef', 'projectRef', 'setSessions([])']) {
if (!aiPageSource.includes(required)) failures.push(`project AI page must gate stale requests with ${required}`)
}
for (const forbidden of ['DeepSeek V4.0 Flash', '给 DeepSeek 发送消息', 'IconAttachment', 'agent-send-button']) {
if (aiPageSource.includes(forbidden)) failures.push(`project AI page contains unsupported chat control ${forbidden}`)
}
@@ -140,7 +145,7 @@ const unsupportedControls = [
},
{
file: 'src/pages/projects/project-statusbar.tsx',
forbidden: ['升级', '支付', 'billing-plan', '152GB', 'AI 空闲'],
forbidden: ['升级', '支付', 'billing-plan', '152GB', 'AI 空闲', '服务已连接', 'IconCheckCircle'],
},
{
file: 'src/pages/projects/project-topbar.tsx',

View File

@@ -43,6 +43,12 @@ const projectPatchRequests = []
const inboxAnalyzeRequests = []
const inboxConfirmRequests = []
const aiSessionRequests = []
let delayedAIListsRemaining = 0
let delayNextAICreate = false
let delayedAICreateCompleted = false
let failNextSecondProjectAIList = false
let expectingSecondProjectAIListError = false
let expectedSecondProjectAIListConsoleErrorCount = 0
let expectingProjectPatchError = false
let expectedProjectPatchConsoleErrorCount = 0
let expectingInboxConfirmError = false
@@ -71,6 +77,13 @@ const controlledAISessions = [
]
page.on('console', (message) => {
if (message.type() !== 'error') return
if (
expectingSecondProjectAIListError &&
message.text().includes('Failed to load resource')
) {
expectedSecondProjectAIListConsoleErrorCount += 1
return
}
if (
expectingProjectPatchError &&
expectedProjectPatchConsoleErrorCount === 0 &&
@@ -188,6 +201,7 @@ const secondVisualCheckWorkspace = {
},
channels: [
{ id: 'second-overview', projectId: secondProjectId, type: 'overview', title: '概况', icon: 'home', count: 0, url: '', sortOrder: 0 },
{ id: 'second-ai', projectId: secondProjectId, type: 'ai_sessions', title: 'AI 助手', icon: 'robot', count: 0, url: '', sortOrder: 1 },
],
tags: [],
recentSessions: [],
@@ -232,8 +246,11 @@ await page.route('http://localhost:9150/api/v1/**', async (route) => {
return
}
if (url.pathname === `/api/v1/projects/${projectId}/ai-sessions` && method === 'GET') {
await new Promise((resolve) => setTimeout(resolve, 120))
await route.fulfill({ json: controlledAISessions })
const responseSnapshot = structuredClone(controlledAISessions)
const delay = delayedAIListsRemaining > 0 ? 700 : 120
if (delayedAIListsRemaining > 0) delayedAIListsRemaining -= 1
await new Promise((resolve) => setTimeout(resolve, delay))
await route.fulfill({ json: responseSnapshot }).catch(() => {})
return
}
if (url.pathname === `/api/v1/projects/${projectId}/ai-sessions` && method === 'POST') {
@@ -248,8 +265,24 @@ await page.route('http://localhost:9150/api/v1/**', async (route) => {
createdAt: '2026-07-21T03:10:00Z',
updatedAt: '2026-07-21T03:10:00Z',
}
if (delayNextAICreate) {
delayNextAICreate = false
await new Promise((resolve) => setTimeout(resolve, 700))
delayedAICreateCompleted = true
}
controlledAISessions.unshift(created)
await route.fulfill({ status: 201, json: created })
await route.fulfill({ status: 201, json: created }).catch(() => {})
return
}
if (url.pathname === `/api/v1/projects/${secondProjectId}/ai-sessions` && method === 'GET') {
if (failNextSecondProjectAIList) {
await route.fulfill({
status: 500,
json: { error: { code: 'ai_list_failed', message: '新项目会话加载失败' } },
})
return
}
await route.fulfill({ json: [] })
return
}
if (url.pathname === `/api/v1/inbox/${inboxItemId}/analyze` && method === 'POST') {
@@ -990,6 +1023,57 @@ for (const channel of [
activeChannel,
}
}, channel))
if (channel.label === 'AI 助手') {
await page.locator('.channel-button', { hasText: '工作计划' }).click()
delayedAIListsRemaining = 2
await page.locator('.channel-button', { hasText: 'AI 助手' }).click()
const sameProjectRacePage = page.locator('.project-ai-page')
await sameProjectRacePage.locator('input').fill('乱序请求保留的新会话')
await sameProjectRacePage.locator('textarea').fill('POST 完成后,旧 GET 不得覆盖结果')
await sameProjectRacePage.getByRole('button', { name: '创建会话', exact: true }).click()
await sameProjectRacePage.getByText('乱序请求保留的新会话', { exact: true }).waitFor({ state: 'visible', timeout: 2000 })
await page.waitForTimeout(850)
if (await sameProjectRacePage.getByText('乱序请求保留的新会话', { exact: true }).count() !== 1) {
failures.push('a stale AI session GET must not overwrite a newer POST result in the same project')
}
delayedAIListsRemaining = 0
delayNextAICreate = true
await sameProjectRacePage.locator('input').fill('旧项目延迟会话')
await sameProjectRacePage.locator('textarea').fill('切换项目后不得回流')
await sameProjectRacePage.getByRole('button', { name: '创建会话', exact: true }).click()
failNextSecondProjectAIList = true
expectingSecondProjectAIListError = true
await page.locator('.project-button[title="并行项目"]').click()
await page.locator('.project-title h5').getByText('并行项目', { exact: true }).waitFor({ state: 'visible', timeout: 2000 })
await page.locator('.channel-button', { hasText: 'AI 助手' }).click()
const secondProjectAIPage = page.locator('.project-ai-page')
await secondProjectAIPage.waitFor({ state: 'visible', timeout: 2000 })
await page.waitForTimeout(500)
const secondProjectAIText = await secondProjectAIPage.textContent()
if (!secondProjectAIText?.includes('新项目会话加载失败')) {
failures.push(`the new project AI list failure must be visible, got ${JSON.stringify(secondProjectAIText)}`)
}
if (!secondProjectAIText?.includes('暂无 AI 会话')) {
failures.push(`the new project AI list failure must retain an empty list, got ${JSON.stringify(secondProjectAIText)}`)
}
await page.waitForTimeout(850)
if (!delayedAICreateCompleted) failures.push('the simulated old-project AI POST did not complete')
if (await secondProjectAIPage.getByText('旧项目延迟会话', { exact: true }).count() !== 0) {
failures.push('an old-project AI POST must not mutate the newly selected project')
}
if (await secondProjectAIPage.getByText('新项目会话加载失败', { exact: true }).count() !== 1) {
failures.push('an old-project AI POST completion must not clear the new project load error')
}
failNextSecondProjectAIList = false
expectingSecondProjectAIListError = false
if (expectedSecondProjectAIListConsoleErrorCount < 1) {
failures.push(`expected a simulated second-project AI list console error, got ${expectedSecondProjectAIListConsoleErrorCount}`)
}
await page.locator('.project-button[title="森林项目已更新"]').click()
await page.waitForTimeout(250)
}
}
const unsupportedControlMetrics = await page.evaluate(() => ({
@@ -1170,9 +1254,9 @@ for (const check of channelPageChecks) {
}
}
if (aiSessionRequests.length !== 1) failures.push(`expected one controlled AI session request, got ${aiSessionRequests.length}`)
if (aiSessionRequests.length === 1) {
const requestKeys = Object.keys(aiSessionRequests[0]).sort()
if (aiSessionRequests.length !== 3) failures.push(`expected three controlled AI session requests, got ${aiSessionRequests.length}`)
for (const request of aiSessionRequests) {
const requestKeys = Object.keys(request).sort()
if (JSON.stringify(requestKeys) !== JSON.stringify(['context', 'title'])) {
failures.push(`AI session create must send only title/context, got ${JSON.stringify(requestKeys)}`)
}
@@ -1180,7 +1264,7 @@ if (aiSessionRequests.length === 1) {
if (unsupportedControlMetrics.topbarLabels.some((label) => label?.includes('停靠'))) {
failures.push(`topbar must not expose dock controls, got ${JSON.stringify(unsupportedControlMetrics.topbarLabels)}`)
}
if (/升级|支付|AI 空闲|GB 可用/.test(unsupportedControlMetrics.statusbarText)) {
if (/升级|支付|AI 空闲|GB 可用|服务已连接/.test(unsupportedControlMetrics.statusbarText)) {
failures.push(`statusbar contains unsupported product state: ${unsupportedControlMetrics.statusbarText}`)
}
if (!unsupportedControlMetrics.newChannelText.includes('暂未开放') || unsupportedControlMetrics.newChannelButtonCount !== 0) {