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
171 changes: 169 additions & 2 deletions .playwright/scripts/__tests__/agentInput.e2e.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -188,8 +188,8 @@ test('model retries retain their run budget, expose progress and allow cancellin
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.locator('.buddy-chat-reasoning-entry__header').click()
await expect(page.locator('.buddy-chat-reasoning-entry__body')).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)
Expand Down Expand Up @@ -256,6 +256,173 @@ test('model retries retain their run budget, expose progress and allow cancellin
}
})

test('activity groups retain open reasoning across tools, reply phases and canvas replay', async ({ buddy }) => {
const gates = Object.fromEntries(['tools', 'arguments', 'thinking', 'reply', 'finish'].map(name => [name, Promise.withResolvers()]))
const requests = []
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')))
const common = { id: `activity-${requests.length}`, model: 'activity-fixture', object: 'chat.completion.chunk', created: 1 }
const send = (delta, finishReason = null) => response.write(`data: ${JSON.stringify({ ...common, choices: [{ index: 0, delta, finish_reason: finishReason }] })}\n\n`)
response.writeHead(200, { 'content-type': 'text/event-stream' })
send({ role: 'assistant' })
if (requests.length === 1) {
send({ reasoning_content: '先检查工作目录,再确认文件是否可读。' })
await gates.tools.promise
send({ tool_calls: [{ index: 0, id: 'list-workspace', type: 'function', function: { name: 'ls', arguments: '{' } }] })
await gates.arguments.promise
send({ tool_calls: [
{ index: 0, function: { arguments: '"path":"."}' } },
{ index: 1, id: 'read-missing', type: 'function', function: { name: 'read', arguments: JSON.stringify({ path: 'activity-fixture-missing.txt' }) } },
] })
send({}, 'tool_calls')
}
else {
await gates.thinking.promise
send({ reasoning_content: '目录检查已完成,文件不存在。保留异常信息,继续整理结果。' })
await gates.reply.promise
send({ content: '检查完成:工作目录可访问,指定文件不存在。' })
await gates.finish.promise
send({}, 'stop')
}
response.end('data: [DONE]\n\n')
})
server.listen(0, '127.0.0.1')
await once(server, 'listening')
const instance = await buddy.createInstance('activity-groups')
try {
const application = await instance.launch()
await useSyntheticCredentialStorage(application)
const { page } = application
await page.evaluate(async (baseUrl) => {
const providers = window.lexoraDesktop.localChat.providers
await providers.upsertCustom({ id: 'activity-fixture', displayName: 'Activity fixture', api: 'openai-completions', baseUrl, enabled: true, models: [{ id: 'activity-fixture', name: 'Activity fixture', input: ['text'], reasoning: false, contextWindow: 128000, maxTokens: 1024 }] })
const stop = providers.onAuthChallenge((challenge) => {
if (challenge.providerId === 'activity-fixture' && challenge.type === 'secret')
void providers.respondToAuth(challenge.challengeId, 'offline-fixture-key')
})
try {
await providers.login('activity-fixture', 'api_key')
}
finally {
stop()
}
await providers.setDefaultModel({ providerId: 'activity-fixture', modelId: 'activity-fixture', reasoning: null })
}, `http://127.0.0.1:${server.address().port}/v1`)
await page.reload()
await page.locator('.desktop-chat-composer__prosemirror:visible').fill('检查工作目录和指定文件,并说明结果。')
await page.getByRole('button', { name: '发送消息', exact: true }).click()
const activity = page.locator('.buddy-chat-run-activity')
const group = page.locator('.buddy-chat-activity-group')
await expect(activity).toContainText('正在思考')
await expect(group.locator('.buddy-chat-reasoning-entry__header')).toContainText('正在思考')
await group.locator('.buddy-chat-reasoning-entry__header').click()
const originalBody = await group.locator('.buddy-chat-reasoning-entry__body').elementHandle()
await expect(group.locator('.buddy-chat-reasoning-entry__body')).toContainText('先检查工作目录')
gates.tools.resolve()
await expect(activity).toContainText('准备中')
await expect(group).toHaveAttribute('data-status', 'completed')
expect(await originalBody.evaluate(element => element.isConnected)).toBe(true)
gates.arguments.resolve()
await expect.poll(() => requests.length).toBe(2)
await expect(group).toHaveAttribute('data-status', 'completed')
await expect(group.locator('.buddy-chat-activity-group__header')).toContainText('思考 · 读取 1 个文件 · 搜索 1 次')
await expect(group.locator('.buddy-chat-activity-group__header')).toHaveAttribute('aria-expanded', 'true')
expect(await originalBody.evaluate(element => element.isConnected)).toBe(true)
await expect(activity).toContainText('正在处理')
await expect(group.locator('.buddy-chat-activity-group__issues')).toContainText('1 项异常')
const failedTool = group.locator('[data-tool-call-id="read-missing"]')
await expectInlineToolStatus(failedTool)
const groupIcon = await group.locator('.buddy-chat-activity-group__header > .buddy-chat-activity-row__icon').elementHandle()
await page.mouse.move(0, 0)
await page.screenshot({ path: path.join(instance.artifactDirectory, 'activity-settled-light.png'), animations: 'disabled' })
await page.emulateMedia({ reducedMotion: 'no-preference' })
gates.thinking.resolve()
await expect(group).toHaveAttribute('data-status', 'running')
expect(await groupIcon.evaluate(element => element.isConnected)).toBe(true)
expect(await groupIcon.evaluate(element => getComputedStyle(element).animationName)).toBe('none')
await expect(group.locator('.buddy-chat-activity-group__status')).toHaveText('运行中')
await expect(group.locator('.buddy-shimmer-text--continuous')).toHaveCount(1)
expect(await group.locator('.buddy-shimmer-text--continuous').evaluate(element => getComputedStyle(element).animationDuration)).toBe('3s')
expect(await activity.locator('.buddy-shimmer-text--continuous').evaluate(element => getComputedStyle(element).animationDuration)).toBe('1.8s')
await expect(group.locator('.buddy-chat-reasoning-entry__header').last()).toHaveAttribute('aria-expanded', 'false')
await page.emulateMedia({ reducedMotion: 'reduce' })
expect(await group.locator('.buddy-shimmer-text--continuous').evaluate(element => getComputedStyle(element).animationName)).toBe('none')
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, 'activity-running-dark.png'), animations: 'disabled' })
await group.evaluate(element => element.style.maxWidth = '320px')
expect(await group.evaluate(element => element.scrollWidth <= element.clientWidth + 4)).toBe(true)
await expectInlineToolStatus(failedTool)
await page.screenshot({ path: path.join(instance.artifactDirectory, 'activity-running-narrow.png'), animations: 'disabled' })
await group.evaluate(element => element.style.removeProperty('max-width'))
gates.reply.resolve()
await expect(activity).toContainText('正在回复')
await expect(group).toHaveAttribute('data-status', 'completed')
expect(await originalBody.evaluate(element => element.isConnected)).toBe(true)
gates.finish.resolve()
await expect.poll(() => completedRuns(instance.home)).toBe(1)
await expect(activity).toHaveCount(0)
expect(await originalBody.evaluate(element => element.isConnected)).toBe(true)
await page.getByTestId('conversation-canvas-toggle').click()
await page.locator('.conversation-node[data-kind="answer"]').click()
const detail = page.getByTestId('canvas-node-detail')
await expect(detail.locator('.buddy-chat-activity-group__header')).toContainText('思考 · 读取 1 个文件 · 搜索 1 次')
await detail.locator('.buddy-chat-activity-group__header').click()
await expectInlineToolStatus(detail.locator('[data-tool-call-id="read-missing"]'))
await detail.locator('.buddy-chat-reasoning-entry__header').first().click()
await expect(detail.locator('.buddy-chat-reasoning-entry__body')).toContainText('先检查工作目录')
await page.screenshot({ path: path.join(instance.artifactDirectory, 'activity-canvas-dark.png'), animations: 'disabled' })
expect(application.diagnostics.console.filter(item => item.type === 'pageerror')).toEqual([])
}
finally {
for (const gate of Object.values(gates))
gate.resolve()
await instance.stop()
server.closeAllConnections()
await new Promise(resolve => server.close(resolve))
}
})

