feat(web): retry transient model requests

This commit is contained in:
Yichen Jiang
2026-07-26 14:09:31 +08:00
parent 84be7cc622
commit a430207427
32 changed files with 791 additions and 46 deletions

View File

@@ -10,7 +10,7 @@ The TUI surface:
- tells the agent where its own source lives: after boot it adds a prompt section naming this harness checkout, resolved from the launcher's real path so it holds under a PATH symlink and an arbitrary cwd, so the self-referential `cordis` toolset can read and modify it;
- applies the personal overlay from `~/.dsh` (see [app-boot's Personal config](../../packages/ui/app-boot/README.md#personal-config)): `.env` fills environment gaps (ambient > project `.env` > personal `.env`), `config.yaml` patches the booted tree.
The Web and headless surfaces boot one shared composition (`cordis.yml`): both treat the invoking directory as the default project and Workspace root, create named Workspaces beneath that root unless `--workspace-root <path>` overrides it, load applicable `AGENTS.md`/`CLAUDE.md` instructions into each agent-loop request prefix with a 65,536-byte render budget, and opt into first-message model titles. Headless differs only in listening on an OS-assigned port (parallel `dsh -p` runs never collide; the stderr-printed URL opens the live session in a browser). Both need the frontend dist and client bundles built (`pnpm run build && pnpm run build:web`).
The Web and headless surfaces boot one shared composition (`cordis.yml`): both treat the invoking directory as the default project and Workspace root, create named Workspaces beneath that root unless `--workspace-root <path>` overrides it, load applicable `AGENTS.md`/`CLAUDE.md` instructions into each agent-loop request prefix with a 65,536-byte render budget, opt into first-message model titles, and use the same bounded transient model-request retry policy as the TUI. Headless differs only in listening on an OS-assigned port (parallel `dsh -p` runs never collide; the stderr-printed URL opens the live session in a browser). Both need the frontend dist and client bundles built (`pnpm run build && pnpm run build:web`).
## Install (developer machine)

View File

@@ -59,6 +59,9 @@
config:
agents: []
- id: llm-retry
name: '@deepseek-ai/dsh-llm-retry'
# The native DeepSeek adapter; reads the key/base-url the boot's layered
# .env loading (cwd then $DSH_HOME) left in the environment.
- id: llm-deepseek

View File

@@ -41,6 +41,7 @@
"@deepseek-ai/dsh-host-webserver": "workspace:^",
"@deepseek-ai/dsh-llm": "workspace:^",
"@deepseek-ai/dsh-llm-deepseek": "workspace:^",
"@deepseek-ai/dsh-llm-retry": "workspace:^",
"@deepseek-ai/dsh-paths": "workspace:^",
"@deepseek-ai/dsh-session": "workspace:^",
"@deepseek-ai/dsh-session-persistence-jsonl": "workspace:^",

View File

@@ -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')
})

View File

@@ -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)', () => {

View 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
}
}