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
31 changes: 26 additions & 5 deletions .playwright/scripts/__tests__/agent-queue.e2e.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import { DatabaseSync } from 'node:sqlite'
import { expect, test, useSyntheticCredentialStorage } from '../fixtures/electron.mjs'

for (const mode of ['idle-follow-up', 'native-wait-steer']) {
test(`waiting parent accepts ${mode} while its child continues`, async ({ buddy }, testInfo) => {
test(`waiting parent accepts ${mode} while its child continues`, async ({ buddy }) => {
const childGate = Promise.withResolvers()
const parentRequests = []
let npcId
Expand Down Expand Up @@ -80,8 +80,31 @@ for (const mode of ['idle-follow-up', 'native-wait-steer']) {
const parentId = read('SELECT id FROM runs WHERE parent_run_id IS NULL')[0].id
const inputs = mode === 'native-wait-steer' ? ['INPUT_A_517', 'INPUT_C_517', 'INPUT_B_517'] : ['INPUT_A_517']
for (const input of inputs) {
await composer.fill(input)
await page.getByRole('button', { name: '加入队列', exact: true }).click()
if (mode === 'native-wait-steer') {
await page.evaluate(async ({ text, parentId }) => {
const api = window.lexoraDesktop.localChat
const run = await api.runs.get(parentId)
const source = await api.composerDrafts.open({
draftId: crypto.randomUUID(),
scope: { kind: 'conversation_branch', conversationId: run.conversationId, branchId: run.branchId },
initialContent: { version: 1, body: [{ type: 'paragraph', content: [] }], panelResourceIds: [] },
initialExecutionConfig: { approvalPolicy: run.approvalPolicy, executionProfile: run.executionProfile },
initialModelSelection: null,
})
const draft = await api.composerDrafts.save({
draftId: source.draftId,
expectedRevision: source.revision,
content: { version: 1, body: [{ type: 'paragraph', content: [{ type: 'text', text }] }], panelResourceIds: [] },
executionConfig: source.executionConfig,
modelSelection: source.modelSelection,
})
await api.chat.enqueue({ draftId: draft.draftId, expectedRevision: draft.revision, requestId: crypto.randomUUID() })
}, { text: input, parentId })
}
else {
await composer.fill(input)
await page.locator('.desktop-chat-composer__actions').getByRole('button', { name: '补充当前工作', exact: true }).click()
}
}
if (mode === 'native-wait-steer') {
await expect(page.locator('.chat-message-queue__row')).toHaveCount(3)
Expand All @@ -93,8 +116,6 @@ for (const mode of ['idle-follow-up', 'native-wait-steer']) {
expect(parentRequests.slice(2).map(payload => JSON.stringify(payload.messages.filter(message => message.role === 'user').at(-1)))).toEqual((mode === 'native-wait-steer' ? ['INPUT_B_517', 'INPUT_A_517', 'INPUT_C_517'] : inputs).map(input => expect.stringContaining(input)))
expect(read('SELECT DISTINCT run_id FROM chat_queue')).toEqual([{ run_id: parentId }])
expect(read('SELECT status FROM runs WHERE parent_run_id IS NOT NULL')).toEqual([{ status: 'running' }])
await expect(page.getByText('队列操作未完成,请稍后重试', { exact: true })).toHaveCount(0)
await page.screenshot({ path: testInfo.outputPath(`${mode}-child-still-running.png`), animations: 'disabled' })
childGate.resolve()
await expect.poll(() => read('SELECT status FROM runs').map(row => row.status)).toEqual(['completed', 'completed'])
expect(JSON.stringify(parentRequests.at(-1))).toContain('CHILD_FINISHED_6942')
Expand Down
198 changes: 142 additions & 56 deletions .playwright/scripts/__tests__/model-selection.e2e.mjs

Large diffs are not rendered by default.

32 changes: 32 additions & 0 deletions .playwright/scripts/fixtures/model-controls.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import { expect } from './electron.mjs'

export async function selectComposerModel(page, model, reasoning) {
await page.locator('.desktop-model-selector__trigger:visible').click()
await page.locator('.desktop-model-selector__advanced').click()
const advanced = page.locator('.desktop-model-selector__panel--advanced')
await advanced.getByRole('button', { name: /^模型/ }).click()
const picker = page.locator('.desktop-model-picker')
await picker.getByPlaceholder('搜索模型…').fill(model.modelId)
await picker.getByRole('menuitemradio', { name: `${model.displayName} ${model.modelId}`, exact: true }).click()
if (reasoning !== null) {
const index = model.reasoningOptions.indexOf(reasoning)
if (index < 0)
throw new Error(`Unsupported fixture reasoning: ${model.modelId}/${reasoning}`)
await advanced.locator('.desktop-model-selector__back').click()
const slider = page.getByRole('slider')
await expect(slider).toHaveAttribute('aria-disabled', 'false')
const bounds = await slider.boundingBox()
await slider.click({ position: { x: 8 + (bounds.width - 16) * index / Math.max(1, model.reasoningOptions.length - 1), y: bounds.height / 2 } })
await expect(slider).toHaveValue(String(index))
}
await page.keyboard.press('Escape')
}

export async function selectComposerPermission(page, name) {
await page.locator('.desktop-permission-mode-selector__trigger:visible').click()
await page.getByRole('menuitemradio', { name: new RegExp(name) }).click()
if (name === '完全访问') {
await page.getByRole('checkbox', { name: '我已了解风险,并愿意继续' }).check()
await page.getByRole('button', { name: '允许完全访问', exact: true }).click()
}
}
1 change: 1 addition & 0 deletions apps/buddy/electron/main/local-chat/executions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { LOCAL_CHAT_IPC_CHANNELS } from '../../shared/localChatApi'
export function registerExecutionsIpc({ handle, request }: LocalChatIpcContext): void {
handle(LOCAL_CHAT_IPC_CHANNELS.executionsList, (_event, input) => request(executionPresentationRpc.list, executionPresentationRpc.list.input.parse(input)))
handle(LOCAL_CHAT_IPC_CHANNELS.executionsDetail, (_event, input) => request(executionPresentationRpc.detail, executionPresentationRpc.detail.input.parse(input)))
handle(LOCAL_CHAT_IPC_CHANNELS.executionsInputs, (_event, input) => request(executionPresentationRpc.inputs, executionPresentationRpc.inputs.input.parse(input)))
handle(LOCAL_CHAT_IPC_CHANNELS.executionsEvents, (_event, input) => request(executionPresentationRpc.events, executionPresentationRpc.events.input.parse(input)))
handle(LOCAL_CHAT_IPC_CHANNELS.executionsSendDraft, (_event, input) => request(executionPresentationRpc.sendDraft, executionPresentationRpc.sendDraft.input.parse(input)))
handle(LOCAL_CHAT_IPC_CHANNELS.executionsSend, (_event, input) => request(executionPresentationRpc.send, executionPresentationRpc.send.input.parse(input)))
Expand Down
1 change: 1 addition & 0 deletions apps/buddy/electron/preload/local-chat/executions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ export function createExecutionsApi(): Pick<LocalChatApi, 'executions'> {
executions: Object.freeze({
list: input => ipcRenderer.invoke(LOCAL_CHAT_IPC_CHANNELS.executionsList, input),
detail: input => ipcRenderer.invoke(LOCAL_CHAT_IPC_CHANNELS.executionsDetail, input),
inputs: input => ipcRenderer.invoke(LOCAL_CHAT_IPC_CHANNELS.executionsInputs, input),
events: input => ipcRenderer.invoke(LOCAL_CHAT_IPC_CHANNELS.executionsEvents, input),
send: input => ipcRenderer.invoke(LOCAL_CHAT_IPC_CHANNELS.executionsSend, input),
sendDraft: input => ipcRenderer.invoke(LOCAL_CHAT_IPC_CHANNELS.executionsSendDraft, input),
Expand Down
5 changes: 4 additions & 1 deletion apps/buddy/electron/shared/localChatApi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,8 @@ import type { ConversationListFilter, ConversationModelControls, ConversationTim
import type { ConversationNodeDetailRequest, LocalConversationTree } from '../../shared/conversation/conversationTree'
import type { LocalTaskMark, LocalTaskMarkState, TaskMarkClearInput, TaskMarkInput, TaskMarkReadInput } from '../../shared/conversation/taskMarkApi'
import type { LocalWorkspaceSetting, LocalWorkspaceStateValue } from '../../shared/conversation/workspaceApi'
import type { ExecutionAnswer, ExecutionEventsRequest, ExecutionsChanged, ExecutionScope, ExecutionSend, ExecutionSendDraft, ExecutionTarget, LocalExecution, LocalExecutionDetail, LocalExecutionEventsPage } from '../../shared/executions/executionPresentationApi'
import type { LocalExecutionInputsPage } from '../../shared/executions/executionInputReceipt'
import type { ExecutionAnswer, ExecutionEventsRequest, ExecutionInputsRequest, ExecutionsChanged, ExecutionScope, ExecutionSend, ExecutionSendDraft, ExecutionTarget, LocalExecution, LocalExecutionDetail, LocalExecutionEventsPage } from '../../shared/executions/executionPresentationApi'
import type { DirectoryPage, FilePreview } from '../../shared/files/filePreview'
import type { TaskFile, TaskFileLink, TaskFileTarget } from '../../shared/files/taskFileApi'
import type { WebSettings, WebSettingsSnapshot } from '../../shared/network/webProtocol'
Expand All @@ -50,6 +51,7 @@ import type { LocalUsageSnapshot } from '../../shared/usage/usageApi'
export const LOCAL_CHAT_IPC_CHANNELS = {
executionsList: 'lexora:local-chat:executions:list',
executionsDetail: 'lexora:local-chat:executions:detail',
executionsInputs: 'lexora:local-chat:executions:inputs',
executionsAnswer: 'lexora:local-chat:executions:answer',
executionsCancel: 'lexora:local-chat:executions:cancel',
executionsEvents: 'lexora:local-chat:executions:events',
Expand Down Expand Up @@ -257,6 +259,7 @@ export interface LocalChatApi {
executions: {
list: (input: ExecutionScope) => Promise<readonly LocalExecution[]>
detail: (input: ExecutionTarget) => Promise<LocalExecutionDetail>
inputs: (input: ExecutionInputsRequest) => Promise<LocalExecutionInputsPage>
events: (input: ExecutionEventsRequest) => Promise<LocalExecutionEventsPage>
send: (input: ExecutionSend) => Promise<{ accepted: boolean }>
sendDraft: (input: ExecutionSendDraft) => Promise<LocalComposerDraft | null>
Expand Down
2 changes: 1 addition & 1 deletion apps/buddy/service/src/BuddyService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1543,7 +1543,7 @@ export async function startBuddyService(
)
register(registerRuntimeRequest(options.rpc, promptsRpc.get, () => getBuiltinPromptCatalog()))
register(registerContextPanelRpc({ rpc: options.rpc, runs, events: options.eventLog }))
register(registerExecutionRpc({ rpc: options.rpc, service: executionPresentation, inputs: new ExecutionComposerInputService({ drafts: composerDraftService, conversations, executions: executionRecords, mailbox: createMailboxRepository(options.database), delivery: executionPresentation }) }))
register(registerExecutionRpc({ rpc: options.rpc, service: executionPresentation, inputs: new ExecutionComposerInputService({ drafts: composerDraftService, conversations, executions: executionRecords, getRunPermissionCeiling, mailbox: createMailboxRepository(options.database), delivery: executionPresentation }) }))
register(
registerAttachmentRpc({
rpc: options.rpc,
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type { ExecutionSendDraft } from '../../../../shared/executions/executionPresentationApi'
import type { BuddyExecutionProfile } from '../../../../shared/permissions/executionProfile'
import type { ComposerDraftService } from '../../chat/ComposerDraftService'
import type { ConversationRepository } from '../../storage/conversationRepository'
import type { ExecutionRepository, ExecutionStateRecord } from '../../storage/executionRepository'
Expand All @@ -14,6 +15,7 @@ interface ExecutionComposerInputOptions {
drafts: Pick<ComposerDraftService, 'update'>
conversations: Pick<ConversationRepository, 'findById'>
executions: Pick<ExecutionRepository, 'find'>
getRunPermissionCeiling: (runId: string) => BuddyExecutionProfile | undefined
mailbox: Pick<MailboxRepository, 'find' | 'findQuestion' | 'listQuestions'>
delivery: Pick<ExecutionPresentationService, 'send' | 'answer'>
}
Expand All @@ -26,32 +28,63 @@ export class ExecutionComposerInputService {
}

async send(input: ExecutionSendDraft) {
const digest = createHash('sha256').update(JSON.stringify(['composer-execution-input', input.draftId, input.expectedRevision, input.conversationId, input.branchId, input.rootRunId, input.runId])).digest('hex')
if (input.delivery && (input.delivery.mode !== 'current' || input.delivery.target?.rootRunId !== input.rootRunId || input.delivery.target.runId !== input.runId))
throw new BuddyServiceError('INPUT_DELIVERY_INCOMPATIBLE')
const digest = createHash('sha256').update(JSON.stringify(['composer-execution-input', input.draftId, input.expectedRevision, input.conversationId, input.branchId, input.rootRunId, input.runId, ...(input.delivery ? [input.delivery] : [])])).digest('hex')
const messageId = `${digest.slice(0, 8)}-${digest.slice(8, 12)}-8${digest.slice(13, 16)}-a${digest.slice(17, 20)}-${digest.slice(20, 32)}`
let accepted = false
const draft = await this.#options.drafts.update(input.draftId, async (draft) => {
const execution = this.#options.executions.find(input.runId)
if (!execution || !['actor_child', 'root'].includes(execution.run.executionKind))
throw new BuddyServiceError('VALIDATION_FAILED')
throw new BuddyServiceError(input.delivery ? 'INPUT_DELIVERY_TARGET_UNAVAILABLE' : 'VALIDATION_FAILED')
if (input.delivery && input.delivery.actorId !== execution.run.buddyId)
throw new BuddyServiceError('INPUT_DELIVERY_TARGET_UNAVAILABLE')
const previous = this.#options.mailbox.find(messageId)
const question = this.#options.mailbox.listQuestions(input.runId)
.find(question => question.kind === 'question' && question.audience === 'user' && this.#options.mailbox.findQuestion(question.questionId)?.open)
const questionId = previous?.message.kind === 'answer' ? previous.message.questionId : question?.kind === 'question' ? question.questionId : null
const conversation = this.#options.conversations.findById(input.conversationId)
if (!previous) {
if (draft.revision !== input.expectedRevision)
throw new BuddyServiceError('DRAFT_CONFLICT')
const actors = [...new Set(getBuddyUserContentActorReferences(draft.content).map(reference => reference.actorId))]
if (draft.scope.kind !== 'conversation_branch' || draft.scope.conversationId !== input.conversationId || draft.scope.branchId !== input.branchId
|| actors.length !== 1 || actors[0] !== execution.run.buddyId || !isExecutionTextInput(draft.content)
|| execution.run.rootRunId !== input.rootRunId || execution.run.conversationId !== input.conversationId || execution.run.branchId !== input.branchId
|| this.#options.conversations.findById(input.conversationId)?.activeBranchId !== input.branchId) {
|| !((actors.length === 1 && actors[0] === execution.run.buddyId)
|| (actors.length === 0 && conversation?.buddyId === execution.run.buddyId
&& (questionId || (input.delivery?.mode === 'current' && execution.run.executionKind === 'actor_child'))))
|| execution.run.rootRunId !== input.rootRunId || execution.run.conversationId !== input.conversationId || execution.run.branchId !== input.branchId
|| this.#options.conversations.findById(input.conversationId)?.activeBranchId !== input.branchId) {
throw new BuddyServiceError('VALIDATION_FAILED')
}
if (!isExecutionTextInput(draft.content))
throw new BuddyServiceError(input.delivery ? 'INPUT_DELIVERY_INCOMPATIBLE' : 'VALIDATION_FAILED')
if (input.delivery) {
const executionProfile = this.#options.getRunPermissionCeiling(execution.run.id) === 'read_only'
? 'read_only'
: conversation?.executionProfile
const selection = execution.run.modelOverride ?? {
modelId: execution.run.model,
providerId: execution.run.provider,
reasoning: execution.initialInput?.reasoning ?? null,
serviceTier: execution.initialInput?.serviceTier ?? null,
}
if (draft.executionConfig.approvalPolicy !== conversation?.approvalPolicy || draft.executionConfig.executionProfile !== executionProfile
|| (draft.modelSelection && (draft.modelSelection.modelId !== selection.modelId || draft.modelSelection.providerId !== selection.providerId
|| draft.modelSelection.reasoning !== selection.reasoning || draft.modelSelection.serviceTier !== selection.serviceTier))) {
throw new BuddyServiceError('INPUT_DELIVERY_INCOMPATIBLE')
}
}
}
if (!previous && this.#unavailable(execution))
if (!previous && this.#unavailable(execution)) {
if (input.delivery)
throw new BuddyServiceError('INPUT_DELIVERY_TARGET_UNAVAILABLE')
return null
const question = this.#options.mailbox.listQuestions(input.runId)
.find(question => question.kind === 'question' && question.audience === 'user' && this.#options.mailbox.findQuestion(question.questionId)?.open)
const questionId = previous?.message.kind === 'answer' ? previous.message.questionId : question?.kind === 'question' ? question.questionId : null
if (!previous && execution.run.executionKind === 'root' && !questionId)
}
if (!previous && execution.run.executionKind === 'root' && !questionId) {
if (input.delivery)
throw new BuddyServiceError('INPUT_DELIVERY_INCOMPATIBLE')
return null
}
const delivery = {
conversationId: input.conversationId,
branchId: input.branchId,
Expand All @@ -69,8 +102,11 @@ export class ExecutionComposerInputService {
catch (error) {
const recorded = this.#options.mailbox.find(messageId)
const current = this.#options.executions.find(input.runId)
if (((error instanceof ExecutionAdmissionError && error.code === 'stale') || (error instanceof BuddyServiceError && error.code === 'VALIDATION_FAILED')) && recorded === null && current && this.#unavailable(current))
if (((error instanceof ExecutionAdmissionError && error.code === 'stale') || (error instanceof BuddyServiceError && error.code === 'VALIDATION_FAILED')) && recorded === null && (!current || this.#unavailable(current))) {
if (input.delivery)
throw new BuddyServiceError('INPUT_DELIVERY_TARGET_UNAVAILABLE')
return null
}
throw error
}
accepted = true
Expand Down
Loading