async function expectInlineToolStatus(row) {
const spacing = await row.evaluate((element) => {
const status = element.querySelector('.buddy-chat-tool__status')
const headerBounds = element.querySelector('.buddy-chat-tool__header').getBoundingClientRect()
const iconBounds = element.querySelector('.buddy-chat-activity-row__icon').getBoundingClientRect()
const targetBounds = status.previousElementSibling.getBoundingClientRect()
const statusBounds = status.getBoundingClientRect()
const chevronBounds = element.querySelector('.buddy-chat-activity-row__chevron').getBoundingClientRect()
const textBaselines = [...element.querySelectorAll('.buddy-chat-tool__title, .buddy-chat-tool__summary, .buddy-chat-tool__context, .buddy-chat-tool__status > span')].map((text) => {
const marker = document.createElement('span')
marker.style.cssText = 'display: inline-block; width: 0; height: 0; vertical-align: baseline;'
text.append(marker)
const baseline = marker.getBoundingClientRect().top
marker.remove()
return baseline
})
return {
targetGap: statusBounds.left - targetBounds.right,
chevronGap: chevronBounds.left - statusBounds.right,
textBaselineOffset: Math.max(...textBaselines) - Math.min(...textBaselines),
iconCenterOffset: Math.abs(iconBounds.y + iconBounds.height / 2 - headerBounds.y - headerBounds.height / 2),
chevronCenterOffset: Math.abs(chevronBounds.y + chevronBounds.height / 2 - headerBounds.y - headerBounds.height / 2),
}
})
expect(spacing.targetGap).toBeGreaterThanOrEqual(0)
expect(spacing.targetGap).toBeLessThanOrEqual(12)
expect(spacing.chevronGap).toBeGreaterThanOrEqual(0)
expect(spacing.chevronGap).toBeLessThanOrEqual(12)
expect(spacing.textBaselineOffset).toBeLessThan(1)
expect(spacing.iconCenterOffset).toBeLessThan(1)
expect(spacing.chevronCenterOffset).toBeLessThan(1)
}

