Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
129 changes: 129 additions & 0 deletions .playwright/scripts/__tests__/agentInput.e2e.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,135 @@ for (const dimensions of [[2, 1], [2400, 1200]]) {
})
}

test('model retries retain their run budget, expose progress and allow cancelling unlimited backoff', async ({ buddy }) => {
const requests = []
const finishThinking = Promise.withResolvers()
let recover = true
const server = createServer(async (request, response) => {
if (request.method !== 'POST' || request.url !== '/v1/chat/completions') {
response.writeHead(404).end()
return
}
const chunks = []
for await (const chunk of request)
chunks.push(chunk)
requests.push(JSON.parse(Buffer.concat(chunks).toString('utf8')))
if (!recover || requests.length < 3) {
response.writeHead(503, { 'content-type': 'application/json' }).end(JSON.stringify({ error: { message: 'Service temporarily unavailable', type: 'server_error' } }))
return
}
const common = { id: 'recovered', model: 'retry-fixture', object: 'chat.completion.chunk', created: 1 }
response.writeHead(200, { 'content-type': 'text/event-stream' })
response.write(`data: ${JSON.stringify({ ...common, choices: [{ index: 0, delta: { role: 'assistant', reasoning_content: 'Inspecting the recovered request.' }, finish_reason: null }] })}\n\n`)
await finishThinking.promise
response.write(`data: ${JSON.stringify({ ...common, choices: [{ index: 0, delta: { role: 'assistant', content: 'Recovered after retry.' }, finish_reason: null }] })}\n\n`)
response.write(`data: ${JSON.stringify({ ...common, choices: [{ index: 0, delta: {}, finish_reason: 'stop' }], usage: { prompt_tokens: 32, completion_tokens: 8, total_tokens: 40 } })}\n\n`)
response.end('data: [DONE]\n\n')
})
server.listen(0, '127.0.0.1')
await once(server, 'listening')
const instance = await buddy.createInstance('model-retry-progress')
try {
const application = await instance.launch()
await useSyntheticCredentialStorage(application)
const { page } = application
await page.evaluate(async (baseUrl) => {
await window.lexoraDesktop.settings.update({ runtime: { modelRetryLimit: 2 } })
const providers = window.lexoraDesktop.localChat.providers
await providers.upsertCustom({ id: 'retry-fixture', displayName: 'Retry fixture', api: 'openai-completions', baseUrl, enabled: true, models: [{ id: 'retry-fixture', name: 'Retry fixture', input: ['text'], reasoning: false, contextWindow: 128000, maxTokens: 1024 }] })
const stop = providers.onAuthChallenge((challenge) => {
if (challenge.providerId === 'retry-fixture' && challenge.type === 'secret')
void providers.respondToAuth(challenge.challengeId, 'offline-fixture-key')
})
try {
await providers.login('retry-fixture', 'api_key')
}
finally {
stop()
}
await providers.setDefaultModel({ providerId: 'retry-fixture', modelId: 'retry-fixture', reasoning: null })
}, `http://127.0.0.1:${server.address().port}/v1`)
await page.reload()
const editor = page.locator('.desktop-chat-composer__prosemirror:visible')
const activity = page.locator('.buddy-chat-run-activity')
await editor.fill('Recover from the transient error.')
await page.getByRole('button', { name: '发送消息', exact: true }).click()
await expect(activity).toContainText(/重试 1\s*\/\s*2/)
await page.evaluate(() => window.lexoraDesktop.settings.update({ runtime: { modelRetryLimit: 0 } }))
await expect(activity).toContainText(/重试 2\s*\/\s*2/)
await expect(activity).toContainText('秒后重试')
await expect(activity).toContainText('已用')
await page.screenshot({ path: path.join(instance.artifactDirectory, 'retry-progress-finite.png'), animations: 'disabled' })
await expect(activity).toContainText('正在思考')
await expect(activity).not.toContainText('重试')
await activity.getByRole('button', { name: '查看思考详情', exact: true }).click()
await expect(activity.locator('.buddy-chat-run-activity__reasoning')).toContainText('Inspecting the recovered request.')
await page.screenshot({ path: path.join(instance.artifactDirectory, 'retry-recovered-reasoning.png'), animations: 'disabled' })
finishThinking.resolve()
await expect.poll(() => completedRuns(instance.home)).toBe(1)
expect(requests).toHaveLength(3)
for (const body of requests)
expect(body.messages.filter(message => message.role === 'user')).toHaveLength(1)
await expect(activity).toHaveCount(0)

recover = false
await editor.fill('This next run has automatic retry disabled.')
await page.getByRole('button', { name: '发送消息', exact: true }).click()
await expect.poll(() => readDatabase(instance.home, db => db.prepare('SELECT COUNT(*) AS count FROM runs WHERE status = \'failed\'').get().count)).toBe(1)
expect(requests).toHaveLength(4)
await expect(activity).toHaveCount(0)

await page.evaluate(() => window.lexoraDesktop.settings.update({ runtime: { modelRetryLimit: 'unlimited' } }))
await editor.fill('Keep retrying until I stop this run.')
await page.getByRole('button', { name: '发送消息', exact: true }).click()
await expect.poll(() => readDatabase(instance.home, (db) => {
const row = db.prepare('SELECT payload_json FROM run_events JOIN runs ON runs.id = run_events.run_id WHERE runs.status = ? AND event_type = ? ORDER BY sequence DESC LIMIT 1').get('running', 'run.progress')
return row ? JSON.parse(row.payload_json) : null
})).toMatchObject({ retry: { attempt: 1, maxAttempts: 'unlimited' } })
const retryRun = readDatabase(instance.home, db => db.prepare('SELECT id FROM runs WHERE status = ?').get('running'))
const publicProgress = await page.evaluate(async runId => (await window.lexoraDesktop.localChat.runs.listEvents({ runId })).filter(event => event.type === 'run.progress'), retryRun.id)
expect(publicProgress.at(-1).payload.retry).toMatchObject({ maxAttempts: 'unlimited' })
await expect(activity).toContainText(/重试 1\s*\//)
await expect(activity.locator('.buddy-chat-run-activity__unlimited svg')).toBeVisible()
await page.reload()
await expect(activity.locator('.buddy-chat-run-activity__unlimited svg')).toBeVisible()
const typography = await activity.evaluate((element) => {
return ['.buddy-chat-activity-status__label', '.buddy-chat-run-activity__retry-attempt', '.buddy-chat-run-activity__duration'].map((selector) => {
const node = element.querySelector(selector)
const style = getComputedStyle(node)
const bounds = node.getBoundingClientRect()
return { fontFamily: style.fontFamily, fontSize: style.fontSize, lineHeight: style.lineHeight, centerY: bounds.y + bounds.height / 2 }
})
})
expect(new Set(typography.map(item => `${item.fontFamily}:${item.fontSize}:${item.lineHeight}`)).size).toBe(1)
expect(Math.max(...typography.map(item => item.centerY)) - Math.min(...typography.map(item => item.centerY))).toBeLessThan(1)
await page.screenshot({ path: path.join(instance.artifactDirectory, 'retry-progress-unlimited-light.png'), animations: 'disabled' })
await page.evaluate(() => window.lexoraDesktop.settings.update({ desktop: { theme: 'dark' } }))
await expect(page.locator('.buddy-app')).toHaveClass(/is-dark/)
await page.screenshot({ path: path.join(instance.artifactDirectory, 'retry-progress-unlimited-dark.png'), animations: 'disabled' })
await activity.evaluate(element => element.style.maxWidth = '320px')
await page.screenshot({ path: path.join(instance.artifactDirectory, 'retry-progress-unlimited-narrow.png'), animations: 'disabled' })
const narrow = await activity.evaluate((element) => {
const label = element.querySelector('.buddy-chat-activity-status__label').getBoundingClientRect()
const metadata = element.querySelector('.buddy-chat-run-activity__retry-meta').getBoundingClientRect()
return { labelLeft: label.x, metadataLeft: metadata.x, labelBottom: label.bottom, metadataTop: metadata.y, width: element.clientWidth, scrollWidth: element.scrollWidth }
})
expect(narrow.metadataLeft).toBe(narrow.labelLeft)
expect(narrow.metadataTop).toBeGreaterThanOrEqual(narrow.labelBottom)
expect(narrow.scrollWidth).toBeLessThanOrEqual(narrow.width)
await page.getByRole('button', { name: '停止运行', exact: true }).click()
await expect.poll(() => readDatabase(instance.home, db => db.prepare('SELECT COUNT(*) AS count FROM runs WHERE status = \'cancelled\'').get().count)).toBe(1)
await expect(activity).toHaveCount(0)
expect(application.diagnostics.console.filter(item => item.type === 'pageerror')).toEqual([])
}
finally {
finishThinking.resolve()
await instance.stop()
server.closeAllConnections()
await new Promise(resolve => server.close(resolve))
}
})

async function useSyntheticCredentialStorage({ app, page }) {
await app.evaluate(({ app, safeStorage }) => {
if (app.getName() !== 'Lexora Buddy Test')
Expand Down
139 changes: 139 additions & 0 deletions .playwright/scripts/__tests__/settings.e2e.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -391,6 +391,145 @@ export function render(context, container) {
expect(diagnostics.console.filter(item => item.type === 'pageerror')).toEqual([])
})

test('global model retry settings preserve finite, unlimited and disabled values across restart', async ({ buddy }) => {
const instance = await buddy.createInstance('model-retry-settings')
let desktop = await instance.launch()
await openSettings(desktop.page)
await desktop.page.locator('.desktop-settings-sidebar').getByRole('link', { name: '运行时', exact: true }).click()
const row = () => desktop.page.getByTestId('model-retry-limit-setting')
const countRow = () => desktop.page.getByTestId('model-retry-count-setting')
const input = () => countRow().locator('input.n-input__input-el')
const limit = () => desktop.page.evaluate(async () => (await window.lexoraDesktop.settings.get()).runtime.modelRetryLimit)
const mode = label => row().getByRole('button', { name: label, exact: true })
const select = label => mode(label).click()
const expectCapsuleAligned = () => expect.poll(() => row().evaluate((element) => {
const capsule = element.querySelector('.n-tabs-capsule')?.getBoundingClientRect()
const selected = element.querySelector('[aria-pressed="true"]')?.getBoundingClientRect()
return !!capsule && !!selected && Math.abs(capsule.x - selected.x) < 1 && Math.abs(capsule.width - selected.width) < 1
})).toBe(true)
await expect(row().getByRole('button')).toHaveCount(3)
await expect(mode('次数')).toHaveAttribute('aria-pressed', 'true')
await expect(row().locator('small')).toHaveText('遇到临时错误时重试模型请求。')
await expect(desktop.page.locator('.runtime-settings__group > .runtime-settings__row')).toHaveCount(2)
await expect(input()).toHaveValue('3')
await input().fill('12')
expect(await limit()).toBe(3)
await input().press('Enter')
await expect.poll(limit).toBe(12)
await expect(input()).toBeEnabled()
await select('次数')
expect(await limit()).toBe(12)
const modeBox = await row().boundingBox()
const countBox = await countRow().boundingBox()
expect(countBox.y).toBeCloseTo(modeBox.y + modeBox.height, 0)
expect(countBox.x).toBe(modeBox.x)
expect(countBox.width).toBe(modeBox.width)
expect((await row().locator('.runtime-settings__retry-modes').boundingBox()).width).toBeLessThan(200)
await expectCapsuleAligned()
await desktop.page.screenshot({ path: path.join(instance.artifactDirectory, 'retry-settings-finite-light.png'), animations: 'disabled' })
await desktop.page.evaluate(() => window.lexoraDesktop.settings.update({ desktop: { language: 'en-US' } }))
await expect(mode('Limited')).toHaveAttribute('aria-pressed', 'true')
await expectCapsuleAligned()
await desktop.page.screenshot({ path: path.join(instance.artifactDirectory, 'retry-settings-finite-english.png'), animations: 'disabled' })
await desktop.page.evaluate(() => window.lexoraDesktop.settings.update({ desktop: { language: 'zh-CN' } }))
await expect(mode('次数')).toHaveAttribute('aria-pressed', 'true')
await expectCapsuleAligned()
await row().locator('.n-tabs-capsule').evaluate((element) => {
const from = element.getBoundingClientRect().x
window.retrySegmentMotion = null
element.addEventListener('transitionrun', () => {
const animation = element.getAnimations().find(item => item.transitionProperty === 'transform')
if (!animation)
return
const duration = Number(animation.effect.getTiming().duration)
animation.pause()
animation.currentTime = duration / 2
window.retrySegmentMotion = {
from,
middle: element.getBoundingClientRect().x,
to: element.closest('.n-tabs').querySelector('[aria-pressed="true"]').getBoundingClientRect().x,
duration,
}
animation.play()
}, { once: true })
})
await mode('无限').press('Space')
await expect.poll(limit).toBe('unlimited')
await expect(mode('无限')).toHaveAttribute('aria-pressed', 'true')
await expect.poll(() => desktop.page.evaluate(() => window.retrySegmentMotion)).not.toBeNull()
const motion = await desktop.page.evaluate(() => window.retrySegmentMotion)
expect(motion.duration).toBeGreaterThan(0)
expect(motion.middle).toBeGreaterThan(motion.from)
expect(motion.middle).toBeLessThan(motion.to)
await expectCapsuleAligned()
await expect(countRow()).toHaveCount(0)
await expect(row().locator('small')).toHaveText('遇到临时错误时重试模型请求。')
await desktop.page.emulateMedia({ reducedMotion: 'reduce' })
await expect(row().locator('.n-tabs-capsule')).toHaveCSS('transition-duration', '0s')
await select('禁用')
await expect.poll(limit).toBe(0)
await expectCapsuleAligned()
await select('无限')
await expect.poll(limit).toBe('unlimited')
await expectCapsuleAligned()
await desktop.page.emulateMedia({ reducedMotion: 'no-preference' })
await desktop.page.getByTestId('cache-warming-setting').locator('.n-select').click()
await desktop.page.locator('.n-base-select-menu').getByText('任务执行期间', { exact: true }).click()
await expect.poll(() => desktop.page.evaluate(async () => (await window.lexoraDesktop.settings.get()).runtime)).toEqual({ cacheWarming: 'streaming', modelRetryLimit: 'unlimited' })
await desktop.page.screenshot({ path: path.join(instance.artifactDirectory, 'retry-settings-light.png'), animations: 'disabled' })
await desktop.page.evaluate(() => window.lexoraDesktop.settings.update({ desktop: { theme: 'dark' } }))
await expect(desktop.page.locator('.buddy-app')).toHaveClass(/is-dark/)
await desktop.app.evaluate(({ BrowserWindow }) => BrowserWindow.getAllWindows().find(window => window.isVisible()).setSize(980, 680))
await desktop.page.screenshot({ path: path.join(instance.artifactDirectory, 'retry-settings-dark.png'), animations: 'disabled' })
await instance.stop()
desktop = await instance.launch()
await openSettings(desktop.page)
await desktop.page.locator('.desktop-settings-sidebar').getByRole('link', { name: '运行时', exact: true }).click()
await expect(mode('无限')).toHaveAttribute('aria-pressed', 'true')
await expect(countRow()).toHaveCount(0)
expect(await limit()).toBe('unlimited')
await select('次数')
await expect(input()).toHaveValue('3')
await input().fill('5')
await input().press('Tab')
await expect.poll(limit).toBe(5)
await expect(input()).toBeEnabled()
await desktop.app.evaluate(({ BrowserWindow }) => BrowserWindow.getAllWindows().find(window => window.isVisible()).setSize(980, 680))
await expect.poll(() => desktop.page.evaluate(() => window.innerWidth)).toBe(980)
await expectCapsuleAligned()
await desktop.page.screenshot({ path: path.join(instance.artifactDirectory, 'retry-settings-finite-dark-narrow.png'), animations: 'disabled' })
await select('禁用')
await expect.poll(limit).toBe(0)
await expect(countRow()).toHaveCount(0)
expect(await fs.readFile(path.join(instance.home, 'config.toml'), 'utf8')).toContain('model_retry_limit = 0')
await desktop.app.evaluate(({ ipcMain }) => {
const gate = new Promise(resolve => globalThis.releaseRetrySettingFailure = resolve)
ipcMain.removeHandler('lexora:settings:update')
ipcMain.handle('lexora:settings:update', async () => {
await gate
throw new Error('Isolated retry-setting save failure')
})
})
await select('次数')
try {
await expect(mode('次数')).toHaveAttribute('aria-pressed', 'true')
await expect(mode('无限')).toBeDisabled()
await expect(input()).toHaveValue('3')
await expect(input()).toBeDisabled()
expect(await limit()).toBe(0)
}
finally {
await desktop.app.evaluate(() => globalThis.releaseRetrySettingFailure())
}
await expect(desktop.page.getByText('保存失败,已恢复原设置', { exact: true })).toBeVisible()
await expect(mode('禁用')).toHaveAttribute('aria-pressed', 'true')
await expect(mode('次数')).toBeEnabled()
await expectCapsuleAligned()
await expect(countRow()).toHaveCount(0)
expect(await limit()).toBe(0)
expect(desktop.diagnostics.console.filter(item => item.type === 'pageerror')).toEqual([])
})

async function openSettings(page) {
await page.locator('.desktop-app-sidebar').getByRole('button', { name: '设置', exact: true }).click()
await page.locator('.desktop-settings-sidebar').getByRole('link', { name: '常规', exact: true }).click()
Expand Down
2 changes: 1 addition & 1 deletion apps/buddy/electron/main/app/DesktopRuntimeHost.ts
Original file line number Diff line number Diff line change
Expand Up @@ -224,7 +224,7 @@ export class DesktopRuntimeHost {
await this.#network?.apply(config.proxy)
const previous = this.#config?.runtime
this.#config = config
if (previous?.cacheWarming !== config.runtime.cacheWarming)
if (previous?.cacheWarming !== config.runtime.cacheWarming || previous?.modelRetryLimit !== config.runtime.modelRetryLimit)
this.#service?.notify(runtimePreferencesRpc.changed, config.runtime)
await Promise.all(this.#features.map(feature => feature.applyConfig(config)))
if (app.isPackaged && this.#environment.paths.profile === 'stable')
Expand Down
5 changes: 3 additions & 2 deletions apps/buddy/electron/main/config/LexoraConfigStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,7 @@ const petConfigSchema = z.object({
const lexoraConfigFileSchema = z.object({
runtime: z.object({
cache_warming: runtimePreferencesSchema.shape.cacheWarming.default(DEFAULT_RUNTIME_PREFERENCES.cacheWarming),
model_retry_limit: runtimePreferencesSchema.shape.modelRetryLimit.default(DEFAULT_RUNTIME_PREFERENCES.modelRetryLimit),
}).passthrough().prefault({}),
browser: z.object({
screenshot_destination: browserPreferencesSchema.shape.screenshotDestination.default(DEFAULT_BROWSER_PREFERENCES.screenshotDestination),
Expand Down Expand Up @@ -277,7 +278,7 @@ function decodeConfig(value: unknown): LexoraConfig {
}

return {
runtime: { cacheWarming: config.runtime.cache_warming },
runtime: { cacheWarming: config.runtime.cache_warming, modelRetryLimit: config.runtime.model_retry_limit },
browser: {
screenshotDestination: config.browser.screenshot_destination,
defaultZoomFactor: config.browser.default_zoom_factor,
Expand Down Expand Up @@ -327,7 +328,7 @@ function decodeConfig(value: unknown): LexoraConfig {

function encodeConfig(config: LexoraConfig) {
return {
runtime: { cache_warming: config.runtime.cacheWarming },
runtime: { cache_warming: config.runtime.cacheWarming, model_retry_limit: config.runtime.modelRetryLimit },
browser: {
screenshot_destination: config.browser.screenshotDestination,
default_zoom_factor: config.browser.defaultZoomFactor,
Expand Down
Loading