feat(web): retry transient model requests
This commit is contained in:
@@ -25,6 +25,9 @@ const bundles = new Map(PLUGINS.map(plugin => [
|
||||
|
||||
interface FixtureTiming {
|
||||
appendTitle(id: string, title: string): void
|
||||
beginModelRetry(id: string): void
|
||||
scheduleModelRetry(id: string, retry?: number, delayMs?: number): void
|
||||
completeModelRetry(id: string): void
|
||||
}
|
||||
|
||||
interface FixtureWindow extends Window {
|
||||
@@ -78,7 +81,7 @@ function titleSurfaces(label: string): { sidebar: string; breadcrumb: string; do
|
||||
return { sidebar, breadcrumb, documentTitle: document.title }
|
||||
}
|
||||
|
||||
it('projects initial and revised durable titles through the built nine-plugin fixture app', async () => {
|
||||
function bootFixtureApp(): void {
|
||||
const root = document.querySelector<HTMLElement>('#root')
|
||||
if (root === null) throw new Error('snapshot root missing')
|
||||
act(() => {
|
||||
@@ -92,18 +95,26 @@ it('projects initial and revised durable titles through the built nine-plugin fi
|
||||
void entry.run()
|
||||
unmount = () => { entry.dispose() }
|
||||
})
|
||||
}
|
||||
|
||||
async function selectFixtureSession(): Promise<void> {
|
||||
const tree = await screen.findByRole('tree', { name: 'Sessions' }, { timeout: 10_000 })
|
||||
const projectCount = await within(tree).findByText('4 sessions')
|
||||
const projectRow = projectCount.closest<HTMLElement>('[role="treeitem"]')
|
||||
if (projectRow === null) throw new Error('fixture project row missing')
|
||||
fireEvent.click(projectRow)
|
||||
|
||||
const initialLabel = 'Fixture 历史会话'
|
||||
const initialRowLabel = await screen.findByText(initialLabel)
|
||||
const initialRowLabel = await screen.findByText('Fixture 历史会话')
|
||||
const initialRow = initialRowLabel.closest<HTMLElement>('[role="treeitem"]')
|
||||
if (initialRow === null) throw new Error('fixture session row missing')
|
||||
fireEvent.click(initialRow)
|
||||
}
|
||||
|
||||
it('projects initial and revised durable titles through the built nine-plugin fixture app', async () => {
|
||||
bootFixtureApp()
|
||||
await selectFixtureSession()
|
||||
|
||||
const initialLabel = 'Fixture 历史会话'
|
||||
await waitFor(() => { expect(document.title).toBe(`${initialLabel} — DeepSeek Harness`) })
|
||||
const initial = titleSurfaces(initialLabel)
|
||||
|
||||
@@ -116,3 +127,61 @@ it('projects initial and revised durable titles through the built nine-plugin fi
|
||||
await expect(`${JSON.stringify({ initial, revised }, null, 2)}\n`)
|
||||
.toMatchFileSnapshot('./snapshots/session-title.json')
|
||||
})
|
||||
|
||||
it('retracts a failed stream at llm/retry and retains the durable notice after recovery', async () => {
|
||||
bootFixtureApp()
|
||||
await selectFixtureSession()
|
||||
const timing = (globalThis as Record<string, unknown>).__fxTiming as FixtureTiming
|
||||
|
||||
act(() => { timing.beginModelRetry('fx-alpha') })
|
||||
const partial = await screen.findByText('应撤回的半截回复')
|
||||
const beforeRetry = { partial: partial.textContent }
|
||||
|
||||
act(() => { timing.scheduleModelRetry('fx-alpha') })
|
||||
const firstNotice = await screen.findByRole('status')
|
||||
await waitFor(() => { expect(screen.queryByText('应撤回的半截回复')).toBeNull() })
|
||||
const disclosure = firstNotice.closest('details')
|
||||
if (disclosure === null) throw new Error('retry disclosure missing')
|
||||
const firstRetry = {
|
||||
notice: firstNotice.textContent,
|
||||
rows: screen.getAllByRole('status').length,
|
||||
}
|
||||
|
||||
act(() => { timing.scheduleModelRetry('fx-alpha', 2, 1_500) })
|
||||
const notice = screen.getByRole('status')
|
||||
await waitFor(() => { expect(notice.textContent).toContain('(2/2)') })
|
||||
const latestDisclosure = notice.closest('details')
|
||||
const summary = notice.closest('summary')
|
||||
if (latestDisclosure === null || summary === null) throw new Error('latest retry disclosure missing')
|
||||
await waitFor(() => { expect(screen.queryByText('第 2 次应撤回的回复')).toBeNull() })
|
||||
const scheduled = {
|
||||
partialVisible: screen.queryByText('应撤回的半截回复') !== null
|
||||
|| screen.queryByText('第 2 次应撤回的回复') !== null,
|
||||
notice: notice.textContent,
|
||||
rows: screen.getAllByRole('status').length,
|
||||
reusedDisclosure: latestDisclosure === disclosure,
|
||||
detailsOpen: latestDisclosure.open,
|
||||
animated: latestDisclosure.dataset.active === 'true',
|
||||
}
|
||||
fireEvent.click(summary)
|
||||
const expanded = {
|
||||
detailsOpen: latestDisclosure.open,
|
||||
delay: screen.getByText('重试延迟:').parentElement?.textContent,
|
||||
failure: screen.getByText('失败原因:').parentElement?.textContent,
|
||||
}
|
||||
|
||||
act(() => { timing.completeModelRetry('fx-alpha') })
|
||||
const recovered = await screen.findByText('重试后的完整回复')
|
||||
await waitFor(() => { expect(screen.getByRole('status').textContent).toContain('已重试') })
|
||||
const completedNotice = screen.getByRole('status')
|
||||
const completedDisclosure = completedNotice.closest('details')
|
||||
if (completedDisclosure === null) throw new Error('completed retry disclosure missing')
|
||||
const completed = {
|
||||
recovered: recovered.textContent,
|
||||
retryNoticeStillVisible: completedNotice.textContent,
|
||||
animated: completedDisclosure.dataset.active === 'true',
|
||||
}
|
||||
|
||||
await expect(`${JSON.stringify({ beforeRetry, firstRetry, scheduled, expanded, completed }, null, 2)}\n`)
|
||||
.toMatchFileSnapshot('./snapshots/model-retry.json')
|
||||
})
|
||||
|
||||
@@ -271,6 +271,98 @@ describe('dsh web keyless CLI smoke', () => {
|
||||
rmSync(workspace, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it('retries a partial transport failure through the shipped Web composition', async () => {
|
||||
requireDist()
|
||||
const workspace = mkdtempSync(join(tmpdir(), 'dsh-web-retry-'))
|
||||
const promptMarker = 'WEB_RETRY_REQUEST'
|
||||
const recoveredMarker = 'WEB_RETRY_RECOVERED'
|
||||
let mainAttempts = 0
|
||||
const provider = createServer((request, response) => {
|
||||
let body = ''
|
||||
request.setEncoding('utf8')
|
||||
request.on('data', (chunk: string) => { body += chunk })
|
||||
request.on('end', () => {
|
||||
const parsed = JSON.parse(body) as { max_tokens?: number; messages?: unknown[] }
|
||||
const titleRequest = parsed.max_tokens === 64
|
||||
const mainRequest = !titleRequest && body.includes(promptMarker)
|
||||
response.writeHead(200, { 'content-type': 'text/event-stream' })
|
||||
if (!mainRequest) {
|
||||
response.end([
|
||||
'data: {"choices":[{"delta":{"content":"Web retry title"}}]}',
|
||||
'data: {"choices":[{"delta":{"content":""},"finish_reason":"stop"}],"usage":{"prompt_tokens":1,"completion_tokens":1}}',
|
||||
'data: [DONE]',
|
||||
'',
|
||||
].join('\n\n'))
|
||||
return
|
||||
}
|
||||
mainAttempts++
|
||||
if (mainAttempts === 1) {
|
||||
response.write('data: {"choices":[{"delta":{"content":"WEB_RETRY_DISCARDED"}}]}\n\n')
|
||||
setTimeout(() => { response.destroy() }, 20)
|
||||
return
|
||||
}
|
||||
response.end([
|
||||
`data: {"choices":[{"delta":{"content":"${recoveredMarker}"}}]}`,
|
||||
'data: {"choices":[{"delta":{"content":""},"finish_reason":"stop"}],"usage":{"prompt_tokens":3,"completion_tokens":1}}',
|
||||
'data: [DONE]',
|
||||
'',
|
||||
].join('\n\n'))
|
||||
})
|
||||
})
|
||||
await new Promise<void>(resolve => provider.listen(0, '127.0.0.1', resolve))
|
||||
const address = provider.address()
|
||||
if (address === null || typeof address === 'string') throw new Error('mock provider did not bind a TCP port')
|
||||
const tsxLoader = pathToFileURL(createRequire(join(REPO_ROOT, 'package.json')).resolve('tsx')).href
|
||||
const child = spawn(
|
||||
process.execPath,
|
||||
['--import', tsxLoader, join(REPO_ROOT, 'apps/cli/src/bin.ts'), 'web', '--port', '0'],
|
||||
{
|
||||
cwd: workspace,
|
||||
env: {
|
||||
...process.env,
|
||||
DEEPSEEK_API_KEY: 'keyless-web-retry',
|
||||
DEEPSEEK_BASE_URL: `http://127.0.0.1:${address.port}`,
|
||||
DSH_HOME: join(workspace, '.dsh'),
|
||||
TSX_TSCONFIG_PATH: join(REPO_ROOT, 'tsconfig.json'),
|
||||
},
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
},
|
||||
)
|
||||
try {
|
||||
const baseUrl = await waitForReadyLine(child)
|
||||
const created = await rpc<{ sessionId: string }>(baseUrl, 'session.create', {})
|
||||
await rpc<{ accepted: true }>(baseUrl, 'session.prompt', {
|
||||
sessionId: created.sessionId,
|
||||
mode: 'queue',
|
||||
content: [{ type: 'text', text: promptMarker }],
|
||||
})
|
||||
let page: HistoryPage | undefined
|
||||
await expect.poll(async () => {
|
||||
page = await history(baseUrl, created.sessionId)
|
||||
return hasAssistantMarker(page, recoveredMarker)
|
||||
}, { timeout: 20_000 }).toBe(true)
|
||||
if (page === undefined) throw new Error('retry history was not observed')
|
||||
const retry = page.events.find(({ event }) => event.type === 'llm/retry')?.event
|
||||
expect(mainAttempts).toBe(2)
|
||||
expect(retry?.data).toMatchObject({
|
||||
turn: 1,
|
||||
step: 1,
|
||||
retry: 1,
|
||||
maxRetries: 2,
|
||||
failure: { code: 'TRANSPORT' },
|
||||
})
|
||||
expect(JSON.stringify(page.events)).toContain('WEB_RETRY_DISCARDED')
|
||||
} finally {
|
||||
const closed = child.exitCode === null
|
||||
? new Promise<void>((resolveClose) => { child.once('close', () => { resolveClose() }) })
|
||||
: Promise.resolve()
|
||||
if (child.exitCode === null) child.kill('SIGTERM')
|
||||
await closed
|
||||
await new Promise<void>(resolveClose => provider.close(() => { resolveClose() }))
|
||||
rmSync(workspace, { recursive: true, force: true })
|
||||
}
|
||||
}, 30_000)
|
||||
})
|
||||
|
||||
describe.skipIf(!process.env.DEEPSEEK_API_KEY || notReady.length > 0)('web smoke (real host, real key, W5)', () => {
|
||||
|
||||
27
apps/web/tests/snapshots/model-retry.json
Normal file
27
apps/web/tests/snapshots/model-retry.json
Normal file
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"beforeRetry": {
|
||||
"partial": "应撤回的半截回复"
|
||||
},
|
||||
"firstRetry": {
|
||||
"notice": "正在重试模型请求(1/2) · 1s",
|
||||
"rows": 1
|
||||
},
|
||||
"scheduled": {
|
||||
"partialVisible": false,
|
||||
"notice": "正在重试模型请求(2/2) · 2s",
|
||||
"rows": 1,
|
||||
"reusedDisclosure": true,
|
||||
"detailsOpen": false,
|
||||
"animated": true
|
||||
},
|
||||
"expanded": {
|
||||
"detailsOpen": true,
|
||||
"delay": "重试延迟:1500ms",
|
||||
"failure": "失败原因:连接被重置"
|
||||
},
|
||||
"completed": {
|
||||
"recovered": "重试后的完整回复",
|
||||
"retryNoticeStillVisible": "已重试模型请求(2/2) · 2s",
|
||||
"animated": false
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user