async function useSyntheticCredentialStorage({ app, page }) {
await app.evaluate(({ app, safeStorage }) => {
if (app.getName() !== 'Lexora Buddy Test')
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -230,6 +230,21 @@ describe('projectPiEvent product messages', () => {
}])
})

it.each([undefined, 'commentary', 'final_answer'] as const)('distinguishes %s text from thinking, tool arguments and empty message starts', (textPhase) => {
const state = createPiEventProjectionState()
const partial: AssistantMessage = { api: 'openai-responses', content: [{ type: 'text', text: '', textSignature: JSON.stringify({ v: 1, id: 'text', phase: textPhase }) }], model: 'fixture', provider: 'fixture', role: 'assistant', stopReason: 'pending', timestamp: 1, usage: emptyUsage() }
projectPiEvent({ type: 'message_start', message: partial }, state)
expect(state.progress?.phase).toBe('model_streaming')
projectPiEvent({ type: 'message_update', message: partial, assistantMessageEvent: { type: 'text_start', contentIndex: 0, partial } }, state)
expect(state.progress?.phase).toBe(textPhase === 'commentary' ? 'model_streaming' : 'model_responding')
projectPiEvent({ type: 'message_update', message: partial, assistantMessageEvent: { type: 'thinking_end', contentIndex: 1, content: 'Previous thought', partial } }, state)
expect(state.progress?.phase).toBe(textPhase === 'commentary' ? 'model_streaming' : 'model_responding')
projectPiEvent({ type: 'message_update', message: partial, assistantMessageEvent: { type: 'text_end', contentIndex: 0, content: 'Visible text', partial } }, state)
expect(state.progress?.phase).toBe('model_streaming')
const preparing = projectPiEvent({ type: 'message_update', message: partial, assistantMessageEvent: { type: 'toolcall_start', contentIndex: 1, partial } }, state)
expect(preparing.events).toEqual([{ type: 'run.progress', payload: { phase: 'preparing', toolName: null } }])
})

it('keeps Harness-tracked file changes out of run outputs', () => {
const state = createPiEventProjectionState()
projectPiEvent({
Expand Down Expand Up @@ -482,7 +497,7 @@ describe('projectPiEvent product messages', () => {
messageId,
},
type: 'message.block.started',
}])
}, { type: 'run.progress', payload: { phase: 'model_thinking', toolName: null } }])
expect(projectPiEvent({
assistantMessageEvent: {
contentIndex: 0,
Expand Down Expand Up @@ -519,7 +534,7 @@ describe('projectPiEvent product messages', () => {
messageId,
},
type: 'message.block.completed',
}])
}, { type: 'run.progress', payload: { phase: 'model_streaming', toolName: null } }])
expect(JSON.stringify(completed)).not.toContain('private-provider-signature')
})

Expand Down
30 changes: 30 additions & 0 deletions apps/buddy/service/src/agent/events/projectPiEvent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -431,6 +431,36 @@ function progressProjection(
function projectMessageUpdate(
event: AssistantMessageEvent,
state: PiEventProjectionState,
): PiEventProjection {
const content = projectMessageContentUpdate(event, state)
let phase: BuddyRunProgress['phase'] | null = null
switch (event.type) {
case 'thinking_start':
case 'thinking_delta':
phase = 'model_thinking'
break
case 'text_start':
case 'text_delta':
phase = resolvePiTextPhase(event.partial.content[event.contentIndex]) === 'commentary' ? 'model_streaming' : 'model_responding'
break
case 'thinking_end':
phase = state.progress?.phase === 'model_thinking' ? 'model_streaming' : null
break
case 'text_end':
phase = state.progress?.phase === 'model_responding' ? 'model_streaming' : null
break
case 'toolcall_start':
case 'toolcall_delta':
case 'toolcall_end':
phase = 'preparing'
break
}
return { events: [...content.events, ...(phase ? progressProjection(state, phase).events : [])] }
}

function projectMessageContentUpdate(
event: AssistantMessageEvent,
state: PiEventProjectionState,
): PiEventProjection {
const messageId = state.assistantMessageId ?? randomUUID()
state.assistantMessageId = messageId
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -218,6 +218,8 @@ describe('buddyAgentRunner', () => {
'message.started',
'run.progress',
'message.delta',
'run.progress',
'message.delta',
'message.completed',
'usage.recorded',
])
Expand Down Expand Up @@ -265,6 +267,8 @@ describe('buddyAgentRunner', () => {
'message.started',
'run.progress',
'message.delta',
'run.progress',
'message.delta',
'message.completed',
])
})
Expand Down
2 changes: 2 additions & 0 deletions apps/buddy/shared/runs/runProgress.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ export const buddyRunProgressSchema = z.object({
'idle',
'model_requesting',
'model_streaming',
'model_thinking',
'model_responding',
'preparing',
'awaiting_approval',
'tool_executing',
Expand Down
16 changes: 5 additions & 11 deletions apps/buddy/src/i18n/locales/en-US/chat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -321,23 +321,14 @@ export default {
'desktop.chat.activityWebCount': 'Web actions: {count}',
'desktop.chat.activityToolCount': 'Tool calls: {count}',
'desktop.chat.activityApprovalCount': '{count} awaiting approval',
'desktop.chat.activityPreparingCount': '{count} preparing',
'desktop.chat.activityExecutingTool': 'Running: {tool}',
'desktop.chat.activityExecutingCount': 'Running {count} tools',
'desktop.chat.activityPreparingTools': 'Preparing {count} tools',
'desktop.chat.activityViewReasoning': 'View reasoning',
'desktop.chat.activityViewCall': 'View current tool call',
'desktop.chat.activityNextCall': 'View next tool call',
'desktop.chat.activityRunDuration': 'Elapsed this turn',
'desktop.chat.processReasoningInterrupted': 'Reasoning interrupted',
'desktop.chat.compactionInterrupted': 'Context compaction interrupted',
'desktop.chat.activityRunningCount': 'Running: {count}',
'desktop.chat.activityCalls': 'Calls: {count}',
'desktop.chat.activityIssueCount': 'Issues: {count}',
'desktop.chat.activityNextIssue': 'Go to next issue',
'desktop.chat.processReasoning': 'Reasoning',
'desktop.chat.processAwaitingApproval': 'Waiting for approval',
'desktop.chat.processReasoningDone': 'Reasoning completed',
'desktop.chat.processReasoningRunning': 'Thinking',
'desktop.chat.processToolCommand': 'Run command',
'desktop.chat.processToolAutomation': 'Automation',
Expand Down Expand Up @@ -504,9 +495,12 @@ export default {
'desktop.chat.retryUnlimited': 'Unlimited retries',
'desktop.chat.retryElapsed': 'Elapsed {duration}',
'desktop.chat.progressModelRequesting': 'Waiting for model',
'desktop.chat.progressModelStreaming': 'Generating response',
'desktop.chat.progressModelResponding': 'Responding',
'desktop.chat.progressStopping': 'Stopping',
'desktop.chat.progressQueued': 'Waiting to start',
'desktop.chat.progressApproval': 'Approval pending',
'desktop.chat.progressPreparing': 'Preparing',
'desktop.chat.progressToolExecuting': 'Running tool',
'desktop.chat.progressToolExecuting': 'Running',
'desktop.chat.draftRestoration.restoring': 'Restoring unsent input. Your input will be saved once restoration finishes.',
'desktop.chat.draftRestoration.failed': 'Unsent input could not be restored. Current input is not saved yet. Retry before sending or quitting.',
'desktop.chat.draftRestoration.conflict': 'Saved content conflicts with input entered during restoration. New input cannot be saved until this is resolved.',
Expand Down
Loading