From 0983e409e14d1f8d50756f3ba74ed427b71b2d0d Mon Sep 17 00:00:00 2001 From: shanyuhai123 <864299347@qq.com> Date: Wed, 28 Oct 2026 08:25:39 +0800 Subject: [PATCH] =?UTF-8?q?feat(buddy):=20=E5=AE=8C=E5=96=84=E6=B6=88?= =?UTF-8?q?=E6=81=AF=E6=8A=95=E9=80=92=E4=B8=8E=E5=AF=B9=E8=AF=9D=E6=97=B6?= =?UTF-8?q?=E9=97=B4=E7=BA=BF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../scripts/__tests__/agent-queue.e2e.mjs | 31 +- .../scripts/__tests__/model-selection.e2e.mjs | 198 ++++++++--- .../scripts/fixtures/model-controls.mjs | 32 ++ .../electron/main/local-chat/executions.ts | 1 + .../electron/preload/local-chat/executions.ts | 1 + apps/buddy/electron/shared/localChatApi.ts | 5 +- apps/buddy/service/src/BuddyService.ts | 2 +- .../ExecutionComposerInputService.ts | 58 +++- .../agent/execution/ExecutionCoordinator.ts | 64 ++++ .../execution/ExecutionDelegationService.ts | 47 ++- .../execution/ExecutionPresentationService.ts | 63 +++- .../ExecutionDelegationService.spec.ts | 33 +- .../ExecutionPresentationService.spec.ts | 327 ++++++++++++++---- .../executionInputPresentation.spec.ts | 177 ++++++++++ .../execution/executionForwardEvidence.ts | 60 ++++ .../agent/execution/registerExecutionRpc.ts | 1 + .../agent/extensions/delegationCapability.ts | 33 +- .../sessions/__tests__/modelRetry.spec.ts | 135 +++++--- .../sessions/createReusableBuddySession.ts | 66 +++- .../service/src/chat/ChatQueueService.ts | 96 ++++- .../buddy/service/src/chat/ChatTurnService.ts | 9 +- .../chat/__tests__/ChatQueueService.spec.ts | 325 +++++++++++++++-- apps/buddy/service/src/rpc/runtimeRequest.ts | 2 + .../__tests__/chatQueueRepository.spec.ts | 69 ++++ .../conversationHistoryRepositories.spec.ts | 166 ++++----- .../src/storage/__tests__/schema.spec.ts | 34 ++ .../__tests__/turnRequestRepository.spec.ts | 47 ++- .../src/storage/chatQueueRepository.ts | 26 +- .../storage/conversationTimelineRepository.ts | 73 +++- .../src/storage/executionPublicContext.ts | 178 ++++++++++ .../src/storage/executionRepository.ts | 69 +++- .../service/src/storage/mailboxRepository.ts | 20 ++ .../storage/migrations/v35InputDelivery.ts | 1 + apps/buddy/service/src/storage/schema.ts | 4 +- .../src/storage/turnRequestRepository.ts | 76 +++- apps/buddy/shared/conversation/chatApi.ts | 10 +- .../buddy/shared/conversation/chatQueueApi.ts | 4 +- .../shared/conversation/conversationApi.ts | 2 + .../executions/executionInputReceipt.ts | 33 ++ .../executions/executionPresentationApi.ts | 12 +- apps/buddy/shared/runtime/localChatError.ts | 4 + apps/buddy/src/i18n/locales/en-US/tasks.ts | 9 + apps/buddy/src/i18n/locales/zh-CN/tasks.ts | 9 + apps/buddy/src/modules/tasks/contracts.ts | 4 + .../__tests__/chatExecutionInputRows.spec.ts | 82 +++++ .../__tests__/chatTranscriptGroups.spec.ts | 132 +++++++ .../__tests__/chatTranscriptSegments.spec.ts | 155 +++++---- .../transcript/chatExecutionInputRows.ts | 54 +++ .../transcript/chatIncrementalTranscript.ts | 17 +- .../tasks/model/transcript/chatMessageTime.ts | 8 + .../transcript/chatPersistedTranscriptRows.ts | 5 +- .../transcript/chatRunStreamingMessages.ts | 41 ++- .../transcript/chatTranscriptActivities.ts | 14 +- .../model/transcript/chatTranscriptGroups.ts | 51 +++ .../transcript/chatTranscriptProjection.ts | 87 +---- .../transcript/chatTranscriptSegments.ts | 7 +- .../model/transcript/chatTranscriptTypes.ts | 9 + .../state/__tests__/useTaskCapability.spec.ts | 162 +++------ .../__tests__/useActorInputTarget.spec.ts | 76 ++-- .../state/composer/useActorInputTarget.ts | 42 ++- .../useTaskDraftModelBinding.spec.ts | 233 ++++++++++--- .../state/drafts/useTaskModelPersistence.ts | 130 +++---- .../__tests__/useChatExecutionTree.spec.ts | 106 ++++++ .../__tests__/useChatTurnExecution.spec.ts | 249 +++++++++++-- .../runs/__tests__/useExecutionDetail.spec.ts | 14 +- .../tasks/state/runs/useChatExecution.ts | 1 + .../tasks/state/runs/useChatExecutionTree.ts | 39 ++- .../tasks/state/runs/useChatTurnExecution.ts | 109 +++++- .../modules/tasks/state/useTaskCapability.ts | 45 ++- .../widgets/composer/ChatMessageQueue.vue | 25 +- .../widgets/composer/DesktopChatComposer.vue | 8 +- .../composer/DesktopComposerSubmitActions.vue | 28 +- .../modules/tasks/widgets/composer/typing.ts | 5 + .../executions/BuddyExecutionInputs.vue | 2 +- .../transcript/BuddyChatActorProcess.vue | 2 - .../transcript/BuddyChatExecutionInput.vue | 47 +++ .../transcript/BuddyChatMessageList.vue | 9 + .../transcript/DesktopChatTranscript.vue | 1 + .../widgets/workspace/DesktopTaskComposer.vue | 3 +- .../__tests__/useChatViewport.spec.ts | 109 +++--- .../__tests__/useChatWorkspace.spec.ts | 17 +- .../modules/tasks/widgets/workspace/typing.ts | 2 +- .../widgets/workspace/useChatViewport.ts | 9 +- .../widgets/workspace/useTaskComposer.ts | 9 +- apps/buddy/src/shared/lib/localChatError.ts | 2 + 85 files changed, 3761 insertions(+), 1001 deletions(-) create mode 100644 .playwright/scripts/fixtures/model-controls.mjs create mode 100644 apps/buddy/service/src/agent/execution/__tests__/executionInputPresentation.spec.ts create mode 100644 apps/buddy/service/src/agent/execution/executionForwardEvidence.ts create mode 100644 apps/buddy/service/src/storage/executionPublicContext.ts create mode 100644 apps/buddy/service/src/storage/migrations/v35InputDelivery.ts create mode 100644 apps/buddy/shared/executions/executionInputReceipt.ts create mode 100644 apps/buddy/src/modules/tasks/model/transcript/__tests__/chatExecutionInputRows.spec.ts create mode 100644 apps/buddy/src/modules/tasks/model/transcript/__tests__/chatTranscriptGroups.spec.ts create mode 100644 apps/buddy/src/modules/tasks/model/transcript/chatExecutionInputRows.ts create mode 100644 apps/buddy/src/modules/tasks/model/transcript/chatTranscriptGroups.ts create mode 100644 apps/buddy/src/modules/tasks/state/runs/__tests__/useChatExecutionTree.spec.ts create mode 100644 apps/buddy/src/modules/tasks/widgets/transcript/BuddyChatExecutionInput.vue diff --git a/.playwright/scripts/__tests__/agent-queue.e2e.mjs b/.playwright/scripts/__tests__/agent-queue.e2e.mjs index d9632a108..9bf85ac98 100644 --- a/.playwright/scripts/__tests__/agent-queue.e2e.mjs +++ b/.playwright/scripts/__tests__/agent-queue.e2e.mjs @@ -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 @@ -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) @@ -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') diff --git a/.playwright/scripts/__tests__/model-selection.e2e.mjs b/.playwright/scripts/__tests__/model-selection.e2e.mjs index 17d06674d..c9f350194 100644 --- a/.playwright/scripts/__tests__/model-selection.e2e.mjs +++ b/.playwright/scripts/__tests__/model-selection.e2e.mjs @@ -5,8 +5,9 @@ import { createServer } from 'node:http' import path from 'node:path' import { DatabaseSync } from 'node:sqlite' import { expect, test, useSyntheticCredentialStorage } from '../fixtures/electron.mjs' +import { selectComposerModel, selectComposerPermission } from '../fixtures/model-controls.mjs' -test('model controls preview dragging, commit on release and retain advanced panel selections', async ({ buddy }) => { +test('model controls commit effort on release and retain effort and speed across panels', async ({ buddy }) => { const instance = await buddy.createInstance('model-selection') const desktop = await instance.launch() const { page } = desktop @@ -52,11 +53,6 @@ test('model controls preview dragging, commit on release and retain advanced pan await expect(trigger).toHaveAttribute('aria-label', `${model.displayName} · ${finalLevel}`) await expect(slider).toBeVisible() - await page.keyboard.press('Escape') - await expect(slider).toBeHidden() - await trigger.click() - await expect(slider).toHaveValue(String(targetIndex)) - const fastMode = page.getByRole('switch', { name: 'Fast', exact: true }) await fastMode.click() await expect(fastMode).toBeChecked() @@ -66,25 +62,19 @@ test('model controls preview dragging, commit on release and retain advanced pan await expect(speed).toBeChecked() await speed.click() await expect(speed).not.toBeChecked() - await advanced.getByRole('button', { name: /^模型/ }).click() - const models = page.locator('.desktop-model-picker') - await expect(models).toBeVisible() - await models.getByPlaceholder('搜索模型…').fill(model.displayName) - await models.getByRole('menuitemradio', { name: `${model.displayName} ${model.modelId}`, exact: true }).click() - await expect(trigger).toContainText(model.displayName) await advanced.locator('.desktop-model-selector__back').click() await expect(slider).toBeVisible() await expect(fastMode).not.toBeChecked() - const advancedValue = await slider.inputValue() await page.keyboard.press('Escape') await expect(slider).toBeHidden() await trigger.click() - await expect(slider).toHaveValue(advancedValue) + await expect(slider).toHaveValue(String(targetIndex)) + await expect(fastMode).not.toBeChecked() await page.keyboard.press('Escape') expect(desktop.diagnostics.console.filter(item => item.type === 'pageerror')).toEqual([]) }) -test('live model selection resumes retries and drives regeneration while permission changes revoke approvals', async ({ buddy }, testInfo) => { +test('live model selection resumes retries and regeneration while permissions revoke approvals and constrain an API queued review', async ({ buddy }) => { const requests = [] let requestedWrite = false let gatedResponse = null @@ -118,7 +108,7 @@ test('live model selection resumes retries and drives regeneration while permiss const desktop = await instance.launch() await useSyntheticCredentialStorage(desktop) const { page } = desktop - await page.evaluate(async (baseUrl) => { + const models = await page.evaluate(async (baseUrl) => { await window.lexoraDesktop.settings.update({ runtime: { modelRetryLimit: 'unlimited' } }) const providers = window.lexoraDesktop.localChat.providers await providers.upsertCustom({ id: 'live-fixture', displayName: 'Live fixture', api: 'openai-completions', baseUrl, enabled: true, models: ['a', 'b'].map(id => ({ id: `model-${id}`, name: `Model ${id.toUpperCase()}`, input: ['text'], reasoning: false, contextWindow: 128000, maxTokens: 1024 })) }) @@ -131,32 +121,21 @@ test('live model selection resumes retries and drives regeneration while permiss } finally { stop() } await providers.setDefaultModel({ providerId: 'live-fixture', modelId: 'model-a', reasoning: null }) + return providers.listModels('live-fixture') }, `http://127.0.0.1:${server.address().port}/v1`) await page.reload() await page.locator('.desktop-chat-composer__prosemirror:visible').fill('Keep this input while changing model and permissions.') await page.getByRole('button', { name: '发送消息', exact: true }).click() await expect(page.locator('.buddy-chat-run-activity')).toContainText('秒后重试') - await page.locator('.desktop-permission-mode-selector__trigger:visible').click() - for (const name of ['本机只读', '人工审批', '智能审批', '完全访问']) - await expect(page.getByRole('menuitemradio', { name: new RegExp(name) })).toBeEnabled() - await page.screenshot({ path: testInfo.outputPath('running-permission-menu.png'), animations: 'disabled' }) - await page.getByRole('menuitemradio', { name: /人工审批/ }).click() + await selectComposerPermission(page, '人工审批') await expect(page.locator('.desktop-permission-mode-selector__trigger:visible')).toContainText('人工审批') const trigger = page.locator('.desktop-model-selector__trigger:visible') - await expect(trigger).toBeEnabled() - await trigger.click() - await page.locator('.desktop-model-selector__advanced').click() - await page.locator('.desktop-model-selector__panel--advanced').getByRole('button', { name: /^模型/ }).click() - await page.screenshot({ path: testInfo.outputPath('running-model-menu.png'), animations: 'disabled' }) - await page.locator('.desktop-model-picker').getByRole('menuitemradio', { name: 'Model B model-b', exact: true }).click() - await page.keyboard.press('Escape') + await selectComposerModel(page, models.find(model => model.modelId === 'model-b'), null) await expect(trigger).toContainText('Model B') const read = query => readModelDatabase(instance.home, query) await expect.poll(() => read(db => db.prepare('SELECT COUNT(*) AS count FROM approvals WHERE status = ?').get('pending').count)).toBe(1) const approval = read(db => db.prepare('SELECT id FROM approvals WHERE status = ?').get('pending')) - await page.locator('.desktop-permission-mode-selector__trigger:visible').click() - await page.screenshot({ path: testInfo.outputPath('approval-permission-menu.png'), animations: 'disabled' }) - await page.getByRole('menuitemradio', { name: /本机只读/ }).click() + await selectComposerPermission(page, '本机只读') await expect.poll(() => read(db => db.prepare('SELECT status FROM approvals WHERE id = ?').get(approval.id).status)).toBe('cancelled') await expect.poll(() => read(db => db.prepare('SELECT COUNT(*) AS count FROM runs WHERE status = ?').get('completed').count)).toBe(1) expect(requests.filter(body => body.model === 'model-b')).toHaveLength(2) @@ -169,7 +148,6 @@ test('live model selection resumes retries and drives regeneration while permiss await expect(page.locator('.desktop-permission-mode-selector__trigger:visible')).toContainText('本机只读') const regenerate = page.getByTestId(`regenerate-run-${first.id}`) await expect(regenerate).toBeVisible() - await page.screenshot({ path: testInfo.outputPath('current-model-regenerate-footer.png'), animations: 'disabled' }) await regenerate.click() await expect.poll(() => read(db => db.prepare('SELECT COUNT(*) AS count FROM runs WHERE status = ?').get('completed').count)).toBe(2) expect(requests.at(-1).model).toBe('model-b') @@ -188,10 +166,7 @@ test('live model selection resumes retries and drives regeneration while permiss await page.locator('.desktop-chat-composer__prosemirror:visible').fill('/review inspect the current task') await page.getByRole('button', { name: '发送消息', exact: true }).click() await expect.poll(() => requests.length).toBe(beforeReview + 1) - await page.locator('.desktop-permission-mode-selector__trigger:visible').click() - await page.getByRole('menuitemradio', { name: /完全访问/ }).click() - await page.getByRole('checkbox', { name: '我已了解风险,并愿意继续' }).check() - await page.getByRole('button', { name: '允许完全访问', exact: true }).click() + await selectComposerPermission(page, '完全访问') await expect(page.locator('.desktop-permission-mode-selector__trigger:visible')).toContainText('完全访问') releaseResponse() await expect.poll(() => read(db => db.prepare('SELECT COUNT(*) AS count FROM runs WHERE status = ?').get('completed').count)).toBe(3) @@ -200,8 +175,7 @@ test('live model selection resumes retries and drives regeneration while permiss const taskFile = path.join(instance.home, 'buddy/conversations', first.conversation_id, 'workspace/hot-permission.txt') await expect(readFile(taskFile, 'utf8')).rejects.toMatchObject({ code: 'ENOENT' }) - await page.locator('.desktop-permission-mode-selector__trigger:visible').click() - await page.getByRole('menuitemradio', { name: /本机只读/ }).click() + await selectComposerPermission(page, '本机只读') const ordinaryGate = Promise.withResolvers() gatedResponse = ordinaryGate.promise releaseResponse = () => { @@ -214,12 +188,9 @@ test('live model selection resumes retries and drives regeneration while permiss await expect.poll(() => requests.length).toBe(beforeOrdinary + 1) const ordinary = read(db => db.prepare('SELECT id, execution_profile FROM runs WHERE status = ?').get('running')) expect(ordinary.execution_profile).toBe('read_only') - await page.locator('.desktop-permission-mode-selector__trigger:visible').click() - await page.getByRole('menuitemradio', { name: /完全访问/ }).click() - await page.getByRole('checkbox', { name: '我已了解风险,并愿意继续' }).check() - await page.getByRole('button', { name: '允许完全访问', exact: true }).click() + await selectComposerPermission(page, '完全访问') await page.locator('.desktop-chat-composer__prosemirror:visible').fill('/review inspect without changing anything') - await page.getByRole('button', { name: '加入队列', exact: true }).click() + await enqueueComposerThroughApi(page, read, ordinary.id, 'next') await expect.poll(() => read(db => db.prepare('SELECT COUNT(*) AS count FROM chat_queue WHERE state = ?').get('waiting').count)).toBe(1) requestedWrite = false releaseResponse() @@ -234,7 +205,6 @@ test('live model selection resumes retries and drives regeneration while permiss await expect(readFile(taskFile, 'utf8')).rejects.toMatchObject({ code: 'ENOENT' }) await expect(page.locator('.chat-message-queue__row')).toHaveCount(0) await expect(page.getByRole('button', { name: '发送消息', exact: true })).toBeVisible() - await page.screenshot({ path: testInfo.outputPath('queued-review-read-only.png'), animations: 'disabled' }) requestedWrite = false await page.locator('.desktop-chat-composer__prosemirror:visible').fill('Create the file for an ordinary task.') @@ -251,7 +221,7 @@ test('live model selection resumes retries and drives regeneration while permiss } }) -test('queued steering uses live permissions and deferred follow-up starts with the latest settings', async ({ buddy }, testInfo) => { +test('API queued steering uses live permissions and explicit next input starts with the latest settings', async ({ buddy }) => { const requests = [] const gates = new Map([0, 2].map(index => [index, Promise.withResolvers()])) const instance = await buddy.createInstance('queue-live-permissions') @@ -302,19 +272,14 @@ test('queued steering uses live permissions and deferred follow-up starts with t await expect.poll(() => requests.length).toBe(1) const first = read(db => db.prepare('SELECT id, approval_policy, execution_profile FROM runs WHERE status = ?').get('running')) expect(first.execution_profile).toBe('workspace_write') - await permission.click() - await page.getByRole('menuitemradio', { name: /完全访问/ }).click() - await page.getByRole('checkbox', { name: '我已了解风险,并愿意继续' }).check() - await page.getByRole('button', { name: '允许完全访问', exact: true }).click() + await selectComposerPermission(page, '完全访问') await expect(permission).toContainText('完全访问') await composer.fill('Include this immediate instruction after the permission change.') - await page.getByRole('button', { name: '加入队列', exact: true }).click() + await enqueueComposerThroughApi(page, read, first.id) await expect(page.locator('.chat-message-queue__row')).toHaveCount(1) await page.getByRole('button', { name: '立即发送', exact: true }).click() await expect.poll(() => read(db => db.prepare('SELECT state, run_id FROM chat_queue ORDER BY rowid DESC LIMIT 1').get())).toEqual({ state: 'sent', run_id: first.id }) await expect(page.locator('.chat-message-queue__row')).toHaveCount(0) - await expect(page.getByText('队列操作未完成,请稍后重试', { exact: true })).toHaveCount(0) - await page.screenshot({ path: testInfo.outputPath('steering-after-full-access.png'), animations: 'disabled' }) gates.get(0).resolve() await expect.poll(() => read(db => db.prepare('SELECT status FROM runs WHERE id = ?').get(first.id).status)).toBe('completed') expect(requests).toHaveLength(2) @@ -328,10 +293,9 @@ test('queued steering uses live permissions and deferred follow-up starts with t const second = read(db => db.prepare('SELECT id, approval_policy, execution_profile FROM runs WHERE status = ?').get('running')) expect(second.execution_profile).toBe('full_access') await composer.fill('Process this queued follow-up with the latest read-only permissions.') - await page.getByRole('button', { name: '加入队列', exact: true }).click() + await enqueueComposerThroughApi(page, read, second.id, 'next') await expect(page.locator('.chat-message-queue__row')).toHaveCount(1) - await permission.click() - await page.getByRole('menuitemradio', { name: /本机只读/ }).click() + await selectComposerPermission(page, '本机只读') await expect(permission).toContainText('本机只读') gates.get(2).resolve() await expect.poll(() => read(db => db.prepare('SELECT status FROM runs WHERE id = ?').get(second.id).status)).toBe('completed') @@ -345,7 +309,6 @@ test('queued steering uses live permissions and deferred follow-up starts with t expect(read(db => db.prepare('SELECT COUNT(*) AS count FROM runs').get().count)).toBe(3) expect(read(db => db.prepare('SELECT id, approval_policy, execution_profile FROM runs WHERE id = ?').get(second.id))).toEqual(second) await expect(page.locator('.chat-message-queue__row')).toHaveCount(0) - await page.screenshot({ path: testInfo.outputPath('follow-up-after-read-only.png'), animations: 'disabled' }) expect(desktop.diagnostics.console.filter(item => item.type === 'pageerror')).toEqual([]) } finally { @@ -357,6 +320,129 @@ test('queued steering uses live permissions and deferred follow-up starts with t } }) +test('switching model and effort during a tool batch immediately supplements the same run without replaying tools', async ({ buddy }) => { + test.setTimeout(180000) + const instance = await buddy.createInstance('tool-boundary-model-selection') + const requests = [] + const errors = [] + let workspace + const server = createServer(async (request, response) => { + try { + const chunks = [] + for await (const chunk of request) + chunks.push(chunk) + const payload = JSON.parse(Buffer.concat(chunks).toString('utf8')) + requests.push(payload) + const first = requests.length === 1 + const shell = payload.tools.find(tool => tool.function?.name === 'lexora_shell')?.function.name + ?? payload.tools.find(tool => tool.function?.name === 'bash')?.function.name + if (first && !shell) + throw new Error('The bounded tool fixture requires a disclosed shell tool') + const parameters = command => ({ command, timeout: 120, ...(shell === 'lexora_shell' ? { shell: 'system' } : {}) }) + const calls = first + ? [ + { index: 0, id: 'gate-once', type: 'function', function: { name: shell, arguments: JSON.stringify(parameters('printf \'gate-once\\n\' >> hot-switch-calls; while [ ! -f hot-switch-release ]; do sleep 0.05; done; printf \'gate complete\'')) } }, + { index: 1, id: 'tail-once', type: 'function', function: { name: shell, arguments: JSON.stringify(parameters('printf \'tail-once\\n\' >> hot-switch-calls')) } }, + ] + : [] + const common = { id: `hot-${requests.length}`, created: 1, model: payload.model, object: 'chat.completion.chunk' } + response.writeHead(200, { 'content-type': 'text/event-stream' }) + response.write(`data: ${JSON.stringify({ ...common, choices: [{ index: 0, delta: first ? { role: 'assistant', tool_calls: calls } : { role: 'assistant', content: '已在同一轮处理新模型补充。' }, finish_reason: null }] })}\n\n`) + response.write(`data: ${JSON.stringify({ ...common, choices: [{ index: 0, delta: {}, finish_reason: first ? 'tool_calls' : 'stop' }], usage: { prompt_tokens: 32, completion_tokens: 8, total_tokens: 40 } })}\n\n`) + response.end('data: [DONE]\n\n') + } + catch (error) { + errors.push(String(error)) + response.destroy() + } + }) + server.listen(0, '127.0.0.1') + await once(server, 'listening') + try { + const desktop = await instance.launch() + const { page } = desktop + await useSyntheticCredentialStorage(desktop) + const models = await page.evaluate(async (baseUrl) => { + const api = window.lexoraDesktop.localChat + await window.lexoraDesktop.settings.update({ desktop: { language: 'zh-CN' }, runtime: { codemode: false, modelRetryLimit: 0 } }) + await api.providers.upsertCustom({ id: 'hot-model-fixture', displayName: 'Hot model fixture', api: 'openai-completions', baseUrl, enabled: true, models: ['a', 'b'].map(suffix => ({ id: `hot-model-${suffix}`, name: `Hot Model ${suffix.toUpperCase()}`, input: ['text'], reasoning: true, contextWindow: 128000, maxTokens: 2048 })) }) + const stop = api.providers.onAuthChallenge((challenge) => { + if (challenge.providerId === 'hot-model-fixture' && challenge.type === 'secret') + void api.providers.respondToAuth(challenge.challengeId, 'offline-fixture-key') + }) + try { + await api.providers.login('hot-model-fixture', 'api_key') + } + finally { + stop() + } + await api.providers.setDefaultModel({ providerId: 'hot-model-fixture', modelId: 'hot-model-a', reasoning: 'low' }) + return api.providers.listModels('hot-model-fixture') + }, `http://127.0.0.1:${server.address().port}/v1`) + await page.reload() + await selectComposerModel(page, models.find(model => model.modelId === 'hot-model-a'), 'low') + await selectComposerPermission(page, '完全访问') + const editor = page.locator('.desktop-chat-composer__prosemirror:visible') + await editor.fill('Execute the two bounded fixture tools once, then wait for the supplemental instruction.') + await page.getByRole('button', { name: '发送消息', exact: true }).click() + const read = query => readModelDatabase(instance.home, query) + await expect.poll(() => read(db => db.prepare('SELECT COUNT(*) n FROM runs').get().n)).toBe(1) + const run = read(db => db.prepare('SELECT id, conversation_id, model FROM runs').get()) + workspace = path.join(instance.home, 'buddy/conversations', run.conversation_id, 'workspace') + await expect.poll(async () => readFile(path.join(workspace, 'hot-switch-calls'), 'utf8').catch(() => '')).toContain('gate-once') + expect(read(db => db.prepare('SELECT status FROM runs WHERE id=?').get(run.id).status)).toBe('running') + const supplemental = 'SWITCHED_INPUT_482:保留工具结果,继续同一项工作。' + await editor.fill(supplemental) + await selectComposerModel(page, models.find(model => model.modelId === 'hot-model-b'), 'high') + await editor.press('Enter') + await expect(editor).toHaveText('') + await expect.poll(() => read(db => db.prepare('SELECT state,run_id FROM chat_queue ORDER BY rowid DESC LIMIT 1').get())).toEqual({ state: 'sent', run_id: run.id }) + expect(requests).toHaveLength(1) + await writeFile(path.join(workspace, 'hot-switch-release'), 'release') + await expect.poll(() => read(db => db.prepare('SELECT status FROM runs WHERE id=?').get(run.id).status)).toBe('completed') + expect(requests).toHaveLength(2) + expect(requests.map(request => [request.model, request.reasoning_effort])).toEqual([['hot-model-a', 'low'], ['hot-model-b', 'high']]) + expect(JSON.stringify(requests[1].messages)).toContain(supplemental) + expect((await readFile(path.join(workspace, 'hot-switch-calls'), 'utf8')).trim().split('\n').sort()).toEqual(['gate-once', 'tail-once']) + expect(read(db => db.prepare('SELECT id,model FROM runs').all())).toEqual([{ id: run.id, model: 'hot-model-a' }]) + const answer = read(db => db.prepare('SELECT content_json FROM messages WHERE run_id=? AND role=\'assistant\' ORDER BY rowid DESC LIMIT 1').get(run.id)) + expect(JSON.parse(answer.content_json).modelId).toBe('hot-model-b') + await expect(page.locator('.chat-message-queue__row')).toHaveCount(0) + expect(errors).toEqual([]) + expect(desktop.diagnostics.console.filter(item => item.type === 'pageerror')).toEqual([]) + } + finally { + if (workspace) + await writeFile(path.join(workspace, 'hot-switch-release'), 'release').catch(() => {}) + await instance.stop() + server.closeAllConnections() + await new Promise(resolve => server.close(resolve)) + } +}) + +async function enqueueComposerThroughApi(page, read, runId, mode) { + const run = read(db => db.prepare('SELECT buddy_id,conversation_id,branch_id,root_run_id FROM runs WHERE id=?').get(runId)) + const marker = await page.locator('.desktop-chat-composer__prosemirror:visible').textContent() + expect(marker).toBeTruthy() + let draft + await expect.poll(async () => { + const row = read(db => db.prepare('SELECT id FROM composer_drafts WHERE scope_kind=\'conversation_branch\' AND conversation_id=? AND branch_id=? ORDER BY updated_at DESC LIMIT 1').get(run.conversation_id, run.branch_id)) + draft = row ? await page.evaluate(id => window.lexoraDesktop.localChat.composerDrafts.get(id), row.id) : null + const permissions = read(db => db.prepare('SELECT approval_policy,execution_profile FROM conversations WHERE id=?').get(run.conversation_id)) + return Boolean(draft && JSON.stringify(draft.content).includes(marker) + && draft.executionConfig.approvalPolicy === permissions.approval_policy + && draft.executionConfig.executionProfile === permissions.execution_profile) + }).toBe(true) + await page.evaluate(({ draft, run, runId, mode }) => window.lexoraDesktop.localChat.chat.enqueue({ + draftId: draft.draftId, + expectedRevision: draft.revision, + requestId: crypto.randomUUID(), + ...(mode ? { delivery: { mode, actorId: run.buddy_id, target: { rootRunId: run.root_run_id, runId } } } : {}), + }), { draft, run, runId, mode }) + await page.reload() + await expect(page.locator('.desktop-chat-composer__prosemirror:visible')).toHaveText('') +} + function readModelDatabase(home, query) { const database = new DatabaseSync(path.join(home, 'buddy/buddy.sqlite3'), { readOnly: true }) try { diff --git a/.playwright/scripts/fixtures/model-controls.mjs b/.playwright/scripts/fixtures/model-controls.mjs new file mode 100644 index 000000000..220a82f63 --- /dev/null +++ b/.playwright/scripts/fixtures/model-controls.mjs @@ -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() + } +} diff --git a/apps/buddy/electron/main/local-chat/executions.ts b/apps/buddy/electron/main/local-chat/executions.ts index bfa22e864..2110a4193 100644 --- a/apps/buddy/electron/main/local-chat/executions.ts +++ b/apps/buddy/electron/main/local-chat/executions.ts @@ -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))) diff --git a/apps/buddy/electron/preload/local-chat/executions.ts b/apps/buddy/electron/preload/local-chat/executions.ts index 3a574419a..3ccddb5ea 100644 --- a/apps/buddy/electron/preload/local-chat/executions.ts +++ b/apps/buddy/electron/preload/local-chat/executions.ts @@ -8,6 +8,7 @@ export function createExecutionsApi(): Pick { 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), diff --git a/apps/buddy/electron/shared/localChatApi.ts b/apps/buddy/electron/shared/localChatApi.ts index a7559dbaa..62de4f6dd 100644 --- a/apps/buddy/electron/shared/localChatApi.ts +++ b/apps/buddy/electron/shared/localChatApi.ts @@ -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' @@ -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', @@ -257,6 +259,7 @@ export interface LocalChatApi { executions: { list: (input: ExecutionScope) => Promise detail: (input: ExecutionTarget) => Promise + inputs: (input: ExecutionInputsRequest) => Promise events: (input: ExecutionEventsRequest) => Promise send: (input: ExecutionSend) => Promise<{ accepted: boolean }> sendDraft: (input: ExecutionSendDraft) => Promise diff --git a/apps/buddy/service/src/BuddyService.ts b/apps/buddy/service/src/BuddyService.ts index 7e1c436fc..817dadbdb 100644 --- a/apps/buddy/service/src/BuddyService.ts +++ b/apps/buddy/service/src/BuddyService.ts @@ -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, diff --git a/apps/buddy/service/src/agent/execution/ExecutionComposerInputService.ts b/apps/buddy/service/src/agent/execution/ExecutionComposerInputService.ts index d8dbde0e0..6a11b79bc 100644 --- a/apps/buddy/service/src/agent/execution/ExecutionComposerInputService.ts +++ b/apps/buddy/service/src/agent/execution/ExecutionComposerInputService.ts @@ -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' @@ -14,6 +15,7 @@ interface ExecutionComposerInputOptions { drafts: Pick conversations: Pick executions: Pick + getRunPermissionCeiling: (runId: string) => BuddyExecutionProfile | undefined mailbox: Pick delivery: Pick } @@ -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, @@ -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 diff --git a/apps/buddy/service/src/agent/execution/ExecutionCoordinator.ts b/apps/buddy/service/src/agent/execution/ExecutionCoordinator.ts index eaf5203c4..5b1ce766f 100644 --- a/apps/buddy/service/src/agent/execution/ExecutionCoordinator.ts +++ b/apps/buddy/service/src/agent/execution/ExecutionCoordinator.ts @@ -224,6 +224,62 @@ export class ExecutionCoordinator implements ExecutionLifecyclePort { }) } + forward(callerRunId: string, targetRunId: string, inputSourceId: string) { + const source = this.#require(callerRunId) + const target = this.#options.executions.requirePublicWork(callerRunId, targetRunId) + return this.#serializeRoots([source.run.rootRunId, target.run.rootRunId], async () => { + const current = this.#require(callerRunId) + const receiver = this.#options.executions.requirePublicWork(callerRunId, targetRunId) + const input = this.#options.executions.findAcceptedUserInput(callerRunId, inputSourceId) + if (!input) + throw new ExecutionAdmissionError('unauthorized') + const metadata = { + runId: targetRunId, + rootRunId: receiver.run.rootRunId, + workId: receiver.work.workId, + actorId: receiver.run.buddyId, + inputSourceId, + inputRunId: callerRunId, + messageId: input.messageId, + origin: 'user' as const, + } + const previous = this.#options.mailbox.find(input.messageId) + if (previous) { + const message = previous.message + if (message.kind !== 'supplement' || message.origin !== 'user' || message.purpose + || message.sourceRunId !== targetRunId || message.targetRunId !== targetRunId || message.text !== input.text) { + throw new ExecutionAdmissionError('conflict') + } + return { ...metadata, status: 'accepted' as const, replayed: true, targetGeneration: message.targetGeneration, payloadHash: message.payloadHash, deliveryState: previous.state } + } + if (this.#stopped) + throw new ExecutionAdmissionError('stale') + this.#options.executions.assertCurrent(this.#fence(current)) + if (input.text === null) + return { ...metadata, status: 'unsupported_input' as const, replayed: false } + const chain = [receiver] + while (chain.at(-1)!.run.parentRunId) + chain.push(this.#require(chain.at(-1)!.run.parentRunId!)) + if (chain.some(record => record.phase === 'closing' || record.phase === 'closed' || record.cancelRequestedAt + || !['queued', 'running'].includes(record.run.status))) { + return { ...metadata, status: 'closed' as const, replayed: false, phase: receiver.phase, targetStatus: receiver.run.status } + } + this.#options.executions.assertCurrent(this.#fence(receiver)) + this.#options.availability?.assertRunExecutable(callerRunId) + this.#options.availability?.assertRunExecutable(targetRunId) + await this.#options.events.append({ runId: targetRunId, type: 'execution.mailbox.sent', payload: { + kind: 'supplement', + origin: 'user', + messageId: input.messageId, + text: input.text, + targetRunId, + targetGeneration: receiver.run.generation, + } }) + const accepted = this.#options.mailbox.find(input.messageId)! + return { ...metadata, status: 'accepted' as const, replayed: false, targetGeneration: accepted.message.targetGeneration, payloadHash: accepted.message.payloadHash, deliveryState: accepted.state } + }) + } + answerQuestion(sourceRunId: string | null, questionId: string, text: string, messageId?: string): Promise { const question = this.#options.mailbox.findQuestion(questionId) if (!question || question.message.kind !== 'question') @@ -576,6 +632,14 @@ export class ExecutionCoordinator implements ExecutionLifecyclePort { this.#changes.fire({ rootRunId }) } + #serializeRoots(rootRunIds: string[], operation: () => Promise): Promise { + const roots = [...new Set(rootRunIds)].sort() + const next = (index: number): Promise => index === roots.length + ? operation() + : this.#serialize(roots[index]!, () => next(index + 1)) + return next(0) + } + #serialize(rootRunId: string, operation: () => Promise): Promise { const before = this.#tails.get(rootRunId) ?? Promise.resolve() const pending = before.catch(() => {}).then(operation) diff --git a/apps/buddy/service/src/agent/execution/ExecutionDelegationService.ts b/apps/buddy/service/src/agent/execution/ExecutionDelegationService.ts index 13be1763e..fefc37532 100644 --- a/apps/buddy/service/src/agent/execution/ExecutionDelegationService.ts +++ b/apps/buddy/service/src/agent/execution/ExecutionDelegationService.ts @@ -18,6 +18,7 @@ import { ExecutionAdmissionError, executionFence, hashChildAdmissionRequest, MAX import { getRoleToolDenial } from '../extensions/discovery/SessionToolScope' import { isPiShellToolName, isShellToolName } from '../extensions/piBuiltinTools' import { BuddySessionStorageError } from '../sessions/BuddySessionErrors' +import { readClosedForwardTargets } from './executionForwardEvidence' import { readExecutionMailboxEntries } from './executionMailboxEntries' import { executionResultPage } from './executionResultPage' @@ -38,6 +39,11 @@ const delegationFields = z.object({ }).strict() export const delegationInputSchema = z.discriminatedUnion('operation', [ + z.object({ + operation: z.literal('forward'), + runId: z.string().min(1).max(256), + inputSourceId: z.string().min(1).max(256), + }).strict(), delegationFields.extend({ operation: z.enum(['list', 'context', 'history', 'delegate', 'resume', 'submit', 'review', 'status', 'result', 'report', 'send', 'wait', 'answer', 'cancel']) }), delegationFields.extend({ operation: z.literal('question'), @@ -58,7 +64,7 @@ export interface ExecutionDelegationServiceOptions { definitions: Pick executions: ExecutionRepository mailbox: MailboxRepository - coordinator: Pick + coordinator: Pick readToolOptions: (signal: AbortSignal) => Promise stopExecution: (runId: string) => Promise readResult: (runId: string) => Promise @@ -80,6 +86,8 @@ export class ExecutionDelegationService { const input = delegationInputSchema.parse(value) signal.throwIfAborted() const source = this.#require(caller.run.runId) + if (input.operation === 'forward') + return this.#options.coordinator.forward(source.run.id, input.runId, input.inputSourceId) this.#options.executions.assertCurrent(executionFence(source, this.#require(source.run.rootRunId))) if (input.operation === 'submit') { if (!input.submission) @@ -105,18 +113,37 @@ export class ExecutionDelegationService { })) } } if (input.operation === 'context') { + if (input.runId) { + const { work, reply } = this.#options.executions.readPublicWork(source.run.id, input.runId) + return executionResultPage({ + sources: [], + attachmentIds: [], + inputs: [], + works: [{ ...work, actorName: this.#options.buddies.get(work.actorId).name }], + replySourceId: reply?.sourceId ?? null, + replyMessageId: reply?.messageId ?? null, + }, reply?.text ?? null, input.offset ?? 0) + } const materials = this.#options.executions.listContext(source.run.id).filter(material => material.kind !== 'message' || material.text.trim() || material.references?.length) const offset = input.offset ?? 0 const attachments = this.#options.executions.listAttachments(source.run.id) const page = materials.slice(offset, offset + 12) - return { attachmentIds: attachments.slice(offset, offset + 12), sources: page.map(material => material.kind === 'message' - ? { sourceId: material.sourceId, actorId: material.actorId, role: material.role, excerpt: material.text.slice(0, 240), referenceCount: material.references?.length ?? 0 } - : { sourceId: material.sourceId, actorId: material.actorId, artifactCount: material.artifacts.length, names: material.artifacts.slice(0, 3).map(artifact => artifact.name.slice(0, 80)) }), nextOffset: offset + 12 < Math.max(materials.length, attachments.length) ? offset + 12 : null } + const inputs = this.#options.executions.listAcceptedUserInputs(source.run.id, offset, 13) + const works = this.#options.executions.listPublicWork(source.run.id, offset, 13) + return { + inputs: inputs.slice(0, 12).map(({ text: _text, ...input }) => input), + works: works.slice(0, 12).map(work => ({ ...work, actorName: this.#options.buddies.get(work.actorId).name })), + attachmentIds: attachments.slice(offset, offset + 12), + sources: page.map(material => material.kind === 'message' + ? { sourceId: material.sourceId, actorId: material.actorId, role: material.role, excerpt: material.text.slice(0, 240), referenceCount: material.references?.length ?? 0 } + : { sourceId: material.sourceId, actorId: material.actorId, artifactCount: material.artifacts.length, names: material.artifacts.slice(0, 3).map(artifact => artifact.name.slice(0, 80)) }), + nextOffset: inputs.length > 12 || works.length > 12 || offset + 12 < Math.max(materials.length, attachments.length) ? offset + 12 : null, + } } if (input.operation === 'list') return this.#list(caller, source, signal) if (input.operation === 'delegate' || input.operation === 'resume') - return this.#delegate(caller, source, requestKey, input, signal) + return this.#delegate(caller, source, requestKey, input, signal, entries) if (input.operation === 'report' || (input.operation === 'send' && input.runId === source.run.parentRunId)) { if (!source.run.parentRunId || !input.text || (input.operation === 'report' && (input.runId || input.targetId))) throw new ExecutionAdmissionError('unauthorized') @@ -282,13 +309,21 @@ export class ExecutionDelegationService { return { self, actors: catalog.actors.map(describe), npcs: catalog.npcs.map(describe) } } - async #delegate(caller: DelegationCaller, source: ExecutionStateRecord, requestKey: string, input: DelegationInput, signal: AbortSignal) { + async #delegate(caller: DelegationCaller, source: ExecutionStateRecord, requestKey: string, input: Exclude, signal: AbortSignal, entries: readonly SessionEntry[]) { const previous = input.operation === 'resume' && input.runId ? this.#require(input.runId) : null const targetId = previous?.run.buddyId ?? input.targetId if (!targetId || (!input.text && !input.brief) || source.run.actorSnapshot?.kind !== 'actor' || !caller.run.requestModel || (input.operation === 'resume' && !previous)) { throw new ExecutionAdmissionError('unauthorized') } + this.#options.executions.assertNoForwardFallback(source.run.id, targetId) + const latestInput = this.#options.executions.findForwardGuardInput(source.run.id) + if (latestInput) { + for (const runId of readClosedForwardTargets(entries, source.run.id, latestInput)) { + if (this.#options.executions.requirePublicWork(source.run.id, runId).run.buddyId === targetId) + throw new ExecutionAdmissionError('forward_closed') + } + } const catalog = this.#catalog(source) const target = [...catalog.actors, ...catalog.npcs].find(item => item.id === targetId) if (!target) diff --git a/apps/buddy/service/src/agent/execution/ExecutionPresentationService.ts b/apps/buddy/service/src/agent/execution/ExecutionPresentationService.ts index 0e5eabc38..48fab8fbb 100644 --- a/apps/buddy/service/src/agent/execution/ExecutionPresentationService.ts +++ b/apps/buddy/service/src/agent/execution/ExecutionPresentationService.ts @@ -1,4 +1,5 @@ -import type { ExecutionAnswer, ExecutionEventsRequest, ExecutionsChanged, ExecutionScope, ExecutionSend, ExecutionTarget, LocalExecution, LocalExecutionDetail, LocalExecutionEventsPage } from '../../../../shared/executions/executionPresentationApi' +import type { LocalExecutionInput, LocalExecutionInputsPage } from '../../../../shared/executions/executionInputReceipt' +import type { ExecutionAnswer, ExecutionEventsRequest, ExecutionInputsRequest, ExecutionsChanged, ExecutionScope, ExecutionSend, ExecutionTarget, LocalExecution, LocalExecutionDetail, LocalExecutionEventsPage } from '../../../../shared/executions/executionPresentationApi' import type { LocalRunTokenUsage } from '../../../../shared/usage/runTokenUsage' import type { BuddiesService } from '../../buddies/BuddiesService' import type { ChangeCaptureService } from '../../changes/ChangeCaptureService' @@ -9,13 +10,14 @@ import type { ApprovalRepository } from '../../storage/approvalRepository' import type { ArtifactRepository } from '../../storage/artifactRepository' import type { ConversationRepository } from '../../storage/conversationRepository' import type { ExecutionRepository, ExecutionStateRecord } from '../../storage/executionRepository' -import type { MailboxRepository } from '../../storage/mailboxRepository' +import type { ExecutionMailboxRecord, MailboxRepository } from '../../storage/mailboxRepository' import type { RunRepository } from '../../storage/runRepository' import type { UsageRepository } from '../../storage/usageRepository' import type { ExecutionCoordinator } from './ExecutionCoordinator' import { randomUUID } from 'node:crypto' import { Emitter } from '../../../../shared/events/Emitter' -import { executionAnswerSchema, executionEventsRequestSchema, executionScopeSchema, executionSendSchema, executionTargetSchema } from '../../../../shared/executions/executionPresentationApi' +import { EXECUTION_INPUT_PAGE_SIZE } from '../../../../shared/executions/executionInputReceipt' +import { executionAnswerSchema, executionEventsRequestSchema, executionInputsRequestSchema, executionScopeSchema, executionSendSchema, executionTargetSchema } from '../../../../shared/executions/executionPresentationApi' import { toPublicRunEvent } from '../../../../shared/runs/publicRunEvent' import { buddyRunProgressSchema } from '../../../../shared/runs/runProgress' import { projectRunOutputs } from '../../conversations/projectRunOutputs' @@ -27,8 +29,8 @@ export interface ExecutionPresentationOptions { approvals: Pick conversations: Pick runs: Pick - executions: Pick - mailbox: Pick + executions: Pick + mailbox: Pick buddies: Pick coordinator: Pick mutations: Pick @@ -80,6 +82,30 @@ export class ExecutionPresentationService { }) } + inputs(input: ExecutionInputsRequest): LocalExecutionInputsPage { + const scope = parse(executionInputsRequestSchema, input) + const visibleRoots = new Set(this.#visibleRoots(scope)) + const targets = new Map(scope.runIds.map((runId) => { + const record = this.#options.executions.find(runId) + if (!record || record.run.conversationId !== scope.conversationId || !visibleRoots.has(record.run.rootRunId) + || (record.run.executionKind !== 'root' && record.run.executionKind !== 'actor_child')) { + throw new BuddyServiceError('VALIDATION_FAILED') + } + return [runId, record] as const + })) + const page = this.#options.mailbox.listInputPage(scope.runIds, { offset: scope.offset, limit: EXECUTION_INPUT_PAGE_SIZE }) + return { + inputs: page.records.flatMap((message) => { + const record = targets.get(message.message.targetRunId) + const input = record && this.#presentInput(record.run.id, message, message.createdAt) + if (!record || !input || this.#options.executions.isForwardedPublicInput(message.message)) + return [] + return [{ ...input, conversationId: scope.conversationId, branchId: scope.branchId, runId: record.run.id, rootRunId: record.run.rootRunId, buddyId: record.run.buddyId, buddyName: this.#options.buddies.get(record.run.buddyId).name, task: record.initialInput?.prompt.slice(0, 300) ?? '' }] + }), + nextOffset: page.hasMore ? scope.offset + page.records.length : null, + } + } + async detail(input: ExecutionTarget): Promise { const target = parse(executionTargetSchema, input) this.#requireTarget(target) @@ -100,16 +126,8 @@ export class ExecutionPresentationService { if (event.type !== 'execution.mailbox.sent') return [] const message = this.#options.mailbox.find((event.payload as { messageId: string }).messageId) - if (!message || message.message.targetRunId !== target.runId) - return [] - const value = message.message - const question = value.kind === 'answer' ? this.#options.mailbox.findQuestion(value.questionId) : null - const questionMessage = question && question.message.kind === 'question' ? question.message : null - const userInput = (value.kind === 'supplement' && value.origin === 'user') - || (value.kind === 'answer' && value.sourceRunId === target.runId && questionMessage?.audience === 'user') - if (!userInput || (value.kind !== 'supplement' && value.kind !== 'answer')) - return [] - return [{ kind: value.kind, messageId: value.messageId, text: value.text, createdAt: event.createdAt, state: message.state }] + const input = message && this.#presentInput(target.runId, message, event.createdAt) + return input ? [input] : [] }), result: result || null, artifacts: [...new Map(artifacts.map(artifact => [artifact.artifactId, artifact])).values()], @@ -174,6 +192,21 @@ export class ExecutionPresentationService { this.#changes.dispose() } + #presentInput(runId: string, record: ExecutionMailboxRecord, createdAt: string): LocalExecutionInput | null { + const value = record.message + const state = record.state === 'undelivered' && record.receipt ? 'accepted' : record.state + if (value.targetRunId !== runId || value.sourceRunId !== runId) + return null + if (value.kind === 'supplement' && value.origin === 'user') + return { kind: value.kind, messageId: value.messageId, text: value.text, createdAt, state } + if (value.kind === 'answer') { + const question = this.#options.mailbox.findQuestion(value.questionId)?.message + if (question?.kind === 'question' && question.audience === 'user' && question.sourceRunId === runId) + return { kind: value.kind, messageId: value.messageId, text: value.text, createdAt, state } + } + return null + } + #visibleRoots(scope: ExecutionScope): string[] { const conversation = this.#options.conversations.findById(scope.conversationId) if (!conversation || conversation.deletedAt || !this.#options.conversations.listBranches(scope.conversationId).some(branch => branch.id === scope.branchId)) diff --git a/apps/buddy/service/src/agent/execution/__tests__/ExecutionDelegationService.spec.ts b/apps/buddy/service/src/agent/execution/__tests__/ExecutionDelegationService.spec.ts index 08b09f266..e5ab4edc0 100644 --- a/apps/buddy/service/src/agent/execution/__tests__/ExecutionDelegationService.spec.ts +++ b/apps/buddy/service/src/agent/execution/__tests__/ExecutionDelegationService.spec.ts @@ -38,6 +38,7 @@ function fixture() { assertShellExecutionAllowed: () => {}, admitChild: async input => f.executions.admitChild(input).execution, send, + forward: vi.fn(), wait, answerQuestion: vi.fn(), cancel: async () => false, @@ -69,22 +70,14 @@ describe('execution delegation boundary', () => { expect([...catalog.actors, ...catalog.npcs].map(actor => actor.id)).not.toContain(profile.id) }) - it('keeps the calling identity available when no delegation targets are eligible', async () => { - const f = fixture() - f.conversations.removeMember({ id: 'conversation', buddyId: f.actor.id, updatedAt: '2026-10-26T00:00:00.000Z' }) - const actor = f.buddies.get('default') - f.buddies.update({ buddyId: actor.id, expectedRevision: actor.revision, settings: buddySettingsSchema.parse({ ...actor.settings, npcTeamEnabled: false }) }) - await expect(f.service.execute(f.caller, 'empty-list', { operation: 'list' }, f.controller.signal)).resolves.toEqual({ self: { id: actor.id, name: actor.name, description: actor.description, kind: 'actor', executionKind: 'root' }, actors: [], npcs: [] }) - }) - - it('keeps a legacy execution identity stable when its profile changes during the turn', async () => { + it('keeps a legacy execution identity stable after profile changes even with no eligible targets', async () => { const f = fixture() const profile = f.buddies.get('default') const stored = f.runs.findById('root')!.actorSnapshot - expect(stored?.name).toBeUndefined() - f.buddies.update({ buddyId: profile.id, expectedRevision: profile.revision, name: 'New profile name', description: 'New profile background' }) - const catalog = await f.service.execute(f.caller, 'legacy-list', { operation: 'list' }, f.controller.signal) as { self: unknown } - expect(catalog.self).toEqual({ id: profile.id, name: profile.name, description: profile.description, kind: profile.kind, executionKind: 'root' }) + f.conversations.removeMember({ id: 'conversation', buddyId: f.actor.id, updatedAt: '2026-10-26T00:00:00.000Z' }) + f.buddies.update({ buddyId: profile.id, expectedRevision: profile.revision, name: 'New profile name', description: 'New profile background', settings: buddySettingsSchema.parse({ ...profile.settings, npcTeamEnabled: false }) }) + const catalog = await f.service.execute(f.caller, 'legacy-list', { operation: 'list' }, f.controller.signal) + expect(catalog).toEqual({ self: { id: profile.id, name: profile.name, description: profile.description, kind: profile.kind, executionKind: 'root' }, actors: [], npcs: [] }) expect(f.runs.findById('root')!.actorSnapshot).toEqual(stored) }) @@ -116,7 +109,6 @@ describe('execution delegation boundary', () => { const report = { operation: 'report', text: 'Validated the first boundary; continuing the task' } await expect(f.service.execute(child, 'progress', report, f.controller.signal)).resolves.toEqual({ runId: 'root', status: 'queued' }) expect(f.send.mock.calls[0]).toEqual(['child', expect.objectContaining({ kind: 'supplement', purpose: 'progress', targetRunId: 'root', text: report.text })]) - await expect(f.service.execute(child, 'legacy', { operation: 'send', runId: 'root', text: 'More progress' }, f.controller.signal)).resolves.toMatchObject({ status: 'queued' }) await expect(f.service.execute(child, 'inspect-parent', { operation: 'status', runId: 'root' }, f.controller.signal)).resolves.toMatchObject({ runId: 'root', questions: [] }) await expect(f.service.execute(child, 'override', { ...report, runId: 'root' }, f.controller.signal)).rejects.toMatchObject({ code: 'unauthorized' }) await expect(f.service.execute(f.caller, 'root-report', report, f.controller.signal)).rejects.toMatchObject({ code: 'unauthorized' }) @@ -157,27 +149,16 @@ describe('execution delegation boundary', () => { expect(f.executions.find('child')?.phase).toBe('working') }) - it('does not leak sibling questions or private reports through peer discovery', async () => { - const f = fixture() - f.executions.admitChild(f.child('child')) - f.executions.admitChild(f.child('sibling')) - const status = f.service.status('child', 'child') - expect(status.peers).toMatchObject([{ runId: 'sibling', questions: [] }]) - expect(status.budget.remaining).toBe(62) - }) - it('rejects a supplement with no explicit receiver or with a self receiver', async () => { const f = fixture() for (const runId of [undefined, 'root']) await expect(f.service.execute(f.caller, 'loop', { operation: 'send', runId, text: 'Acknowledged' }, f.controller.signal)).rejects.toThrow() - expect(f.send).not.toHaveBeenCalled() }) it('reads completed reports in pages without reopening the child or reading siblings', async () => { const f = fixture() f.executions.admitChild(f.child('child')) await expect(f.service.execute(f.caller, 'pending-result', { operation: 'result', runId: 'child' }, f.controller.signal)).resolves.toMatchObject({ result: null }) - expect(f.readResult).not.toHaveBeenCalled() f.executions.beginClosing(f.fence('child'), 'failure') f.executions.finishClosing(f.fence('child'), 'failed', '2026-10-12T00:01:00.000Z', 'MODEL_UNAVAILABLE') f.readResult.mockResolvedValue('A'.repeat(32000).concat('Complete remaining findings')) @@ -186,8 +167,8 @@ describe('execution delegation boundary', () => { const remaining = first.nextOffset === null ? '' : (await f.service.execute(f.caller, 'remaining', { operation: 'result', runId: 'child', offset: first.nextOffset }, f.controller.signal) as { result: string }).result expect(first.result + remaining).toBe('A'.repeat(32000).concat('Complete remaining findings')) expect(f.executions.find('child')?.phase).toBe('closed') - expect(f.send).not.toHaveBeenCalled() }) + it('accepts an isolated child before capability preflight and captures the actual request model', async () => { const f = fixture() const request = { operation: 'delegate', targetId: f.npc.id, text: 'Inspect only the named file' } diff --git a/apps/buddy/service/src/agent/execution/__tests__/ExecutionPresentationService.spec.ts b/apps/buddy/service/src/agent/execution/__tests__/ExecutionPresentationService.spec.ts index 011d27ae8..82be0aa09 100644 --- a/apps/buddy/service/src/agent/execution/__tests__/ExecutionPresentationService.spec.ts +++ b/apps/buddy/service/src/agent/execution/__tests__/ExecutionPresentationService.spec.ts @@ -1,17 +1,22 @@ import type { ExecutionsChanged } from '../../../../../shared/executions/executionPresentationApi' +import type { BuddyExecutionProfile } from '../../../../../shared/permissions/executionProfile' import { mkdtemp, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, describe, expect, it } from 'vitest' +import { buddySettingsSchema } from '../../../../../shared/buddies/buddySettings' import { createBuddyUserContent } from '../../../../../shared/conversation/buddyUserContent' +import { resolveBuddyPermissionSettings } from '../../../../../shared/permissions/permissionMode' import { BuddiesService } from '../../../buddies/BuddiesService' import { MutationCoordinator } from '../../../changes/MutationCoordinator' +import { isReviewUserMessage } from '../../../chat/buddyReviewPrompt' import { ComposerDraftService } from '../../../chat/ComposerDraftService' import { createRunEventLog } from '../../../events/createRunEventLog' import { RunLifecycleService } from '../../../runs/RunLifecycleService' import { createExecutionStorageFixture, executionNow } from '../../../storage/__tests__/executionFixture' import { createApprovalRepository } from '../../../storage/approvalRepository' import { createArtifactRepository } from '../../../storage/artifactRepository' +import { createChatQueueRepository } from '../../../storage/chatQueueRepository' import { createComposerDraftRepository } from '../../../storage/composerDraftRepository' import { hashChildAdmissionRequest } from '../../../storage/executionRepository' import { createMailboxRepository } from '../../../storage/mailboxRepository' @@ -56,7 +61,19 @@ async function fixture() { stopExecution: async () => false, }) const drafts = new ComposerDraftService(createComposerDraftRepository(storage.database), async () => {}) - const inputOptions = { drafts, conversations: storage.conversations, executions: storage.executions, mailbox, delivery: service } + const inputOptions = { + drafts, + conversations: storage.conversations, + executions: storage.executions, + getRunPermissionCeiling: (runId: string): BuddyExecutionProfile | undefined => { + const execution = storage.executions.find(runId) + if (execution?.fileAccessCeiling === 'read_only' || execution?.run.actorSnapshot?.settings.limits.fileAccess === 'read_only') + return 'read_only' + return execution?.run.triggeringMessageId && isReviewUserMessage(storage.conversations.findMessageById(execution.run.triggeringMessageId)?.content) ? 'read_only' : undefined + }, + mailbox, + delivery: service, + } const composerInput = new ExecutionComposerInputService(inputOptions) cleanups.push(async () => { await drafts.dispose() @@ -169,36 +186,6 @@ describe('execution presentation ownership', () => { expect(f.service.list(scope).find(record => record.runId === 'child')).toMatchObject({ status: 'cancelled', canAnswer: false, canCancel: false, cleanup: 'degraded', affectedResources: [{ kind: 'path', path: '/work/reports' }], questions: [{ open: false, answered: false }] }) }) - it('attributes recorded usage to one execution without counting its caller, siblings or cache warming', async () => { - const f = await fixture() - f.executions.admitChild(f.child('child')) - f.executions.admitChild(f.child('sibling')) - for (const [runId, purpose, input] of [ - ['root', 'turn', 100], - ['sibling', 'turn', 1000], - ['child', 'cache_warm', 999], - ['child', 'turn', 12], - ['child', 'tool', 7], - ] as const) { - await f.usageService.record({ - runId, - purpose, - sourceEntryId: `${runId}-${purpose}`, - createdAt: executionNow, - model: 'fixture', - provider: 'fixture', - usage: { input, output: 3, cacheRead: 2, cacheWrite: 1, totalTokens: input + 6, cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 } }, - }) - } - const detail = await f.service.detail(target) - expect(detail.execution).toMatchObject({ runId: 'child', parentRunId: 'root' }) - expect(detail.usage).toEqual({ inputTokens: 19, outputTokens: 6, cacheReadTokens: 4, cacheWriteTokens: 2 }) - expect(detail.execution.usage).toEqual(detail.usage) - expect(f.service.list(scope).find(record => record.runId === 'child')?.usage).toEqual(detail.usage) - expect((await f.service.detail({ ...target, runId: 'root' })).usage?.inputTokens).toBe(100) - expect((await f.service.detail({ ...target, runId: 'sibling' })).usage?.inputTokens).toBe(1000) - }) - it('preserves exact per-run usage through nested work, continuation, replay and later roots', async () => { const f = await fixture() f.executions.admitChild(f.child('actor', f.actor.id)) @@ -214,8 +201,7 @@ describe('execution presentation ownership', () => { return f.executions.admitChild({ ...input, requestHash: hashChildAdmissionRequest(input) }).execution } settle('first') - const second = resume('second', 'first') - expect(second.work?.previousRunId).toBe('first') + resume('second', 'first') for (const [runId, input, purpose] of [ ['root', 10, 'turn'], @@ -223,6 +209,7 @@ describe('execution presentation ownership', () => { ['actor', 20, 'turn'], ['nested', 30, 'turn'], ['first', 40, 'turn'], + ['second', 999, 'cache_warm'], ['second', 50, 'turn'], ['second', 5, 'compaction'], ] as const) { @@ -247,9 +234,10 @@ describe('execution presentation ownership', () => { ['first', 40], ['second', 55], ]) - expect(f.usage.summarizeForRuns(['second', 'second']).get('second')).toEqual({ inputTokens: 55, outputTokens: 6, cacheReadTokens: 4, cacheWriteTokens: 2 }) - expect(f.usage.summarizeForRuns([]).size).toBe(0) - expect(f.usage.summarizeForRuns(['missing']).size).toBe(0) + const detail = await f.service.detail({ ...target, runId: 'second' }) + expect(detail.usage).toEqual({ inputTokens: 55, outputTokens: 6, cacheReadTokens: 4, cacheWriteTokens: 2 }) + expect(detail.execution.usage).toEqual(detail.usage) + expect(before.find(record => record.runId === 'second')?.usage).toEqual(detail.usage) await f.events.replayAll() expect(listUsage()).toEqual(before) @@ -257,9 +245,7 @@ describe('execution presentation ownership', () => { settle(runId) f.conversations.createMessage({ id: 'next-goal', conversationId: 'conversation', branchId: 'branch', runId: null, role: 'user', content: { text: 'Continue the work' }, createdAt: executionNow }) f.runs.create({ ...f.runs.findById('root')!, id: 'next-root', rootRunId: 'next-root', triggeringMessageId: 'next-goal', status: 'running', completedAt: null }) - const third = resume('third', 'second', 'next-root') - expect(third.run.threadId).toBe(second.run.threadId) - expect(third.run.rootRunId).toBe('next-root') + resume('third', 'second', 'next-root') expect(listUsage().filter(record => record.rootRunId === 'next-root')).toEqual([ { runId: 'next-root', rootRunId: 'next-root', usage: null }, { runId: 'third', rootRunId: 'next-root', usage: null }, @@ -300,19 +286,6 @@ describe('execution presentation ownership', () => { await expect(f.service.send({ ...input, runId: 'sibling', messageId: '22222222-2222-4222-8222-222222222222' })).rejects.toMatchObject({ code: 'VALIDATION_FAILED' }) }) - it('stops the current children without cancelling their caller and retains closed records', async () => { - const f = await fixture() - f.executions.admitChild(f.child('child')) - f.executions.admitChild(f.child('sibling')) - await expect(f.service.cancelChildren({ ...target, runId: 'root' })).resolves.toEqual({ cancelledRunIds: ['child', 'sibling'] }) - expect(f.service.list(scope).filter(record => record.parentRunId === 'root')).toEqual([ - expect.objectContaining({ runId: 'child', status: 'cancelled', phase: 'closed', canSend: false }), - expect.objectContaining({ runId: 'sibling', status: 'cancelled', phase: 'closed', canSend: false }), - ]) - expect(f.executions.find('root')?.run.status).toBe('running') - await expect(f.service.send({ ...target, messageId: '11111111-1111-4111-8111-111111111111', text: 'Late instruction' })).rejects.toMatchObject({ code: 'VALIDATION_FAILED' }) - }) - it('pages sanitized process events and counts separate invocations of a reused call ID without leaking the transcript', async () => { const f = await fixture() f.executions.admitChild(f.child('child')) @@ -342,14 +315,19 @@ describe('execution presentation ownership', () => { expect(JSON.stringify(f.service.list(scope))).not.toContain('private-') await expect(f.service.events({ ...target, runId: 'root', rootRunId: 'child' })).rejects.toMatchObject({ code: 'VALIDATION_FAILED' }) }) - it('allows cancellation while the caller closes without reopening its input channel', async () => { + it('cancels current children while their caller closes without cancelling it or reopening input', async () => { const f = await fixture() f.executions.admitChild(f.child('child')) + f.executions.admitChild(f.child('sibling')) f.database.prepare('UPDATE execution_state SET phase = \'closing\' WHERE run_id = \'root\'').run() expect(f.service.list(scope).find(record => record.runId === 'child')).toMatchObject({ canCancel: true, canSend: false }) - await expect(f.service.cancelChildren({ ...target, runId: 'root' })).resolves.toEqual({ cancelledRunIds: ['child'] }) - expect(f.executions.find('child')?.run.status).toBe('cancelled') - expect(f.executions.find('root')?.cancelRequestedAt).toBeNull() + await expect(f.service.cancelChildren({ ...target, runId: 'root' })).resolves.toEqual({ cancelledRunIds: ['child', 'sibling'] }) + expect(f.service.list(scope).filter(record => record.parentRunId === 'root')).toEqual([ + expect.objectContaining({ runId: 'child', status: 'cancelled', phase: 'closed', canSend: false }), + expect.objectContaining({ runId: 'sibling', status: 'cancelled', phase: 'closed', canSend: false }), + ]) + expect(f.executions.find('root')).toMatchObject({ cancelRequestedAt: null, run: { status: 'running' } }) + await expect(f.service.send({ ...target, messageId: '11111111-1111-4111-8111-111111111111', text: 'Late instruction' })).rejects.toMatchObject({ code: 'VALIDATION_FAILED' }) }) }) @@ -364,30 +342,245 @@ async function inputFixture() { } describe('actor input from the composer', () => { - it('retries a lost acknowledgement without duplicating input, clears only the accepted draft and preserves newer edits after closure', async () => { + it.each([ + { mode: 'read_only', kind: 'supplement' }, + { mode: 'manual_approval', kind: 'answer' }, + { mode: 'full_access', kind: 'supplement' }, + ] as const)('accepts a current $kind after switching to $mode without changing the original run', async ({ mode, kind }) => { const f = await inputFixture() - const failing = new ExecutionComposerInputService({ + if (kind === 'answer') + await f.coordinator.send('child', { kind: 'question', audience: 'user', messageId: 'permission-question', questionId: 'permission-question', targetRunId: 'child', targetGeneration: 0, text: 'Choose a destination' }) + const original = f.runs.findById('child')! + const executionConfig = resolveBuddyPermissionSettings(mode) + f.conversations.setPermissionSettings({ id: 'conversation', ...executionConfig, updatedAt: executionNow }) + const draft = await f.drafts.save({ ...f.draft, expectedRevision: f.draft.revision, executionConfig }) + const input = { ...f.input, expectedRevision: draft.revision, delivery: { mode: 'current' as const, actorId: f.actor.id, target: { rootRunId: 'root', runId: 'child' } } } + + expect((await f.composerInput.send(input))?.content).toEqual(createBuddyUserContent()) + expect((await f.service.detail(target)).inputs).toEqual([expect.objectContaining({ kind, text: '@Reviewer Additional direction' })]) + expect(f.runs.findById('child')).toEqual(original) + }) + + it('rejects stale composer permissions after a live change without consuming the draft', async () => { + const f = await inputFixture() + f.conversations.setPermissionSettings({ id: 'conversation', ...resolveBuddyPermissionSettings('read_only'), updatedAt: executionNow }) + const input = { ...f.input, delivery: { mode: 'current' as const, actorId: f.actor.id, target: { rootRunId: 'root', runId: 'child' } } } + + await expect(f.composerInput.send(input)).rejects.toMatchObject({ code: 'INPUT_DELIVERY_INCOMPATIBLE' }) + expect(f.drafts.get(f.draft.draftId)).toEqual(f.draft) + expect((await f.service.detail(target)).inputs).toEqual([]) + }) + + it.each(['review', 'actor', 'ancestor'] as const)('keeps the %s read-only ceiling when the conversation has full access', async (source) => { + const f = await fixture() + let runId = 'root' + let actorId = 'default' + if (source === 'review') { + f.database.prepare('UPDATE messages SET content_json = ? WHERE id = ?').run(JSON.stringify({ userContent: createBuddyUserContent('/review inspect'), resourceSnapshots: [] }), 'goal') + f.database.prepare('UPDATE runs SET execution_profile = ? WHERE id = ?').run('read_only', 'root') + } + else { + const limitedActorId = source === 'ancestor' ? 'default' : f.actor.id + const buddy = f.buddies.get(limitedActorId) + f.buddies.update({ buddyId: buddy.id, expectedRevision: buddy.revision, settings: buddySettingsSchema.parse({ ...buddy.settings, limits: { fileAccess: 'read_only' } }) }) + if (source === 'ancestor') { + const snapshot = f.snapshot('default') + f.database.prepare('UPDATE runs SET actor_snapshot_json = ? WHERE id = ?').run(JSON.stringify(snapshot), 'root') + } + const child = f.child('child', f.actor.id) + child.run.executionProfile = 'read_only' + child.requestHash = hashChildAdmissionRequest(child) + f.executions.admitChild(child) + await f.coordinator.begin('child', new AbortController().signal) + runId = 'child' + actorId = f.actor.id + } + await f.coordinator.send(runId, { kind: 'question', audience: 'user', messageId: 'ceiling-question', questionId: 'ceiling-question', targetRunId: runId, targetGeneration: 0, text: 'Choose a destination' }) + const original = f.runs.findById(runId) + const executionConfig = resolveBuddyPermissionSettings('full_access') + f.conversations.setPermissionSettings({ id: 'conversation', ...executionConfig, updatedAt: executionNow }) + const content = createBuddyUserContent('Use the local directory') + const initialContent = runId === 'child' + ? { ...content, body: [{ type: 'paragraph' as const, content: [{ type: 'actor_ref' as const, actorId, name: f.actor.name }, ...content.body[0]!.content] }] } + : content + const draft = await f.drafts.open({ draftId: 'ceiling-answer', scope: { kind: 'conversation_branch', ...scope }, initialContent, initialExecutionConfig: executionConfig, initialModelSelection: null }) + const input = { ...scope, rootRunId: 'root', runId, draftId: draft.draftId, expectedRevision: draft.revision, delivery: { mode: 'current' as const, actorId, target: { rootRunId: 'root', runId } } } + + await expect(f.composerInput.send(input)).rejects.toMatchObject({ code: 'INPUT_DELIVERY_INCOMPATIBLE' }) + expect(f.drafts.get(draft.draftId)).toEqual(draft) + expect(f.mailbox.findQuestion('ceiling-question')).toMatchObject({ open: true, answer: null }) + expect(f.runs.findById(runId)).toEqual(original) + + const readOnly = await f.drafts.save({ ...draft, expectedRevision: draft.revision, executionConfig: resolveBuddyPermissionSettings('read_only') }) + expect((await f.composerInput.send({ ...input, expectedRevision: readOnly.revision }))?.content).toEqual(createBuddyUserContent()) + expect(f.mailbox.findQuestion('ceiling-question')).toMatchObject({ open: false, answer: { targetRunId: runId } }) + expect(f.runs.findById(runId)).toEqual(original) + }) + + it('delivers explicit current input to an older live actor child even after a newer root has completed', async () => { + const f = await inputFixture() + const latest = { ...f.runs.findById('root')!, id: 'latest-root', rootRunId: 'latest-root', status: 'completed' as const, triggeringMessageId: 'latest-goal', startedAt: '2026-10-13T00:00:00.000Z' } + f.runs.create(latest) + f.conversations.createMessage({ id: 'latest-goal', conversationId: 'conversation', branchId: 'branch', runId: null, role: 'user', content: { text: 'A later independent task' }, createdAt: latest.startedAt }) + const input = { ...f.input, delivery: { mode: 'current' as const, actorId: f.actor.id, target: { rootRunId: 'root', runId: 'child' } } } + expect((await f.composerInput.send(input))?.content).toEqual(createBuddyUserContent()) + expect(f.mailbox.listUnconsumed('child')).toEqual([expect.objectContaining({ kind: 'supplement', origin: 'user', targetRunId: 'child', text: '@Reviewer Additional direction' })]) + expect(f.mailbox.listUnconsumed('latest-root')).toEqual([]) + expect(f.conversations.listBranchMessages('conversation', 'branch')).toHaveLength(2) + }) + + it.each([true, false])('rejects stale explicit current input before or during delivery (closed before: %s) without consuming its draft', async (closedBefore) => { + const f = await inputFixture() + const input = { ...f.input, delivery: { mode: 'current' as const, actorId: f.actor.id, target: { rootRunId: 'root', runId: 'child' } } } + if (closedBefore) + await f.service.cancel(target) + const service = new ExecutionComposerInputService({ + ...f.inputOptions, + delivery: { + answer: input => f.service.answer(input), + send: async (input) => { + if (!closedBefore) + await f.service.cancel(target) + return f.service.send(input) + }, + }, + }) + await expect(service.send(input)).rejects.toMatchObject({ code: 'INPUT_DELIVERY_TARGET_UNAVAILABLE' }) + expect(f.drafts.get(f.draft.draftId)).toEqual(f.draft) + expect((await f.service.detail(target)).inputs).toEqual([]) + }) + + it('confirms an accepted explicit input after lost acknowledgement and target closure without redelivering', async () => { + const f = await inputFixture() + const input = { ...f.input, delivery: { mode: 'current' as const, actorId: f.actor.id, target: { rootRunId: 'root', runId: 'child' } } } + const service = new ExecutionComposerInputService({ ...f.inputOptions, delivery: { answer: input => f.service.answer(input), send: async (input) => { await f.service.send(input) - throw new Error('Connection lost') + throw new Error('Lost acknowledgement') }, }, }) - await expect(failing.send(f.input)).rejects.toThrow('Connection lost') + await expect(service.send(input)).rejects.toThrow('Lost acknowledgement') expect(f.drafts.get(f.draft.draftId)).toEqual(f.draft) - const cleared = (await f.composerInput.send(f.input))! + await f.service.cancel(target) + const cleared = (await f.composerInput.send(input))! expect(cleared.content).toEqual(createBuddyUserContent()) const receipt = (await f.service.detail(target)).inputs - expect(receipt).toEqual([expect.objectContaining({ kind: 'supplement', text: '@Reviewer Additional direction' })]) - expect(f.mailbox.listUnconsumed('root')).toEqual([]) - expect(f.conversations.listBranchMessages('conversation', 'branch')).toHaveLength(1) - await f.service.cancel(target) + expect(receipt).toEqual([expect.objectContaining({ kind: 'supplement', text: '@Reviewer Additional direction', state: 'undelivered' })]) const newer = await f.drafts.save({ draftId: cleared.draftId, expectedRevision: cleared.revision, content: createBuddyUserContent('A new unsent draft'), executionConfig: cleared.executionConfig, modelSelection: null }) - expect(await f.composerInput.send(f.input)).toEqual(newer) - expect((await f.service.detail(target)).inputs).toEqual(receipt.map(input => ({ ...input, state: 'undelivered' }))) + expect(await f.composerInput.send(input)).toEqual(newer) + expect((await f.service.detail(target)).inputs).toEqual(receipt) + expect(f.conversations.listBranchMessages('conversation', 'branch')).toHaveLength(1) + }) + + it('supplements an unmentioned current leader actor child and confirms its receipt after the leader changes', async () => { + const f = await inputFixture() + f.conversations.setBuddy({ id: 'conversation', buddyId: f.actor.id, updatedAt: executionNow }) + const draft = await f.drafts.save({ draftId: f.draft.draftId, expectedRevision: f.draft.revision, content: createBuddyUserContent('Continue with the local directory'), executionConfig: f.draft.executionConfig, modelSelection: null }) + const input = { ...f.input, expectedRevision: draft.revision, delivery: { mode: 'current' as const, actorId: f.actor.id, target: { rootRunId: 'root', runId: 'child' } } } + const cleared = await f.composerInput.send(input) + expect(cleared?.content).toEqual(createBuddyUserContent()) + expect(f.mailbox.listUnconsumed('child')).toEqual([expect.objectContaining({ kind: 'supplement', origin: 'user', text: 'Continue with the local directory' })]) + f.conversations.setBuddy({ id: 'conversation', buddyId: 'default', updatedAt: executionNow }) + expect(await f.composerInput.send(input)).toEqual(cleared) + expect((await f.service.detail(target)).inputs).toHaveLength(1) + expect(f.conversations.listBranchMessages('conversation', 'branch')).toHaveLength(1) + }) + + it.each(['previous-leader', 'legacy'] as const)('rejects an unmentioned child supplement for %s without consuming the draft', async (reason) => { + const f = await inputFixture() + if (reason === 'legacy') + f.conversations.setBuddy({ id: 'conversation', buddyId: f.actor.id, updatedAt: executionNow }) + const draft = await f.drafts.save({ draftId: f.draft.draftId, expectedRevision: f.draft.revision, content: createBuddyUserContent('Unmentioned supplement'), executionConfig: f.draft.executionConfig, modelSelection: null }) + const input = { ...f.input, expectedRevision: draft.revision, ...(reason === 'previous-leader' ? { delivery: { mode: 'current' as const, actorId: f.actor.id, target: { rootRunId: 'root', runId: 'child' } } } : {}) } + await expect(f.composerInput.send(input)).rejects.toMatchObject({ code: 'VALIDATION_FAILED' }) + expect(f.drafts.get(draft.draftId)).toEqual(draft) + expect((await f.service.detail(target)).inputs).toEqual([]) + }) + + it('answers the current leader user question without an actor mention and replays the same receipt', async () => { + const f = await fixture() + await f.coordinator.send('root', { kind: 'question', audience: 'user', messageId: 'root-question-message', questionId: 'root-question', targetRunId: 'root', targetGeneration: 0, text: 'Choose a destination' }) + const draft = await f.drafts.open({ draftId: 'leader-answer', scope: { kind: 'conversation_branch', ...scope }, initialContent: createBuddyUserContent('Use the local directory'), initialExecutionConfig: { approvalPolicy: 'policy', executionProfile: 'workspace_write' }, initialModelSelection: null }) + const input = { ...scope, rootRunId: 'root', runId: 'root', draftId: draft.draftId, expectedRevision: draft.revision, delivery: { mode: 'current' as const, actorId: 'default', target: { rootRunId: 'root', runId: 'root' } } } + const cleared = await f.composerInput.send(input) + expect(cleared?.content).toEqual(createBuddyUserContent()) + expect(f.mailbox.findQuestion('root-question')).toMatchObject({ open: false, answer: { text: 'Use the local directory' } }) + expect(await f.composerInput.send(input)).toEqual(cleared) + expect((await f.service.detail({ ...scope, rootRunId: 'root', runId: 'root' })).inputs).toHaveLength(1) + expect(f.conversations.listBranchMessages('conversation', 'branch')).toHaveLength(1) + }) + + it('does not let an unmentioned answer target the previous leader after the task leader changes', async () => { + const f = await fixture() + await f.coordinator.send('root', { kind: 'question', audience: 'user', messageId: 'root-question-message', questionId: 'root-question', targetRunId: 'root', targetGeneration: 0, text: 'Choose a destination' }) + const draft = await f.drafts.open({ draftId: 'leader-answer', scope: { kind: 'conversation_branch', ...scope }, initialContent: createBuddyUserContent('Use the local directory'), initialExecutionConfig: { approvalPolicy: 'policy', executionProfile: 'workspace_write' }, initialModelSelection: null }) + f.conversations.setBuddy({ id: 'conversation', buddyId: f.actor.id, updatedAt: executionNow }) + await expect(f.composerInput.send({ ...scope, rootRunId: 'root', runId: 'root', draftId: draft.draftId, expectedRevision: draft.revision, delivery: { mode: 'current', actorId: 'default', target: { rootRunId: 'root', runId: 'root' } } })).rejects.toMatchObject({ code: 'VALIDATION_FAILED' }) + expect(f.drafts.get(draft.draftId)).toEqual(draft) + expect(f.mailbox.findQuestion('root-question')).toMatchObject({ open: true, answer: null }) + }) + + it('rejects rich question answers and incompatible model settings without consuming the draft', async () => { + const f = await inputFixture() + await f.coordinator.send('child', { kind: 'question', audience: 'user', messageId: 'question-message', questionId: 'question', targetRunId: 'child', targetGeneration: 0, text: 'Choose a destination' }) + const delivery = { mode: 'current' as const, actorId: f.actor.id, target: { rootRunId: 'root', runId: 'child' } } + const rich = await f.drafts.save({ draftId: f.draft.draftId, expectedRevision: f.draft.revision, content: { ...f.draft.content, panelResourceIds: ['attachment'] }, executionConfig: f.draft.executionConfig, modelSelection: null }) + await expect(f.composerInput.send({ ...f.input, expectedRevision: rich.revision, delivery })).rejects.toMatchObject({ code: 'INPUT_DELIVERY_INCOMPATIBLE' }) + expect(f.drafts.get(f.draft.draftId)).toEqual(rich) + const changedModel = await f.drafts.save({ draftId: rich.draftId, expectedRevision: rich.revision, content: f.draft.content, executionConfig: rich.executionConfig, modelSelection: { providerId: 'fixture', modelId: 'another', reasoning: null, serviceTier: null } }) + await expect(f.composerInput.send({ ...f.input, expectedRevision: changedModel.revision, delivery })).rejects.toMatchObject({ code: 'INPUT_DELIVERY_INCOMPATIBLE' }) + expect(f.drafts.get(f.draft.draftId)).toEqual(changedModel) + expect(f.mailbox.findQuestion('question')).toMatchObject({ open: true, answer: null }) + expect((await f.service.detail(target)).inputs).toEqual([]) + }) + + it('keeps rich current input out of a pending question and queues next without answering or publishing it', async () => { + const f = await inputFixture() + await f.coordinator.send('child', { kind: 'question', audience: 'user', messageId: 'question-message', questionId: 'question', targetRunId: 'child', targetGeneration: 0, text: 'Choose a destination' }) + const queue = createChatQueueRepository(f.database) + const prepared = { + ...f.draft.executionConfig, + buddyId: f.actor.id, + branchId: 'branch', + conversationId: 'conversation', + createdAt: executionNow, + draft: { draftId: f.draft.draftId, expectedRevision: f.draft.revision }, + attachmentBindings: [], + model: 'fixture', + provider: 'fixture', + spaceId: null, + title: null, + requestId: 'queued-question', + requestFingerprint: 'queued-question', + runId: 'next-run', + runInput: { attachmentIds: [], contextItems: [], prompt: 'Only after this work', reasoning: null, serviceTier: null }, + userMessageContent: { userContent: { ...f.draft.content, quotes: [{ id: 'quote', text: 'Goal', source: { ...scope, messageId: 'goal', role: 'user', runId: null } }] }, resourceSnapshots: [] }, + userMessageId: 'queued-input', + } + const delivery = { mode: 'current' as const, actorId: f.actor.id, target: { rootRunId: 'root', runId: 'child' } } + expect(() => queue.enqueue({ ...prepared, delivery })).toThrow(expect.objectContaining({ code: 'INPUT_DELIVERY_INCOMPATIBLE' })) + expect(f.drafts.get(f.draft.draftId)).toEqual(f.draft) + queue.enqueue({ ...prepared, delivery: { ...delivery, mode: 'next' } }) + expect(f.mailbox.findQuestion('question')).toMatchObject({ open: true, answer: null }) + expect((await f.service.detail(target)).inputs).toEqual([]) + expect(f.conversations.listBranchMessages('conversation', 'branch')).toHaveLength(1) + expect(queue.list(scope)).toMatchObject([{ id: 'queued-input', delivery: { ...delivery, mode: 'next' }, state: 'waiting' }]) + }) + + it('rejects next or null-target envelopes on the private current-input channel', async () => { + const f = await inputFixture() + for (const delivery of [ + { mode: 'next' as const, actorId: f.actor.id, target: { rootRunId: 'root', runId: 'child' } }, + { mode: 'current' as const, actorId: f.actor.id, target: null }, + ]) { + await expect(f.composerInput.send({ ...f.input, delivery })).rejects.toMatchObject({ code: 'INPUT_DELIVERY_INCOMPATIBLE' }) + } + expect(f.drafts.get(f.draft.draftId)).toEqual(f.draft) + expect((await f.service.detail(target)).inputs).toEqual([]) }) it('answers a pending user question through the composer and safely replays its accepted input', async () => { diff --git a/apps/buddy/service/src/agent/execution/__tests__/executionInputPresentation.spec.ts b/apps/buddy/service/src/agent/execution/__tests__/executionInputPresentation.spec.ts new file mode 100644 index 000000000..0569022ef --- /dev/null +++ b/apps/buddy/service/src/agent/execution/__tests__/executionInputPresentation.spec.ts @@ -0,0 +1,177 @@ +import type { ExecutionMailboxDraft } from '../../../../../shared/executions/executionApi' +import type { ExecutionPresentationOptions } from '../ExecutionPresentationService' +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import { BuddiesService } from '../../../buddies/BuddiesService' +import { MutationCoordinator } from '../../../changes/MutationCoordinator' +import { createRunEventLog } from '../../../events/createRunEventLog' +import { createExecutionStorageFixture, executionNow } from '../../../storage/__tests__/executionFixture' +import { createApprovalRepository } from '../../../storage/approvalRepository' +import { createArtifactRepository } from '../../../storage/artifactRepository' +import { createMailboxRepository } from '../../../storage/mailboxRepository' +import { createUsageRepository } from '../../../storage/usageRepository' +import { ExecutionPresentationService } from '../ExecutionPresentationService' + +const cleanups: (() => Promise)[] = [] +afterEach(async () => { + for (const cleanup of cleanups.splice(0).reverse()) + await cleanup() +}) +const scope = { conversationId: 'conversation', branchId: 'branch' } + +async function fixture() { + const storage = createExecutionStorageFixture() + storage.conversations.createMessage({ id: 'goal', ...scope, runId: null, role: 'user', content: { text: 'Public goal' }, createdAt: executionNow }) + storage.executions.admitChild(storage.child('actor', storage.actor.id)) + storage.executions.admitChild(storage.child('npc')) + const directory = await mkdtemp(join(tmpdir(), 'lexora-input-receipts-')) + const events = createRunEventLog({ database: storage.database, conversationsDirectory: directory }) + const mutations = new MutationCoordinator() + const options: ExecutionPresentationOptions = { + approvals: createApprovalRepository(storage.database), + conversations: storage.conversations, + runs: storage.runs, + executions: storage.executions, + mailbox: createMailboxRepository(storage.database), + buddies: new BuddiesService(storage.buddies), + coordinator: { + onDidChange: () => ({ dispose() {} }), + answerQuestion: async () => {}, + send: async () => {}, + cancel: async () => false, + }, + mutations, + events, + artifacts: createArtifactRepository(storage.database), + changes: { listSummariesForRuns: () => [] }, + usage: createUsageRepository(storage.database), + stopExecution: async () => false, + } + const services = [new ExecutionPresentationService(options)] + cleanups.push(async () => { + services.forEach(service => service.dispose()) + await events.close() + mutations.dispose() + storage.database.close() + await rm(directory, { recursive: true, force: true }) + }) + return { + ...storage, + events, + mailbox: options.mailbox, + service: services[0]!, + send: (runId: string, payload: ExecutionMailboxDraft) => events.append({ runId, type: 'execution.mailbox.sent', payload }), + reopen() { + const service = new ExecutionPresentationService({ + ...options, + mailbox: createMailboxRepository(storage.database), + }) + services.push(service) + return service + }, + } +} + +function supplement(messageId: string, targetRunId: string, user = true): ExecutionMailboxDraft { + return { kind: 'supplement', messageId, targetRunId, targetGeneration: 0, text: messageId, ...(user ? { origin: 'user' as const } : {}) } +} + +describe('execution input receipts for the UI', () => { + it('projects only explicit user supplements and self answers to user questions without publishing them', async () => { + const f = await fixture() + await f.send('root', supplement('root-user-input', 'root')) + await f.send('actor', supplement('actor-user-input', 'actor')) + await f.send('npc', supplement('npc-private-input', 'npc')) + await f.send('root', supplement('agent-to-agent', 'actor', false)) + await f.send('root', supplement('cross-run-user-marker', 'actor')) + await f.send('actor', supplement('legacy-self-input', 'actor', false)) + for (const audience of ['user', 'caller'] as const) { + const questionId = `question-${audience}` + await f.send('actor', { kind: 'question', audience, messageId: questionId, questionId, targetRunId: audience === 'user' ? 'actor' : 'root', targetGeneration: 0, text: questionId }) + await f.send('actor', { kind: 'answer', messageId: `answer-${audience}`, questionId, targetRunId: 'actor', targetGeneration: 0, text: `answer-${audience}` }) + } + await f.send('root', { kind: 'answer', messageId: 'agent-answer', questionId: 'question-user', targetRunId: 'actor', targetGeneration: 0, text: 'Agent answer must stay private' }) + const publicMessages = f.conversations.listBranchMessages(scope.conversationId, scope.branchId) + const publications = f.database.prepare('SELECT * FROM conversation_publications').all() + const detail = await f.service.detail({ ...scope, rootRunId: 'root', runId: 'actor' }) + const page = f.reopen().inputs({ ...scope, runIds: ['root', 'actor'] }) + expect(page.inputs.map(input => input.messageId)).toEqual(['root-user-input', 'actor-user-input', 'answer-user']) + expect(page.inputs[1]).toMatchObject({ ...scope, runId: 'actor', rootRunId: 'root', buddyId: f.actor.id, buddyName: f.actor.name, task: 'Delegated goal', kind: 'supplement', state: 'pending' }) + expect(page.inputs.filter(input => input.runId === 'actor').map(({ kind, messageId, text, createdAt, state }) => ({ kind, messageId, text, createdAt, state }))).toEqual(detail.inputs) + expect(f.conversations.listBranchMessages(scope.conversationId, scope.branchId)).toEqual(publicMessages) + expect(f.database.prepare('SELECT * FROM conversation_publications').all()).toEqual(publications) + expect(JSON.stringify(f.service.list(scope))).not.toContain('actor-user-input') + }) + + it.each([true, false])('hides only a verified original public input from the main receipt projection (exact=%s)', async (exact) => { + const f = await fixture() + const text = exact ? 'Public goal' : 'Different user instruction' + await f.send('actor', { ...supplement('goal', 'actor'), text }) + const detail = await f.service.detail({ ...scope, rootRunId: 'root', runId: 'actor' }) + expect(detail.inputs).toMatchObject([{ messageId: 'goal', text }]) + expect(f.reopen().inputs({ ...scope, runIds: ['actor'] }).inputs).toHaveLength(exact ? 0 : 1) + expect(f.conversations.listBranchMessages(scope.conversationId, scope.branchId).map(message => message.id)).toEqual(['goal']) + }) + + it('validates every requested run against the visible task branch and excludes NPC targets', async () => { + const f = await fixture() + await f.send('npc', supplement('private-npc', 'npc')) + f.conversations.createBranch({ id: 'other', conversationId: scope.conversationId, parentBranchId: null, forkedFromMessageId: null, createdAt: executionNow, activate: false }) + for (const input of [ + { ...scope, runIds: ['npc'] }, + { ...scope, runIds: ['actor', 'missing'] }, + { ...scope, branchId: 'other', runIds: ['actor'] }, + { ...scope, conversationId: 'other-task', runIds: ['root'] }, + ]) + expect(() => f.service.inputs(input)).toThrow(expect.objectContaining({ code: 'VALIDATION_FAILED' })) + }) + + it('pages the durable index without silently truncating inputs or treating a filtered page as the end', async () => { + const f = await fixture() + for (let index = 0; index < 100; index++) + await f.send('root', supplement(`private-${index}`, 'actor', false)) + await f.send('actor', supplement('last-visible', 'actor')) + const service = f.reopen() + const first = service.inputs({ ...scope, runIds: ['actor'] }) + expect(first).toEqual({ inputs: [], nextOffset: 100 }) + const last = service.inputs({ ...scope, runIds: ['actor'], offset: first.nextOffset! }) + expect(last.inputs.map(input => input.messageId)).toEqual(['last-visible']) + expect(last.nextOffset).toBeNull() + }) + + it('bounds large pages without truncating a user input', async () => { + const f = await fixture() + const text = '正文'.repeat(150_000) + for (const id of ['large-one', 'large-two']) + await f.send('actor', { ...supplement(id, 'actor'), text }) + const first = f.service.inputs({ ...scope, runIds: ['actor'] }) + expect(first.inputs).toHaveLength(1) + expect(first.inputs[0]?.text).toBe(text) + expect(first.nextOffset).toBe(1) + const next = f.service.inputs({ ...scope, runIds: ['actor'], offset: first.nextOffset! }) + expect(next.inputs.map(input => input.messageId)).toEqual(['large-two']) + expect(next.inputs[0]?.text).toBe(text) + expect(next.nextOffset).toBeNull() + }) + + it('keeps the received fact after cancellation while pending inputs become undelivered', async () => { + const f = await fixture() + for (const id of ['received', 'pending', 'consumed']) + await f.send('actor', supplement(id, 'actor')) + for (const id of ['received', 'consumed']) { + const message = f.mailbox.find(id)!.message + const receipt = { messageId: id, sourceRunId: 'actor', payloadHash: message.payloadHash, contextEntryId: `entry-${id}` } + await f.events.append({ runId: 'actor', type: 'execution.mailbox.accepted', payload: receipt }) + if (id === 'consumed') + await f.events.append({ runId: 'actor', type: 'execution.mailbox.consumed', payload: receipt }) + } + f.executions.requestCancel('actor', executionNow) + expect(f.reopen().inputs({ ...scope, runIds: ['actor'] }).inputs.map(input => [input.messageId, input.state])).toEqual([ + ['received', 'accepted'], + ['pending', 'undelivered'], + ['consumed', 'consumed'], + ]) + }) +}) diff --git a/apps/buddy/service/src/agent/execution/executionForwardEvidence.ts b/apps/buddy/service/src/agent/execution/executionForwardEvidence.ts new file mode 100644 index 000000000..f32bdd25a --- /dev/null +++ b/apps/buddy/service/src/agent/execution/executionForwardEvidence.ts @@ -0,0 +1,60 @@ +import type { SessionEntry } from '@earendil-works/pi-coding-agent' +import { z } from 'zod' +import { EXECUTION_DELEGATION_TOOL_NAME } from '../../../../shared/executions/executionApi' +import { readCheckpoint } from '../sessions/tree/BuddyTreeStore' + +const forwardCall = z.object({ + operation: z.literal('forward'), + runId: z.string().min(1), + inputSourceId: z.string().min(1), +}).strict() + +const closedReceipt = z.object({ + status: z.literal('closed'), + replayed: z.literal(false), + origin: z.literal('user'), + runId: z.string(), + inputRunId: z.string(), + inputSourceId: z.string(), + messageId: z.string(), +}) + +export function readClosedForwardTargets(entries: readonly SessionEntry[], callerId: string, input: { sourceId: string, messageId: string }): Set { + const targets = new Set() + const calls = new Map>() + let runId: string | null = null + for (const entry of entries) { + const checkpoint = readCheckpoint(entry) + if (checkpoint) { + runId = checkpoint.position === 'before' ? checkpoint.runId : null + calls.clear() + } + if (runId !== callerId || entry.type !== 'message') + continue + const message = entry.message + if (message.role === 'assistant') { + for (const block of message.content) { + if (block.type !== 'toolCall' || block.name !== EXECUTION_DELEGATION_TOOL_NAME) + continue + const parsed = forwardCall.safeParse(block.arguments) + if (parsed.success && parsed.data.inputSourceId === input.sourceId) + calls.set(block.id, parsed.data) + } + } + if (message.role !== 'toolResult') + continue + const call = calls.get(message.toolCallId) + calls.delete(message.toolCallId) + if (!call || message.isError || message.toolName !== EXECUTION_DELEGATION_TOOL_NAME + || message.content.length !== 1 || message.content[0]?.type !== 'text' + || message.content[0].text !== JSON.stringify(message.details)) { + continue + } + const parsed = closedReceipt.safeParse(message.details) + if (parsed.success && parsed.data.inputRunId === callerId && parsed.data.messageId === input.messageId + && parsed.data.inputSourceId === input.sourceId && parsed.data.runId === call.runId) { + targets.add(call.runId) + } + } + return targets +} diff --git a/apps/buddy/service/src/agent/execution/registerExecutionRpc.ts b/apps/buddy/service/src/agent/execution/registerExecutionRpc.ts index f5f7fbd96..0febfcefc 100644 --- a/apps/buddy/service/src/agent/execution/registerExecutionRpc.ts +++ b/apps/buddy/service/src/agent/execution/registerExecutionRpc.ts @@ -8,6 +8,7 @@ export function registerExecutionRpc(options: { rpc: RuntimeRequestRegistrar, se const stops = [ registerRuntimeRequest(options.rpc, executionPresentationRpc.list, input => options.service.list(input)), registerRuntimeRequest(options.rpc, executionPresentationRpc.detail, input => options.service.detail(input)), + registerRuntimeRequest(options.rpc, executionPresentationRpc.inputs, input => options.service.inputs(input)), registerRuntimeRequest(options.rpc, executionPresentationRpc.events, input => options.service.events(input)), registerRuntimeRequest(options.rpc, executionPresentationRpc.send, input => options.service.send(input)), registerRuntimeRequest(options.rpc, executionPresentationRpc.sendDraft, input => options.inputs.send(input)), diff --git a/apps/buddy/service/src/agent/extensions/delegationCapability.ts b/apps/buddy/service/src/agent/extensions/delegationCapability.ts index 1bf923145..c72153a8b 100644 --- a/apps/buddy/service/src/agent/extensions/delegationCapability.ts +++ b/apps/buddy/service/src/agent/extensions/delegationCapability.ts @@ -14,7 +14,7 @@ export function createDelegationCapability(options: { getPermissions: () => BuddyPermissionSettings actorKind: 'actor' | 'npc' }): BuddyCapability { - const operations = ['status', 'result', 'submit', 'report', 'send', 'wait', 'question', 'answer', ...options.actorKind === 'actor' ? ['list', 'context', 'history', 'delegate', 'resume', 'review', 'cancel'] : []] + const operations = ['status', 'result', 'submit', 'report', 'send', 'wait', 'question', 'answer', ...options.actorKind === 'actor' ? ['list', 'context', 'forward', 'history', 'delegate', 'resume', 'review', 'cancel'] : []] return { extension: { name: 'lexora-agents', @@ -23,12 +23,20 @@ export function createDelegationCapability(options: { name: DELEGATION_TOOL_NAME, exposure: 'model-only', label: 'Partners and NPCs', - description: `${options.actorKind === 'npc' ? 'You are an NPC child execution. Report progress while working; your final reply is delivered to your caller automatically. Inspect related executions, exchange supplements, wait, or ask and answer questions. You cannot create new child executions.' : 'Manage local collaboration within this task. List allowed partners and NPC definitions, delegate a task to targetId, inspect status, read child results, exchange progress and supplements, wait, ask and answer questions, or cancel children.'} Call lexora_agents directly; it is unavailable in the CodeMode tools object. Both actors and NPCs can ask the human directly with operation: question and audience: user. NPCs are not restricted to caller questions.`, + description: `${options.actorKind === 'npc' ? 'You are an NPC child execution. Report progress while working; your final reply is delivered to your caller automatically. Inspect related executions, exchange supplements, wait, or ask and answer questions. You cannot create new child executions.' : 'Manage local collaboration within this task. For revisions to existing public actor work, use context to discover works and your accepted user inputs, then forward the original input without rewriting it. Use context with runId for its current state and latest public final reply. Otherwise list allowed partners and NPC definitions, delegate new work, inspect owned assignments, exchange supplements, wait, ask questions, or cancel children.'} Call lexora_agents directly; it is unavailable in the CodeMode tools object. Both actors and NPCs can ask the human directly with operation: question and audience: user. NPCs are not restricted to caller questions.`, promptGuidelines: [ 'The list result identifies you as self. actors and npcs contain only other eligible delegation targets and intentionally exclude you; your absence from those arrays does not mean you are unavailable.', 'Complete bounded tasks directly by default. Delegate when the target supplies a needed capability, isolates substantial research, or handles independent work with a concrete benefit. File count alone is not a reason to delegate. Do not spawn agents merely to use them.', 'New delegations use isolated context: only the task and explicitly selected materials are supplied. Use context to inspect available source and attachment IDs, then contextSourceIds and attachmentIds to hand off needed inputs. Use contextMode: conversation only when the partner needs shared task history; private caller reasoning is never copied. For independent review, use isolated context.', - 'Use history to find assignments you own in this task, then resume with a terminal runId and new instructions to continue the same work and private thread. It creates a new attempt, preserving the earlier result. Read the previous result first; do not retry an operation with uncertain side effects. Only the actor that owned the assignment may resume it.', + ...options.actorKind === 'actor' + ? [ + 'Input without an explicit actor mention belongs to the current main actor; a mention does not switch the main actor. You may still need to hand an exact user revision to existing public actor work, rather than rewrite the task or create a replacement.', + 'For a revision to existing public actor work, use context: inputs are only user inputs accepted by your current run, and works identify public actor work already visible to you in this conversation and branch. Select the exact work by runId, then use forward with runId and inputSourceId. Do not pass text: the server copies the original user input unchanged. No attachment, reference, private input, or permission is implicitly transferred.', + 'Use context with the selected runId to read its current phase/status and latest public final reply, following nextOffset for long replies. This is a targeted public refresh, not permission to read its private context. An old initial snapshot is not evidence that the revision remains unfinished.', + 'A forward accepted receipt is durable routing, not proof that the actor completed the revision; deliveryState reports receipt/consumption. Retrying the same input and target replays confirmation without another delivery. closed or unsupported_input does not deliver anything. Never treat busy, closed, or an accepted forward as a reason to delegate replacement work, resume an unrelated old assignment, or rephrase the user input through send.', + 'history lists only assignments owned by your actor, not all public work in the conversation. For your own explicitly continued assignment, read its result then resume the exact terminal runId to preserve its private thread. resume creates a new attempt; it is not the forwarding mechanism for existing public actor work. Do not retry an operation with uncertain side effects.', + ] + : [], 'Give each child a self-contained scope, required facts and inputs, expected deliverable, and verification responsibility. NPC context is private. Reuse the best-fitting definition for homogeneous work; different roles serve different responsibilities.', 'Missing user information can be part of a delegated task. When the user explicitly asks a child to investigate and ask them for missing details, delegate first with those unknowns; let the child ask the user and resume after their answer. Do not ask the user yourself before delegation merely to prepare the child input.', 'Actor partners speak directly in the task conversation under their own identity. Their replies are already visible to the user: do not relay or reprint them. Add only useful coordination, synthesis, or the requested integrated deliverable. NPC replies remain private; convey their relevant results to the user yourself.', @@ -66,7 +74,8 @@ export function createDelegationCapability(options: { acceptance: Type.Optional(Type.Array(Type.String({ minLength: 1, maxLength: 8000 }), { maxItems: 16 })), }, { additionalProperties: false })), targetId: Type.Optional(Type.String({ description: 'Reusable definition ID from list. Repeated NPC delegations create independent instances, including concurrent instances of the same definition.' })), - runId: Type.Optional(Type.String({ description: 'Your execution, caller, direct child, or sibling ID returned by status. Only direct children may be cancelled or have private results read.' })), + runId: Type.Optional(Type.String({ description: 'Exact execution ID from status/history, or public works returned by context. context + runId reads current public state/reply; forward requires the existing target runId. Public discovery grants no private result, cancel, send, or resume authority.' })), + inputSourceId: Type.Optional(Type.String({ description: 'Required only for forward: an inputs[].sourceId returned by context for a user input accepted by your current run. forward accepts only operation, runId, inputSourceId; no text or material overrides.' })), text: Type.Optional(Type.String({ minLength: 1, description: 'Task, supplement, question, or answer.' })), questionId: Type.Optional(Type.String()), audience: Type.Optional(Type.Union([Type.Literal('caller'), Type.Literal('user')], { description: 'Required for question. user: show a question to the human; caller: ask your delegating agent.' })), @@ -91,11 +100,17 @@ export function createDelegationCapability(options: { const code = error && typeof error === 'object' && 'code' in error ? String(error.code) : 'invalid_request' const guidance = code === 'budget_exceeded' ? 'The root turn has exhausted its 64 child attempts. Keep completed evidence, summarize remaining work, and do not retry delegation.' - : code === 'SHELL_SUPERVISION_UNAVAILABLE' - ? 'This platform cannot supervise shell descendants during delegation. Use file tools in a new delegated task, or continue shell work directly in this task.' - : code === 'MUTATION_RESOURCE_UNSAFE' - ? 'Cleanup of an owned process is unresolved. Do not retry writes or delegate this task until the affected resource is safe.' - : 'Inspect current targets and execution state before retrying.' + : code === 'input_forwarded' + ? 'Your latest accepted user input was already forwarded to this actor work. Use context with the original target runId to inspect current public state/reply. Do not create a replacement or resume a different assignment.' + : code === 'forward_closed' + ? 'Forwarding this exact input found the selected public work closed. Inspect that work with context; do not replace it by delegating or resuming another assignment for the same input.' + : code === 'busy' || code === 'stale' + ? 'The requested execution is busy, closing, closed, or no longer current. Use context to locate the exact existing public work and inspect it; do not create a replacement or resume unrelated work.' + : code === 'SHELL_SUPERVISION_UNAVAILABLE' + ? 'This platform cannot supervise shell descendants during delegation. Use file tools in a new delegated task, or continue shell work directly in this task.' + : code === 'MUTATION_RESOURCE_UNSAFE' + ? 'Cleanup of an owned process is unresolved. Do not retry writes or delegate this task until the affected resource is safe.' + : 'Inspect current targets and execution state before retrying.' return { content: [{ type: 'text', text: `Collaboration request rejected: ${code}. ${guidance}` }], details: { code }, isError: true } } }, diff --git a/apps/buddy/service/src/agent/sessions/__tests__/modelRetry.spec.ts b/apps/buddy/service/src/agent/sessions/__tests__/modelRetry.spec.ts index 84c5f80f5..839326997 100644 --- a/apps/buddy/service/src/agent/sessions/__tests__/modelRetry.spec.ts +++ b/apps/buddy/service/src/agent/sessions/__tests__/modelRetry.spec.ts @@ -1,6 +1,7 @@ import type { Api, AssistantMessage, Model } from '@earendil-works/pi-ai' import type { ModelRetryLimit } from '../../../../../shared/runtime/runtimePreferences' import type { BuddyExtensionRunContextStore } from '../../extensions/BuddyExtensionRunContext' +import type { BuddyInProcessExtension } from '../../extensions/BuddyInProcessExtension' import { mkdtemp, realpath, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' @@ -18,58 +19,91 @@ afterEach(async () => { }) describe('model retry policy', () => { - it('keeps an in-flight response on its model and uses the selected model for the next tool step', async () => { - const fixture = await createFixture(() => undefined, [], true) + it.each([false, true])('prepares the smaller model context before requesting it, with another switch during compaction: %s', async (switchDuringCompaction) => { + const compactedModels: string[] = [] + const fixture = await createFixture(() => undefined, [], true, () => { + compactedModels.push(fixture.session.model!.id) + if (switchDuringCompaction) + fixture.selectFirstModel() + }) const first = createAssistantMessageEventStream() const started = Promise.withResolvers() - const requests: string[] = [] + const requests: { model: string, context: string }[] = [] const toolModels: string[] = [] fixture.session.subscribe((event) => { if (event.type === 'tool_execution_start') toolModels.push(fixture.runContext.current!.requestModel!.modelId) }) - vi.spyOn(fixture.runtime, 'streamSimple').mockImplementation((model) => { - requests.push(model.id) - if (requests.length > 1) - return response(model) - started.resolve() - return first + vi.spyOn(fixture.runtime, 'streamSimple').mockImplementation((model, context) => { + requests.push({ model: model.id, context: JSON.stringify(context.messages) }) + if (requests.length === 1) { + started.resolve() + return first + } + if (requests.length === 2 && switchDuringCompaction) { + const stream = createAssistantMessageEventStream() + void response(model).result().then(message => stream.push({ type: 'done', reason: 'toolUse', message: { ...message, stopReason: 'toolUse', content: [{ type: 'toolCall', id: 'inspect-again', name: 'ls', arguments: { path: '.' } }] } })) + return stream + } + return response(model) }) - const release = await fixture.activate(2) - const prompt = fixture.reusable.prompt('Inspect the current directory') + const release = await fixture.activate(0) + const prompt = fixture.reusable.prompt('Inspect the current directory and preserve the task context') await started.promise - expect(fixture.runContext.current?.requestModel).toEqual({ providerId: fixture.models[0]!.provider, modelId: fixture.models[0]!.id, reasoning: fixture.session.thinkingLevel, serviceTier: null }) + expect(fixture.runContext.current?.requestModel?.modelId).toBe(fixture.models[0]!.id) fixture.selectNextModel() expect(fixture.runContext.current?.requestModel?.modelId).toBe(fixture.models[0]!.id) const original = await response(fixture.models[0]!).result() - first.push({ type: 'done', reason: 'toolUse', message: { ...original, stopReason: 'toolUse', content: [{ type: 'toolCall', id: 'inspect', name: 'ls', arguments: { path: '.' } }] } }) + first.push({ type: 'done', reason: 'toolUse', message: { ...original, stopReason: 'toolUse', usage: { ...original.usage, input: 20_000, totalTokens: 20_000 }, content: [{ type: 'toolCall', id: 'inspect', name: 'ls', arguments: { path: '.' } }] } }) await prompt - expect(requests).toEqual([fixture.models[0]!.id, fixture.models[1]!.id]) - expect(fixture.runContext.current?.requestModel?.modelId).toBe(fixture.models[1]!.id) - expect(toolModels).toEqual([fixture.models[0]!.id]) - expect(fixture.session.messages.filter(message => message.role === 'user')).toHaveLength(1) - expect(fixture.session.messages.at(-1)).toMatchObject({ model: fixture.models[1]!.id, stopReason: 'stop' }) + expect(compactedModels).toEqual([fixture.models[1]!.id]) + expect(requests.map(request => request.model)).toEqual([fixture.models[0]!.id, fixture.models[1]!.id, ...switchDuringCompaction ? [fixture.models[0]!.id] : []]) + expect(requests[1]!.context).toContain('Preserved task context before switching models') + expect(toolModels).toEqual([fixture.models[0]!.id, ...switchDuringCompaction ? [fixture.models[1]!.id] : []]) + expect(fixture.session.messages.at(-1)).toMatchObject({ model: requests.at(-1)!.model, stopReason: 'stop' }) + expect(fixture.session.sessionManager.getEntries().filter(entry => entry.type === 'message' && entry.message.role === 'user')).toHaveLength(1) release() }) - it('switches a waiting retry immediately without resubmitting input or rewriting the failed model', async () => { - const requests: string[] = [] - const fixture = await createFixture((model) => { - requests.push(model.id) - return requests.length === 1 ? '503 service unavailable' : undefined - }, [], true) + + it.each(['idle', 'retry'] as const)('compacts with the selected smaller model before the first %s request', async (mode) => { + const compactedModels: string[] = [] + const fixture = await createFixture(() => undefined, [], true, () => compactedModels.push(fixture.session.model!.id)) + const requests: { model: string, context: string }[] = [] + vi.spyOn(fixture.runtime, 'streamSimple').mockImplementation((model, context) => { + requests.push({ model: model.id, context: JSON.stringify(context.messages) }) + if (mode === 'retry' && requests.length === 2) + return response(model, '503 service unavailable') + if (requests.length === 1) { + const stream = createAssistantMessageEventStream() + void response(model).result().then(message => stream.push({ type: 'done', reason: 'stop', message: { ...message, usage: { ...message.usage, input: 20_000, totalTokens: 20_000 } } })) + return stream + } + return response(model) + }) const release = await fixture.activate(2) - const waiting = Promise.withResolvers() - fixture.session.subscribe(event => event.type === 'auto_retry_start' && waiting.resolve()) - fixture.session.settingsManager.applyOverrides({ retry: { baseDelayMs: 60_000 } }) - const prompt = fixture.reusable.prompt('Switch the failing model') - await waiting.promise - fixture.selectNextModel() - await prompt - expect(requests).toEqual([fixture.models[0]!.id, fixture.models[1]!.id]) + await fixture.reusable.prompt('Retain this task context for a smaller model later. '.repeat(2_000)) + expect(compactedModels).toEqual([]) + if (mode === 'idle') { + fixture.selectNextModel() + await fixture.reusable.prompt('Continue the task after switching') + } + else { + const waiting = Promise.withResolvers() + fixture.session.subscribe(event => event.type === 'auto_retry_start' && waiting.resolve()) + fixture.session.settingsManager.applyOverrides({ retry: { baseDelayMs: 60_000 } }) + const prompt = fixture.reusable.prompt('Continue the task after switching') + await waiting.promise + fixture.selectNextModel() + await prompt + } + expect(compactedModels).toEqual([fixture.models[1]!.id]) + expect(requests.map(request => request.model)).toEqual([...Array.from({ length: mode === 'retry' ? 2 : 1 }, () => fixture.models[0]!.id), fixture.models[1]!.id]) + expect(requests.at(-1)!.context).toContain('Preserved task context before switching models') expect(fixture.session.messages.at(-1)).toMatchObject({ model: fixture.models[1]!.id, stopReason: 'stop' }) const entries = fixture.session.sessionManager.getEntries() - expect(entries.filter(entry => entry.type === 'message' && entry.message.role === 'user')).toHaveLength(1) - expect(entries.some(entry => entry.type === 'message' && entry.message.role === 'assistant' && entry.message.model === fixture.models[0]!.id && entry.message.stopReason === 'error')).toBe(true) + expect(entries.filter(entry => entry.type === 'message' && entry.message.role === 'user')).toHaveLength(2) + if (mode === 'retry') + expect(entries.some(entry => entry.type === 'message' && entry.message.role === 'assistant' && entry.message.model === fixture.models[0]!.id && entry.message.stopReason === 'error')).toBe(true) release() }) @@ -97,9 +131,9 @@ describe('model retry policy', () => { expect(attempts).toEqual([1, 2]) release() }) - it.each([2, 'unlimited'] as const)('restores the %s retry budget for the next tool step after switching models', async (limit) => { + it('restores the finite retry budget for the next tool step after switching models', async () => { const fixture = await createFixture(() => undefined, [], true) - const failures = limit === 'unlimited' ? 2 : limit + const limit = 2 const requests: string[] = [] vi.spyOn(fixture.runtime, 'streamSimple').mockImplementation((model) => { requests.push(model.id) @@ -108,7 +142,7 @@ describe('model retry policy', () => { void response(model).result().then(message => stream.push({ type: 'done', reason: 'toolUse', message: { ...message, stopReason: 'toolUse', content: [{ type: 'toolCall', id: 'inspect', name: 'ls', arguments: { path: '.' } }] } })) return stream } - return response(model, requests.length <= failures + 2 ? '503 service unavailable' : undefined) + return response(model, requests.length <= limit + 2 ? '503 service unavailable' : undefined) }) const release = await fixture.activate(limit) const waiting = Promise.withResolvers() @@ -123,8 +157,8 @@ describe('model retry policy', () => { await waiting.promise fixture.selectNextModel() await prompt - expect(requests).toEqual([fixture.models[0]!.id, ...Array.from({ length: failures + 2 }, () => fixture.models[1]!.id)]) - expect(attempts).toEqual([1, ...Array.from({ length: failures }, (_, index) => index + 1)]) + expect(requests).toEqual([fixture.models[0]!.id, ...Array.from({ length: limit + 2 }, () => fixture.models[1]!.id)]) + expect(attempts).toEqual([1, ...Array.from({ length: limit }, (_, index) => index + 1)]) expect(fixture.session.messages.at(-1)).toMatchObject({ model: fixture.models[1]!.id, stopReason: 'stop' }) release() }) @@ -200,7 +234,6 @@ describe('model retry policy', () => { }) let release = await fixture.activate(2) fixture.reusable.applyPreferences({ cacheWarming: 'streaming', codemode: false, modelRetryLimit: 0 }) - fixture.reusable.applyPreferences({ cacheWarming: 'off', codemode: false, modelRetryLimit: 0 }) fixture.session.settingsManager.applyOverrides({ retry: { baseDelayMs: 1 } }) await fixture.reusable.prompt('First run') expect(requests).toBe(3) @@ -212,11 +245,13 @@ describe('model retry policy', () => { }) }) -async function createFixture(failure: (model: Model) => string | undefined, failureContent: AssistantMessage['content'] = [], liveSelection = false) { +async function createFixture(failure: (model: Model) => string | undefined, failureContent: AssistantMessage['content'] = [], liveSelection = false, beforeCompact?: () => void) { const root = await realpath(await mkdtemp(join(tmpdir(), 'buddy-model-retry-'))) cleanups.push(() => rm(root, { recursive: true, force: true })) const runtime = await ModelRuntime.create({ credentials: new InMemoryCredentialStore(), modelsPath: null, refreshOnCreate: false }) const models = runtime.getModels().filter(model => model.provider === 'anthropic').slice(0, 2) + if (beforeCompact) + models[1] = { ...models[1]!, contextWindow: 8_192, maxTokens: 1_024 } const model = models[0]! let selected = model const runContext: BuddyExtensionRunContextStore = { current: null } @@ -235,10 +270,20 @@ async function createFixture(failure: (model: Model) => string | undefined, modelRuntime: runtime, thinkingLevel: 'off', resources: { skillReadRoots: [], skillReferences: [], approvedSkills: [], context: { agentsFiles: [], diagnostics: [] }, directoryContext: '', revision: 'empty' }, - inProcessExtensions: [createModelSwitchExtension(runContext)], + inProcessExtensions: [createModelSwitchExtension(runContext), ...beforeCompact + ? [{ + name: 'lexora-model-compaction-fixture', + factory(pi) { + pi.on('session_before_compact', (event) => { + beforeCompact() + return { compaction: { summary: 'Preserved task context before switching models', firstKeptEntryId: event.preparation.firstKeptEntryId, tokensBefore: event.preparation.tokensBefore } } + }) + }, + } satisfies BuddyInProcessExtension] + : []], }) const { session } = created - session.settingsManager.setCompactionEnabled(false) + session.settingsManager.applyOverrides({ compaction: { enabled: !!beforeCompact, keepRecentTokens: 1, reserveTokens: 1_024 } }) const reusable = createReusableBuddySession({ session, shutdown: created.shutdown, @@ -264,6 +309,10 @@ async function createFixture(failure: (model: Model) => string | undefined, activate, models, runtime, + selectFirstModel: () => { + selected = models[0]! + reusable.refreshModelSelection?.() + }, selectNextModel: () => { selected = models[1]! reusable.refreshModelSelection?.() diff --git a/apps/buddy/service/src/agent/sessions/createReusableBuddySession.ts b/apps/buddy/service/src/agent/sessions/createReusableBuddySession.ts index 329523d58..0934b54e3 100644 --- a/apps/buddy/service/src/agent/sessions/createReusableBuddySession.ts +++ b/apps/buddy/service/src/agent/sessions/createReusableBuddySession.ts @@ -74,6 +74,24 @@ export function createReusableBuddySession( let pendingRetryOffset: number | null = null let finishingTurn: Promise | undefined let generation: number | undefined + let preparedModelRun: BuddyExtensionRunContextStore['current'] = null + let completedTurn: Parameters>[0] | undefined + const finishAgentTurn = session.agent.finishTurn + session.agent.finishTurn = async (turn, signal) => { + const decision = await finishAgentTurn?.(turn, signal) + completedTurn = turn + if (decision) + return decision + } + const prepareNextTurn = session.agent.prepareNextTurnWithContext + session.agent.prepareNextTurnWithContext = async (turn, signal) => { + preparedModelRun = null + const run = await refreshRequestModel(signal) + const prepared = await prepareNextTurn?.(turn, signal) + if (run && options.runContext.current === run && !run.signal.aborted) + preparedModelRun = run + return prepared + } const prepareRequest = session.agent.prepareRequest session.agent.prepareRequest = async (request, signal) => { options.assertCodemodeReady?.() @@ -83,12 +101,26 @@ export function createReusableBuddySession( pendingRetryOffset = null applySessionSettings(preferences.cacheWarming) } - if (run && !run.signal.aborted && options.getModelSelection) { - const selected = await options.getModelSelection() - signal?.throwIfAborted() - if (selected && options.runContext.current === run && !run.signal.aborted) { - await applyModelSelection(session, options.assertModelAccess, selected) - run.serviceTier = selected.serviceTier ?? null + const modelPrepared = run && preparedModelRun === run + preparedModelRun = null + if (!modelPrepared) { + const previousModel = session.model + await refreshRequestModel(signal) + if (completedTurn && previousModel !== session.model) { + const next = await prepareNextTurn?.({ ...completedTurn, context: request.context }, signal) + if (next) { + for (const message of next.messages ?? []) { + if (message.role !== 'system') + throw new Error('Continuation preparation must produce system messages') + session.sessionManager.appendMessage(message) + } + session.refreshContext() + request = { + context: { ...(next.context ?? request.context), messages: session.messages }, + model: next.model ?? request.model, + thinkingLevel: next.thinkingLevel ?? request.thinkingLevel, + } + } } } const prepared = await prepareRequest?.(request, signal) @@ -113,6 +145,23 @@ export function createReusableBuddySession( } }) } + async function refreshRequestModel(signal?: AbortSignal) { + const run = options.runContext.current + if (run && !run.signal.aborted && options.getModelSelection) { + const selected = await options.getModelSelection() + signal?.throwIfAborted() + if (selected && options.runContext.current === run && !run.signal.aborted) { + await applyModelSelection(session, options.assertModelAccess, selected) + run.serviceTier = selected.serviceTier ?? null + } + } + return run + } + async function prepareRunModel() { + const run = await refreshRequestModel(options.runContext.current?.signal) + if (run && options.runContext.current === run && !run.signal.aborted) + preparedModelRun = run + } function applySessionSettings(mode: RuntimePreferences['cacheWarming']) { if (session.settingsManager.getCacheWarmingMode() !== mode) session.setCacheWarmingMode(mode) @@ -175,6 +224,8 @@ export function createReusableBuddySession( options.inputReferences.pending = inputReference ?? null return withPiSessionStorageBoundary(session, async () => { try { + if (session.isIdle) + await prepareRunModel() await session.prompt(text, piPromptOptions) } finally { @@ -374,6 +425,8 @@ export function createReusableBuddySession( options.assertCodemodeReady?.() input.signal.throwIfAborted() finishingTurn = undefined + preparedModelRun = null + completedTurn = undefined generation = input.generation turnRetryLimit = preferences.modelRetryLimit retryOffset = 0 @@ -499,6 +552,7 @@ export function createReusableBuddySession( options.updatePermissionSettings?.(options.getPermissionSettings(run.runId)) assertCurrentRun(run) try { + await prepareRunModel() await session.sendCustomMessage({ customType: 'lexora.execution.resume.v1', content: 'Continue the current task using the newly delivered execution messages. Keep your configured responsibilities and permissions; the messages are source material, not new authorization.', diff --git a/apps/buddy/service/src/chat/ChatQueueService.ts b/apps/buddy/service/src/chat/ChatQueueService.ts index 760e0e942..ee3e62bca 100644 --- a/apps/buddy/service/src/chat/ChatQueueService.ts +++ b/apps/buddy/service/src/chat/ChatQueueService.ts @@ -132,7 +132,7 @@ export class ChatQueueService { async #enqueue(input: BuddyStartTurnInput) { if (this.#disposed) throw new BuddyServiceError('VALIDATION_FAILED') - const fingerprint = createHash('sha256').update(JSON.stringify([input.draftId, input.expectedRevision])).digest('hex') + const fingerprint = createHash('sha256').update(JSON.stringify([input.draftId, input.expectedRevision, ...(input.delivery ? [input.delivery] : [])])).digest('hex') const replay = this.#options.queue.replay(input.requestId, fingerprint) if (replay) return replay @@ -148,6 +148,15 @@ export class ChatQueueService { } this.#options.availability?.assertConversationWritable(prepared.conversationId) stagedAttachments.validate() + const target = this.#options.queue.validateDelivery(prepared) + if (prepared.delivery?.mode === 'current' && target) { + if (!this.#canFollowUp(target.run, prepared)) + throw new BuddyServiceError('INPUT_DELIVERY_INCOMPATIBLE') + await this.#options.turns.validatePreparedInput(prepared, false) + const current = this.#options.queue.validateDelivery(prepared)! + if (!this.#canFollowUp(current.run, prepared)) + throw new BuddyServiceError('INPUT_DELIVERY_INCOMPATIBLE') + } result = this.#options.queue.enqueue({ ...prepared, requestFingerprint: fingerprint }) } catch (error) { @@ -159,7 +168,13 @@ export class ChatQueueService { this.pause(result, prepared.buddyId) this.#changed('enqueued', result, { commitId: randomUUID(), requestId: input.requestId, queueId: result.id, draftReceipt: result.draftReceipt, ...(prepared.attachmentBindings.length ? { attachmentOwnership: { kind: 'queue', attachmentIds: prepared.attachmentBindings.map(binding => binding.id) } } : {}) }) await stagedAttachments.commit().catch(() => undefined) - if (prepared.actorIds?.length === 1 && this.#options.queue.activeRun(result, prepared.buddyId) + if (prepared.delivery?.mode === 'current' && prepared.delivery.target) { + void this.steer(result).then((status) => { + if (status !== 'accepted') + this.#pauseItem(result) + }).catch(() => this.#pauseItem(result)) + } + else if (!prepared.delivery && prepared.actorIds?.length === 1 && this.#options.queue.activeRun(result, prepared.buddyId) && this.#options.queue.list(result).find(item => item.buddyId === prepared.buddyId)?.id === result.id) { void this.steer(result).catch(() => this.pause(result, prepared.buddyId)) } @@ -180,6 +195,12 @@ export class ChatQueueService { return this.#serialize(target, async () => { if (this.#isCancelling(target, this.#options.queue.pending(target)?.buddyId)) return 'unavailable' + const queued = this.#options.queue.pending(target) + if (queued?.delivery) { + if (queued.delivery.mode === 'next' || !queued.delivery.target) + return await this.#dispatch(target, true) ? 'accepted' : 'unavailable' + return this.#steerCurrent(target, queued) + } if (!active) { if (this.#options.queue.pending(target)?.queueRouting?.mode !== 'actor' && this.#options.queue.activeRun(target)) return 'incompatible' @@ -197,6 +218,9 @@ export class ChatQueueService { await this.#options.turns.validatePreparedInput(input, false) if (this.#disposed || this.#isCancelling(target, this.#options.queue.pending(target)?.buddyId) || this.#options.eventLog.state !== 'open' || !this.#options.queue.pending(target) || !this.#options.queue.isCurrentInput(input) || this.#options.queue.activeRun(target, pending?.buddyId)?.id !== active.id) return 'unavailable' + const current = this.#options.runs.findById(active.id) + if (!current || !this.#canFollowUp(current, input)) + return 'incompatible' return this.#deliver(input, active.id, 'steer') ? 'accepted' : 'unavailable' }, 'unavailable') } @@ -210,13 +234,14 @@ export class ChatQueueService { if (signal.aborted || this.#isCancelling(run, run.buddyId) || this.#options.queue.activeRun(run, run.buddyId)?.id !== runId) return false const next = this.#options.queue.list(run).find(item => item.buddyId === run.buddyId) - if (next?.state !== 'waiting') + if (next?.state !== 'waiting' || (next.delivery && (next.delivery.mode !== 'current' || next.delivery.target?.runId !== runId))) return false const pending = this.#options.queue.pending(next) if (!pending) return false const input = await this.#options.turns.refreshQueuedInput(pending) - if (!this.#canFollowUp(run, input)) + const refreshedRun = this.#options.runs.findById(runId) + if (!refreshedRun || !this.#canFollowUp(refreshedRun, input)) return false await this.#options.turns.validatePreparedInput(input) if (this.#disposed || this.#isCancelling(run, run.buddyId) || this.#options.eventLog.state !== 'open' || signal.aborted) @@ -227,6 +252,9 @@ export class ChatQueueService { || !this.#options.queue.pending(next) || !this.#options.queue.isCurrentInput(input)) { return false } + const current = this.#options.runs.findById(runId) + if (!current || !this.#canFollowUp(current, input)) + return false return this.#deliver(input, runId, 'followUp') }, false) } @@ -237,16 +265,54 @@ export class ChatQueueService { } } + async #steerCurrent(target: LocalChatQueueTarget, pending: QueuedTurnRequestInput): Promise { + try { + this.#options.queue.validateDelivery(pending) + const input = await this.#options.turns.refreshQueuedInput(pending) + const execution = this.#options.queue.validateDelivery(input)! + if (!this.#canFollowUp(execution.run, input)) + throw new BuddyServiceError('INPUT_DELIVERY_INCOMPATIBLE') + await this.#options.turns.validatePreparedInput(input, false) + const current = this.#options.queue.validateDelivery(input)! + if (!this.#canFollowUp(current.run, input)) + throw new BuddyServiceError('INPUT_DELIVERY_INCOMPATIBLE') + if (this.#disposed || this.#options.eventLog.state !== 'open' || this.#isCancelling(target, input.buddyId) + || !this.#options.queue.pending(target) || !this.#options.queue.isCurrentInput(input)) { + throw new BuddyServiceError('INPUT_DELIVERY_TARGET_UNAVAILABLE') + } + if (!this.#deliver(input, execution.run.id, 'steer')) + throw new BuddyServiceError('INPUT_DELIVERY_TARGET_UNAVAILABLE') + return 'accepted' + } + catch (error) { + this.#pauseItem(target) + throw error + } + } + + #pauseItem(target: LocalChatQueueTarget) { + if (!this.#disposed && this.#options.queue.pauseItem(target)) + this.#changed('paused', target) + } + #canFollowUp(run: RunRecord, input: PrepareTurnRequestInput) { + if (input.delivery && (input.delivery.mode !== 'current' || input.delivery.target?.runId !== run.id + || input.delivery.target.rootRunId !== run.rootRunId || input.delivery.actorId !== run.buddyId)) { + return false + } const current = this.#options.runInputs.findByRunId(run.id) + if (!current) + return false + const liveSelection = run.modelOverride ?? run.actorSnapshot?.model + const selection = liveSelection ?? { modelId: run.model, providerId: run.provider, reasoning: current.reasoning, serviceTier: current.serviceTier } return run.status === 'running' && run.branchId === input.branchId && run.buddyId === input.buddyId && (run.actorSnapshot && input.actorSnapshot ? buddyExecutionFingerprint(run.actorSnapshot) === buddyExecutionFingerprint(input.actorSnapshot) : !!run.actorSnapshot === !!input.actorSnapshot) - && run.model === input.model && run.provider === input.provider + && selection.modelId === input.model && selection.providerId === input.provider + && selection.reasoning === input.runInput.reasoning && selection.serviceTier === input.runInput.serviceTier && this.#options.getRunPermissionCeiling(run.id) === input.runExecutionProfile - && run.contextWindow === (input.modelParameters?.contextWindow ?? null) - && run.maxTokens === (input.modelParameters?.maxTokens ?? null) - && current?.reasoning === input.runInput.reasoning && current.serviceTier === input.runInput.serviceTier + && (!!liveSelection || (run.contextWindow === (input.modelParameters?.contextWindow ?? null) + && run.maxTokens === (input.modelParameters?.maxTokens ?? null))) } #deliver(input: QueuedTurnRequestInput, runId: string, mode: 'steer' | 'followUp') { @@ -290,6 +356,19 @@ export class ChatQueueService { visited.add(next.buddyId) if (next.state !== 'waiting') continue + if (next.delivery?.mode === 'current' && next.delivery.target) { + try { + const pending = this.#options.queue.pending(next) + if (pending) + this.#options.queue.validateDelivery(pending) + } + catch (error) { + if (!(error instanceof BuddyServiceError)) + throw error + this.#pauseItem(next) + } + continue + } const latest = this.#options.queue.latestRun(current, next.buddyId) if (latest?.status === 'failed' || latest?.status === 'cancelled') { this.pause(current, next.buddyId) @@ -329,6 +408,7 @@ export class ChatQueueService { return false } return !this.#disposed && !this.#isCancelling(scope, scope.buddyId) && !this.#options.runner.isStopping && this.#options.eventLog.state === 'open' + && this.#options.queue.canDispatchDelivery(scope) && !this.#options.queue.activeRun(scope, scope.buddyId) && (scope.queueRouting?.mode === 'actor' || !this.#options.queue.activeRun(scope)) && !this.#options.runner.hasActiveExecution(scope.conversationId, scope.buddyId) diff --git a/apps/buddy/service/src/chat/ChatTurnService.ts b/apps/buddy/service/src/chat/ChatTurnService.ts index ef641d4b7..e42713c4c 100644 --- a/apps/buddy/service/src/chat/ChatTurnService.ts +++ b/apps/buddy/service/src/chat/ChatTurnService.ts @@ -173,6 +173,8 @@ export class ChatTurnService { ) if (replay) return this.#toTurnStart(replay.request, replay.run) + if (input.delivery?.target) + throw new BuddyServiceError('INPUT_DELIVERY_INCOMPATIBLE') const { prepared, stagedAttachments } = await this.prepareStart(input) const request = await persistPreparedTurn(stagedAttachments, () => { this.#stopping.signal.throwIfAborted() @@ -204,7 +206,9 @@ export class ChatTurnService { const actorIds = [...new Set(getBuddyUserContentActorReferences(draft.content).map(reference => reference.actorId))] if (!existingConversation && actorIds.some(id => !(draft.participants?.actorIds ?? [leaderId]).includes(id))) throw new BuddyServiceError('BUDDY_NOT_MEMBER') - const buddyId = actorIds.length === 1 ? actorIds[0]! : leaderId + const buddyId = input.delivery?.actorId ?? (actorIds.length === 1 ? actorIds[0]! : leaderId) + if (input.delivery && (followupScope || buddyId !== (actorIds.length === 1 ? actorIds[0]! : leaderId))) + throw new BuddyServiceError('INPUT_DELIVERY_INCOMPATIBLE') const spaceId = scope.spaceId ?? existingConversation?.spaceId ?? null const space = spaceId ? requireActiveSpace(this.#options.spaces.findById(spaceId)) @@ -297,6 +301,7 @@ export class ChatTurnService { }), materialized) const persistedAttachmentIds = stagedAttachments.bindings.map(binding => binding.id) const prepared: PrepareTurnRequestInput = { + ...(input.delivery ? { delivery: input.delivery } : {}), leaderId, expectedActorModelSelection: resolvedActor.memberModelSelection, actorSnapshot: resolvedActor.definition, @@ -349,7 +354,7 @@ export class ChatTurnService { const conversation = this.#options.conversations.findById(input.conversationId) if (!conversation || conversation.activeBranchId !== input.branchId || this.#options.conversationLifecycle.isDeleting(input.conversationId)) throw new BuddyServiceError('VALIDATION_FAILED') - const buddyId = input.queueRouting?.mode === 'actor' ? input.queueRouting.actorId : conversation.buddyId + const buddyId = input.delivery?.actorId ?? (input.queueRouting?.mode === 'actor' ? input.queueRouting.actorId : conversation.buddyId) if (buddyId !== input.buddyId) throw new BuddyServiceError('BUDDY_CONFLICT') const resolved = await this.#options.definitions.resolve({ buddyId, kind: 'actor', conversationId: conversation.id, requestedModel: input.modelOverride ?? null }) diff --git a/apps/buddy/service/src/chat/__tests__/ChatQueueService.spec.ts b/apps/buddy/service/src/chat/__tests__/ChatQueueService.spec.ts index f6209fc88..48b04cc84 100644 --- a/apps/buddy/service/src/chat/__tests__/ChatQueueService.spec.ts +++ b/apps/buddy/service/src/chat/__tests__/ChatQueueService.spec.ts @@ -6,6 +6,7 @@ import type { ExecutionSettled } from '../../agent/execution/ActiveRunRegistry' import type { BuddyRunEvent } from '../../events/BuddyRunEvent' import type { RunEventObservation } from '../../events/RunEventPorts' import type { RuntimeModelProvider } from '../../providers/resolveInteractiveModelSelection' +import type { ConversationModelSelection } from '../../storage/conversationRecord' import type { PrepareTurnRequestInput } from '../../storage/turnRequestRepository' import type { ChatQueueChange, ChatQueueServiceOptions } from '../ChatQueueService' import type { TurnRequestCommit } from '../TurnRequestService' @@ -15,7 +16,9 @@ import { createBuddyUserContent } from '../../../../shared/conversation/buddyUse import { Emitter } from '../../../../shared/events/Emitter' import { resolveBuddyPermissionSettings } from '../../../../shared/permissions/permissionMode' import { BuddyDefinitionResolver } from '../../buddies/BuddyDefinitionResolver' +import { ConversationMetadataService } from '../../conversations/ConversationMetadataService' import { RunEventCorruptionError } from '../../events/RunEventFailure' +import { resolveInteractiveModelSelection } from '../../providers/resolveInteractiveModelSelection' import { toPublicRun } from '../../runs/publicRun' import { createAttachmentRepository } from '../../storage/attachmentRepository' import { createBuddyRepository } from '../../storage/buddyRepository' @@ -47,21 +50,271 @@ afterEach(async () => { }) describe('persistent chat queue scheduling', () => { - it('dispatches an explicitly addressed idle actor while the ordinary root keeps running', async () => { + it.each([ + { kind: 'root', change: 'model' }, + { kind: 'actor_child', change: 'reasoning' }, + ] as const)('delivers current input to $kind after a live $change change using resolved request limits', async ({ kind, change }) => { const f = fixture() - const actor = f.buddies.create({ name: 'Independent actor' }) - f.conversations.addMember({ id: 'conversation', buddyId: actor.id, updatedAt: '2026-10-25T00:00:00.000Z' }) await f.enableDefinitions() - const main = f.runs.findById('run-initial')! - f.queue.enqueue({ ...f.input('directed'), buddyId: actor.id, actorIds: [actor.id], userMessageContent: { - userContent: { ...createBuddyUserContent(), body: [{ type: 'paragraph', content: [{ type: 'actor_ref', actorId: actor.id, name: actor.name }, { type: 'text', text: 'Independent request' }] }] }, - resourceSnapshots: [], - } }) + if (kind === 'actor_child') { + const caller = f.buddies.create({ name: 'Caller' }) + f.conversations.addMember({ id: 'conversation', buddyId: caller.id, updatedAt: '2026-10-25T00:00:00.000Z' }) + f.runs.create({ ...f.runs.findById('run-initial')!, id: 'caller', buddyId: caller.id, rootRunId: 'caller', schedulingLane: 'actor' }) + f.database.exec('UPDATE runs SET execution_kind = \'actor_child\', parent_run_id = \'caller\', root_run_id = \'caller\' WHERE id = \'run-initial\'') + } + const original = f.runs.findById('run-initial')! + const originalInput = f.runInputs.findByRunId('run-initial') + const selection: ConversationModelSelection = { providerId: change === 'model' ? 'other-provider' : 'provider', modelId: change === 'model' ? 'larger-model' : 'model', reasoning: 'high', serviceTier: change === 'model' ? 'priority' : null } + f.resolveModel.mockImplementation(async ({ modelId, providerId }) => ({ ...f.model(modelId, providerId), contextWindow: 64000, maxTokens: 4096 })) + await setLiveModel(f, selection) + expect(f.runs.findById('run-initial')).toMatchObject({ model: original.model, provider: original.provider, contextWindow: 32000, maxTokens: 2048, modelOverride: selection }) + expect(f.runInputs.findByRunId('run-initial')).toEqual(originalInput) + const prepared = await prepareCurrentInput(f, 'live-input', selection) + expect(prepared.modelParameters).toEqual({ contextWindow: 64000, maxTokens: 4096 }) + const attachments = createAttachmentRepository(f.database) + if (kind === 'actor_child') { + attachments.create({ id: 'live-image', conversationId: null, draftId: 'draft', messageId: null, name: 'reference.png', mimeType: 'image/png', sizeBytes: 12, storedPath: '/private/source.png', createdAt: prepared.createdAt }) + prepared.runInput.attachmentIds = ['live-image'] + prepared.attachmentBindings = [{ id: 'live-image', sourceAttachmentId: 'live-image', sourceDraftId: 'draft', messageId: prepared.userMessageId, storedPath: '/private/queued.png', createdAt: prepared.createdAt, mimeType: 'image/png' }] + } + await f.service.enqueue(enqueueRequest(f, prepared)) + await f.service.drain() + expect(f.delivered).toEqual(['steer:live-input']) + expect(f.database.prepare('SELECT run_id FROM chat_queue WHERE id = ?').get('live-input')).toEqual({ run_id: 'run-initial' }) + expect(f.runs.findById('run-live-input')).toBeNull() + if (kind === 'actor_child') + expect(attachments.findById('live-image')).toMatchObject({ messageId: 'live-input', draftId: null }) + }) + + it.each(['model', 'provider', 'reasoning', 'serviceTier', 'capability', 'permission'] as const)('still rejects a current %s mismatch against the effective live selection', async (mismatch) => { + const f = fixture() + await f.enableDefinitions() + const selection: ConversationModelSelection = { providerId: 'provider', modelId: 'changed-model', reasoning: 'high', serviceTier: 'priority' } + await setLiveModel(f, selection) + const requested = { ...selection } + if (mismatch === 'model') { + requested.modelId = 'different-model' + } + else if (mismatch === 'provider') { + requested.providerId = 'different-provider' + } + else if (mismatch === 'reasoning') { + requested.reasoning = 'low' + } + else if (mismatch === 'serviceTier') { + requested.serviceTier = null + } + else if (mismatch === 'capability') { + const buddy = f.buddies.get('default') + f.buddies.update({ buddyId: buddy.id, expectedRevision: buddy.revision, settings: buddySettingsSchema.parse({ ...buddy.settings, tools: { mode: 'none' } }) }) + } + const prepared = await prepareCurrentInput(f, 'incompatible', requested) + if (mismatch === 'permission') + prepared.runExecutionProfile = 'read_only' + const draft = f.drafts.findById('draft') + await expect(f.service.enqueue(enqueueRequest(f, prepared))).rejects.toMatchObject({ code: 'INPUT_DELIVERY_INCOMPATIBLE' }) + expect(f.drafts.findById('draft')).toEqual(draft) + expect(f.queue.list(f.scope)).toEqual([]) + expect(f.delivered).toEqual([]) + expect(f.runs.findById('run-initial')?.modelOverride).toEqual(selection) + }) + + it.each(['enqueue', 'steer', 'followUp'] as const)('rechecks a run-only model override changed during %s validation', async (boundary) => { + const f = fixture() + await f.enableDefinitions() + const selection: ConversationModelSelection = { providerId: 'provider', modelId: 'changed-model', reasoning: 'high', serviceTier: null } + await setLiveModel(f, selection) + const prepared = await prepareCurrentInput(f, 'stale-model', selection) + const draft = f.drafts.findById('draft') + if (boundary === 'steer') + f.validate.mockImplementationOnce(async () => {}) + f.validate.mockImplementationOnce(() => setLiveModel(f, { ...selection, reasoning: 'low' }, 'run-initial')) + if (boundary === 'followUp') { + f.queue.enqueue(prepared) + expect(await f.service.followUp('run-initial', f.controller.signal)).toBe(false) + } + else if (boundary === 'enqueue') { + await expect(f.service.enqueue(enqueueRequest(f, prepared))).rejects.toMatchObject({ code: 'INPUT_DELIVERY_INCOMPATIBLE' }) + expect(f.drafts.findById('draft')).toEqual(draft) + expect(f.queue.list(f.scope)).toEqual([]) + } + else { + await f.service.enqueue(enqueueRequest(f, prepared)) + await f.service.drain() + expect(f.queue.list(f.scope)).toMatchObject([{ id: 'stale-model', state: 'paused' }]) + } + expect(f.delivered).toEqual([]) + expect(f.database.prepare('SELECT id FROM messages').all()).toEqual([{ id: 'initial' }]) + }) + + it.each(['legacy', 'current'] as const)('uses the refreshed run selection when a %s follow-up becomes compatible during input refresh', async (delivery) => { + const f = fixture() + await f.enableDefinitions() + const selection: ConversationModelSelection = { providerId: 'provider', modelId: 'changed-model', reasoning: 'high', serviceTier: null } + await setLiveModel(f, selection) + const prepared = await prepareCurrentInput(f, 'refreshed-model', selection) + f.queue.enqueue({ ...prepared, ...(delivery === 'legacy' ? { delivery: undefined } : {}) }) + await setLiveModel(f, { ...selection, modelId: 'temporary-model' }, 'run-initial') + f.turns.refreshQueuedInput = async (input) => { + const refreshed = await f.actualTurns.refreshQueuedInput(input) + await setLiveModel(f, selection, 'run-initial') + return refreshed + } + + expect(await f.service.followUp('run-initial', f.controller.signal)).toBe(true) + expect(f.delivered).toEqual(['followUp:refreshed-model']) + expect(f.queue.list(f.scope)).toEqual([]) + expect(f.database.prepare('SELECT run_id FROM chat_queue WHERE id = ?').get('refreshed-model')).toEqual({ run_id: 'run-initial' }) + expect(f.runs.findById('run-refreshed-model')).toBeNull() + }) + + it.each(['current', 'next'] as const)('requires nonempty %s targets to use enqueue even after durable closure but before owner release', async (mode) => { + const f = fixture() + f.database.exec('UPDATE runs SET status = \'completed\'; UPDATE execution_state SET cleanup_state = \'clean\'') + const draft = f.drafts.findById('draft')! + await expect(f.actualTurns.start({ + draftId: draft.draftId, + expectedRevision: draft.revision, + requestId: 'direct-start', + delivery: { mode, actorId: 'default', target: { rootRunId: 'run-initial', runId: 'run-initial' } }, + })).rejects.toMatchObject({ code: 'INPUT_DELIVERY_INCOMPATIBLE' }) + expect(f.runner.hasActiveExecution('conversation', 'default')).toBe(true) + expect(f.drafts.findById('draft')).toEqual(draft) + expect(f.database.prepare('SELECT id FROM runs').all()).toEqual([{ id: 'run-initial' }]) + }) + + it.each(['none', 'multiple', 'single'] as const)('rejects a first delivery that disagrees with the %s mention routing without consuming its draft', async (mentions) => { + const f = fixture() + const other = f.buddies.create({ name: 'Other actor' }) + f.conversations.addMember({ id: 'conversation', buddyId: other.id, updatedAt: 'now' }) + const previous = f.drafts.findById('draft')! + const actors = mentions === 'none' ? [] : mentions === 'single' ? [other.id] : ['default', other.id] + const content = { + ...createBuddyUserContent(), + body: [{ type: 'paragraph' as const, content: [...actors.map(actorId => ({ type: 'actor_ref' as const, actorId, name: actorId === 'default' ? 'Lexora' : other.name })), { type: 'text' as const, text: 'New request' }] }], + } + const draft = f.drafts.save({ ...previous, expectedRevision: previous.revision, content, now: '2026-10-25T00:00:00.000Z' }) + await expect(f.actualTurns.prepareStart({ + draftId: draft.draftId, + expectedRevision: draft.revision, + requestId: 'misrouted', + delivery: { mode: 'current', actorId: mentions === 'single' ? 'default' : other.id, target: null }, + })).rejects.toMatchObject({ code: 'INPUT_DELIVERY_INCOMPATIBLE' }) + expect(f.drafts.findById('draft')).toEqual(draft) + expect(f.queue.list(f.scope)).toEqual([]) + expect(f.database.prepare('SELECT id FROM messages').all()).toEqual([{ id: 'initial' }]) + }) + + it('keeps next bound across leader changes and out of the current run until closure and owner release', async () => { + const f = fixture() + await f.enableDefinitions() + const delivery = { mode: 'next' as const, actorId: 'default', target: { rootRunId: 'run-initial', runId: 'run-initial' } } + const prepared = { ...f.input('next'), buddyId: 'default', delivery } + await f.service.enqueue(enqueueRequest(f, prepared)) + await f.service.drain() + const other = f.buddies.create({ name: 'Next leader' }) + f.conversations.addMember({ id: 'conversation', buddyId: other.id, updatedAt: 'now' }) + f.conversations.setBuddy({ id: 'conversation', buddyId: other.id, updatedAt: 'now' }) + expect(await f.service.followUp('run-initial', f.controller.signal)).toBe(false) + expect(await f.service.steer(f.target('next'))).toBe('unavailable') + expect(f.delivered).toEqual([]) + expect(f.database.prepare('SELECT id FROM messages').all()).toEqual([{ id: 'initial' }]) + expect(await f.service.reconcile(f.scope)).toBe(false) + f.database.exec('UPDATE runs SET status = \'completed\'') + expect(await f.service.reconcile(f.scope)).toBe(false) + f.database.exec('UPDATE execution_state SET cleanup_state = \'clean\'') + expect(await f.service.reconcile(f.scope)).toBe(false) + f.releaseSlot() expect(await f.service.reconcile(f.scope)).toBe(true) - expect(f.runs.findById('run-directed')).toMatchObject({ buddyId: actor.id, schedulingLane: 'actor', status: 'queued' }) - expect(f.runs.findById('run-initial')).toEqual(main) - expect(f.runs.findActiveForConversation('conversation')?.id).toBe('run-initial') - expect(f.runs.findActiveForConversation('conversation', actor.id)?.id).toBe('run-directed') + expect(f.launched).toEqual(['run-next']) + expect(f.runs.findById('run-next')).toMatchObject({ schedulingLane: 'actor', buddyId: 'default' }) + expect(f.delivered).toEqual([]) + }) + + it('steers queued current input only to its exact older actor child after another actor has finished', async () => { + const f = fixture() + const actor = f.buddies.create({ name: 'Still working' }) + f.conversations.addMember({ id: 'conversation', buddyId: actor.id, updatedAt: 'now' }) + f.runs.create({ ...f.runs.findById('run-initial')!, id: 'actor-child', buddyId: actor.id, rootRunId: 'run-initial', parentRunId: 'run-initial', executionKind: 'actor_child', triggeringMessageId: 'private-child', status: 'running' }) + f.database.prepare('INSERT INTO run_inputs (run_id, prompt, attachment_ids_json, context_items_json, reasoning, service_tier, created_at) VALUES (?, ?, ?, ?, NULL, NULL, ?)').run('actor-child', 'Private child goal', '[]', '[]', 'now') + f.runs.create({ ...f.runs.findById('run-initial')!, id: 'newer-root', rootRunId: 'newer-root', status: 'completed', triggeringMessageId: 'newer-input' }) + const delivery = { mode: 'current' as const, actorId: actor.id, target: { rootRunId: 'run-initial', runId: 'actor-child' } } + const prepared = { ...f.input('supplement'), buddyId: actor.id, delivery } + await f.service.enqueue(enqueueRequest(f, prepared)) + await f.service.drain() + expect(f.delivered).toEqual(['steer:supplement']) + expect(f.database.prepare('SELECT run_id FROM chat_queue WHERE id = ?').get('supplement')).toEqual({ run_id: 'actor-child' }) + expect(f.launched).toEqual([]) + }) + + it('dispatches after the addressed actor child closes without waiting for the other actors in its root', async () => { + const f = fixture() + const actor = f.buddies.create({ name: 'Child actor' }) + f.conversations.addMember({ id: 'conversation', buddyId: actor.id, updatedAt: 'now' }) + f.runs.create({ ...f.runs.findById('run-initial')!, id: 'actor-child', buddyId: actor.id, rootRunId: 'run-initial', parentRunId: 'run-initial', executionKind: 'actor_child', triggeringMessageId: 'private-child', status: 'running' }) + const delivery = { mode: 'next' as const, actorId: actor.id, target: { rootRunId: 'run-initial', runId: 'actor-child' } } + f.queue.enqueue({ ...f.input('after-child'), buddyId: actor.id, delivery }) + expect(await f.service.followUp('actor-child', f.controller.signal)).toBe(false) + expect(await f.service.reconcile(f.scope)).toBe(false) + f.database.prepare('UPDATE runs SET status = \'completed\' WHERE id = ?').run('actor-child') + f.database.prepare('UPDATE execution_state SET cleanup_state = \'clean\' WHERE run_id = ?').run('actor-child') + expect(await f.service.reconcile(f.scope)).toBe(true) + expect(f.runs.findById('run-initial')?.status).toBe('running') + expect(f.runs.findById('run-after-child')).toMatchObject({ buddyId: actor.id, schedulingLane: 'actor', rootRunId: 'run-after-child' }) + expect(f.delivered).toEqual([]) + }) + + it('rejects unaccepted current input when its target is closed and retains the draft', async () => { + const f = fixture() + const delivery = { mode: 'current' as const, actorId: 'default', target: { rootRunId: 'run-initial', runId: 'run-initial' } } + const prepared = { ...f.input('current'), buddyId: 'default', delivery } + const request = enqueueRequest(f, prepared) + const draft = f.drafts.findById('draft') + f.database.exec('UPDATE runs SET status = \'completed\'') + await expect(f.service.enqueue(request)).rejects.toMatchObject({ code: 'INPUT_DELIVERY_TARGET_UNAVAILABLE' }) + expect(f.drafts.findById('draft')).toEqual(draft) + expect(f.queue.list(f.scope)).toEqual([]) + expect(f.delivered).toEqual([]) + expect(f.launched).toEqual([]) + }) + + it('preserves a committed current queue item when the target closes during steering instead of opening a new run', async () => { + const f = fixture() + const delivery = { mode: 'current' as const, actorId: 'default', target: { rootRunId: 'run-initial', runId: 'run-initial' } } + const prepared = { ...f.input('current'), buddyId: 'default', delivery } + f.validate.mockImplementationOnce(async () => {}) + f.validate.mockImplementationOnce(async () => { + f.database.exec('UPDATE runs SET status = \'completed\'; UPDATE execution_state SET cleanup_state = \'clean\'') + f.releaseSlot() + }) + const receipt = await f.service.enqueue(enqueueRequest(f, prepared)) + await f.service.drain() + expect(receipt).toMatchObject({ id: 'current' }) + expect(f.queue.list(f.scope)).toEqual([expect.objectContaining({ id: 'current', state: 'paused', delivery })]) + expect(await f.service.reconcile(f.scope)).toBe(false) + expect(f.launched).toEqual([]) + expect(f.delivered).toEqual([]) + expect(f.database.prepare('SELECT id FROM messages').all()).toEqual([{ id: 'initial' }]) + expect(f.drafts.findById('draft')?.revision).toBe(prepared.draft.expectedRevision + 1) + }) + + it('replays an accepted explicit current queue request after closure but rejects changed delivery intent', async () => { + const f = fixture() + const delivery = { mode: 'current' as const, actorId: 'default', target: { rootRunId: 'run-initial', runId: 'run-initial' } } + const prepared = { ...f.input('current'), buddyId: 'default', delivery } + const request = enqueueRequest(f, prepared) + const receipt = await f.service.enqueue(request) + await f.service.drain() + expect(f.delivered).toEqual(['steer:current']) + f.database.exec('UPDATE runs SET status = \'completed\'') + const other = f.buddies.create({ name: 'Next leader' }) + f.conversations.addMember({ id: 'conversation', buddyId: other.id, updatedAt: 'now' }) + f.conversations.setBuddy({ id: 'conversation', buddyId: other.id, updatedAt: 'now' }) + f.turns.prepareStart = input => f.actualTurns.prepareStart(input) + await expect(f.service.enqueue(request)).resolves.toEqual(receipt) + await expect(f.service.enqueue({ ...request, delivery: { ...delivery, mode: 'next' } })).rejects.toMatchObject({ code: 'VALIDATION_FAILED' }) + expect(f.delivered).toEqual(['steer:current']) + expect(f.database.prepare('SELECT id FROM messages ORDER BY rowid').all()).toEqual([{ id: 'initial' }, { id: 'current' }]) }) it('keeps each actor queue ordered without letting a busy actor block an idle peer', async () => { @@ -84,6 +337,7 @@ describe('persistent chat queue scheduling', () => { expect(await f.service.reconcile(f.scope)).toBe(true) expect(f.launched).toEqual(['run-first', 'run-second']) expect(f.runs.findById('run-initial')?.status).toBe('running') + expect(f.runs.findActiveForConversation('conversation')?.id).toBe('run-initial') }) it('pauses a failed actor queue while another actor can still receive new work', async () => { @@ -585,12 +839,12 @@ describe('persistent chat queue scheduling', () => { expect(f.database.prepare('SELECT id FROM messages').all()).toEqual([{ id: 'initial' }]) }) - it.each(([ - { from: 'read_only', to: 'full_access' }, - { from: 'full_access', to: 'read_only' }, - { from: 'manual_approval', to: 'policy_approval' }, - { from: 'policy_approval', to: 'manual_approval' }, - ] as const).flatMap(transition => (['steer', 'followUp'] as const).map(mode => ({ ...transition, mode }))))( + it.each([ + { from: 'read_only', to: 'full_access', mode: 'steer' }, + { from: 'full_access', to: 'read_only', mode: 'followUp' }, + { from: 'manual_approval', to: 'policy_approval', mode: 'followUp' }, + { from: 'policy_approval', to: 'manual_approval', mode: 'steer' }, + ] as const)( 'delivers $mode in the same run after switching permissions from $from to $to', async ({ from, to, mode }) => { const f = fixture() @@ -663,8 +917,6 @@ describe('persistent chat queue scheduling', () => { it.each([ { mode: 'followUp', executionProfile: 'workspace_write' }, - { mode: 'steer', executionProfile: 'workspace_write' }, - { mode: 'followUp', executionProfile: 'read_only' }, { mode: 'steer', executionProfile: 'read_only' }, ] as const)('keeps a review out of an ordinary $executionProfile run during $mode and starts it read-only afterward', async ({ mode, executionProfile }) => { const f = fixture() @@ -727,6 +979,37 @@ describe('persistent chat queue scheduling', () => { }) }) +async function setLiveModel(f: ReturnType, selection: ConversationModelSelection, runId?: string) { + const metadata = new ConversationMetadataService({ + runs: f.runs, + runInputs: f.runInputs, + repository: f.conversations, + resolveModelSelection: value => resolveInteractiveModelSelection(f.providers, value), + sessions: { invalidateConversation: async () => ({ pending: 0, degraded: 0 }) }, + }) + try { + await metadata.setModelSelection({ id: 'conversation', actorId: 'default', ...(runId ? { runId } : {}), modelSelection: selection }) + } + finally { + await metadata.dispose() + } +} + +function prepareCurrentInput(f: ReturnType, id: string, selection: ConversationModelSelection) { + return f.actualTurns.refreshQueuedInput({ + ...f.input(id), + buddyId: 'default', + modelOverride: selection, + queueRouting: { mode: 'actor', actorId: 'default' }, + delivery: { mode: 'current', actorId: 'default', target: { rootRunId: f.runs.findById('run-initial')!.rootRunId, runId: 'run-initial' } }, + }) +} + +function enqueueRequest(f: ReturnType, prepared: PrepareTurnRequestInput) { + f.turns.prepareStart = async () => ({ prepared, stagedAttachments: { validate: () => {}, bindings: [], commit: async () => {}, rollback: async () => {} } }) + return { draftId: prepared.draft.draftId, expectedRevision: prepared.draft.expectedRevision, requestId: prepared.requestId, ...(prepared.delivery ? { delivery: prepared.delivery } : {}) } +} + function fixture(queuedOnStartup = false, availability?: ChatQueueServiceOptions['availability']) { const database = openBuddyDatabase({ databasePath: ':memory:' }) const drafts = createComposerDraftRepository(database) @@ -842,7 +1125,7 @@ function fixture(queuedOnStartup = false, availability?: ChatQueueServiceOptions } fixtures.push({ database, service, continuation }) const scope = { conversationId: 'conversation', branchId: 'branch' } - return { database, drafts, queue, input, service, continuation, settled, settle, committed, eventLog, buddies, members, conversations, setPermissions, enableDefinitions, resolveModel, model, releaseSlot: (cleanup: ExecutionSettled['cleanup'] = 'completed') => { + return { database, drafts, queue, input, service, actualTurns, providers, continuation, settled, settle, committed, eventLog, buddies, members, conversations, setPermissions, enableDefinitions, resolveModel, model, releaseSlot: (cleanup: ExecutionSettled['cleanup'] = 'completed') => { occupied = false cleanupDegraded = cleanup === 'degraded' }, scope, target: (id: string) => ({ ...scope, id }), controller: new AbortController(), validate, delivered, launched, runInputs, runs, runner, requests, turns } diff --git a/apps/buddy/service/src/rpc/runtimeRequest.ts b/apps/buddy/service/src/rpc/runtimeRequest.ts index 47b7e1c6c..f5bc46e00 100644 --- a/apps/buddy/service/src/rpc/runtimeRequest.ts +++ b/apps/buddy/service/src/rpc/runtimeRequest.ts @@ -33,6 +33,8 @@ export type BuddyServiceErrorCode | 'BUDDY_DEFAULT_PROTECTED' | 'BUDDY_RESET_UNAVAILABLE' | 'DRAFT_CONFLICT' + | 'INPUT_DELIVERY_TARGET_UNAVAILABLE' + | 'INPUT_DELIVERY_INCOMPATIBLE' | 'CONVERSATION_BUSY' | 'CONVERSATION_QUEUE_NOT_EMPTY' | 'CONVERSATION_SPACE_CONFLICT' diff --git a/apps/buddy/service/src/storage/__tests__/chatQueueRepository.spec.ts b/apps/buddy/service/src/storage/__tests__/chatQueueRepository.spec.ts index 67f636f67..e40dfac8e 100644 --- a/apps/buddy/service/src/storage/__tests__/chatQueueRepository.spec.ts +++ b/apps/buddy/service/src/storage/__tests__/chatQueueRepository.spec.ts @@ -8,6 +8,7 @@ import { createAttachmentRepository } from '../attachmentRepository' import { createBuddyRepository } from '../buddyRepository' import { createChatQueueRepository } from '../chatQueueRepository' import { createComposerDraftRepository } from '../composerDraftRepository' +import { createConversationRepository } from '../conversationRepository' import { openBuddyDatabase } from '../database' import { createRunInputRepository } from '../runInputRepository' import { createTurnRequestRepository } from '../turnRequestRepository' @@ -49,6 +50,74 @@ function fixture() { } describe('chat queue ownership and delivery', () => { + it('never commits next input into a running product run, including a forged pending envelope', () => { + const f = fixture() + f.queue.enqueue({ ...f.input('next'), delivery: { mode: 'next', actorId: 'default', target: { rootRunId: 'run-initial', runId: 'run-initial' } } }) + const pending = f.queue.pending(f.target('next'))! + expect(() => f.queue.commitInRun(pending, 'run-initial', undefined)).toThrow(expect.objectContaining({ code: 'INPUT_DELIVERY_INCOMPATIBLE' })) + expect(() => f.queue.commitInRun({ ...pending, delivery: undefined }, 'run-initial', undefined)).toThrow() + expect(f.queue.list(f.scope)).toHaveLength(1) + expect(f.database.prepare('SELECT id FROM messages').all()).toEqual([{ id: 'initial' }]) + }) + + it('keeps next bound across leader changes, requires clean target closure and replays its dispatch', () => { + const f = fixture() + const delivery = { mode: 'next' as const, actorId: 'default', target: { rootRunId: 'run-initial', runId: 'run-initial' } } + f.queue.enqueue({ ...f.input('next'), delivery }) + const pending = f.queue.pending(f.target('next'))! + const other = createBuddyRepository(f.database).create({ name: 'New leader' }) + const conversations = createConversationRepository(f.database) + conversations.addMember({ id: 'conversation', buddyId: other.id, updatedAt: 'now' }) + conversations.setBuddy({ id: 'conversation', buddyId: other.id, updatedAt: 'now' }) + expect(f.queue.isCurrentInput(pending)).toBe(true) + expect(f.queue.pending(f.target('next'))).toMatchObject({ buddyId: 'default', delivery }) + expect(() => f.turns.prepare(pending)).toThrow(expect.objectContaining({ code: 'INPUT_DELIVERY_TARGET_UNAVAILABLE' })) + f.database.exec('UPDATE runs SET status = \'completed\'') + expect(() => f.turns.prepare(pending)).toThrow(expect.objectContaining({ code: 'INPUT_DELIVERY_TARGET_UNAVAILABLE' })) + f.database.exec('UPDATE execution_state SET cleanup_state = \'clean\'') + expect(f.turns.prepare(pending)).toMatchObject({ created: true, runId: 'run-next' }) + expect(f.database.prepare('SELECT scheduling_lane, buddy_id FROM runs WHERE id = ?').get('run-next')).toEqual({ scheduling_lane: 'actor', buddy_id: 'default' }) + expect(f.turns.prepare(pending)).toMatchObject({ created: false, runId: 'run-next' }) + }) + + it('does not convert a closed current target into a new run and preserves unaccepted drafts', () => { + const f = fixture() + const delivery = { mode: 'current' as const, actorId: 'default', target: { rootRunId: 'run-initial', runId: 'run-initial' } } + f.queue.enqueue({ ...f.input('current'), delivery }) + const pending = f.queue.pending(f.target('current'))! + f.database.exec('UPDATE runs SET status = \'completed\'; UPDATE execution_state SET cleanup_state = \'clean\'') + expect(() => f.turns.prepare(pending)).toThrow(expect.objectContaining({ code: 'INPUT_DELIVERY_TARGET_UNAVAILABLE' })) + const draft = f.drafts.findById('draft') + expect(() => f.queue.enqueue({ ...f.input('late'), delivery })).toThrow(expect.objectContaining({ code: 'INPUT_DELIVERY_TARGET_UNAVAILABLE' })) + expect(f.drafts.findById('draft')).toEqual(draft) + expect(f.queue.list(f.scope).map(item => item.id)).toEqual(['current']) + expect(f.database.prepare('SELECT id FROM messages').all()).toEqual([{ id: 'initial' }]) + }) + + it('treats a null target as idle admission, never as whichever run becomes latest', () => { + const f = fixture() + const delivery = { mode: 'current' as const, actorId: 'default', target: null } + const draft = f.drafts.findById('draft') + expect(() => f.queue.enqueue({ ...f.input('busy'), delivery })).toThrow(expect.objectContaining({ code: 'INPUT_DELIVERY_TARGET_UNAVAILABLE' })) + expect(f.drafts.findById('draft')).toEqual(draft) + f.database.exec('UPDATE runs SET status = \'completed\'; UPDATE execution_state SET cleanup_state = \'clean\'') + f.queue.enqueue({ ...f.input('idle'), delivery }) + f.database.exec('UPDATE runs SET status = \'running\'') + expect(() => f.queue.commitInRun(f.queue.pending(f.target('idle'))!, 'run-initial', undefined)).toThrow(expect.objectContaining({ code: 'INPUT_DELIVERY_INCOMPATIBLE' })) + expect(f.queue.list(f.scope)[0]?.delivery).toEqual(delivery) + }) + + it.each([ + { rootRunId: 'another-root', runId: 'run-initial' }, + { rootRunId: 'run-initial', runId: 'missing-run' }, + ])('rejects a mismatched explicit target $runId without consuming the draft', (target) => { + const f = fixture() + const draft = f.drafts.findById('draft') + expect(() => f.queue.enqueue({ ...f.input('wrong'), delivery: { mode: 'current', actorId: 'default', target } })).toThrow(expect.objectContaining({ code: 'INPUT_DELIVERY_TARGET_UNAVAILABLE' })) + expect(f.drafts.findById('draft')).toEqual(draft) + expect(f.queue.list(f.scope)).toEqual([]) + }) + it('rejects an input prepared before a space move without consuming its draft', () => { const f = fixture() const prepared = f.input('late') diff --git a/apps/buddy/service/src/storage/__tests__/conversationHistoryRepositories.spec.ts b/apps/buddy/service/src/storage/__tests__/conversationHistoryRepositories.spec.ts index e075643b3..5170a40cf 100644 --- a/apps/buddy/service/src/storage/__tests__/conversationHistoryRepositories.spec.ts +++ b/apps/buddy/service/src/storage/__tests__/conversationHistoryRepositories.spec.ts @@ -40,11 +40,77 @@ describe('conversation history repositories', () => { ]) expect(f.database.prepare('SELECT * FROM messages ORDER BY id').all()).toEqual(stored) f.database.prepare('UPDATE conversation_publications SET source_sequence = 3 WHERE message_id = ?').run('actor-comment') - expect(f.conversations.listTimelinePage('conversation', 'branch', { limit: 100 }).items.find(item => item.id === 'actor-comment')).toMatchObject({ content: { text: 'Checking' } }) const mismatched = f.conversations.listTimelinePage('conversation', 'branch', { limit: 100 }).items.find(item => item.id === 'actor-comment') expect(mismatched?.kind === 'message' ? mismatched.content : null).toEqual({ text: 'Checking' }) }) + it.each(['message.completed', 'message.interrupted'])('reads public message start times for roots and actors through their matching %s source', (type) => { + const f = createExecutionStorageFixture() + databases.push(f.database) + f.conversations.createMessage({ id: 'goal', conversationId: 'conversation', branchId: 'branch', runId: null, role: 'user', content: { text: 'Work together' }, createdAt: '2026-10-12T00:00:00.000Z' }) + f.executions.admitChild(f.child('actor', f.actor.id)) + f.executions.admitChild(f.child('private')) + const projector = new RunEventProjector(f.database) + projector.project([{ runId: 'private', sequence: 1, type: 'message.started', createdAt: '2026-10-12T00:00:01.000Z', payload: { messageId: 'actor-reply', role: 'assistant' } }]) + const payload = (messageId: string, text: string) => type === 'message.completed' + ? { messageId, role: 'assistant', content: { text }, stopReason: 'completed' } + : { messageId, role: 'assistant', content: { text, state: 'interrupted', truncated: false }, reason: 'runtime_restarted' } + for (const runId of ['root', 'actor']) { + projector.project([ + { runId, sequence: 1, type: 'message.started', createdAt: '2026-10-12T00:00:01.000Z', payload: { messageId: `${runId}-other`, role: 'assistant' } }, + { runId, sequence: 2, type: 'message.started', createdAt: '2026-10-12T00:00:02.000Z', payload: { messageId: `${runId}-reply`, role: 'assistant' } }, + { runId, sequence: 3, type: 'message.started', createdAt: '2026-10-12T00:00:03.000Z', payload: { messageId: `${runId}-reply`, role: 'assistant' } }, + { runId, sequence: 4, type, createdAt: '2026-10-12T00:00:20.000Z', payload: payload(`${runId}-reply`, 'Visible before the follow-up') }, + { runId, sequence: 5, type, createdAt: '2026-10-12T00:00:21.000Z', payload: payload(`${runId}-legacy`, 'No recorded start') }, + { runId, sequence: 6, type: 'message.started', createdAt: '2026-10-12T00:00:22.000Z', payload: { messageId: `${runId}-legacy`, role: 'assistant' } }, + ]) + } + const stored = f.database.prepare('SELECT * FROM messages ORDER BY id').all() + const page = f.conversations.listTimelinePage('conversation', 'branch', { limit: 100 }) + for (const runId of ['root', 'actor']) { + expect(page.items.find(item => item.id === `${runId}-reply`)).toMatchObject({ startedAt: '2026-10-12T00:00:02.000Z', createdAt: '2026-10-12T00:00:20.000Z' }) + expect(page.items.find(item => item.id === `${runId}-legacy`)).toMatchObject({ createdAt: '2026-10-12T00:00:21.000Z' }) + expect(page.items.find(item => item.id === `${runId}-legacy`)).not.toHaveProperty('startedAt') + } + f.database.prepare('UPDATE conversation_publications SET source_sequence = 5 WHERE message_id = ?').run('actor-reply') + expect(f.conversations.listTimelinePage('conversation', 'branch', { limit: 100 }).items.find(item => item.id === 'actor-reply')).not.toHaveProperty('startedAt') + expect(f.database.prepare('SELECT * FROM messages ORDER BY id').all()).toEqual(stored) + }) + + it('projects only accepted steering ownership from the matching public conversation and branch', () => { + const f = createExecutionStorageFixture() + databases.push(f.database) + for (const [id, seconds] of [['goal', '00'], ['accepted', '01'], ['waiting', '02'], ['foreign', '03']] as const) + f.conversations.createMessage({ id, conversationId: 'conversation', branchId: 'branch', runId: null, role: 'user', content: { text: id }, createdAt: `2026-10-12T00:00:${seconds}.000Z` }) + f.conversations.createBranch({ id: 'other-branch', conversationId: 'conversation', parentBranchId: 'branch', forkedFromMessageId: 'goal', createdAt: '2026-10-12T00:00:04.000Z', activate: false }) + const insert = f.database.prepare('INSERT INTO chat_queue (id, conversation_id, branch_id, request_id, request_fingerprint, prepared_json, state, run_id, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)') + for (const [id, branch, state] of [['accepted', 'branch', 'sent'], ['waiting', 'branch', 'waiting'], ['foreign', 'other-branch', 'sent']]) + insert.run(`queue-${id}`, 'conversation', branch, `request-${id}`, 'fixture', JSON.stringify({ userMessageId: id }), state, 'root', '2026-10-12T00:00:05.000Z') + const stored = f.database.prepare('SELECT * FROM messages ORDER BY id').all() + const items = f.conversations.listTimelinePage('conversation', 'branch', { limit: 100 }).items + expect(items.find(item => item.id === 'accepted')).toMatchObject({ inputRunId: 'root', runId: null }) + expect(items.find(item => item.id === 'waiting')).not.toHaveProperty('inputRunId') + expect(items.find(item => item.id === 'foreign')).not.toHaveProperty('inputRunId') + expect(f.database.prepare('SELECT * FROM messages ORDER BY id').all()).toEqual(stored) + }) + + it.each([1, 100])('expands accepted-input-only pages back to their root trigger (limit %s)', (limit) => { + const f = createExecutionStorageFixture() + databases.push(f.database) + const time = (seconds: number) => new Date(Date.UTC(2026, 9, 12, 0, 0, seconds)).toISOString() + f.conversations.createMessage({ id: 'goal', conversationId: 'conversation', branchId: 'branch', runId: null, role: 'user', content: { text: 'Work together' }, createdAt: time(0) }) + const insert = f.database.prepare('INSERT INTO chat_queue (id, conversation_id, branch_id, request_id, request_fingerprint, prepared_json, state, run_id, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)') + for (let index = 1; index <= limit; index++) { + const id = `steering-${index}` + f.conversations.createMessage({ id, conversationId: 'conversation', branchId: 'branch', runId: null, role: 'user', content: { text: id }, createdAt: time(index) }) + insert.run(`queue-${id}`, 'conversation', 'branch', `request-${id}`, 'fixture', JSON.stringify({ userMessageId: id }), 'sent', 'root', time(index)) + } + const page = f.conversations.listTimelinePage('conversation', 'branch', { limit }) + expect(page.items.map(item => item.id)).toEqual(['goal', ...Array.from({ length: limit }, (_, index) => `steering-${index + 1}`)]) + expect(page.nextBefore).toBeNull() + expect(f.runs.listForTimeline('conversation', 'branch', page.items.filter(item => item.kind === 'message' && item.role === 'user').map(item => item.id), []).map(run => run.id)).toEqual(['root']) + }) + it('rolls back conversation creation when its root branch cannot be inserted', () => { const database = openBuddyDatabase({ databasePath: ':memory:' }) databases.push(database) @@ -545,104 +611,6 @@ describe('conversation history repositories', () => { expect(stored.filter(message => message.role === 'tool')).toHaveLength(1500) }) - it('keeps 10k-item timeline pagination index-backed and boundary-stable', () => { - const database = openBuddyDatabase({ databasePath: ':memory:' }) - databases.push(database) - const conversations = createConversationRepository(database) - conversations.create({ - branchId: 'branch-1', - createdAt: '2026-08-14T00:00:00.000Z', - approvalPolicy: 'policy' as const, - executionProfile: 'workspace_write', - id: 'conversation-1', - spaceId: null, - title: null, - }) - const insertMessage = database.prepare(` - INSERT INTO messages ( - id, conversation_id, branch_id, run_id, role, content_json, created_at - ) VALUES (?, 'conversation-1', 'branch-1', NULL, 'user', ?, ?) - `) - const insertRun = database.prepare(` - INSERT INTO runs ( - id, conversation_id, branch_id, triggering_message_id, provider, model, - purpose, status, pi_session_file, error_code, started_at, completed_at - ) VALUES ( - ?, 'conversation-1', 'branch-1', 'message-00000', 'provider-1', 'model-1', - 'conversation.compaction', 'completed', '/tmp/session.jsonl', NULL, ?, ? - ) - `) - const insertEvent = database.prepare(` - INSERT INTO run_events (run_id, sequence, event_type, payload_json, created_at) - VALUES (?, 1, 'context.compaction.completed', ?, ?) - `) - const expected: Array<{ id: string, kind: 'compaction' | 'message', occurredAt: string }> = [] - const base = Date.parse('2026-08-14T00:00:00.000Z') - database.exec('BEGIN IMMEDIATE') - try { - for (let index = 0; index < 9_900; index += 1) { - const id = `message-${index.toString().padStart(5, '0')}` - const occurredAt = new Date(base + index * 10).toISOString() - insertMessage.run(id, JSON.stringify({ text: `${index}` }), occurredAt) - expected.push({ id, kind: 'message', occurredAt }) - } - for (let index = 0; index < 100; index += 1) { - const id = `compact-${index.toString().padStart(3, '0')}` - const occurredAt = new Date(base + index * 990 + 5).toISOString() - insertRun.run(id, occurredAt, occurredAt) - insertEvent.run( - id, - JSON.stringify({ estimatedTokensAfter: 500, tokensBefore: 1_000 }), - occurredAt, - ) - expected.push({ id, kind: 'compaction', occurredAt }) - } - database.exec('COMMIT') - } - catch (error) { - database.exec('ROLLBACK') - throw error - } - expected.sort((left, right) => left.occurredAt.localeCompare(right.occurredAt)) - - const newest = conversations.listTimelinePage('conversation-1', 'branch-1', { - limit: 100, - }) - const older = conversations.listTimelinePage('conversation-1', 'branch-1', { - before: newest.nextBefore, - limit: 100, - }) - expect(newest.items.map(item => ({ id: item.id, kind: item.kind }))) - .toEqual(expected.slice(-100).map(({ id, kind }) => ({ id, kind }))) - expect(older.items.map(item => ({ id: item.id, kind: item.kind }))) - .toEqual(expected.slice(-200, -100).map(({ id, kind }) => ({ id, kind }))) - expect(new Set([ - ...newest.items.map(item => item.id), - ...older.items.map(item => item.id), - ])).toHaveLength(200) - - const plan = database.prepare(` - EXPLAIN QUERY PLAN - WITH timeline AS ( - SELECT id, created_at AS occurred_at, 0 AS sort_rank - FROM messages - WHERE conversation_id = ? AND branch_id = ? - UNION ALL - SELECT id, started_at AS occurred_at, 1 AS sort_rank - FROM runs - WHERE conversation_id = ? AND branch_id = ? - AND purpose = 'conversation.compaction' - ) - SELECT * FROM timeline - ORDER BY occurred_at DESC, sort_rank DESC, id DESC - LIMIT ? - `).all('conversation-1', 'branch-1', 'conversation-1', 'branch-1', 101) as Array<{ - detail: string - }> - expect(plan.some(row => row.detail.includes('idx_messages_conversation_branch'))).toBe(true) - expect(plan.some(row => row.detail.includes('idx_runs_conversation_branch'))).toBe(true) - }) - it('activates an owned branch and rejects foreign branches', () => { const database = openBuddyDatabase({ databasePath: ':memory:' }) databases.push(database) diff --git a/apps/buddy/service/src/storage/__tests__/schema.spec.ts b/apps/buddy/service/src/storage/__tests__/schema.spec.ts index 1413c5e0c..93b1e87fc 100644 --- a/apps/buddy/service/src/storage/__tests__/schema.spec.ts +++ b/apps/buddy/service/src/storage/__tests__/schema.spec.ts @@ -83,6 +83,40 @@ function seedRun( } describe('buddy schema', { timeout: MIGRATION_TEST_TIMEOUT }, () => { + it('upgrades schema 34 without rewriting queued input or historical data', () => { + const directory = mkdtempSync(join(tmpdir(), 'buddy-input-delivery-migration-')) + directories.push(directory) + const databasePath = join(directory, 'buddy.sqlite3') + const previous = openMigrationFixtureDatabase(databasePath) + for (const migration of BUDDY_SCHEMA_MIGRATIONS.filter(migration => migration.version <= 34)) + previous.exec(migration.sql) + seedRun(previous) + const legacy = JSON.stringify({ buddyId: 'default', userMessageContent: { text: 'Keep this queued input' }, requestId: 'legacy' }, null, 2) + previous.prepare('INSERT INTO chat_queue (id, conversation_id, branch_id, request_id, request_fingerprint, prepared_json, state, created_at) VALUES (?, \'conversation-1\', \'branch-1\', ?, ?, ?, \'paused\', ?)').run('legacy', 'legacy', 'legacy-fingerprint', legacy, '2026-10-27T00:00:00.000Z') + const before = { + queue: previous.prepare('SELECT * FROM chat_queue ORDER BY id').all(), + messages: previous.prepare('SELECT * FROM messages ORDER BY id').all(), + runs: previous.prepare('SELECT * FROM runs ORDER BY id').all(), + } + previous.exec('PRAGMA user_version = 34') + previous.close() + + const migrated = openBuddyDatabase({ databasePath }) + expect(migrated.prepare('PRAGMA user_version').get()).toEqual({ user_version: BUDDY_SCHEMA_VERSION }) + expect(BUDDY_SCHEMA_VERSION).toBeGreaterThan(34) + expect(migrated.prepare('SELECT * FROM chat_queue ORDER BY id').all()).toEqual(before.queue) + expect(migrated.prepare('SELECT * FROM messages ORDER BY id').all()).toEqual(before.messages) + expect(migrated.prepare('SELECT * FROM runs ORDER BY id').all()).toEqual(before.runs) + expect(migrated.prepare('PRAGMA foreign_key_check').all()).toEqual([]) + migrated.close() + + const reopened = openBuddyDatabase({ databasePath }) + databases.push(reopened) + expect(reopened.prepare('SELECT * FROM chat_queue ORDER BY id').all()).toEqual(before.queue) + expect(reopened.prepare('SELECT * FROM messages ORDER BY id').all()).toEqual(before.messages) + expect(reopened.prepare('SELECT * FROM runs ORDER BY id').all()).toEqual(before.runs) + }) + it('marks the actor identity format upgrade without rewriting legacy execution snapshots', () => { const directory = mkdtempSync(join(tmpdir(), 'buddy-actor-identity-migration-')) directories.push(directory) diff --git a/apps/buddy/service/src/storage/__tests__/turnRequestRepository.spec.ts b/apps/buddy/service/src/storage/__tests__/turnRequestRepository.spec.ts index 1b21f7d33..3b4862555 100644 --- a/apps/buddy/service/src/storage/__tests__/turnRequestRepository.spec.ts +++ b/apps/buddy/service/src/storage/__tests__/turnRequestRepository.spec.ts @@ -10,6 +10,7 @@ import { ComposerDraftCommitConflictError } from '../commitComposerDraft' import { createComposerDraftRepository } from '../composerDraftRepository' import { createConversationRepository } from '../conversationRepository' import { openBuddyDatabase } from '../database' +import { createThreadBindingRepository } from '../threadBindingRepository' import { createTurnRequestRepository, TurnRequestConflictError, @@ -23,6 +24,37 @@ afterEach(() => { }) describe('turnRequestRepository', () => { + it.each([ + { mode: 'current', mentioned: false, reuseWork: false }, + { mode: 'current', mentioned: true, reuseWork: true }, + { mode: 'next', mentioned: false, reuseWork: true }, + ] as const)('separates automatic input lanes from explicit and legacy queued continuation ($mode, $mentioned)', ({ mode, mentioned, reuseWork }) => { + const database = createDatabase() + const repository = createTurnRequestRepository(database) + repository.prepare(createInput()) + const taskThread = database.prepare('SELECT thread_id FROM runs WHERE id = ?').get('run-1')!.thread_id + const privateThread = createThreadBindingRepository(database).createIsolated('conversation-1', 'default', 'actor', createInput().createdAt).id + database.prepare(`UPDATE runs SET status = 'completed', thread_id = ?, pi_session_file = ? WHERE id = ?`) + .run(privateThread, '/private/original.jsonl', 'run-1') + database.prepare(`UPDATE execution_state SET phase = 'closed', cleanup_state = 'clean' WHERE run_id = ?`).run('run-1') + const revision = createComposerDraftRepository(database).findById('draft-1')!.revision + repository.prepare({ + ...createInput(), + requestId: 'request-2', + requestFingerprint: 'fingerprint-2', + runId: 'run-2', + userMessageId: 'message-2', + buddyId: 'default', + actorIds: mentioned ? ['default'] : undefined, + delivery: { mode, actorId: 'default', target: null }, + draft: { draftId: 'draft-1', expectedRevision: revision }, + }) + expect(database.prepare('SELECT scheduling_lane, thread_id FROM runs WHERE id = ?').get('run-2')) + .toEqual({ scheduling_lane: 'actor', thread_id: reuseWork ? privateThread : taskThread }) + expect(database.prepare('SELECT thread_id, pi_session_file FROM runs WHERE id = ?').get('run-1')) + .toEqual({ thread_id: privateThread, pi_session_file: '/private/original.jsonl' }) + }) + it('rejects new and retried acceptance without consuming drafts or changing requests while quarantined', () => { const database = createDatabase() const repository = createTurnRequestRepository(database) @@ -141,8 +173,6 @@ describe('turnRequestRepository', () => { `) const drafts = createComposerDraftRepository(database) const original = drafts.findById('draft-1') - expect(drafts.discard({ draftId: 'draft-1', expectedRevision: original!.revision })).toBe(false) - expect(drafts.findById('draft-1')).toEqual(original) drafts.open({ ...createDraftInput(), draftId: 'followup-draft', @@ -269,23 +299,16 @@ describe('turnRequestRepository', () => { expect(repository.prepare(input)).toMatchObject({ created: false }) expect(drafts.findById('draft-1')).toEqual(newer) expect(() => repository.prepare({ ...input, requestFingerprint: 'different' })).toThrow(TurnRequestConflictError) - }) - - it('rejects a second turn while the conversation already has an incomplete run', () => { - const database = createDatabase() - const repository = createTurnRequestRepository(database) - const first = createInput() - repository.prepare(first) - - expect(repository.prepare(first)).toMatchObject({ created: false, runId: 'run-1' }) expect(() => repository.prepare({ - ...first, + ...input, + draft: { draftId: 'draft-1', expectedRevision: newer.revision }, requestFingerprint: 'fingerprint-2', requestId: 'request-2', runId: 'run-2', userMessageId: 'message-2', })).toThrow(TurnRequestConflictError) expect(database.prepare('SELECT COUNT(*) AS count FROM runs').get()).toEqual({ count: 1 }) + expect(drafts.findById('draft-1')).toEqual(newer) }) it('atomically replaces an interrupted run without duplicating the user message', () => { diff --git a/apps/buddy/service/src/storage/chatQueueRepository.ts b/apps/buddy/service/src/storage/chatQueueRepository.ts index 9b745c106..e21891f6a 100644 --- a/apps/buddy/service/src/storage/chatQueueRepository.ts +++ b/apps/buddy/service/src/storage/chatQueueRepository.ts @@ -9,7 +9,7 @@ import { BuddyServiceError } from '../rpc/runtimeRequest' import { createAttachmentRepository } from './attachmentRepository' import { createComposerDraftCommitter } from './commitComposerDraft' import { withTransaction } from './database' -import { assertTurnAttachmentBindings, TurnRequestAttachmentError } from './turnRequestRepository' +import { assertTurnAttachmentBindings, assertTurnInputDelivery, findTurnInputDeliveryTarget, TurnRequestAttachmentError } from './turnRequestRepository' interface QueueRow { id: string @@ -25,7 +25,7 @@ export type ChatQueueRepository = ReturnType export interface QueuedTurnRequestInput extends PrepareTurnRequestInput { queueRouting?: { mode: 'current_actor' } | { mode: 'actor', actorId: string } - queueActorBinding?: { currentBuddyId: string, modelSelectionJson: string | null, updatedAt: string } + queueActorBinding?: { currentBuddyId?: string, modelSelectionJson: string | null, updatedAt?: string } } export function createChatQueueRepository(database: DatabaseSync) { @@ -34,6 +34,8 @@ export function createChatQueueRepository(database: DatabaseSync) { const find = (id: string) => database.prepare('SELECT * FROM chat_queue WHERE id = ?').get(id) as QueueRow | undefined const decode = (row: QueueRow) => JSON.parse(row.prepared_json) as QueuedTurnRequestInput const routing = (input: QueuedTurnRequestInput): NonNullable => { + if (input.delivery) + return { mode: 'actor', actorId: input.delivery.actorId } if (input.queueRouting) return input.queueRouting const content = buddyUserMessageContentV1Schema.parse(input.userMessageContent) @@ -47,7 +49,7 @@ export function createChatQueueRepository(database: DatabaseSync) { const queueRouting = routing(input) const buddyId = queueRouting.mode === 'actor' ? queueRouting.actorId : current.buddy_id const member = database.prepare('SELECT model_selection_json FROM conversation_members WHERE conversation_id = ? AND actor_id = ?').get(input.conversationId, buddyId) as { model_selection_json: string | null } | undefined - return { ...input, approvalPolicy: current.approvalPolicy, executionProfile: current.executionProfile, ...(buddyId !== input.buddyId && queueRouting.mode === 'current_actor' ? { modelOverride: undefined } : {}), actorSnapshot: undefined, buddyId, queueRouting, queueActorBinding: { currentBuddyId: current.buddy_id, modelSelectionJson: member?.model_selection_json ?? null, updatedAt: current.updated_at } } + return { ...input, approvalPolicy: current.approvalPolicy, executionProfile: current.executionProfile, ...(buddyId !== input.buddyId && queueRouting.mode === 'current_actor' ? { modelOverride: undefined } : {}), actorSnapshot: undefined, buddyId, queueRouting, queueActorBinding: { ...(!input.delivery ? { currentBuddyId: current.buddy_id, updatedAt: current.updated_at } : {}), modelSelectionJson: member?.model_selection_json ?? null } } } const isCurrentInput = (input: QueuedTurnRequestInput) => { const current = bindActor(input) @@ -80,6 +82,7 @@ export function createChatQueueRepository(database: DatabaseSync) { enqueue(input: PrepareTurnRequestInput) { return withTransaction(database, () => { assertScope(input) + assertTurnInputDelivery(database, input, 'enqueue') const conversation = database.prepare('SELECT space_id FROM conversations WHERE id = ?').get(input.conversationId) if (conversation?.space_id !== input.spaceId) throw new BuddyServiceError('CONVERSATION_SPACE_CONFLICT') @@ -117,7 +120,7 @@ export function createChatQueueRepository(database: DatabaseSync) { return [] return (database.prepare('SELECT * FROM chat_queue WHERE conversation_id = ? AND branch_id = ? AND state IN (\'waiting\', \'paused\') ORDER BY rowid').all(scope.conversationId, scope.branchId) as unknown as QueueRow[]).map((row) => { const input = bindActor(decode(row)) - return { buddyId: input.buddyId!, id: row.id, conversationId: row.conversation_id, branchId: row.branch_id, state: row.state as 'waiting' | 'paused', content: buddyUserMessageContentV1Schema.parse(input.userMessageContent), createdAt: row.created_at, attachments: input.runInput.attachmentIds.map((id) => { + return { buddyId: input.buddyId!, ...(input.delivery ? { delivery: input.delivery } : {}), id: row.id, conversationId: row.conversation_id, branchId: row.branch_id, state: row.state as 'waiting' | 'paused', content: buddyUserMessageContentV1Schema.parse(input.userMessageContent), createdAt: row.created_at, attachments: input.runInput.attachmentIds.map((id) => { const attachment = attachments.findById(id) if (!attachment) throw new TurnRequestAttachmentError() @@ -150,6 +153,17 @@ export function createChatQueueRepository(database: DatabaseSync) { return row && row.conversation_id === target.conversationId && row.branch_id === target.branchId && ['waiting', 'paused'].includes(row.state) ? bindActor(decode(row)) : null }, isCurrentInput, + validateDelivery(input: PrepareTurnRequestInput) { + assertTurnInputDelivery(database, input, 'enqueue') + return findTurnInputDeliveryTarget(database, input) + }, + canDispatchDelivery(input: PrepareTurnRequestInput): boolean { + const target = findTurnInputDeliveryTarget(database, input) + return !target || (input.delivery?.mode === 'next' && target.phase === 'closed' && target.cleanupState === 'clean') + }, + pauseItem(target: LocalChatQueueTarget) { + return Number(database.prepare('UPDATE chat_queue SET state = \'paused\' WHERE id = ? AND conversation_id = ? AND branch_id = ? AND state = \'waiting\'').run(target.id, target.conversationId, target.branchId).changes) + }, cancel(target: LocalChatQueueTarget) { const row = find(target.id) if (!row || row.conversation_id !== target.conversationId || row.branch_id !== target.branchId) @@ -181,8 +195,10 @@ export function createChatQueueRepository(database: DatabaseSync) { assertScope(input) if (!isCurrentInput(input)) throw new BuddyServiceError('BUDDY_CONFLICT') - if (!database.prepare('SELECT id FROM chat_queue WHERE id = ? AND state IN (\'waiting\', \'paused\')').get(input.queuedMessageId!)) + const queued = find(input.queuedMessageId!) + if (!queued || !['waiting', 'paused'].includes(queued.state) || JSON.stringify(decode(queued).delivery) !== JSON.stringify(input.delivery)) throw new BuddyServiceError('VALIDATION_FAILED') + assertTurnInputDelivery(database, input, 'in_run', runId) const run = database.prepare(`SELECT r.id, r.actor_snapshot_json FROM runs r JOIN conversations c ON c.id = r.conversation_id WHERE r.id = ? AND r.conversation_id = ? AND r.branch_id = ? AND r.status = 'running' AND r.purpose = 'chat' AND r.execution_kind IN ('root', 'actor_child') AND c.approval_policy = ? AND c.execution_profile = ? AND r.buddy_id = ?`).get(runId, input.conversationId, input.branchId, input.approvalPolicy, input.executionProfile, input.buddyId!) diff --git a/apps/buddy/service/src/storage/conversationTimelineRepository.ts b/apps/buddy/service/src/storage/conversationTimelineRepository.ts index 214a257cd..f84a03778 100644 --- a/apps/buddy/service/src/storage/conversationTimelineRepository.ts +++ b/apps/buddy/service/src/storage/conversationTimelineRepository.ts @@ -11,6 +11,8 @@ import { extensionActionTimelineSchema } from '../../../shared/extensions/extens export interface ConversationTimelineMessageRecord extends MessageRecord { kind: 'message' + inputRunId?: string + startedAt?: string } export interface ConversationTimelineCompactionRecord { @@ -95,17 +97,39 @@ export function createConversationTimelineRepository( branches: ConversationBranchLineage, ): ConversationTimelineRepository { const findMessage = database.prepare('SELECT * FROM messages WHERE id = ?') - const findMessagePhase = database.prepare(` + const findMessagePresentation = database.prepare(` SELECT json_extract(events.payload_json, '$.phase') AS phase, - json_extract(events.payload_json, '$.stopReason') AS stopReason + json_extract(events.payload_json, '$.stopReason') AS stopReason, + ( + SELECT started.created_at FROM run_events started + WHERE started.run_id = publications.run_id + AND started.event_type = 'message.started' + AND started.sequence < events.sequence + AND json_extract(started.payload_json, '$.messageId') = publications.message_id + AND json_extract(started.payload_json, '$.role') = 'assistant' + ORDER BY started.sequence + LIMIT 1 + ) AS startedAt FROM conversation_publications publications INNER JOIN run_events events ON events.run_id = publications.run_id AND events.sequence = publications.source_sequence WHERE publications.message_id = ? AND publications.run_id = ? - AND events.event_type = 'message.completed' + AND events.event_type IN ('message.completed', 'message.interrupted') AND json_extract(events.payload_json, '$.messageId') = publications.message_id `) + const findInputRuns = database.prepare(` + SELECT messages.id AS messageId, runs.id AS runId + FROM chat_queue queue + INNER JOIN runs ON runs.id = queue.run_id AND runs.conversation_id = queue.conversation_id + AND runs.branch_id = queue.branch_id AND runs.execution_kind IN ('root', 'actor_child') + INNER JOIN messages ON messages.id = json_extract(queue.prepared_json, '$.userMessageId') + AND messages.conversation_id = queue.conversation_id AND messages.branch_id = queue.branch_id + AND messages.role = 'user' + WHERE queue.conversation_id = ? AND queue.state = 'sent' + AND messages.id IN (SELECT value FROM json_each(?)) + `) + const findCompactionRun = database.prepare(` SELECT * FROM runs WHERE id = ? AND purpose = 'conversation.compaction' @@ -302,13 +326,23 @@ export function createConversationTimelineRepository( let hasMore = initialPage.hasMore const segmentIndexes = new Map(segments.map((segment, index) => [segment.branchId, index])) const triggeringMessages = new Map() + const inputRunIds = new Map() + const inspectedInputs = new Set() while (selected[0]) { + const inputIds = selected.filter(row => row.kind === 'message' && row.role === 'user' && !inspectedInputs.has(row.id)).map(row => row.id) + if (inputIds.length) { + for (const id of inputIds) + inspectedInputs.add(id) + for (const input of findInputRuns.all(conversationId, JSON.stringify(inputIds)) as { messageId: string, runId: string }[]) + inputRunIds.set(input.messageId, input.runId) + } const triggeringMessage = findEarliestMissingTriggeringMessage( selected, conversationId, segments, segmentIndexes, triggeringMessages, + inputRunIds, findTriggeringMessageForRun, ) if (!triggeringMessage) @@ -339,15 +373,22 @@ export function createConversationTimelineRepository( return { items: selected.map((row) => { const item = toTimelineItem(row) - if (item.kind !== 'message' || item.role !== 'assistant' || !item.runId - || !item.content || typeof item.content !== 'object' || Array.isArray(item.content)) { - return item + if (item.kind === 'message' && item.role === 'user') { + const inputRunId = inputRunIds.get(item.id) + return inputRunId ? { ...item, inputRunId } : item } - const source = findMessagePhase.get(item.id, item.runId) as { phase: unknown, stopReason: unknown } | undefined + if (item.kind !== 'message' || item.role !== 'assistant' || !item.runId) + return item + const source = findMessagePresentation.get(item.id, item.runId) as { phase: unknown, stopReason: unknown, startedAt: unknown } | undefined const phase = source?.phase ?? (source?.stopReason === 'tool_use' ? 'commentary' : null) - return phase === 'commentary' || phase === 'final_answer' - ? { ...item, content: { ...item.content, phase } } - : item + return { + ...item, + ...(typeof source?.startedAt === 'string' ? { startedAt: source.startedAt } : {}), + ...(item.content && typeof item.content === 'object' && !Array.isArray(item.content) + && (phase === 'commentary' || phase === 'final_answer') + ? { content: { ...item.content, phase } } + : {}), + } }), nextBefore: hasMore && selected[0] ? toTimelineBoundary(selected[0]) @@ -377,6 +418,7 @@ function findEarliestMissingTriggeringMessage( segments: VisibleConversationBranchSegment[], segmentIndexes: Map, triggeringMessages: Map, + inputRunIds: ReadonlyMap, findTriggeringMessageForRun: ReturnType, ): MessageRow | null { const selectedMessageIds = new Set(rows.flatMap(row => ( @@ -384,14 +426,17 @@ function findEarliestMissingTriggeringMessage( ))) let earliest: MessageRow | null = null for (const row of rows) { - if (row.kind !== 'message' || !row.run_id) + if (row.kind !== 'message') + continue + const runId = row.run_id ?? inputRunIds.get(row.id) + if (!runId) continue - let triggeringMessage = triggeringMessages.get(row.run_id) + let triggeringMessage = triggeringMessages.get(runId) if (!triggeringMessage) { - triggeringMessage = findTriggeringMessageForRun.get(row.run_id) as MessageRow | undefined + triggeringMessage = findTriggeringMessageForRun.get(runId) as MessageRow | undefined if (!triggeringMessage) throw new ConversationTimelineRepositoryError('run binding is invalid') - triggeringMessages.set(row.run_id, triggeringMessage) + triggeringMessages.set(runId, triggeringMessage) } assertVisibleTriggeringMessage(triggeringMessage, conversationId, segments, segmentIndexes) if (selectedMessageIds.has(triggeringMessage.id)) diff --git a/apps/buddy/service/src/storage/executionPublicContext.ts b/apps/buddy/service/src/storage/executionPublicContext.ts new file mode 100644 index 000000000..4f691e94f --- /dev/null +++ b/apps/buddy/service/src/storage/executionPublicContext.ts @@ -0,0 +1,178 @@ +import type { DatabaseSync } from 'node:sqlite' +import type { ExecutionMailboxMessage } from '../../../shared/executions/executionApi' +import type { RunRecord } from './runRecord' +import { MAX_BUDDY_MESSAGE_TEXT_LENGTH } from '../../../shared/conversation/buddyMessageContent' +import { buddyUserContentToText, readBuddyUserMessageContent } from '../../../shared/conversation/buddyUserContent' +import { executionMailboxMessageSchema } from '../../../shared/executions/executionApi' +import { readPublicMessageText } from '../conversations/publicConversationContext' + +interface InputRow { + sourceId: string + messageId: string + sequence: number + contentJson: string + hasMaterials: number + actorId: string + acceptedAt: string + initialInput: number +} + +const acceptedInput = `( + (owner.execution_kind = 'root' AND owner.triggering_message_id = messages.id) + OR EXISTS (SELECT 1 FROM chat_queue queue WHERE queue.run_id = owner.id AND queue.state = 'sent' + AND queue.conversation_id = owner.conversation_id AND queue.branch_id = owner.branch_id + AND json_extract(queue.prepared_json, '$.userMessageId') = messages.id) +)` + +const publicActor = `owner.execution_kind IN ('root', 'actor_child') + AND json_extract(owner.actor_snapshot_json, '$.kind') = 'actor'` + +function readForwardableUserText(content: unknown, actorId: string): string | null { + if (typeof content === 'string') + return content.length > 0 && content.length <= MAX_BUDDY_MESSAGE_TEXT_LENGTH ? content : null + const structured = readBuddyUserMessageContent(content) + if (structured) { + const user = structured.userContent + const mentions = user.body.flatMap(paragraph => paragraph.content.filter(node => node.type === 'actor_ref')) + if (mentions.length > 1 || mentions.some(mention => mention.actorId !== actorId)) + return null + if (structured.resourceSnapshots.length || user.panelResourceIds.length || user.quotes?.length + || user.resourceQuotes?.length || user.sessionReferences?.length + || user.body.some(paragraph => paragraph.content.some(node => node.type !== 'text' && node.type !== 'hard_break' && node.type !== 'actor_ref'))) { + return null + } + return readForwardableUserText(buddyUserContentToText(user), actorId) + } + if (content && typeof content === 'object' && Object.keys(content).length === 1 && 'text' in content) + return readForwardableUserText(content.text, actorId) + return null +} + +export function createExecutionPublicContext(database: DatabaseSync) { + const inputs = `SELECT publications.source_id AS sourceId, messages.id AS messageId, + publications.sequence, messages.content_json AS contentJson, owner.buddy_id AS actorId, + messages.created_at AS acceptedAt, + (owner.execution_kind = 'root' AND owner.triggering_message_id = messages.id) AS initialInput, + (EXISTS (SELECT 1 FROM attachments WHERE message_id = messages.id) + OR EXISTS (SELECT 1 FROM run_inputs WHERE run_id = owner.id AND owner.triggering_message_id = messages.id + AND (json_array_length(attachment_ids_json) > 0 OR json_array_length(context_items_json) > 0)) + OR EXISTS (SELECT 1 FROM chat_queue queue WHERE queue.run_id = owner.id AND queue.state = 'sent' + AND queue.conversation_id = owner.conversation_id AND queue.branch_id = owner.branch_id + AND json_extract(queue.prepared_json, '$.userMessageId') = messages.id + AND (json_array_length(queue.prepared_json, '$.runInput.attachmentIds') > 0 + OR json_array_length(queue.prepared_json, '$.runInput.contextItems') > 0))) AS hasMaterials + FROM runs owner JOIN messages ON messages.conversation_id = owner.conversation_id AND messages.branch_id = owner.branch_id + JOIN conversation_publications publications ON publications.message_id = messages.id + AND publications.conversation_id = owner.conversation_id AND publications.branch_id = owner.branch_id + WHERE owner.id = ? AND ${publicActor} AND messages.role = 'user' AND ${acceptedInput}` + + function presentInput(row: InputRow) { + const content: unknown = JSON.parse(row.contentJson) + const text = row.hasMaterials ? null : readForwardableUserText(content, row.actorId) + return { sourceId: row.sourceId, messageId: row.messageId, sequence: row.sequence, text, excerpt: (text ?? readPublicMessageText(content, 'user', row.messageId)).slice(0, 240), forwardable: text !== null } + } + + function isForwardedInput(message: ExecutionMailboxMessage): boolean { + if (message.kind !== 'supplement' || message.origin !== 'user' || message.purpose + || message.sourceRunId !== message.targetRunId) { + return false + } + const row = database.prepare(`SELECT owner.id, publications.source_id AS sourceId FROM runs owner + JOIN runs target ON target.id = ? AND target.conversation_id = owner.conversation_id AND target.branch_id = owner.branch_id + JOIN messages ON messages.id = ? AND messages.conversation_id = owner.conversation_id AND messages.branch_id = owner.branch_id + JOIN conversation_publications publications ON publications.message_id = messages.id + WHERE owner.id <> target.id AND ${publicActor} AND messages.role = 'user' AND ${acceptedInput} + ORDER BY owner.started_at DESC LIMIT 1`).get(message.targetRunId, message.messageId) + if (!row) + return false + const input = database.prepare(`${inputs} AND publications.source_id = ?`).get(row.id, row.sourceId) as unknown as InputRow | undefined + return !!input && presentInput(input).text === message.text + } + + function hasLaterHumanInput(runId: string, input: InputRow): boolean { + const rows = database.prepare(`SELECT mailbox.payload_json FROM execution_mailbox_sources mailbox + JOIN runs owner ON owner.id = mailbox.target_run_id AND owner.id = mailbox.source_run_id + WHERE owner.id = ? AND mailbox.target_generation = owner.generation + AND (? = 1 OR mailbox.created_at > ?) + AND NOT EXISTS (SELECT 1 FROM execution_mailbox_receipts receipt WHERE receipt.mailbox_id = mailbox.id AND receipt.kind = 'rejected') + AND ((json_extract(mailbox.payload_json, '$.kind') = 'supplement' + AND json_extract(mailbox.payload_json, '$.origin') = 'user' + AND json_extract(mailbox.payload_json, '$.purpose') IS NULL) + OR (json_extract(mailbox.payload_json, '$.kind') = 'answer' + AND EXISTS (SELECT 1 FROM execution_mailbox_sources question + WHERE question.source_run_id = owner.id AND question.target_run_id = owner.id + AND question.target_generation = owner.generation AND question.source_sequence < mailbox.source_sequence + AND json_extract(question.payload_json, '$.kind') = 'question' + AND json_extract(question.payload_json, '$.audience') = 'user' + AND json_extract(question.payload_json, '$.questionId') = json_extract(mailbox.payload_json, '$.questionId') + AND NOT EXISTS (SELECT 1 FROM execution_mailbox_receipts receipt WHERE receipt.mailbox_id = question.id AND receipt.kind = 'rejected')))) + ORDER BY mailbox.source_sequence DESC`).iterate(runId, input.initialInput, input.acceptedAt) + for (const row of rows) { + const message = executionMailboxMessageSchema.parse(JSON.parse(row.payload_json as string)) + if (!isForwardedInput(message)) + return true + } + return false + } + + const visibleWork = `SELECT target.id, MAX(publications.sequence) AS publicSequence + FROM runs target + JOIN conversation_publications publications ON publications.conversation_id = target.conversation_id + AND publications.branch_id = target.branch_id AND publications.sequence <= ? + LEFT JOIN messages ON messages.id = publications.message_id + WHERE target.conversation_id = ? AND target.branch_id = ? + AND target.execution_kind IN ('root', 'actor_child') + AND json_extract(target.actor_snapshot_json, '$.kind') = 'actor' + AND target.id <> ? + AND ((publications.run_id = target.id AND (publications.kind = 'output' OR messages.role = 'assistant')) + OR (target.execution_kind = 'root' AND publications.message_id = target.triggering_message_id AND messages.role = 'user'))` + + return { + listInputs(runId: string, offset: number, limit: number) { + return (database.prepare(`${inputs} ORDER BY publications.sequence DESC LIMIT ? OFFSET ?`) + .all(runId, limit, offset) as unknown as InputRow[]).map(presentInput) + }, + findInput(runId: string, sourceId: string) { + const row = database.prepare(`${inputs} AND publications.source_id = ?`).get(runId, sourceId) as unknown as InputRow | undefined + return row ? presentInput(row) : null + }, + findForwardGuardInput(runId: string) { + const input = database.prepare(`${inputs} ORDER BY publications.sequence DESC LIMIT 1`).get(runId) as unknown as InputRow | undefined + return input && !hasLaterHumanInput(runId, input) ? presentInput(input) : null + }, + listWorks(caller: RunRecord, boundary: number, offset: number, limit: number) { + return database.prepare(`${visibleWork} GROUP BY target.id ORDER BY publicSequence DESC, target.id DESC LIMIT ? OFFSET ?`) + .all(boundary, caller.conversationId, caller.branchId, caller.id, limit, offset) as { id: string }[] + }, + hasWork(caller: RunRecord, boundary: number, runId: string) { + return !!database.prepare(`${visibleWork} AND target.id = ? GROUP BY target.id`) + .get(boundary, caller.conversationId, caller.branchId, caller.id, runId) + }, + readTask(run: RunRecord, boundary: number): string { + const row = database.prepare(`SELECT messages.content_json AS content, messages.role + FROM conversation_publications publications JOIN messages ON messages.id = publications.message_id + WHERE publications.conversation_id = ? AND publications.branch_id = ? AND publications.sequence <= ? + AND ((publications.message_id = ? AND messages.role = 'user') OR (publications.run_id = ? AND messages.role = 'assistant')) + ORDER BY publications.sequence LIMIT 1`).get(run.conversationId, run.branchId, boundary, run.triggeringMessageId, run.id) + return row ? readPublicMessageText(JSON.parse(row.content as string), row.role as 'user' | 'assistant').slice(0, 300) : '' + }, + readReply(run: RunRecord) { + const row = database.prepare(`SELECT publications.source_id AS sourceId, messages.id AS messageId, messages.content_json AS content + FROM conversation_publications publications JOIN messages ON messages.id = publications.message_id + JOIN run_events events ON events.run_id = publications.run_id AND events.sequence = publications.source_sequence + WHERE publications.run_id = ? AND publications.conversation_id = ? AND publications.branch_id = ? + AND messages.role = 'assistant' AND messages.run_id = publications.run_id + AND events.event_type = 'message.completed' + AND json_extract(events.payload_json, '$.messageId') = messages.id + AND COALESCE(json_extract(events.payload_json, '$.phase'), 'final') <> 'commentary' + AND COALESCE(json_extract(events.payload_json, '$.stopReason'), 'completed') = 'completed' + AND length(trim(CASE WHEN json_type(messages.content_json) = 'text' + THEN json_extract(messages.content_json, '$') ELSE json_extract(messages.content_json, '$.text') END)) > 0 + ORDER BY publications.sequence DESC LIMIT 1`).get(run.id, run.conversationId, run.branchId) + return row + ? { sourceId: row.sourceId as string, messageId: row.messageId as string, text: readPublicMessageText(JSON.parse(row.content as string), 'assistant') } + : null + }, + isForwardedInput, + } +} diff --git a/apps/buddy/service/src/storage/executionRepository.ts b/apps/buddy/service/src/storage/executionRepository.ts index f34f2e17c..665ba37b7 100644 --- a/apps/buddy/service/src/storage/executionRepository.ts +++ b/apps/buddy/service/src/storage/executionRepository.ts @@ -7,12 +7,14 @@ import type { RunRecord, RunStatus } from './runRecord' import type { CreateRunInput } from './runRepository' import { createHash } from 'node:crypto' import { buddySettingsSchema } from '../../../shared/buddies/buddySettings' +import { executionMailboxMessageSchema } from '../../../shared/executions/executionApi' import { executionReviewSchema, executionSubmissionSchema } from '../../../shared/executions/executionWork' import { isExecutionProfileWithin } from '../../../shared/permissions/executionProfile' import { buddyExecutionDefinitionSchema } from '../buddies/BuddyExecutionDefinition' import { freezeConversationMemberModel } from './conversationMemberRepository' import { createConversationPublicationRepository } from './conversationPublicationRepository' import { withTransaction } from './database' +import { createExecutionPublicContext } from './executionPublicContext' import { createExecutionWorkRepository } from './executionWorkRepository' import { createRunInputRepository } from './runInputRepository' import { createRunRepository } from './runRepository' @@ -61,7 +63,7 @@ export interface AdmitChildExecutionInput { export const MAX_CHILD_ATTEMPTS_PER_ROOT = 64 export class ExecutionAdmissionError extends Error { - readonly code: 'conflict' | 'stale' | 'unauthorized' | 'busy' | 'pending' | 'budget_exceeded' + readonly code: 'conflict' | 'stale' | 'unauthorized' | 'busy' | 'pending' | 'budget_exceeded' | 'input_forwarded' | 'forward_closed' constructor(code: ExecutionAdmissionError['code']) { super(`Execution admission ${code}`) @@ -95,6 +97,7 @@ export function createExecutionRepository(database: DatabaseSync) { const inputs = createRunInputRepository(database) const work = createExecutionWorkRepository(database) const publications = createConversationPublicationRepository(database) + const publicContext = createExecutionPublicContext(database) function find(runId: string): ExecutionStateRecord | null { const run = runs.findById(runId) @@ -203,9 +206,72 @@ export function createExecutionRepository(database: DatabaseSync) { WHERE work.work_id = ? AND queue.state = 'sent'`).all(record.work.workId, record.work.workId) as { id: string }[]).map(row => row.id) } + function assertNoForwardFallback(callerId: string, actorId: string): void { + const latestInput = publicContext.findForwardGuardInput(callerId) + if (!latestInput) + return + const forwarded = database.prepare(`SELECT mailbox.payload_json FROM execution_mailbox_sources mailbox + JOIN runs target ON target.id = mailbox.target_run_id + WHERE mailbox.id = ? AND target.buddy_id = ?`).get(latestInput.messageId, actorId) + const message = forwarded && executionMailboxMessageSchema.safeParse(JSON.parse(forwarded.payload_json as string)) + if (message && message.success && publicContext.isForwardedInput(message.data)) + throw new ExecutionAdmissionError('input_forwarded') + } + + function publicCaller(runId: string): ExecutionStateRecord { + const caller = requireRecord(runId) + if (caller.run.actorSnapshot?.kind !== 'actor' || !['root', 'actor_child'].includes(caller.run.executionKind)) + throw new ExecutionAdmissionError('unauthorized') + return caller + } + + function requirePublicWork(callerId: string, runId: string): ExecutionStateRecord { + const caller = publicCaller(callerId) + if (!publicContext.hasWork(caller.run, contextBoundary(caller), runId)) + throw new ExecutionAdmissionError('unauthorized') + return requireRecord(runId) + } + + function summarizePublicWork(caller: ExecutionStateRecord, record: ExecutionStateRecord) { + return { + runId: record.run.id, + rootRunId: record.run.rootRunId, + workId: record.work.workId, + actorId: record.run.buddyId, + phase: record.phase, + status: record.run.status, + errorCode: record.run.errorCode, + task: publicContext.readTask(record.run, contextBoundary(caller)), + } + } + return { find, listAttachments, + assertNoForwardFallback, + requirePublicWork, + isForwardedPublicInput: publicContext.isForwardedInput, + listAcceptedUserInputs(runId: string, offset = 0, limit = 13) { + publicCaller(runId) + return publicContext.listInputs(runId, offset, limit) + }, + findForwardGuardInput(runId: string) { + publicCaller(runId) + return publicContext.findForwardGuardInput(runId) + }, + findAcceptedUserInput(runId: string, sourceId: string) { + publicCaller(runId) + return publicContext.findInput(runId, sourceId) + }, + listPublicWork(runId: string, offset = 0, limit = 13) { + const caller = publicCaller(runId) + return publicContext.listWorks(caller.run, contextBoundary(caller), offset, limit) + .map(row => summarizePublicWork(caller, requireRecord(row.id))) + }, + readPublicWork(callerId: string, runId: string) { + const record = requirePublicWork(callerId, runId) + return { work: summarizePublicWork(publicCaller(callerId), record), reply: publicContext.readReply(record.run) } + }, assertCurrent(fence: ExecutionFence): ExecutionStateRecord { return assertFence(fence) }, @@ -287,6 +353,7 @@ export function createExecutionRepository(database: DatabaseSync) { || !caller.run.actorSnapshot || caller.run.actorSnapshot.kind !== 'actor') { throw new ExecutionAdmissionError('unauthorized') } + assertNoForwardFallback(caller.run.id, snapshot.id) if (snapshot.kind === 'actor' && !database.prepare('SELECT 1 FROM conversation_members WHERE conversation_id = ? AND actor_id = ? AND removed_at IS NULL').get(caller.run.conversationId, snapshot.id)) throw new ExecutionAdmissionError('unauthorized') if (snapshot.kind === 'npc') { diff --git a/apps/buddy/service/src/storage/mailboxRepository.ts b/apps/buddy/service/src/storage/mailboxRepository.ts index abc08f782..e4463adfd 100644 --- a/apps/buddy/service/src/storage/mailboxRepository.ts +++ b/apps/buddy/service/src/storage/mailboxRepository.ts @@ -157,6 +157,26 @@ export function createMailboxRepository(database: DatabaseSync) { return { find, + listInputPage(runIds: readonly string[], options: { offset: number, limit: number }) { + const rows = database.prepare(` + SELECT id, created_at, length(CAST(payload_json AS BLOB)) AS payload_bytes FROM execution_mailbox_sources + WHERE target_run_id IN (${runIds.map(() => '?').join(', ')}) + AND json_extract(payload_json, '$.kind') IN ('supplement', 'answer') + ORDER BY rowid LIMIT ? OFFSET ? + `).all(...runIds, options.limit + 1, options.offset) + let bytes = 0 + let count = 0 + for (const row of rows.slice(0, options.limit)) { + if (count > 0 && bytes + Number(row.payload_bytes) > 1024 * 1024) + break + bytes += Number(row.payload_bytes) + count++ + } + return { + records: rows.slice(0, count).map(row => ({ ...find(row.id as string)!, createdAt: row.created_at as string })), + hasMore: rows.length > count, + } + }, findQuestion(questionId: string) { const question = database.prepare('SELECT id FROM execution_mailbox_sources WHERE json_extract(payload_json, \'$.kind\') = \'question\' AND json_extract(payload_json, \'$.questionId\') = ?').get(questionId) if (!question) diff --git a/apps/buddy/service/src/storage/migrations/v35InputDelivery.ts b/apps/buddy/service/src/storage/migrations/v35InputDelivery.ts new file mode 100644 index 000000000..c936a92f5 --- /dev/null +++ b/apps/buddy/service/src/storage/migrations/v35InputDelivery.ts @@ -0,0 +1 @@ +export const BUDDY_V35_INPUT_DELIVERY_SCHEMA_SQL = 'SELECT 1;' diff --git a/apps/buddy/service/src/storage/schema.ts b/apps/buddy/service/src/storage/schema.ts index d885ec865..e20af55ce 100644 --- a/apps/buddy/service/src/storage/schema.ts +++ b/apps/buddy/service/src/storage/schema.ts @@ -35,6 +35,7 @@ import { BUDDY_V32_CHANGE_CAPTURE_SCHEMA_SQL } from './migrations/v32ChangeCaptu import { BUDDY_V33_EXECUTION_WORK_SCHEMA_SQL } from './migrations/v33ExecutionWork' import { BUDDY_V34_ACTOR_IDENTITY_SCHEMA_SQL } from './migrations/v34ActorIdentity' +import { BUDDY_V35_INPUT_DELIVERY_SCHEMA_SQL } from './migrations/v35InputDelivery' export interface BuddySchemaMigration { foreignKeys?: 'off' @@ -42,7 +43,7 @@ export interface BuddySchemaMigration { version: number } -export const BUDDY_SCHEMA_VERSION = 34 as const +export const BUDDY_SCHEMA_VERSION = 35 as const export const BUDDY_SCHEMA_MIGRATIONS: readonly BuddySchemaMigration[] = [ { sql: BUDDY_V1_INITIAL_SCHEMA_SQL, version: 1 }, @@ -79,4 +80,5 @@ export const BUDDY_SCHEMA_MIGRATIONS: readonly BuddySchemaMigration[] = [ { sql: BUDDY_V32_CHANGE_CAPTURE_SCHEMA_SQL, version: 32 }, { sql: BUDDY_V33_EXECUTION_WORK_SCHEMA_SQL, version: 33 }, { sql: BUDDY_V34_ACTOR_IDENTITY_SCHEMA_SQL, version: 34 }, + { sql: BUDDY_V35_INPUT_DELIVERY_SCHEMA_SQL, version: 35 }, ] diff --git a/apps/buddy/service/src/storage/turnRequestRepository.ts b/apps/buddy/service/src/storage/turnRequestRepository.ts index 9d9d6b752..554d04113 100644 --- a/apps/buddy/service/src/storage/turnRequestRepository.ts +++ b/apps/buddy/service/src/storage/turnRequestRepository.ts @@ -1,4 +1,5 @@ import type { DatabaseSync } from 'node:sqlite' +import type { BuddyInputDelivery } from '../../../shared/conversation/chatApi' import type { BuddyServiceTier, BuddyThinkingLevel } from '../../../shared/conversation/modelSelection' import type { BuddyApprovalPolicy } from '../../../shared/permissions/approvalPolicy' import type { BuddyExecutionProfile } from '../../../shared/permissions/executionProfile' @@ -13,8 +14,63 @@ import { createComposerDraftCommitter } from './commitComposerDraft' import { freezeConversationMemberModel } from './conversationMemberRepository' import { CONVERSATION_CURRENT_MODEL_SELECTION_SQL } from './conversationRecord' import { withTransaction } from './database' +import { createExecutionRepository, ExecutionAdmissionError, executionFence } from './executionRepository' +import { createMailboxRepository } from './mailboxRepository' import { actorThreadId, createThreadBindingRepository } from './threadBindingRepository' +export function findTurnInputDeliveryTarget(database: DatabaseSync, input: PrepareTurnRequestInput) { + const delivery = input.delivery + if (!delivery) + return null + if (input.followup || (input.buddyId && input.buddyId !== delivery.actorId)) + throw new BuddyServiceError('INPUT_DELIVERY_INCOMPATIBLE') + if (!delivery.target) + return null + const executions = createExecutionRepository(database) + const target = executions.find(delivery.target.runId) + const root = executions.find(delivery.target.rootRunId) + if (!target || !root || target.run.rootRunId !== root.run.id || root.run.executionKind !== 'root' + || target.run.conversationId !== input.conversationId || root.run.conversationId !== input.conversationId + || target.run.branchId !== input.branchId || root.run.branchId !== input.branchId + || target.run.buddyId !== delivery.actorId || target.run.purpose !== 'chat' + || !['root', 'actor_child'].includes(target.run.executionKind)) { + throw new BuddyServiceError('INPUT_DELIVERY_TARGET_UNAVAILABLE') + } + return target +} + +export function assertTurnInputDelivery(database: DatabaseSync, input: PrepareTurnRequestInput, boundary: 'enqueue' | 'dispatch' | 'in_run', runId?: string): void { + const delivery = input.delivery + if (!delivery) + return + const target = findTurnInputDeliveryTarget(database, input) + if (boundary === 'in_run' && (delivery.mode !== 'current' || !target || target.run.id !== runId)) + throw new BuddyServiceError('INPUT_DELIVERY_INCOMPATIBLE') + if (boundary === 'dispatch' && delivery.mode === 'current' && target) + throw new BuddyServiceError('INPUT_DELIVERY_TARGET_UNAVAILABLE') + if (boundary === 'dispatch' && target && (target.phase !== 'closed' || target.cleanupState !== 'clean')) + throw new BuddyServiceError('INPUT_DELIVERY_TARGET_UNAVAILABLE') + if (!target && database.prepare(`SELECT 1 FROM runs r JOIN execution_state e ON e.run_id = r.id + WHERE r.conversation_id = ? AND r.buddy_id = ? AND (r.status IN ('queued', 'running') OR e.phase <> 'closed' OR e.cleanup_state <> 'clean') LIMIT 1`).get(input.conversationId, delivery.actorId)) { + throw new BuddyServiceError('INPUT_DELIVERY_TARGET_UNAVAILABLE') + } + if (target && delivery.mode === 'current') { + const executions = createExecutionRepository(database) + const root = executions.find(target.run.rootRunId)! + try { + executions.assertCurrent(executionFence(target, root)) + } + catch (error) { + if (error instanceof ExecutionAdmissionError) + throw new BuddyServiceError('INPUT_DELIVERY_TARGET_UNAVAILABLE') + throw error + } + const mailbox = createMailboxRepository(database) + if (mailbox.listQuestions(target.run.id).some(question => question.kind === 'question' && question.audience === 'user' && mailbox.findQuestion(question.questionId)?.open)) + throw new BuddyServiceError('INPUT_DELIVERY_INCOMPATIBLE') + } +} + export function resolveTurnExecutionProfile(input: { executionProfile: BuddyExecutionProfile, runExecutionProfile?: BuddyExecutionProfile }): BuddyExecutionProfile { const profile = input.runExecutionProfile ?? input.executionProfile if (!isExecutionProfileWithin(profile, input.executionProfile)) @@ -23,6 +79,7 @@ export function resolveTurnExecutionProfile(input: { executionProfile: BuddyExec } export interface PrepareTurnRequestInput { + delivery?: BuddyInputDelivery leaderId?: string expectedActorModelSelection?: ConversationModelSelection | null actorIds?: readonly string[] @@ -230,7 +287,7 @@ export function createTurnRequestRepository(database: DatabaseSync): TurnRequest const activateBranch = database.prepare(` UPDATE conversations SET active_branch_id = ?, updated_at = ? WHERE id = ? `) - const persistActor = (input: { branchId: string, queuedMessageId?: string, expectedActorModelSelection?: ConversationModelSelection | null, conversationId: string, createdAt: string, buddyId?: string, actorSnapshot?: BuddyExecutionDefinition, modelOverride?: ConversationModelSelection | null, actorIds?: readonly string[] }, selection: ConversationModelSelection) => { + const persistActor = (input: { delivery?: BuddyInputDelivery, branchId: string, queuedMessageId?: string, expectedActorModelSelection?: ConversationModelSelection | null, conversationId: string, createdAt: string, buddyId?: string, actorSnapshot?: BuddyExecutionDefinition, modelOverride?: ConversationModelSelection | null, actorIds?: readonly string[] }, selection: ConversationModelSelection) => { const current = findConversation.get(input.conversationId) as unknown as ConversationBindingRow const actorId = input.buddyId ?? current.buddy_id const buddy = database.prepare('SELECT kind, enabled, deleted_at, revision FROM buddies WHERE id = ?').get(actorId) @@ -248,7 +305,7 @@ export function createTurnRequestRepository(database: DatabaseSync): TurnRequest : undefined const queuedRouting = queued ? (JSON.parse(String(queued.prepared_json)) as { queueRouting?: { mode: string, actorId?: string } }).queueRouting : undefined const fixedQueuedActor = queuedRouting?.mode === 'actor' ? queuedRouting.actorId : undefined - const expectedActor = mentioned.length === 1 ? mentioned[0] : fixedQueuedActor ?? current.buddy_id + const expectedActor = mentioned.length === 1 ? mentioned[0] : input.delivery?.actorId ?? fixedQueuedActor ?? current.buddy_id if (actorId !== expectedActor || (input.actorSnapshot && (input.actorSnapshot.id !== actorId || input.actorSnapshot.revision !== buddy.revision))) throw new BuddyServiceError('BUDDY_CONFLICT') for (const id of input.actorSnapshot?.settings.npcTeamEnabled ? input.actorSnapshot.settings.npcIds : []) { @@ -499,14 +556,15 @@ export function createTurnRequestRepository(database: DatabaseSync): TurnRequest return toRecord(existing, false) } if (input.queuedMessageId) { - const queued = database.prepare('SELECT id FROM chat_queue WHERE id = ? AND state IN (\'waiting\', \'paused\') AND conversation_id = ? AND branch_id = ?').get(input.queuedMessageId, input.conversationId, input.branchId) - if (!queued) + const queued = database.prepare('SELECT prepared_json FROM chat_queue WHERE id = ? AND state IN (\'waiting\', \'paused\') AND conversation_id = ? AND branch_id = ?').get(input.queuedMessageId, input.conversationId, input.branchId) + if (!queued || JSON.stringify((JSON.parse(String(queued.prepared_json)) as PrepareTurnRequestInput).delivery) !== JSON.stringify(input.delivery)) throw new TurnRequestConflictError() } - const directed = input.actorIds?.length === 1 && !input.followup + assertTurnInputDelivery(database, input, 'dispatch') + const actorLane = (input.delivery !== undefined || input.actorIds?.length === 1) && !input.followup const conversation = findConversation.get(input.conversationId) as ConversationBindingRow | undefined const buddyId = input.buddyId ?? conversation?.buddy_id ?? (database.prepare('SELECT default_buddy_id FROM buddy_catalog WHERE id = 1').get() as { default_buddy_id: string }).default_buddy_id - if (!input.queuedMessageId && !input.followup && database.prepare(`SELECT id FROM chat_queue WHERE conversation_id = ? AND branch_id = ? AND state IN ('waiting', 'paused') AND (? = 0 OR CASE WHEN json_extract(prepared_json, '$.queueRouting.mode') = 'actor' THEN json_extract(prepared_json, '$.queueRouting.actorId') ELSE ? END = ?) LIMIT 1`).get(input.conversationId, input.branchId, directed ? 1 : 0, conversation?.buddy_id ?? buddyId, buddyId)) + if (!input.queuedMessageId && !input.followup && database.prepare(`SELECT id FROM chat_queue WHERE conversation_id = ? AND branch_id = ? AND state IN ('waiting', 'paused') AND (? = 0 OR CASE WHEN json_extract(prepared_json, '$.queueRouting.mode') = 'actor' THEN json_extract(prepared_json, '$.queueRouting.actorId') ELSE ? END = ?) LIMIT 1`).get(input.conversationId, input.branchId, actorLane ? 1 : 0, conversation?.buddy_id ?? buddyId, buddyId)) throw new TurnRequestConflictError() const buddy = database.prepare('SELECT deleted_at FROM buddies WHERE id = ?').get(buddyId) if (!buddy) @@ -525,7 +583,7 @@ export function createTurnRequestRepository(database: DatabaseSync): TurnRequest : database.prepare(`SELECT 1 FROM runs WHERE conversation_id = ? AND status IN ('queued', 'running') AND (buddy_id = ? OR execution_kind = 'maintenance' OR (? = 0 AND execution_kind = 'root' AND scheduling_lane = 'conversation')) LIMIT 1`) - .get(input.conversationId, buddyId, directed ? 1 : 0)) + .get(input.conversationId, buddyId, actorLane ? 1 : 0)) ) { throw new TurnRequestConflictError() } @@ -562,7 +620,7 @@ export function createTurnRequestRepository(database: DatabaseSync): TurnRequest insertForkBranch.run(input.branchId, input.conversationId, input.followup.parentBranchId, input.followup.sourceMessageId, input.createdAt) activateBranch.run(input.branchId, input.createdAt, input.conversationId) } - const actorThread = directed + const actorThread = (input.actorIds?.length === 1 || input.delivery?.mode === 'next') && !input.followup ? database.prepare(`SELECT thread_id FROM runs WHERE conversation_id = ? AND branch_id = ? AND buddy_id = ? AND status IN ('completed', 'failed', 'cancelled') AND pi_session_file IS NOT NULL ORDER BY rowid DESC LIMIT 1`).get(input.conversationId, input.branchId, buddyId)?.thread_id as string | undefined : undefined @@ -596,7 +654,7 @@ export function createTurnRequestRepository(database: DatabaseSync): TurnRequest buddyId, input.actorSnapshot ? JSON.stringify(input.actorSnapshot) : null, ) - database.prepare('UPDATE runs SET scheduling_lane = ?, thread_id = ? WHERE id = ?').run(directed ? 'actor' : 'conversation', threadId, input.runId) + database.prepare('UPDATE runs SET scheduling_lane = ?, thread_id = ? WHERE id = ?').run(actorLane ? 'actor' : 'conversation', threadId, input.runId) insertRunInput.run( input.runId, input.runInput.prompt, diff --git a/apps/buddy/shared/conversation/chatApi.ts b/apps/buddy/shared/conversation/chatApi.ts index 38cf756d9..6a30c642b 100644 --- a/apps/buddy/shared/conversation/chatApi.ts +++ b/apps/buddy/shared/conversation/chatApi.ts @@ -6,6 +6,14 @@ import { idSchema, sessionIdentitySchema } from '../runtime/apiValidation' import { skillReferenceSchema } from '../skills/skillApi' import { buddyComposerDraftSendSchema } from './composerDraft' +export const buddyInputDeliverySchema = z.object({ + mode: z.enum(['current', 'next']), + actorId: idSchema, + target: z.object({ rootRunId: idSchema, runId: idSchema }).strict().nullable(), +}).strict() + +export type BuddyInputDelivery = z.infer + export const _contextItemSchema = z.object({ kind: z.enum(['file', 'skill', 'slashCommand']), value: z.string().min(1), @@ -48,7 +56,7 @@ export const chatRequestSchemas = { requestId: z.string().min(1).max(128), sourceRunId: idSchema, }).strict(), - startTurn: buddyComposerDraftSendSchema.unwrap().extend({ buddyId: idSchema.optional() }).strict(), + startTurn: buddyComposerDraftSendSchema.unwrap().extend({ buddyId: idSchema.optional(), delivery: buddyInputDeliverySchema.optional() }).strict(), } as const export const chatResponseSchemas = { diff --git a/apps/buddy/shared/conversation/chatQueueApi.ts b/apps/buddy/shared/conversation/chatQueueApi.ts index 6f83eb2a8..1f3fcc472 100644 --- a/apps/buddy/shared/conversation/chatQueueApi.ts +++ b/apps/buddy/shared/conversation/chatQueueApi.ts @@ -3,10 +3,12 @@ import type { DeepReadonly } from '../runtime/apiValidation' import { z } from 'zod' import { idSchema } from '../runtime/apiValidation' import { buddyUserMessageContentV1Schema } from './buddyUserContent' +import { buddyInputDeliverySchema } from './chatApi' import { buddyComposerDraftSendSchema } from './composerDraft' export const chatQueueItemSchema = z.object({ buddyId: idSchema, + delivery: buddyInputDeliverySchema.optional(), id: idSchema, conversationId: idSchema, branchId: idSchema, @@ -28,7 +30,7 @@ export type LocalChatQueueReceipt = z.infer const queueSteerResultSchema = z.enum(['accepted', 'incompatible', 'unavailable']) export type LocalChatQueueSteerResult = z.infer export const chatQueueRpc = { - enqueue: { method: 'chat.queue.enqueue', input: buddyComposerDraftSendSchema, response: queueReceiptSchema }, + enqueue: { method: 'chat.queue.enqueue', input: buddyComposerDraftSendSchema.unwrap().extend({ delivery: buddyInputDeliverySchema.optional() }).strict(), response: queueReceiptSchema }, list: { method: 'chat.queue.list', input: scope, response: z.array(chatQueueItemSchema) }, cancel: { method: 'chat.queue.cancel', input: target, response: z.boolean() }, steer: { method: 'chat.queue.steer', input: target, response: queueSteerResultSchema }, diff --git a/apps/buddy/shared/conversation/conversationApi.ts b/apps/buddy/shared/conversation/conversationApi.ts index f2136cd3b..6ad3fee01 100644 --- a/apps/buddy/shared/conversation/conversationApi.ts +++ b/apps/buddy/shared/conversation/conversationApi.ts @@ -80,8 +80,10 @@ export const messageSchema = z.object({ conversationId: idSchema, createdAt: timestampSchema, id: idSchema, + inputRunId: idSchema.optional(), role: z.enum(['user', 'assistant', 'tool']), runId: z.string().nullable(), + startedAt: timestampSchema.optional(), }).strict() export const conversationTimelineItemSchema = z.discriminatedUnion('kind', [ diff --git a/apps/buddy/shared/executions/executionInputReceipt.ts b/apps/buddy/shared/executions/executionInputReceipt.ts new file mode 100644 index 000000000..253b7e531 --- /dev/null +++ b/apps/buddy/shared/executions/executionInputReceipt.ts @@ -0,0 +1,33 @@ +import type { DeepReadonly } from '../runtime/apiValidation' +import { z } from 'zod' +import { idSchema, timestampSchema } from '../runtime/apiValidation' + +export const EXECUTION_INPUT_RUN_BATCH_SIZE = 50 +export const EXECUTION_INPUT_PAGE_SIZE = 100 + +export const executionInputSchema = z.object({ + kind: z.enum(['supplement', 'answer']), + messageId: z.string(), + text: z.string(), + createdAt: timestampSchema, + state: z.enum(['pending', 'accepted', 'consumed', 'undelivered']), +}).strict() + +export const executionInputReceiptSchema = executionInputSchema.extend({ + conversationId: idSchema, + branchId: idSchema, + runId: idSchema, + rootRunId: idSchema, + buddyId: idSchema, + buddyName: z.string().min(1).max(80), + task: z.string().max(300), +}).strict() + +export const executionInputsPageSchema = z.object({ + inputs: z.array(executionInputReceiptSchema).max(EXECUTION_INPUT_PAGE_SIZE), + nextOffset: z.number().int().nonnegative().nullable(), +}).strict() + +export type LocalExecutionInput = DeepReadonly> +export type LocalExecutionInputReceipt = DeepReadonly> +export type LocalExecutionInputsPage = DeepReadonly> diff --git a/apps/buddy/shared/executions/executionPresentationApi.ts b/apps/buddy/shared/executions/executionPresentationApi.ts index e0546c501..aecaa4e45 100644 --- a/apps/buddy/shared/executions/executionPresentationApi.ts +++ b/apps/buddy/shared/executions/executionPresentationApi.ts @@ -4,12 +4,14 @@ import { z } from 'zod' import { artifactSchema } from '../artifacts/artifactApi' import { changeSetSummarySchema } from '../changes/changeApi' import { MAX_BUDDY_MESSAGE_TEXT_LENGTH } from '../conversation/buddyMessageContent' +import { buddyInputDeliverySchema } from '../conversation/chatApi' import { buddyComposerDraftDiscardSchema, buddyComposerDraftSchema } from '../conversation/composerDraft' import { runEventSchema, runSchema, runStatusSchema } from '../runs/runApi' import { buddyRunProgressSchema } from '../runs/runProgress' import { idSchema, timestampSchema } from '../runtime/apiValidation' import { runTokenUsageSchema } from '../usage/runTokenUsage' import { executionPhaseSchema } from './executionApi' +import { EXECUTION_INPUT_RUN_BATCH_SIZE, executionInputSchema, executionInputsPageSchema } from './executionInputReceipt' import { executionWorkSchema } from './executionWork' export const executionScopeSchema = z.object({ conversationId: idSchema, branchId: idSchema }).strict() @@ -54,7 +56,7 @@ export const executionDetailSchema = z.object({ execution: executionPresentationSchema, run: runSchema, caller: z.object({ runId: idSchema, buddyName: z.string().min(1).max(80), ordinal: z.number().int().positive().nullable() }).strict().nullable(), - inputs: z.array(z.object({ kind: z.enum(['supplement', 'answer']), messageId: z.string(), text: z.string(), createdAt: timestampSchema, state: z.enum(['pending', 'accepted', 'consumed', 'undelivered']) }).strict()), + inputs: z.array(executionInputSchema), result: z.string().nullable(), artifacts: z.array(artifactSchema), changes: changeSetSummarySchema.nullable(), @@ -64,7 +66,11 @@ export const executionSendSchema = executionTargetSchema.extend({ messageId: z.uuid(), text: z.string().trim().min(1).max(MAX_BUDDY_MESSAGE_TEXT_LENGTH), }).strict() -export const executionSendDraftSchema = executionTargetSchema.extend(buddyComposerDraftDiscardSchema.unwrap().shape).strict() +export const executionSendDraftSchema = executionTargetSchema.extend({ ...buddyComposerDraftDiscardSchema.unwrap().shape, delivery: buddyInputDeliverySchema.optional() }).strict() +export const executionInputsRequestSchema = executionScopeSchema.extend({ + runIds: z.array(idSchema).min(1).max(EXECUTION_INPUT_RUN_BATCH_SIZE).refine(ids => new Set(ids).size === ids.length), + offset: z.number().int().nonnegative().default(0), +}).strict() export const executionEventsRequestSchema = executionTargetSchema.extend({ afterSequence: z.number().int().nonnegative().default(0) }).strict() export const executionEventsPageSchema = z.object({ events: z.array(runEventSchema), nextSequence: z.number().int().nonnegative(), hasMore: z.boolean() }).strict() export const executionAnswerSchema = executionTargetSchema.extend({ @@ -78,6 +84,7 @@ export type ExecutionTarget = z.infer export type ExecutionAnswer = z.infer export type ExecutionSend = z.infer export type ExecutionSendDraft = z.infer +export type ExecutionInputsRequest = z.input export type ExecutionEventsRequest = z.input export type LocalExecutionEventsPage = DeepReadonly> export type LocalExecution = DeepReadonly> @@ -86,6 +93,7 @@ export type LocalExecutionDetail = DeepReadonly([ 'BUDDY_DEFAULT_PROTECTED', 'BUDDY_RESET_UNAVAILABLE', 'DRAFT_CONFLICT', + 'INPUT_DELIVERY_TARGET_UNAVAILABLE', + 'INPUT_DELIVERY_INCOMPATIBLE', 'CONVERSATION_BUSY', 'CONVERSATION_QUEUE_NOT_EMPTY', 'CONVERSATION_SPACE_CONFLICT', diff --git a/apps/buddy/src/i18n/locales/en-US/tasks.ts b/apps/buddy/src/i18n/locales/en-US/tasks.ts index b07a0dab1..bdf9b0456 100644 --- a/apps/buddy/src/i18n/locales/en-US/tasks.ts +++ b/apps/buddy/src/i18n/locales/en-US/tasks.ts @@ -26,6 +26,15 @@ export default { 'desktop.artifacts.locateFailed': 'Could not locate this artifact in Files. Check that the directory is still accessible.', 'desktop.artifacts.revealFailed': 'Could not open the containing folder. Check that the artifact is still accessible.', 'desktop.artifacts.copyPathFailed': 'Could not copy the path. Please try again.', + 'desktop.chat.inputDeliveryTargetUnavailable': 'This work has ended or cannot receive input. Your draft is kept; review it before sending again.', + 'desktop.chat.inputDeliveryIncompatible': 'This work cannot accept this input. Check the input and model settings. If a question is pending, answer it with text only first.', + 'desktop.chat.inputDeliveryAnswerTextOnly': 'Answer the current question with text only first. Send attachments and references separately afterwards.', + 'desktop.chat.inputDeliveryPaused': 'Not delivered · Can be withdrawn', + 'desktop.chat.executionInputUndelivered': 'Not delivered', + 'desktop.chat.inputDelivery.recipient': 'To', + 'desktop.chat.inputDelivery.current': 'Add to current work', + 'desktop.chat.inputDelivery.next': 'Handle after completion', + 'desktop.chat.inputDelivery.answer': 'Answer current question', 'desktop.chat.panelOpened': 'Resource panel opened', 'desktop.chat.panelClosed': 'Resource panel closed', 'desktop.chat.panelActorSystem': 'System', diff --git a/apps/buddy/src/i18n/locales/zh-CN/tasks.ts b/apps/buddy/src/i18n/locales/zh-CN/tasks.ts index 572d80e5b..3a156a520 100644 --- a/apps/buddy/src/i18n/locales/zh-CN/tasks.ts +++ b/apps/buddy/src/i18n/locales/zh-CN/tasks.ts @@ -24,6 +24,15 @@ export default { 'desktop.artifacts.locateFailed': '无法在文件中定位此产物,请检查目录是否仍可访问', 'desktop.artifacts.revealFailed': '无法打开产物所在文件夹,请检查文件是否仍可访问', 'desktop.artifacts.copyPathFailed': '无法复制路径,请重试', + 'desktop.chat.inputDeliveryTargetUnavailable': '这项工作已结束或无法接收补充。输入已保留,请确认后重新发送。', + 'desktop.chat.inputDeliveryIncompatible': '这项工作暂不能接收这条补充。请检查输入与模型设置;若正在等待回答,请先仅用文字回答。', + 'desktop.chat.inputDeliveryAnswerTextOnly': '请先仅用文字回答当前问题;附件和引用可以在回答后另行发送。', + 'desktop.chat.inputDeliveryPaused': '未投递 · 可撤回', + 'desktop.chat.executionInputUndelivered': '未投递', + 'desktop.chat.inputDelivery.recipient': '发给', + 'desktop.chat.inputDelivery.current': '补充当前工作', + 'desktop.chat.inputDelivery.next': '完成后处理', + 'desktop.chat.inputDelivery.answer': '回答当前问题', 'desktop.chat.panelOpened': '已展开资源面板', 'desktop.chat.panelClosed': '已收起资源面板', 'desktop.chat.panelActorSystem': '系统', diff --git a/apps/buddy/src/modules/tasks/contracts.ts b/apps/buddy/src/modules/tasks/contracts.ts index fe3b274cd..84525a709 100644 --- a/apps/buddy/src/modules/tasks/contracts.ts +++ b/apps/buddy/src/modules/tasks/contracts.ts @@ -8,6 +8,7 @@ import type { LocalConversation, LocalConversationBranch, LocalConversationSumma import type { LocalConversationTree } from '@buddy-shared/conversation/conversationTree' import type { BuddyServiceTier, BuddyThinkingLevel } from '@buddy-shared/conversation/modelSelection' import type { LocalTaskMark, LocalTaskMarkState, TaskMarkInput } from '@buddy-shared/conversation/taskMarkApi' +import type { LocalExecutionInputReceipt } from '@buddy-shared/executions/executionInputReceipt' import type { ExecutionTarget, LocalExecution } from '@buddy-shared/executions/executionPresentationApi' import type { LocalApproval } from '@buddy-shared/permissions/approvalApi' import type { BuddyPermissionMode } from '@buddy-shared/permissions/permissionMode' @@ -147,8 +148,10 @@ export interface TaskComposer { export interface TaskExecution { isActorInput: State + inputExecution: State stoppingRunId: State queuedMessages: State + queueRecipientNames: State> pendingQueueActions: State> cancelQueuedMessage: (id: string) => Promise steerQueuedMessage: (id: string) => Promise @@ -218,6 +221,7 @@ export interface TaskConversationTree { export interface TaskExecutionTree { groups: State records: State + inputs: State revision: State ready: State conversationId: State diff --git a/apps/buddy/src/modules/tasks/model/transcript/__tests__/chatExecutionInputRows.spec.ts b/apps/buddy/src/modules/tasks/model/transcript/__tests__/chatExecutionInputRows.spec.ts new file mode 100644 index 000000000..3a1fabed6 --- /dev/null +++ b/apps/buddy/src/modules/tasks/model/transcript/__tests__/chatExecutionInputRows.spec.ts @@ -0,0 +1,82 @@ +import type { LocalConversationTimelineItem } from '@buddy-shared/conversation/conversationApi' +import type { LocalExecutionInputReceipt } from '@buddy-shared/executions/executionInputReceipt' +import type { LocalExecution } from '@buddy-shared/executions/executionPresentationApi' +import type { LocalRun } from '@buddy-shared/runs/runApi' +import type { ChatTranscriptMessageRow, ChatTranscriptRow } from '../chatTranscriptTypes' +import { describe, expect, it } from 'vitest' +import { projectExecutionGroups } from '../../executions/executionGroups' +import { projectChatAgentTurn } from '../chatAgentTurn' +import { projectChatExecutionInputRows } from '../chatExecutionInputRows' +import { projectChatMessageBranchNavigators } from '../chatMessageBranches' +import { presentChatTranscript } from '../chatTranscriptPresentation' +import { createChatTranscriptProjector, projectChatTranscript } from '../chatTranscriptProjection' + +const time = (seconds: number) => new Date(Date.UTC(2026, 9, 20, 10, 0, seconds)).toISOString() +function run(id: string, start: number, finish: number): LocalRun { + return { id, buddyId: id, branchId: 'branch', conversationId: 'task', triggeringMessageId: `input-${id}`, status: 'completed', startedAt: time(start), completedAt: time(finish), approvalPolicy: 'policy', executionProfile: 'workspace_write', modelId: 'model', providerId: 'provider', purpose: 'chat', reasoningLevel: null, errorCode: null } +} +function execution(runId: string, rootRunId = runId, kind: LocalExecution['kind'] = runId === rootRunId ? 'root' : 'actor_child'): LocalExecution { + return { runId, rootRunId, parentRunId: runId === rootRunId ? null : rootRunId, buddyId: runId, buddyName: runId, ordinal: null, kind, phase: 'closed', status: 'completed', startedAt: time(0), completedAt: time(40), updatedAt: time(40), progress: null, toolCount: 0, usage: null, stopping: false, task: 'Work', waitingFor: null, activeChildren: 0, canCancel: false, canAnswer: false, canSend: false, cleanup: 'clean', errorCode: null, affectedResources: [], questions: [] } +} +function message(id: string, seconds: number, runId: string | null = null): Extract { + return { kind: 'message', id, role: runId ? 'assistant' : 'user', branchId: 'branch', conversationId: 'task', runId, attachments: [], content: { text: id }, createdAt: time(seconds) } +} +function receipt(runId: string, rootRunId: string, seconds: number): LocalExecutionInputReceipt { + return { conversationId: 'task', branchId: 'branch', runId, rootRunId, buddyId: runId, buddyName: runId, task: 'Work', messageId: `receipt-${runId}`, text: 'Keep this private from other actors', kind: 'supplement', createdAt: time(seconds), state: 'pending' } +} + +const a = run('a', 0, 40) +const b = run('b', 10, 20) +const groups = projectExecutionGroups([execution('a'), execution('a-child', 'a'), execution('npc', 'a', 'npc_child'), execution('b')]) +const snapshot = { + runs: [a, b], + outputs: [], + timelineItems: [message('input-a', 0), message('input-b', 10), message('reply-b', 19, 'b'), message('reply-a', 39, 'a-child')], + executionGroups: groups, +} + +describe('private input transcript receipts', () => { + it('keeps late inputs in their actual root group and preserves the recipient reply and root result ownership', () => { + const input = receipt('a-child', 'a', 35) + const rows = projectChatTranscript({ ...snapshot, executionInputs: [input] }).rows + expect(rows.map(row => row.key)).toEqual(['message:input-a', 'execution-input:receipt-a-child', 'message:reply-a', 'message:input-b', 'message:reply-b']) + expect(rows[1]).toEqual({ kind: 'execution-input', key: 'execution-input:receipt-a-child', input }) + expect(rows[2]).toMatchObject({ kind: 'message', message: { runId: 'a-child', createdAt: time(39) }, turnResult: { runId: 'a' } }) + expect(rows[4]).toMatchObject({ kind: 'message', message: { runId: 'b' }, turnResult: { runId: 'b' } }) + }) + + it('refreshes receipt state without changing the row key and excludes private or unknown recipients', () => { + const projector = createChatTranscriptProjector() + const input = receipt('a-child', 'a', 35) + const cached = { ...snapshot, runProjections: snapshot.runs.map(run => ({ turn: projectChatAgentTurn(run, []), recoveryNotices: [], streamingMessages: [] })) } + const first = projector.project({ ...cached, executionInputs: [input] }) + const updated = projector.project({ ...cached, executionInputs: [{ ...input, state: 'accepted' }] }) + expect(updated.rows.map(row => row.key)).toEqual(first.rows.map(row => row.key)) + expect(updated.rows.find(row => row.kind === 'execution-input')).toMatchObject({ input: { state: 'accepted' } }) + expect(projectChatTranscript({ ...snapshot, executionInputs: [receipt('npc', 'a', 35), receipt('unknown', 'a', 35)] }).rows).toEqual(projectChatTranscript(snapshot).rows) + }) + + it('does not attach results across a receipt or transfer child artifacts to the root or receipt', () => { + const turn = projectChatAgentTurn(a, []) + const reply: ChatTranscriptMessageRow = { + kind: 'message', + key: 'message:child', + message: message('child', 10, 'a-child'), + resultRunId: 'a-child', + turnOutputs: { runId: 'a-child', artifacts: [{ artifactId: 'file', conversationId: 'task', runId: 'a-child', createdAt: time(10), updatedAt: time(10), kind: 'file', mimeType: 'text/plain', name: 'result.txt', path: '/workspace/result.txt', previewUrl: null, sizeBytes: 1, sourceArtifactId: null, sourceToolCallId: 'write' }] }, + } + const rows: ChatTranscriptRow[] = [ + { kind: 'message', key: 'message:input-a', message: message('input-a', 0), turnOutputs: null }, + reply, + { kind: 'turn-result', key: 'turn-result:a', turn }, + ] + const presented = presentChatTranscript(projectChatExecutionInputRows(rows, [receipt('a-child', 'a', 20)], [turn], groups), groups).rows + expect(presented.map(row => row.kind)).toEqual(['message', 'message', 'execution-input', 'turn-result']) + expect(presented[1]).toMatchObject({ resultRunId: 'a-child', turnOutputs: reply.turnOutputs }) + expect(presented[1]).not.toHaveProperty('turnResult') + const branch = { id: 'branch', conversationId: 'task', parentBranchId: null, forkedFromMessageId: null, createdAt: time(0) } + const branches = projectChatMessageBranchNavigators(presented, [branch, { ...branch, id: 'retry', parentBranchId: 'branch', forkedFromMessageId: 'input-a', createdAt: time(50) }], 'branch') + expect(branches.get('a')).toMatchObject({ count: 2, nextBranchId: 'retry' }) + expect(branches.has('receipt-a-child')).toBe(false) + }) +}) diff --git a/apps/buddy/src/modules/tasks/model/transcript/__tests__/chatTranscriptGroups.spec.ts b/apps/buddy/src/modules/tasks/model/transcript/__tests__/chatTranscriptGroups.spec.ts new file mode 100644 index 000000000..b38e2f833 --- /dev/null +++ b/apps/buddy/src/modules/tasks/model/transcript/__tests__/chatTranscriptGroups.spec.ts @@ -0,0 +1,132 @@ +import type { LocalConversationTimelineItem } from '@buddy-shared/conversation/conversationApi' +import type { LocalRun, LocalRunEvent } from '@buddy-shared/runs/runApi' +import { describe, expect, it } from 'vitest' +import { projectChatAgentTurn } from '../chatAgentTurn' +import { projectChatTranscriptDisplayRows } from '../chatMessageTime' +import { projectChatRunStreamingMessages } from '../chatRunStreamingMessages' +import { createChatTranscriptProjector, projectChatTranscript } from '../chatTranscriptProjection' + +const time = (seconds: number) => new Date(Date.UTC(2026, 8, 10, 10, 0, seconds)).toISOString() +function run(id: string, start: number, completedAt: number | null = null): LocalRun { + return { id, buddyId: 'default', branchId: 'branch', conversationId: 'conversation', triggeringMessageId: `input-${id}`, status: completedAt === null ? 'running' : 'completed', startedAt: time(start), completedAt: completedAt === null ? null : time(completedAt), approvalPolicy: 'policy', executionProfile: 'workspace_write', modelId: 'model', providerId: 'provider', purpose: 'chat', reasoningLevel: null, errorCode: null } +} +function message(id: string, seconds: number, runId: string | null = null): Extract { + return { kind: 'message', id, role: runId ? 'assistant' : 'user', branchId: 'branch', conversationId: 'conversation', runId, attachments: [], content: { text: id }, createdAt: time(seconds) } +} +function event(runId: string, sequence: number, seconds: number, type: string, payload: LocalRunEvent['payload']): LocalRunEvent { + return { runId, sequence, createdAt: time(seconds), type, payload } +} +function projection(run: LocalRun, events: readonly LocalRunEvent[]) { + return { turn: projectChatAgentTurn(run, events), recoveryNotices: [], streamingMessages: projectChatRunStreamingMessages(run, events) } +} +function order(result: ReturnType) { + return result.rows.flatMap(row => row.kind === 'message' ? [row.message.id] : row.kind === 'activity' ? [row.key] : []) +} + +describe('causal conversation groups', () => { + it('retains both concurrent streams and activity rows in their own initiating groups through incremental updates', () => { + const a = run('a', 0) + const b = run('b', 10) + const aEvents = [event(a.id, 1, 12, 'message.started', { messageId: 'answer-a', role: 'assistant' }), event(a.id, 2, 14, 'message.delta', { messageId: 'answer-a', phase: 'final_answer', delta: 'A is working' })] + const bEvents = [event(b.id, 1, 11, 'message.started', { messageId: 'answer-b', role: 'assistant' }), event(b.id, 2, 13, 'message.delta', { messageId: 'answer-b', phase: 'final_answer', delta: 'B is working' })] + const snapshot = { runs: [b, a], outputs: [], timelineItems: [message('input-a', 0), message('input-b', 10)], runProjections: [projection(b, bEvents), projection(a, aEvents)] } + const projector = createChatTranscriptProjector() + const initial = projector.project(snapshot) + expect(order(initial)).toEqual(['input-a', 'answer-a', 'activity:a', 'input-b', 'answer-b', 'activity:b']) + expect(initial.rows.filter(row => row.kind === 'message' && row.streaming)).toHaveLength(2) + const updated = { ...snapshot, runProjections: [snapshot.runProjections[0]!, projection(a, [...aEvents, event(a.id, 3, 15, 'message.delta', { messageId: 'answer-a', phase: 'final_answer', delta: ' and progressing' })])] } + const result = projector.project(updated) + expect(result.rows).toEqual(projectChatTranscript(updated).rows) + expect(order(result)).toEqual(order(initial)) + expect(result.rows.find(row => row.key === 'message:answer-a')).toMatchObject({ message: { content: { text: 'A is working and progressing' } } }) + }) + + it('keeps the earlier activity before a completed later turn and attaches each result to its own root', () => { + const a = run('a', 0) + const b = run('b', 10, 20) + const bDone = event(b.id, 1, 19, 'message.completed', { messageId: 'answer-b', role: 'assistant', phase: 'final_answer', content: { text: 'Done B' } }) + const snapshot = { runs: [a, b], outputs: [], timelineItems: [message('input-a', 0), message('input-b', 10), message('answer-b', 19, b.id)], runEvents: [bDone] } + const projector = createChatTranscriptProjector() + const initial = projector.project(snapshot) + expect(order(initial)).toEqual(['input-a', 'activity:a', 'input-b', 'answer-b']) + const done = { ...snapshot, runs: [{ ...a, status: 'completed' as const, completedAt: time(40) }, b], timelineItems: [...snapshot.timelineItems, message('answer-a', 39, a.id)], runEvents: [...snapshot.runEvents, event(a.id, 1, 39, 'message.completed', { messageId: 'answer-a', role: 'assistant', phase: 'final_answer', content: { text: 'Done A' } })] } + const result = projector.project(done) + expect(result.rows).toEqual(projectChatTranscript(done).rows) + expect(order(result)).toEqual(['input-a', 'answer-a', 'input-b', 'answer-b']) + for (const id of ['a', 'b']) + expect(result.rows.find(row => row.key === `message:answer-${id}`)).toMatchObject({ turnResult: { runId: id } }) + }) + + it('retains both streams when the second run begins before its trigger is loaded', () => { + const a = run('a', 0) + const c = run('c', 10) + const aEvents = [event(a.id, 1, 1, 'message.started', { messageId: 'answer-a', role: 'assistant' }), event(a.id, 2, 2, 'message.delta', { messageId: 'answer-a', phase: 'final_answer', delta: 'A is visible' })] + const snapshot = { runs: [a, c], outputs: [], timelineItems: [message('input-a', 0)], runProjections: [projection(a, aEvents), projection(c, [])] } + const projector = createChatTranscriptProjector() + projector.project(snapshot) + const cEvents = [event(c.id, 1, 11, 'message.started', { messageId: 'answer-c', role: 'assistant' }), event(c.id, 2, 12, 'message.delta', { messageId: 'answer-c', phase: 'final_answer', delta: 'C also becomes visible' })] + const updated = { ...snapshot, runProjections: [snapshot.runProjections[0]!, projection(c, cEvents)] } + const result = projector.project(updated) + expect(result.rows).toEqual(projectChatTranscript(updated).rows) + expect(result.rows.flatMap(row => row.kind === 'message' && row.streaming ? [row.message.id] : [])).toEqual(['answer-a', 'answer-c']) + }) + + it.each(['a', 'unloaded-child'])('keeps delayed input actions with accepted execution %s without borrowing a concurrent run', (inputRunId) => { + const snapshot = { + runs: [run('a', 0, 40), run('b', 10, 30)], + outputs: [], + timelineItems: [ + message('input-a', 0), + message('input-b', 10), + { ...message('steering-a', 15), inputRunId }, + { kind: 'extension-action' as const, id: 'delayed-action', conversationId: 'conversation', branchId: 'branch', sourceMessageId: 'steering-a', extensionId: 'tests.title', extensionName: 'Title', actionId: 'tests.title.generate', title: 'Generate title', trigger: 'task:input:committed' as const, status: 'completed' as const, message: 'Updated', createdAt: time(41), completedAt: time(42) }, + ], + } + const rows = projectChatTranscript(snapshot).rows + const owners = rows.flatMap(row => row.kind === 'agent-turn' && row.turn.nodes.some(node => node.kind === 'tool' && node.invocation?.id === 'delayed-action') ? [row.turn.runId] : []) + expect(owners).toEqual(inputRunId === 'a' ? ['a'] : []) + if (inputRunId !== 'a') + expect(rows.find(row => row.kind === 'activity-flow')).toMatchObject({ nodes: [{ invocation: { id: 'delayed-action', sourceMessageId: 'steering-a' } }] }) + }) + + it('uses the current trigger attempt for plugin actions when regeneration inherits the original input', () => { + const revised = { ...run('revised', 20, 40), branchId: 'revised-branch', triggeringMessageId: 'input-a' } + const result = projectChatTranscript({ + runs: [revised], + outputs: [], + timelineItems: [ + { ...message('input-a', 0), inputRunId: 'original-run' }, + { kind: 'extension-action', id: 'revised-action', conversationId: 'conversation', branchId: revised.branchId, sourceMessageId: 'input-a', extensionId: 'tests.title', extensionName: 'Title', actionId: 'tests.title.generate', title: 'Generate title', trigger: 'task:input:committed', status: 'completed', message: 'Updated', createdAt: time(21), completedAt: time(22) }, + ], + }) + expect(result.rows.find(row => row.kind === 'agent-turn')).toMatchObject({ turn: { runId: revised.id, nodes: [{ invocation: { id: 'revised-action' } }] } }) + }) + + it('anchors day dividers to initiating inputs when a previous round finishes after midnight', () => { + const snapshot = { + runs: [run('a', 0, 86430), run('b', 10, 30), run('c', 86440)], + outputs: [], + timelineItems: [message('input-a', 0), message('input-b', 10), message('answer-b', 25, 'b'), message('answer-a', 86420, 'a'), message('input-c', 86440)], + } + const result = projectChatTranscript(snapshot) + expect(order(result)).toEqual(['input-a', 'answer-a', 'input-b', 'answer-b', 'input-c', 'activity:c']) + const display = projectChatTranscriptDisplayRows(result.rows) + expect(display.filter(row => row.kind === 'day-divider').map(row => row.createdAt)).toEqual([time(0), time(86440)]) + expect(new Set(display.map(row => row.key)).size).toBe(display.length) + expect(result.rows.find(row => row.key === 'message:answer-a')).toMatchObject({ message: { createdAt: time(86420) } }) + }) + + it('keeps accepted steering with its actual execution instead of the most recent input or actor', () => { + const a = run('a', 0, 40) + const b = run('b', 10, 30) + const steering = { ...message('steering-a', 15), inputRunId: a.id } + const snapshot = { runs: [a, b], outputs: [], timelineItems: [message('input-a', 0), message('opening-a', 5, a.id), message('input-b', 10), steering, message('answer-b', 25, b.id), message('answer-a', 35, a.id)] } + const original = structuredClone(snapshot.timelineItems) + expect(order(projectChatTranscript(snapshot))).toEqual(['input-a', 'opening-a', 'steering-a', 'answer-a', 'input-b', 'answer-b']) + expect(snapshot.timelineItems).toEqual(original) + for (const inputRunId of [undefined, 'unloaded-child']) { + const legacy = { ...snapshot, timelineItems: snapshot.timelineItems.map(item => item.id === steering.id ? { ...message(steering.id, 15), inputRunId } : item) } + expect(order(projectChatTranscript(legacy))).toEqual(['input-a', 'opening-a', 'input-b', 'steering-a', 'answer-b', 'answer-a']) + } + }) +}) diff --git a/apps/buddy/src/modules/tasks/model/transcript/__tests__/chatTranscriptSegments.spec.ts b/apps/buddy/src/modules/tasks/model/transcript/__tests__/chatTranscriptSegments.spec.ts index ca633e113..7d041f557 100644 --- a/apps/buddy/src/modules/tasks/model/transcript/__tests__/chatTranscriptSegments.spec.ts +++ b/apps/buddy/src/modules/tasks/model/transcript/__tests__/chatTranscriptSegments.spec.ts @@ -5,7 +5,6 @@ import type { ChatRunStreamingMessage } from '../chatRunStreamingMessages' import { describe, expect, it } from 'vitest' import { projectExecutionGroups } from '../../executions/executionGroups' import { projectChatAgentTurn } from '../chatAgentTurn' -import { projectChatAgentTurnActions, projectChatMessageActions } from '../chatMessageActions' import { projectChatRunStreamingMessages } from '../chatRunStreamingMessages' import { createChatTranscriptProjector, projectChatTranscript } from '../chatTranscriptProjection' @@ -27,6 +26,37 @@ const run: LocalRun = { reasoningLevel: null, errorCode: null, } +function execution(runId: string, overrides: Partial = {}): LocalExecution { + return { + runId, + rootRunId: run.id, + parentRunId: run.id, + buddyId: 'partner', + buddyName: 'Partner', + ordinal: 1, + kind: 'actor_child', + phase: 'closed', + status: 'completed', + startedAt: time(3), + completedAt: time(10), + updatedAt: time(10), + progress: null, + toolCount: 0, + usage: null, + stopping: false, + task: 'Tell a story', + waitingFor: null, + activeChildren: 0, + canCancel: false, + canAnswer: false, + canSend: false, + cleanup: 'clean', + errorCode: null, + affectedResources: [], + questions: [], + ...overrides, + } +} function message(id: string, role: 'user' | 'assistant', seconds: number): LocalConversationTimelineItem { return { kind: 'message', id, role, branchId: run.branchId, conversationId: run.conversationId, runId: role === 'assistant' ? run.id : null, attachments: [], content: { text: id }, createdAt: time(seconds) } } @@ -52,10 +82,8 @@ function order(projection: ReturnType) { describe('interleaved conversation segments', () => { it.each([ - { sameTimestamp: false, phase: undefined }, { sameTimestamp: true, phase: undefined }, { sameTimestamp: false, phase: 'final_answer' }, - { sameTimestamp: true, phase: 'final_answer' }, ])('keeps reasoning before its reply through rebuild, persistence and completion ($sameTimestamp, $phase)', ({ sameTimestamp, phase }) => { const history: LocalRunEvent[] = [ { runId: run.id, sequence: 1, createdAt: time(1), type: 'message.started', payload: { messageId: 'reply', role: 'assistant' } }, @@ -75,7 +103,7 @@ describe('interleaved conversation segments', () => { expect(rebuilt.rows[1]).toMatchObject({ turn: { nodes: [{ kind: 'reasoning', messageId: 'reply' }] } }) } const done = [...history, { runId: run.id, sequence: 5, createdAt: time(5), type: 'message.completed', payload: { messageId: 'reply', phase: 'final_answer', content: { text: 'Hello world' } } }] - for (const status of ['running', 'completed', 'failed', 'cancelled'] as const) { + for (const status of ['running', 'completed'] as const) { const currentRun = { ...run, status, completedAt: status === 'running' ? null : time(6) } const current = input(currentRun, done, [message('initial', 'user', 0), message('reply', 'assistant', 5)]) const replay = projectChatTranscript(current) @@ -129,6 +157,45 @@ describe('interleaved conversation segments', () => { expect(completed.rows.filter(row => row.kind === 'message' && row.resultRunId).map(row => row.key)).toEqual(['message:final']) }) + it.each([false, true])('keeps public actor replies and independent root outcomes in place with start metadata (root events: %s)', (withRootStart) => { + const completed = { ...run, status: 'completed' as const, completedAt: time(75) } + const followup = { ...completed, id: 'followup-run', buddyId: 'other-actor', triggeringMessageId: 'followup', startedAt: time(30), completedAt: time(45) } + const reply = (id: string, runId: string, startedAt: number, createdAt: number, phase: 'commentary' | 'final_answer' = 'final_answer') => ({ ...message(id, 'assistant', createdAt), runId, startedAt: time(startedAt), content: { text: id, phase } }) + const history: LocalRunEvent[] = [ + events[0]!, + { runId: run.id, sequence: 2, createdAt: time(70), type: 'message.completed', payload: { messageId: 'caller-final', role: 'assistant', phase: 'final_answer', content: { text: 'Done' } } }, + ...(withRootStart ? [{ runId: followup.id, sequence: 1, createdAt: time(32), type: 'message.started', payload: { messageId: 'followup-answer', role: 'assistant' } }] : []), + { runId: followup.id, sequence: 2, createdAt: time(40), type: 'message.completed', payload: { messageId: 'followup-answer', role: 'assistant', phase: 'final_answer', content: { text: 'Follow-up answer' } } }, + ] + const snapshot = { + runs: [completed, followup], + runEvents: history, + outputs: [], + executionGroups: projectExecutionGroups([ + execution(run.id, { kind: 'root', buddyId: 'default', parentRunId: null, ordinal: null, startedAt: completed.startedAt, completedAt: completed.completedAt }), + execution('partner-run', { completedAt: time(60) }), + execution(followup.id, { kind: 'root', buddyId: followup.buddyId, rootRunId: followup.id, parentRunId: null, ordinal: null, startedAt: followup.startedAt, completedAt: followup.completedAt }), + ]), + timelineItems: [ + message('initial', 'user', 0), + message('followup', 'user', 30), + reply('partner-opening', 'partner-run', 10, 35, 'commentary'), + reply('followup-answer', followup.id, 32, 40), + reply('partner-progress', 'partner-run', 41, 43, 'commentary'), + reply('partner-final', 'partner-run', 55, 60), + reply('caller-final', run.id, 65, 70), + ], + } + const projector = createChatTranscriptProjector() + projector.project({ ...snapshot, timelineItems: snapshot.timelineItems.slice(0, 4) }) + const result = projector.project(snapshot) + expect(order(result)).toEqual(['initial', 'before', 'partner-opening', 'partner-progress', 'partner-final', 'caller-final', 'followup', 'followup-answer']) + expect(result.rows.find(row => row.key === 'message:followup-answer')).toMatchObject({ turnResult: { runId: followup.id } }) + expect(result.rows.find(row => row.key === 'message:caller-final')).toMatchObject({ turnResult: { runId: run.id } }) + expect(result.rows.find(row => row.key === 'message:partner-progress')).toHaveProperty('showIdentity', false) + expect(result.rows.filter(row => row.kind === 'turn-result')).toEqual([]) + }) + it('does not reopen the caller speech for orchestration tools between partner messages', () => { const actorReply = (id: string, seconds: number) => ({ ...message(id, 'assistant', seconds), runId: 'partner-run' }) const presentation = { card: 'generic', argumentNames: [], description: null, output: null, truncated: false } @@ -145,35 +212,6 @@ describe('interleaved conversation segments', () => { }) it('keeps terminal status on the first identity of each execution, including repeated runs of the same actor', () => { - const execution = (runId: string, overrides: Partial = {}): LocalExecution => ({ - runId, - rootRunId: run.id, - parentRunId: run.id, - buddyId: 'partner', - buddyName: 'Partner', - ordinal: 1, - kind: 'actor_child', - phase: 'closed', - status: 'completed', - startedAt: time(3), - completedAt: time(10), - updatedAt: time(10), - progress: null, - toolCount: 0, - usage: null, - stopping: false, - task: 'Tell a story', - waitingFor: null, - activeChildren: 0, - canCancel: false, - canAnswer: false, - canSend: false, - cleanup: 'clean', - errorCode: null, - affectedResources: [], - questions: [], - ...overrides, - }) const completed = { ...run, status: 'completed' as const, completedAt: time(30) } const records = [execution(run.id, { kind: 'root', buddyId: 'caller', parentRunId: null, ordinal: null, startedAt: time(0), completedAt: time(30) }), execution('partner-first'), execution('partner-resumed', { ordinal: 2, startedAt: time(12), completedAt: time(20) })] const rootContinuation: LocalRunEvent = { runId: run.id, sequence: 2, createdAt: time(25), type: 'message.completed', payload: { messageId: 'continued', role: 'assistant', phase: 'commentary', content: { text: 'Both assignments are complete.' } } } @@ -182,7 +220,6 @@ describe('interleaved conversation segments', () => { const projector = createChatTranscriptProjector() projector.project({ ...snapshot, runs: [run], runProjections: input(run, [events[0]!, rootContinuation], items).runProjections, executionGroups: projectExecutionGroups([{ ...records[0]!, status: 'running', phase: 'working', completedAt: null }, ...records.slice(1)]) }) const result = projector.project(snapshot) - expect(result.rows).toEqual(projectChatTranscript(snapshot).rows) const statuses = result.rows.flatMap(row => (row.kind === 'message' || row.kind === 'agent-turn') && row.executionStatus ? [{ runId: row.kind === 'message' ? row.message.runId : row.turn.runId, ...row.executionStatus }] : []) @@ -209,23 +246,21 @@ describe('interleaved conversation segments', () => { const live = projector.project(input(run, history, [message('initial', 'user', 0), partner])) expect(live.rows.some(row => row.kind === 'agent-turn' && row.ownsResultActions)).toBe(false) - const snapshot = input(completed, [...history, done], [message('initial', 'user', 0), partner, emptyFinal]) + const snapshot = { + ...input(completed, [...history, done], [message('initial', 'user', 0), partner, emptyFinal]), + executionGroups: projectExecutionGroups([ + execution(run.id, { kind: 'root', buddyId: 'default', parentRunId: null, ordinal: null, startedAt: completed.startedAt, completedAt: completed.completedAt }), + execution('partner-run'), + ]), + } const result = projector.project(snapshot) - expect(result.rows).toEqual(projectChatTranscript(snapshot).rows) - const outcome = result.rows.at(-1)! - expect(outcome).toMatchObject({ kind: 'turn-result', turn: { runId: run.id, status: 'completed', nodes: [] } }) - expect(outcome).not.toHaveProperty('showOutcome', false) - expect(outcome).not.toHaveProperty('showIdentity', false) - expect(result.rows.filter(row => row.kind === 'turn-result')).toEqual([outcome]) - expect(result.rows.filter(row => row.kind === 'agent-turn' && !row.turn.nodes.length)).toEqual([]) - expect(result.rows.some(row => row.kind === 'message' && row.message.id === emptyFinal.id)).toBe(false) - const reply = result.rows.find(row => row.kind === 'message' && row.message.id === partner.id)! + const reply = result.rows.at(-1)! + expect(reply).toMatchObject({ kind: 'message', message: { id: partner.id }, turnResult: { runId: run.id, status: 'completed', nodes: [] } }) expect(reply).not.toHaveProperty('resultRunId') expect(reply).not.toHaveProperty('showActions', false) - if (reply.kind !== 'message' || outcome.kind !== 'turn-result') - throw new Error('Missing partner answer or caller outcome') - expect(projectChatMessageActions(reply.message, false, !!reply.resultRunId)).toMatchObject({ showCopy: true, showTime: true, showRegenerate: false }) - expect(projectChatAgentTurnActions(outcome.turn, false, true)).toMatchObject({ showCopy: false, showTime: true, showRegenerate: true }) + expect(result.rows.filter(row => row.kind === 'turn-result')).toEqual([]) + expect(result.rows.filter(row => row.kind === 'agent-turn' && !row.turn.nodes.length)).toEqual([]) + expect(result.rows.some(row => row.kind === 'message' && row.message.id === emptyFinal.id)).toBe(false) }) it('joins the returning caller output tool and final answer under one identity', () => { @@ -238,7 +273,6 @@ describe('interleaved conversation segments', () => { const visible = live.rows.find(row => row.kind === 'agent-turn' && row.turn.nodes.some(node => node.kind === 'tool'))! expect(visible).not.toHaveProperty('showIdentity', false) const result = projector.project(input(completed, history, items)) - expect(result.rows).toEqual(projectChatTranscript(input(completed, history, items)).rows) expect(result.rows.find(row => row.key === visible.key)).toMatchObject({ turn: { nodes: [{ toolName: 'lexora_output_present' }] } }) expect(result.rows.find(row => row.kind === 'message' && row.message.id === 'final')).toMatchObject({ showIdentity: false, resultRunId: 'run' }) expect(result.rows.find(row => row.kind === 'message' && row.message.id === 'partner')).toMatchObject({ endsSpeech: true }) @@ -247,7 +281,6 @@ describe('interleaved conversation segments', () => { it.each([ { trigger: 'task:input:committed', seconds: 1 }, - { trigger: 'task:input:committed', seconds: 6 }, { trigger: 'task:turn:completed', seconds: 6 }, { trigger: 'user', seconds: 6 }, ] as const)('keeps $trigger actions at $seconds in the originating reply flow during updates and replay', ({ trigger, seconds }) => { @@ -259,7 +292,6 @@ describe('interleaved conversation segments', () => { const invocation = action(seconds, { trigger, status, completedAt: status === 'running' ? null : time(seconds + 1) }) const snapshot = input(completed, [reply], [...messages, invocation]) const projected = projector.project(snapshot) - expect(projected.rows).toEqual(projectChatTranscript(snapshot).rows) expect(projected.rows.map(row => row.key)).toEqual(['message:initial', 'agent-turn:run', 'message:reply']) expect(projected.rows[1]).toMatchObject({ turn: { status: 'completed', nodes: [{ kind: 'tool', invocation }] } }) expect(projected.rows[2]).toMatchObject({ showIdentity: false, resultRunId: run.id, turnResult: { runId: run.id } }) @@ -300,8 +332,6 @@ describe('interleaved conversation segments', () => { expect(actionRows[0]).toMatchObject({ turn: { runId: run.id, nodes: [{ messageId: 'continuation' }, { invocation }] } }) expect(result.rows.indexOf(actionRows[0]!)).toBeLessThan(result.rows.findIndex(row => row.key === 'message:final')) expect(order(result)).toEqual(['initial', 'before', 'steer-a', 'steer-b', 'between', 'hello', 'continuation', 'final', 'followup']) - const advanced = { ...snapshot, runProjections: [{ ...snapshot.runProjections[0]!, turn: { ...snapshot.runProjections[0]!.turn } }, snapshot.runProjections[1]!] } - expect(projector.project(advanced).rows).toEqual(projectChatTranscript(advanced).rows) }) it('does not duplicate earlier actions into a retry of the same input', () => { @@ -332,12 +362,9 @@ describe('interleaved conversation segments', () => { const first = input(run, [], items) const projector = createChatTranscriptProjector() expect(projector.project(first).rows.map(row => row.key)).toEqual(['message:initial', 'agent-turn:run', 'activity:run']) - const thinking = { ...first, runProjections: [{ ...first.runProjections[0]!, turn: { ...first.runProjections[0]!.turn, nodes: [{ id: 'reasoning', kind: 'reasoning' as const, messageId: 'reply', contentIndex: 0, status: 'running' as const, text: 'Planning' }], nodeStartedAt: { reasoning: time(3) } } }] } - expect(projector.project(thinking).rows).toEqual(projectChatTranscript(thinking).rows) const narration: LocalRunEvent = { runId: run.id, sequence: 1, createdAt: time(3), type: 'message.completed', payload: { role: 'assistant', messageId: 'reply', phase: 'commentary', content: { text: 'Working' } } } const next = { ...first, runProjections: input(run, [narration], items).runProjections } const active = projector.project(next) - expect(active.rows).toEqual(projectChatTranscript(next).rows) expect(active.rows.map(row => row.key)).toEqual(['message:initial', 'agent-turn:run', 'activity:run']) expect(active.rows[1]).toMatchObject({ kind: 'agent-turn', turn: { nodes: [{ id: 'extension-action:action-1' }, { id: 'extension-action:action-2' }, { messageId: 'reply' }] } }) const ended = projector.project(input({ ...run, status: 'cancelled', completedAt: time(5) }, [narration], items)) @@ -415,22 +442,6 @@ describe('interleaved conversation segments', () => { expect(order(compactedReplay)).toEqual(order(visible)) }) - it('starts a new identity for a follow-up run while retaining one identity across steering', () => { - const followup: LocalRun = { ...run, id: 'followup-run', triggeringMessageId: 'followup', startedAt: time(60) } - const completed: LocalRun = { ...run, status: 'completed', completedAt: time(50) } - const first = input(completed) - const projection = projectChatTranscript({ - ...first, - runs: [completed, followup], - timelineItems: [...timelineItems, message('followup', 'user', 60)], - runProjections: [...first.runProjections, { turn: projectChatAgentTurn(followup, []), recoveryNotices: [], streamingMessages: [] }], - }) - const identities = projection.rows.flatMap(row => row.kind === 'agent-turn' && row.showIdentity !== false - ? [row.turn.runId] - : row.kind === 'message' && row.message.role === 'assistant' && row.showIdentity !== false ? [row.message.runId] : []) - expect(identities).toEqual([run.id, followup.id]) - }) - it.each(['failed', 'cancelled'] as const)('keeps one terminal outcome without promoting intermediate answers when %s', (status) => { const resumed: LocalRunEvent = { runId: run.id, sequence: 4, createdAt: time(40), type: 'message.started', payload: { messageId: 'continuation', role: 'assistant' } } for (const history of [events, [...events.slice(0, 3), resumed]]) { diff --git a/apps/buddy/src/modules/tasks/model/transcript/chatExecutionInputRows.ts b/apps/buddy/src/modules/tasks/model/transcript/chatExecutionInputRows.ts new file mode 100644 index 000000000..4434f0959 --- /dev/null +++ b/apps/buddy/src/modules/tasks/model/transcript/chatExecutionInputRows.ts @@ -0,0 +1,54 @@ +import type { LocalExecutionInputReceipt } from '@buddy-shared/executions/executionInputReceipt' +import type { ExecutionGroups } from '../executions/executionGroups' +import type { ChatAgentTurn } from './chatAgentTurn' +import type { ChatTranscriptRow } from './chatTranscriptTypes' + +export function projectChatExecutionInputRows( + rows: readonly ChatTranscriptRow[], + inputs: readonly LocalExecutionInputReceipt[], + turns: readonly ChatAgentTurn[], + executions?: ExecutionGroups, +): ChatTranscriptRow[] { + const result = [...rows] + const rootId = (runId: string) => executions?.byRun.get(runId)?.rootRunId ?? runId + const owner = (row: ChatTranscriptRow): string | null => { + if (row.kind === 'execution-input') + return row.input.rootRunId + if (row.kind === 'message') { + const runId = row.message.role === 'user' ? row.message.inputRunId : row.message.runId + return runId ? rootId(runId) : null + } + if (row.kind === 'agent-turn' || row.kind === 'activity' || row.kind === 'turn-result') + return rootId(row.turn.runId) + return row.kind === 'recovery-notice' ? rootId(row.notice.runId) : null + } + const timestamp = (row: ChatTranscriptRow): string | null => { + if (row.kind === 'execution-input') + return row.input.createdAt + if (row.kind === 'message') + return row.message.startedAt ?? row.message.createdAt + if (row.kind === 'agent-turn') + return row.turn.nodeStartedAt?.[row.turn.nodes[0]?.id ?? ''] ?? row.turn.startedAt + if (row.kind === 'turn-result') + return row.turn.completedAt + return null + } + const seen = new Set() + for (const input of inputs.toSorted((left, right) => left.createdAt.localeCompare(right.createdAt) || left.messageId.localeCompare(right.messageId))) { + const execution = executions?.byRun.get(input.runId) + if (!execution || (execution.kind !== 'root' && execution.kind !== 'actor_child') + || execution.rootRunId !== input.rootRunId || seen.has(input.messageId)) { + continue + } + seen.add(input.messageId) + const turn = turns.find(turn => turn.runId === input.rootRunId) + const triggerIndex = turn ? result.findIndex(row => row.kind === 'message' && row.message.id === turn.triggeringMessageId) : -1 + const owned = result.flatMap((row, index) => owner(row) === input.rootRunId ? [{ row, index }] : []) + if (!owned.length && triggerIndex === -1) + continue + const following = owned.find(({ row }) => row.kind === 'activity' || (timestamp(row) ?? '') > input.createdAt) + const index = following?.index ?? ((owned.at(-1)?.index ?? triggerIndex) + 1) + result.splice(index, 0, { key: `execution-input:${input.messageId}`, kind: 'execution-input', input }) + } + return result +} diff --git a/apps/buddy/src/modules/tasks/model/transcript/chatIncrementalTranscript.ts b/apps/buddy/src/modules/tasks/model/transcript/chatIncrementalTranscript.ts index a8f129b8d..136c2bb70 100644 --- a/apps/buddy/src/modules/tasks/model/transcript/chatIncrementalTranscript.ts +++ b/apps/buddy/src/modules/tasks/model/transcript/chatIncrementalTranscript.ts @@ -13,10 +13,12 @@ import type { ChatTranscriptRowPatch, } from './chatTranscriptTypes' import { isVisibleExtensionAction, shouldShowAgentTurn } from './chatPersistedTranscriptRows' -import { selectChatStreamingMessage } from './chatRunStreamingMessages' +import { selectChatStreamingMessages } from './chatRunStreamingMessages' export interface CachedChatTranscriptProjection { executionGroups: ChatTranscriptProjectionInput['executionGroups'] + executionInputs: ChatTranscriptProjectionInput['executionInputs'] + supportsIncrementalUpdates: boolean continuation: ChatTranscriptContinuation includeUnanchoredTurns: boolean interleavedRunIds: ReadonlySet @@ -46,7 +48,9 @@ export function projectIncrementalChatTranscript( const runProjections = input.runProjections if ( !runProjections + || !cached.supportsIncrementalUpdates || cached.executionGroups !== input.executionGroups + || cached.executionInputs !== input.executionInputs || cached.includeUnanchoredTurns !== (input.includeUnanchoredTurns ?? false) || !cached.runProjections || cached.timelineItems !== input.timelineItems @@ -112,11 +116,14 @@ export function projectIncrementalChatTranscript( }) } - const streamingMessage = selectChatStreamingMessage( + const streamingMessages = selectChatStreamingMessages( cached.messages, runProjections.flatMap(projection => projection.streamingMessages), input.runs, - )?.message ?? null + ) + if (streamingMessages.length > 1) + return null + const streamingMessage = streamingMessages[0]?.message ?? null let streamingRowIndex = cached.streamingRowIndex let activityRowIndex = cached.activityRowIndex if (streamingMessage) { @@ -241,6 +248,10 @@ export function createChatTranscriptProjectionCache( } return { executionGroups: input.executionGroups, + executionInputs: input.executionInputs, + supportsIncrementalUpdates: projection.rows.filter(row => row.kind === 'activity').length <= 1 + && projection.rows.filter(row => row.kind === 'message' && row.streaming).length <= 1 + && (activityRowIndex === null || activityRowIndex === projection.rows.length - 1), continuation, includeUnanchoredTurns: input.includeUnanchoredTurns ?? false, interleavedRunIds, diff --git a/apps/buddy/src/modules/tasks/model/transcript/chatMessageTime.ts b/apps/buddy/src/modules/tasks/model/transcript/chatMessageTime.ts index 2ddd33561..a6ee3612d 100644 --- a/apps/buddy/src/modules/tasks/model/transcript/chatMessageTime.ts +++ b/apps/buddy/src/modules/tasks/model/transcript/chatMessageTime.ts @@ -55,8 +55,16 @@ export function projectChatTranscriptDisplayRows( row.kind === 'agent-turn' ? [row.turn.runId] : [] ))) let previousDayKey: string | null = null + let hasInputDayAnchor = false for (const row of rows) { + const isUserInput = row.kind === 'message' && row.message.role === 'user' + if (isUserInput) + hasInputDayAnchor = true + if (hasInputDayAnchor && !isUserInput) { + displayRows.push(row) + continue + } const assistantMessageBelongsToAgentTurn = row.kind === 'message' && row.message.role === 'assistant' && ( diff --git a/apps/buddy/src/modules/tasks/model/transcript/chatPersistedTranscriptRows.ts b/apps/buddy/src/modules/tasks/model/transcript/chatPersistedTranscriptRows.ts index bc9846dcc..be7c221d0 100644 --- a/apps/buddy/src/modules/tasks/model/transcript/chatPersistedTranscriptRows.ts +++ b/apps/buddy/src/modules/tasks/model/transcript/chatPersistedTranscriptRows.ts @@ -4,6 +4,7 @@ import type { LocalConversationTimelineItem, LocalMessage } from '@buddy-shared/ import type { ExtensionActionTimelineItem } from '@buddy-shared/extensions/extensionActionApi' import type { LocalRunOutput } from '@buddy-shared/runs/runApi' +import type { ExecutionGroups } from '../executions/executionGroups' import type { ChatAgentTurn } from './chatAgentTurn' import type { PersistedChatTranscriptRow, PresentedChatTranscriptRow } from './chatTranscriptActivities' import type { @@ -22,6 +23,7 @@ export function projectPersistedChatTranscriptRows( outputs: ReadonlyArray = [], changeSets: ReadonlyArray = [], includeUnanchoredTurns = false, + executions?: ExecutionGroups, ): Array { const rows: PersistedChatTranscriptRow[] = [] const turnsByTrigger = new Map() @@ -55,6 +57,7 @@ export function projectPersistedChatTranscriptRows( turns.filter(turn => includeUnanchoredTurns || timelineMessageIds.has(turn.triggeringMessageId)), visibleActions, items.filter(item => item.kind === 'message'), + executions, ) const independentActionIds = new Set(activities.independentActions.map(action => action.id)) for (const turn of activities.turns) { @@ -132,7 +135,7 @@ export function projectPersistedChatTranscriptRows( } })) } - return interleaveChatTranscriptSegments(rows, new Map(turns.flatMap(turn => Object.entries(turn.messageStartedAt ?? {})))).flatMap((row) => { + return interleaveChatTranscriptSegments(rows, new Map(turns.flatMap(turn => Object.entries(turn.messageStartedAt ?? {}))), executions).flatMap((row) => { if (row.kind !== 'agent-turn' || !row.ownsResultActions || row.turn.status !== 'completed') return [row] return [ diff --git a/apps/buddy/src/modules/tasks/model/transcript/chatRunStreamingMessages.ts b/apps/buddy/src/modules/tasks/model/transcript/chatRunStreamingMessages.ts index e769e2d60..a1ba6e266 100644 --- a/apps/buddy/src/modules/tasks/model/transcript/chatRunStreamingMessages.ts +++ b/apps/buddy/src/modules/tasks/model/transcript/chatRunStreamingMessages.ts @@ -21,13 +21,22 @@ export function projectStreamingAssistantMessage( events: ReadonlyArray, runs: ReadonlyArray, ): StreamingAssistantMessage | null { + const latest = projectChatStreamingMessages(messages, events, runs).at(-1) + return latest ? { id: latest.message.id, text: latest.text } : null +} + +export function projectChatStreamingMessages( + messages: ReadonlyArray, + events: ReadonlyArray, + runs: ReadonlyArray, +): ReadonlyArray { const eventsByRunId = new Map() for (const event of events) { const runEvents = eventsByRunId.get(event.runId) ?? [] runEvents.push(event) eventsByRunId.set(event.runId, runEvents) } - const latest = selectChatStreamingMessage( + return selectChatStreamingMessages( messages, runs.flatMap(run => projectChatRunStreamingMessages( run, @@ -35,9 +44,6 @@ export function projectStreamingAssistantMessage( )), runs, ) - return latest - ? { id: latest.message.id, text: latest.text } - : null } export function projectChatRunStreamingMessages( @@ -151,20 +157,29 @@ export function selectChatStreamingMessage( candidates: ReadonlyArray, runs: ReadonlyArray, ): ChatRunStreamingMessage | null { + return selectChatStreamingMessages(messages, candidates, runs).at(-1) ?? null +} + +export function selectChatStreamingMessages( + messages: ReadonlyArray, + candidates: ReadonlyArray, + runs: ReadonlyArray, +): ReadonlyArray { const persistedMessageIds = new Set(messages.map(message => message.id)) const loadedMessageIds = new Set(persistedMessageIds) const runById = new Map(runs.map(run => [run.id, run])) - const latest = candidates.filter((candidate) => { + const latest = new Map() + for (const candidate of candidates) { if (persistedMessageIds.has(candidate.message.id) || !candidate.message.runId) - return false + continue const run = runById.get(candidate.message.runId) - return !!run && ( - run.status === 'queued' - || run.status === 'running' - || loadedMessageIds.has(run.triggeringMessageId) - ) - }).sort(compareRunStreamingMessages).at(-1) - return latest ?? null + if (!run || (run.status !== 'queued' && run.status !== 'running' && !loadedMessageIds.has(run.triggeringMessageId))) + continue + const previous = latest.get(run.id) + if (!previous || compareRunStreamingMessages(previous, candidate) < 0) + latest.set(run.id, candidate) + } + return [...latest.values()].sort(compareRunStreamingMessages) } function compareRunStreamingMessages( diff --git a/apps/buddy/src/modules/tasks/model/transcript/chatTranscriptActivities.ts b/apps/buddy/src/modules/tasks/model/transcript/chatTranscriptActivities.ts index 44a3186b0..ec5c0297c 100644 --- a/apps/buddy/src/modules/tasks/model/transcript/chatTranscriptActivities.ts +++ b/apps/buddy/src/modules/tasks/model/transcript/chatTranscriptActivities.ts @@ -1,5 +1,6 @@ import type { LocalMessage } from '@buddy-shared/conversation/conversationApi' import type { ExtensionActionTimelineItem } from '@buddy-shared/extensions/extensionActionApi' +import type { ExecutionGroups } from '../executions/executionGroups' import type { ChatAgentToolNode, ChatAgentTurn } from './chatAgentTurn' import type { ChatTranscriptActivityFlowRow, ChatTranscriptAgentTurnRow, ChatTranscriptCompactionRow, ChatTranscriptExtensionActionRow, ChatTranscriptMessageRow } from './chatTranscriptTypes' @@ -10,6 +11,7 @@ export function projectChatTurnExtensionActions( turns: readonly ChatAgentTurn[], actions: readonly ExtensionActionTimelineItem[], messages: readonly LocalMessage[], + executions?: ExecutionGroups, ) { const inputs = new Map(messages.filter(message => message.role === 'user').map(message => [message.id, message])) const sortedTurns = [...turns].sort((left, right) => left.startedAt.localeCompare(right.startedAt) || left.runId.localeCompare(right.runId)) @@ -19,12 +21,16 @@ export function projectChatTurnExtensionActions( const source = action.sourceMessageId ? inputs.get(action.sourceMessageId) : undefined const branchTurns = sortedTurns.filter(turn => turn.branchId === action.branchId) const triggeredTurns = branchTurns.filter(turn => turn.triggeringMessageId === action.sourceMessageId) + const acceptedRunId = source?.inputRunId + const acceptedRootId = acceptedRunId ? executions?.byRun.get(acceptedRunId)?.rootRunId ?? acceptedRunId : null const candidates = triggeredTurns.length ? triggeredTurns - : branchTurns.filter(turn => source - && source.branchId === turn.branchId - && source.createdAt >= turn.startedAt - && (turn.completedAt === null || source.createdAt <= turn.completedAt)) + : acceptedRunId + ? branchTurns.filter(turn => turn.runId === acceptedRunId || turn.runId === acceptedRootId) + : branchTurns.filter(turn => source + && source.branchId === turn.branchId + && source.createdAt >= turn.startedAt + && (turn.completedAt === null || source.createdAt <= turn.completedAt)) const owner = candidates.findLast(turn => turn.startedAt <= action.createdAt) ?? candidates[0] if (!owner) { independentActions.push(action) diff --git a/apps/buddy/src/modules/tasks/model/transcript/chatTranscriptGroups.ts b/apps/buddy/src/modules/tasks/model/transcript/chatTranscriptGroups.ts new file mode 100644 index 000000000..accc4896b --- /dev/null +++ b/apps/buddy/src/modules/tasks/model/transcript/chatTranscriptGroups.ts @@ -0,0 +1,51 @@ +import type { ExecutionGroups } from '../executions/executionGroups' +import type { ChatAgentTurn } from './chatAgentTurn' +import type { ChatTranscriptRow } from './chatTranscriptTypes' + +export function groupChatTranscriptRows( + rows: readonly ChatTranscriptRow[], + turns: readonly ChatAgentTurn[], + executions?: ExecutionGroups, +): ChatTranscriptRow[] { + const rootId = (runId: string) => executions?.byRun.get(runId)?.rootRunId ?? runId + const userIds = new Set(rows.flatMap(row => row.kind === 'message' && row.message.role === 'user' ? [row.message.id] : [])) + const roots = turns.filter(turn => rootId(turn.runId) === turn.runId && userIds.has(turn.triggeringMessageId)) + .toSorted((left, right) => left.startedAt.localeCompare(right.startedAt) || left.runId.localeCompare(right.runId)) + if (roots.length < 2) + return [...rows] + const byRoot = new Map(roots.map(turn => [turn.runId, [] as ChatTranscriptRow[]])) + const byTrigger = new Map() + for (const turn of roots) { + const siblings = byTrigger.get(turn.triggeringMessageId) ?? [] + siblings.push(turn.runId) + byTrigger.set(turn.triggeringMessageId, siblings) + } + if (rows.some(row => row.kind === 'message' && row.message.role === 'user' + && !byTrigger.has(row.message.id) + && (!row.message.inputRunId || !byRoot.has(rootId(row.message.inputRunId))) + && roots.some(turn => row.message.branchId === turn.branchId + && row.message.createdAt >= turn.startedAt + && (!turn.completedAt || row.message.createdAt <= turn.completedAt)))) { + return [...rows] + } + const owner = (row: ChatTranscriptRow) => { + if (row.kind === 'execution-input') + return byRoot.get(row.input.rootRunId) + const runId = row.kind === 'message' + ? row.message.role === 'user' ? row.message.inputRunId : row.message.runId + : row.kind === 'recovery-notice' + ? row.notice.runId + : row.kind === 'agent-turn' || row.kind === 'activity' || row.kind === 'turn-result' ? row.turn.runId : null + return runId ? byRoot.get(rootId(runId)) : undefined + } + for (const row of rows) { + if (row.kind === 'message' && row.message.role === 'user' && byTrigger.has(row.message.id)) + continue + owner(row)?.push(row) + } + return rows.flatMap((row) => { + if (row.kind === 'message' && row.message.role === 'user' && byTrigger.has(row.message.id)) + return [row, ...byTrigger.get(row.message.id)!.flatMap(runId => byRoot.get(runId)!)] + return owner(row) ? [] : [row] + }) +} diff --git a/apps/buddy/src/modules/tasks/model/transcript/chatTranscriptProjection.ts b/apps/buddy/src/modules/tasks/model/transcript/chatTranscriptProjection.ts index 4a5568e8a..ea862767c 100644 --- a/apps/buddy/src/modules/tasks/model/transcript/chatTranscriptProjection.ts +++ b/apps/buddy/src/modules/tasks/model/transcript/chatTranscriptProjection.ts @@ -1,5 +1,4 @@ -import type { LocalConversationTimelineItem, LocalMessage } from '@buddy-shared/conversation/conversationApi' -import type { LocalRun, LocalRunEvent } from '@buddy-shared/runs/runApi' +import type { LocalConversationTimelineItem } from '@buddy-shared/conversation/conversationApi' import type { CachedChatTranscriptProjection } from './chatIncrementalTranscript' import type { @@ -9,10 +8,12 @@ import type { ChatTranscriptRow, } from './chatTranscriptTypes' import { projectChatAgentTurns } from './chatAgentTurn' +import { projectChatExecutionInputRows } from './chatExecutionInputRows' import { createChatTranscriptProjectionCache, projectIncrementalChatTranscript } from './chatIncrementalTranscript' import { projectPersistedChatTranscriptRows } from './chatPersistedTranscriptRows' import { projectChatRecoveryNotices, selectChatRecoveryNotices } from './chatRunRecovery' -import { projectStreamingAssistantMessage, selectChatStreamingMessage } from './chatRunStreamingMessages' +import { projectChatStreamingMessages, selectChatStreamingMessages } from './chatRunStreamingMessages' +import { groupChatTranscriptRows } from './chatTranscriptGroups' import { presentChatTranscript } from './chatTranscriptPresentation' export { projectPersistedChatTranscriptRows } from './chatPersistedTranscriptRows' @@ -52,13 +53,10 @@ function projectChatTranscriptState(input: ChatTranscriptProjectionInput) { input.runs, ) : projectChatRecoveryNotices(input.timelineItems, runEvents, input.runs) - const streamingLocalMessage = input.runProjections - ? selectChatStreamingMessage( - messages, - input.runProjections.flatMap(projection => projection.streamingMessages), - input.runs, - )?.message ?? null - : projectLegacyStreamingLocalMessage(messages, runEvents, input.runs) + const streamingMessages = (input.runProjections + ? selectChatStreamingMessages(messages, input.runProjections.flatMap(projection => projection.streamingMessages), input.runs) + : projectChatStreamingMessages(messages, runEvents, input.runs)).map(candidate => candidate.message) + const streamingById = new Map(streamingMessages.map(message => [message.id, message])) const triggeringMessageIdByRunId = new Map( input.runs.map(run => [run.id, run.triggeringMessageId]), ) @@ -85,14 +83,15 @@ function projectChatTranscriptState(input: ChatTranscriptProjectionInput) { const noticeRunIds = new Set() const rows: ChatTranscriptRow[] = [] for (const row of projectPersistedChatTranscriptRows( - streamingLocalMessage ? [...input.timelineItems, { ...streamingLocalMessage, kind: 'message' }] : input.timelineItems, + streamingMessages.length ? [...input.timelineItems, ...streamingMessages.map(message => ({ ...message, kind: 'message' as const }))] : input.timelineItems, agentTurns, input.outputs, input.changeSets ?? [], input.includeUnanchoredTurns, + input.executionGroups, )) { - rows.push(streamingLocalMessage && row.kind === 'message' && row.message.id === streamingLocalMessage.id - ? { ...row, message: streamingLocalMessage, streaming: true } + rows.push(row.kind === 'message' && streamingById.has(row.message.id) + ? { ...row, message: streamingById.get(row.message.id)!, streaming: true, showActions: false } : row) if (row.kind === 'agent-turn') { if (!noticeRunIds.has(row.turn.runId)) { @@ -108,16 +107,13 @@ function projectChatTranscriptState(input: ChatTranscriptProjectionInput) { } } const visibleRunIds = new Set(rows.flatMap(row => row.kind === 'agent-turn' ? [row.turn.runId] : [])) - const activeAgentTurn = [...agentTurns].reverse().find(turn => visibleRunIds.has(turn.runId) && (turn.status === 'queued' || turn.status === 'running')) - if (activeAgentTurn) { - rows.push({ - key: `activity:${activeAgentTurn.runId}`, - kind: 'activity', - turn: activeAgentTurn, - }) + for (const turn of agentTurns) { + if (visibleRunIds.has(turn.runId) && (turn.status === 'queued' || turn.status === 'running')) + rows.push({ key: `activity:${turn.runId}`, kind: 'activity', turn }) } - const presented = presentChatTranscript(rows, input.executionGroups) + const withInputs = input.executionInputs?.length ? projectChatExecutionInputRows(rows, input.executionInputs, agentTurns, input.executionGroups) : rows + const presented = presentChatTranscript(groupChatTranscriptRows(withInputs, agentTurns, input.executionGroups), input.executionGroups) return { projection: { rows: presented.rows, update: { kind: 'replace' as const } }, continuation: presented.continuation, @@ -154,52 +150,3 @@ export function createChatTranscriptProjector() { }, } } - -function projectLegacyStreamingLocalMessage( - messages: ReadonlyArray, - events: ReadonlyArray, - runs: ReadonlyArray, -): LocalMessage | null { - const streamingMessage = projectStreamingAssistantMessage(messages, events, runs) - return streamingMessage - ? projectStreamingLocalMessage(streamingMessage.id, streamingMessage.text, events, runs) - : null -} - -function projectStreamingLocalMessage( - messageId: string, - text: string, - events: ReadonlyArray, - runs: ReadonlyArray, -): LocalMessage | null { - const sourceEvent = [...events].reverse().find(event => ( - readMessageId(event.payload) === messageId - )) - if (!sourceEvent) - return null - const run = runs.find(item => item.id === sourceEvent.runId) - if (!run) - return null - const startedAt = events.find(event => ( - event.runId === run.id - && event.type === 'message.started' - && readMessageId(event.payload) === messageId - ))?.createdAt ?? sourceEvent.createdAt - return { - attachments: [], - branchId: run.branchId, - content: { text }, - conversationId: run.conversationId, - createdAt: startedAt, - id: messageId, - role: 'assistant', - runId: run.id, - } -} - -function readMessageId(payload: unknown): string | null { - if (!payload || typeof payload !== 'object' || Array.isArray(payload)) - return null - const messageId = (payload as Record).messageId - return typeof messageId === 'string' ? messageId : null -} diff --git a/apps/buddy/src/modules/tasks/model/transcript/chatTranscriptSegments.ts b/apps/buddy/src/modules/tasks/model/transcript/chatTranscriptSegments.ts index 13e2f7a0e..6e6a41e1b 100644 --- a/apps/buddy/src/modules/tasks/model/transcript/chatTranscriptSegments.ts +++ b/apps/buddy/src/modules/tasks/model/transcript/chatTranscriptSegments.ts @@ -1,3 +1,4 @@ +import type { ExecutionGroups } from '../executions/executionGroups' import type { ChatAgentTurnNode } from './chatAgentTurn' import type { PersistedChatTranscriptRow, PresentedChatTranscriptRow } from './chatTranscriptActivities' import type { ChatTranscriptAgentTurnRow } from './chatTranscriptTypes' @@ -6,9 +7,10 @@ import { mergeChatTranscriptActivities } from './chatTranscriptActivities' export function interleaveChatTranscriptSegments( rows: readonly PersistedChatTranscriptRow[], messageStartedAt: ReadonlyMap, + executions?: ExecutionGroups, ): PresentedChatTranscriptRow[] { const timestamp = (row: Exclude) => row.kind === 'message' - ? messageStartedAt.get(row.message.id) ?? row.message.createdAt + ? row.message.startedAt ?? messageStartedAt.get(row.message.id) ?? row.message.createdAt : row.kind === 'extension-action' ? row.action.createdAt : row.compaction.createdAt const timeline = rows.filter(row => row.kind !== 'agent-turn').sort((left, right) => timestamp(left).localeCompare(timestamp(right))) const positions = new Map(timeline.map((row, index) => [row.key, index])) @@ -45,9 +47,12 @@ export function interleaveChatTranscriptSegments( nodes.push(node) groups.set(position, nodes) } + const rootRunId = executions?.byRun.get(row.turn.runId)?.rootRunId ?? row.turn.runId const lastOtherSpeaker = timeline.findLastIndex(candidate => candidate.kind === 'message' && candidate.message.role === 'assistant' + && candidate.message.runId && candidate.message.runId !== row.turn.runId + && (!row.ownsResultActions || (executions?.byRun.get(candidate.message.runId)?.rootRunId ?? candidate.message.runId) === rootRunId) && timestamp(candidate) >= row.turn.startedAt && (!row.turn.completedAt || timestamp(candidate) <= row.turn.completedAt)) if (row.ownsResultActions) { diff --git a/apps/buddy/src/modules/tasks/model/transcript/chatTranscriptTypes.ts b/apps/buddy/src/modules/tasks/model/transcript/chatTranscriptTypes.ts index caeb25c22..4990615aa 100644 --- a/apps/buddy/src/modules/tasks/model/transcript/chatTranscriptTypes.ts +++ b/apps/buddy/src/modules/tasks/model/transcript/chatTranscriptTypes.ts @@ -1,6 +1,7 @@ import type { LocalArtifact } from '@buddy-shared/artifacts/artifactApi' import type { LocalChangeSetSummary } from '@buddy-shared/changes/changeApi' import type { LocalConversationTimelineItem, LocalMessage } from '@buddy-shared/conversation/conversationApi' +import type { LocalExecutionInputReceipt } from '@buddy-shared/executions/executionInputReceipt' import type { LocalRun, LocalRunEvent, LocalRunOutput } from '@buddy-shared/runs/runApi' import type { LocalRunTokenUsage } from '@buddy-shared/usage/runTokenUsage' @@ -34,6 +35,12 @@ export interface ChatTranscriptTurnOutputs { runId: string } +export interface ChatTranscriptExecutionInputRow { + key: string + kind: 'execution-input' + input: LocalExecutionInputReceipt +} + export interface ChatTranscriptCompactionRow { compaction: Extract key: string @@ -90,6 +97,7 @@ export type ChatTranscriptRow | ChatTranscriptCompactionRow | ChatTranscriptActivityFlowRow | ChatTranscriptMessageRow + | ChatTranscriptExecutionInputRow | ChatTranscriptRecoveryNoticeRow | ChatTranscriptTurnResultRow @@ -114,6 +122,7 @@ export type ChatTranscriptProjectionUpdate export interface ChatTranscriptProjectionInput { executionGroups?: ExecutionGroups + executionInputs?: readonly LocalExecutionInputReceipt[] includeUnanchoredTurns?: boolean agentTurns?: ReadonlyArray changeSets?: ReadonlyArray diff --git a/apps/buddy/src/modules/tasks/state/__tests__/useTaskCapability.spec.ts b/apps/buddy/src/modules/tasks/state/__tests__/useTaskCapability.spec.ts index 2f227050b..e957b4ecf 100644 --- a/apps/buddy/src/modules/tasks/state/__tests__/useTaskCapability.spec.ts +++ b/apps/buddy/src/modules/tasks/state/__tests__/useTaskCapability.spec.ts @@ -89,24 +89,6 @@ describe('useTaskCapability', () => { } }) - it('refreshes committed content for the active task without inventing a run output', async () => { - const api = createBranchingDesktopApi() - vi.stubGlobal('window', Object.assign(globalThis, { lexoraDesktop: api })) - const chat = createTestTask(api, { conversationId: 'conversation-1', branchId: 'branch-root', spaceId: null }) - await chat.initialize() - const changed = { changeSetId: 'run-1', conversationId: 'conversation-1', runId: 'run-1', coverage: 'partial' as const, status: 'completed' as const, fileCount: 1, updatedAt: '2026-09-06T00:00:02.000Z' } - vi.mocked(api.localChat.conversations.listTimeline).mockResolvedValue({ changeSets: [changed], items: [], nextCursor: null, outputs: [], runEvents: [], runs: [] }) - const notice = vi.mocked(api.localChat.changes.onChanged).mock.calls[0]![0] - const artifactNotice = vi.mocked(api.localChat.artifacts.onChanged).mock.calls[0]![0] - const timelineNotice = vi.mocked(api.localChat.conversations.onTimelineChanged).mock.calls[0]![0] - notice({ sourceId: 'changes-source', revision: 1, conversationId: 'conversation-1', runId: 'run-1' }) - artifactNotice({ sourceId: 'artifact-source', revision: 1, conversationId: 'conversation-1' }) - timelineNotice({ sourceId: 'action-source', revision: 1, conversationId: 'conversation-1', branchId: 'branch-root' }) - await vi.waitFor(() => expect(chat.workspace.transcript.changeSets.value).toEqual([changed])) - expect(chat.workspace.transcript.runOutputs.value).toEqual([]) - chat.dispose() - }) - it('restores saved attachments when the new-task pane keeps the same draft identity', async () => { const api = createDesktopApi() vi.stubGlobal('window', Object.assign(globalThis, { lexoraDesktop: api })) @@ -177,41 +159,6 @@ describe('useTaskCapability', () => { expect(await chat.workspace.execution.send('first configured task')).toBe(true) }) - it('restores the saved Draft model into the composer and submits that same configuration', async () => { - const api = createDesktopApi() - const models = await api.localChat.providers.listModels() - vi.mocked(api.localChat.providers.listModels).mockResolvedValue(models.map(model => ({ - ...model, - serviceTiers: [{ displayName: 'Fast', id: 'priority' }], - }))) - const savedModel = { - modelId: 'model-2', - providerId: 'provider-1', - reasoning: 'high' as const, - serviceTier: 'priority' as const, - } - const remote = await api.localChat.composerDrafts.open({ - draftId: 'saved-global-draft', - initialContent: createBuddyUserContent('saved input'), - initialExecutionConfig: { approvalPolicy: 'policy', executionProfile: 'workspace_write' }, - initialModelSelection: savedModel, - scope: { kind: 'global' }, - }) - vi.stubGlobal('window', Object.assign(globalThis, { lexoraDesktop: api })) - const chat = createTestTask(api) - await chat.initialize() - - expect(chat.workspace.composer.selectedModelId.value).toBe('provider-1:model-2') - expect(chat.workspace.composer.selectedEffort.value).toBe('high') - expect(chat.workspace.composer.selectedServiceTier.value).toBe('priority') - expect(chat.workspace.composer.draft.value).toBe('saved input') - expect(await chat.workspace.execution.send('saved input')).toBe(true) - const sent = vi.mocked(api.localChat.chat.startTurn).mock.calls[0]![0] - expect(sent.draftId).toBe(remote.draftId) - expect((await api.localChat.composerDrafts.get(sent.draftId)).modelSelection).toEqual(savedModel) - expect(api.localChat.providers.setDefaultModel).not.toHaveBeenCalled() - }) - it.each([ { modelId: 'model-2', reasoning: 'xhigh' as const, serviceTier: null }, { modelId: 'model-2', reasoning: 'medium' as const, serviceTier: 'priority' as const }, @@ -298,46 +245,6 @@ describe('useTaskCapability', () => { expect(chat.workspace.composer.composerContent.value).toMatchObject({ attrs: { panelResourceIds: ['resource-selected'] } }) }) - it('keeps the model control disabled until its captured target is saved', async () => { - const api = createDesktopApi() - const conversation = { - buddyId: 'default', - activeBranchId: 'branch-1', - activity: 'idle' as const, - automationOccurrence: null, - createdAt: '2026-08-14T00:00:00.000Z', - deletedAt: null, - approvalPolicy: 'policy' as const, - executionProfile: 'workspace_write' as const, - id: 'conversation-1', - modelSelection: { - modelId: 'model-1', - providerId: 'provider-1', - reasoning: null, - serviceTier: null, - }, - spaceId: null, - title: 'Conversation 1', - updatedAt: '2026-08-14T00:00:00.000Z', - } - vi.mocked(api.localChat.conversations.list).mockReset().mockResolvedValue([conversation]) - const first = deferred() - vi.mocked(api.localChat.conversations.setModelSelection).mockReturnValue(first.promise) - vi.stubGlobal('window', Object.assign(globalThis, { lexoraDesktop: api })) - const chat = createTestTask(api) - await chat.initialize() - await chat.session.openTask('conversation-1') - - const firstSelection = chat.workspace.composer.selectModel('provider-1:model-2') - await vi.waitUntil(() => chat.workspace.composer.isUpdatingModel.value) - await chat.workspace.composer.selectModel('provider-1:model-1') - first.resolve({ ...conversation, modelSelection: { modelId: 'model-2', providerId: 'provider-1', reasoning: 'medium', serviceTier: null } }) - await firstSelection - expect(chat.workspace.composer.isUpdatingModel.value).toBe(false) - expect(chat.workspace.composer.selectedModelId.value).toBe('provider-1:model-2') - expect(chat.workspace.session.activeConversation.value?.modelSelection?.modelId).toBe('model-2') - }) - it('surfaces an actionable message when Pi session storage is unavailable', async () => { const api = createDesktopApi() vi.mocked(api.localChat.chat.startTurn).mockImplementation(async input => ({ @@ -675,6 +582,53 @@ describe('useTaskCapability', () => { expect(chat.workspace.transcript.messages.value.map(message => message.id)).toEqual(['user-1', 'assistant-1', 'user-2']) }) + it.each([ + { permissions: { approvalPolicy: 'policy', executionProfile: 'read_only' }, pendingModel: false }, + { permissions: { approvalPolicy: 'manual', executionProfile: 'workspace_write' }, pendingModel: false }, + { permissions: { approvalPolicy: 'policy', executionProfile: 'read_only' }, pendingModel: true }, + ] as const)('submits the first regenerated-branch draft with $permissions and pending model $pendingModel', async ({ permissions, pendingModel }) => { + const api = createBranchingDesktopApi(permissions) + const regenerate = vi.mocked(api.localChat.chat.regenerateAssistant).getMockImplementation()! + vi.mocked(api.localChat.chat.regenerateAssistant).mockImplementation(async (input) => { + const result = await regenerate(input) + return { ...result, run: { ...result.run, ...permissions, status: 'completed', completedAt: '2026-08-15T00:00:01.000Z' } } + }) + const startTurn = vi.mocked(api.localChat.chat.startTurn).getMockImplementation()! + let submitted: LocalComposerDraft | null = null + vi.mocked(api.localChat.chat.startTurn).mockImplementation(async (input) => { + submitted = await api.localChat.composerDrafts.get(input.draftId) + return startTurn(input) + }) + vi.stubGlobal('window', Object.assign(globalThis, { lexoraDesktop: api })) + const chat = createTestTask(api, { conversationId: 'conversation-1', branchId: 'branch-root', spaceId: null }) + await chat.initialize() + + expect(await chat.workspace.execution.regenerateAssistant('run-2')).toBe(true) + expect(chat.workspace.session.activeBranchId.value).toBe('branch-regenerated') + const modelSave = deferred() + const modelSelection = { providerId: 'provider-1', modelId: 'model-2', reasoning: 'medium' as const, serviceTier: null } + vi.mocked(api.localChat.conversations.setModelSelection).mockImplementation(() => modelSave.promise) + const selecting = pendingModel ? chat.workspace.composer.selectModel('provider-1:model-2') : Promise.resolve() + const content = { ...createBuddyUserContent(), body: [{ type: 'paragraph' as const, content: [{ type: 'prompt_directive' as const, commandMode: 'prompt' as const, directive: 'slash_command' as const, value: '/review' }, { type: 'text' as const, text: ' inspect the current task' }] }] } + const sending = chat.workspace.execution.send({ content: '/review inspect the current task', userContent: content }) + if (pendingModel) { + expect(await chat.flushDrafts()).toBe(true) + expect((await api.localChat.composerDrafts.get(chat.workspace.composer.draftId.value)).executionConfig).toEqual(permissions) + modelSave.resolve({ ...chat.session.activeTask.value!, modelSelection }) + } + await selecting + expect(await sending).toBe(true) + expect(submitted).toMatchObject({ + content, + executionConfig: permissions, + modelSelection: pendingModel ? modelSelection : null, + scope: { kind: 'conversation_branch', conversationId: 'conversation-1', branchId: 'branch-regenerated' }, + }) + expect(chat.workspace.composer.draft.value).toBe('') + expect(chat.workspace.status.errorMessage.value).toBeNull() + chat.dispose() + }) + it('does not start a turn while permission settings are being committed', async () => { const api = createBranchingDesktopApi() const pendingPermissionSettings = deferred { expect(draft.executionConfig).toEqual({ approvalPolicy: 'policy', executionProfile: 'full_access' }) }) - it.each(['text', 'structured'])('routes %s compact input through the command lifecycle without creating a turn', async (inputKind) => { + it('routes compact input through the command lifecycle without creating a turn', async () => { const api = createBranchingDesktopApi() const compactRun = { buddyId: 'default', @@ -755,18 +709,9 @@ describe('useTaskCapability', () => { await chat.initialize() chat.workspace.composer.updateComposerContent('/compact focus on decisions', null) - const payload = inputKind === 'structured' - ? { - content: '/compact focus on decisions', - userContent: { version: 1 as const, panelResourceIds: [], body: [{ type: 'paragraph' as const, content: [ - { type: 'prompt_directive' as const, directive: 'slash_command' as const, commandMode: 'action' as const, value: '/compact' }, - { type: 'text' as const, text: ' focus on decisions' }, - ] }] }, - } - : '/compact focus on decisions' - await expect(chat.workspace.execution.send(payload)).resolves.toBe(true) - - expect(vi.mocked(api.localChat.composerDrafts.save).mock.calls.at(-1)?.[0].content).toMatchObject({ + await expect(chat.workspace.execution.send('/compact focus on decisions')).resolves.toBe(true) + + expect((await api.localChat.composerDrafts.get(chat.workspace.composer.draftId.value)).content).toMatchObject({ body: [{ content: [ { commandMode: 'action', directive: 'slash_command', value: '/compact' }, { text: ' focus on decisions', type: 'text' }, @@ -838,7 +783,7 @@ function createTaskCapability( }) } -function createBranchingDesktopApi() { +function createBranchingDesktopApi(permissions: Pick = { approvalPolicy: 'policy', executionProfile: 'workspace_write' }) { const api = createDesktopApi() let activeBranchId = 'branch-root' const conversation = () => ({ @@ -848,8 +793,7 @@ function createBranchingDesktopApi() { automationOccurrence: null, createdAt: '2026-08-14T00:00:00.000Z', deletedAt: null, - approvalPolicy: 'policy' as const, - executionProfile: 'workspace_write' as const, + ...permissions, id: 'conversation-1', modelSelection: null, spaceId: null, diff --git a/apps/buddy/src/modules/tasks/state/composer/__tests__/useActorInputTarget.spec.ts b/apps/buddy/src/modules/tasks/state/composer/__tests__/useActorInputTarget.spec.ts index 9bec70113..020ba2311 100644 --- a/apps/buddy/src/modules/tasks/state/composer/__tests__/useActorInputTarget.spec.ts +++ b/apps/buddy/src/modules/tasks/state/composer/__tests__/useActorInputTarget.spec.ts @@ -29,7 +29,7 @@ function execution(runId: string, overrides: Partial = {}): Loca waitingFor: null, activeChildren: 0, canCancel: true, - canAnswer: true, + canAnswer: false, canSend: true, cleanup: 'pending', errorCode: null, @@ -43,53 +43,77 @@ function setup(children: LocalExecution[]) { const groups = shallowRef(projectExecutionGroups([root, ...children])) const content = shallowRef(createBuddyUserContent()) const scopeKey = shallowRef('conversation:conversation:branch') + const defaultActorId = shallowRef('lead') const scope = effectScope() cleanups.push(() => scope.stop()) - const state = scope.run(() => useActorInputTarget({ content, scopeKey, tree: { groups, target: record => ({ conversationId: 'conversation', branchId: 'branch', rootRunId: record.rootRunId, runId: record.runId }) } }))! + const state = scope.run(() => useActorInputTarget({ content, scopeKey, defaultActorId, tree: { groups, target: record => ({ conversationId: 'conversation', branchId: 'branch', rootRunId: record.rootRunId, runId: record.runId }) } }))! const mention = (actorId = 'partner') => { content.value = { ...createBuddyUserContent(), body: [{ type: 'paragraph', content: [{ type: 'actor_ref', actorId, name: 'Partner' }, { type: 'text', text: ' Additional request' }] }] } } - return { - state, - content, - scopeKey, - mention, - update: (children: LocalExecution[]) => { - groups.value = projectExecutionGroups([root, ...children]) - }, - } + return { state, content, scopeKey, defaultActorId, mention, update: (children: LocalExecution[]) => groups.value = projectExecutionGroups([root, ...children]) } } describe('actor composer routing', () => { - it('addresses the role after its execution ends and uses a later active execution when available', () => { - const f = setup([execution('first')]) + it('finds an earlier root child after an independent newer root finishes', () => { + const f = setup([ + execution('first'), + execution('new-root', { kind: 'root', rootRunId: 'new-root', parentRunId: null, buddyId: 'other', phase: 'closed', status: 'completed', startedAt: '2026-10-13T00:01:00.000Z' }), + ]) f.mention() - expect(f.state.target.value?.runId).toBe('first') - f.update([execution('first', { phase: 'closed', canSend: false })]) - expect(f.state.target.value).toBeNull() - expect(f.content.value.body[0]?.content[0]).toMatchObject({ actorId: 'partner' }) - f.update([execution('second')]) - expect(f.state.target.value?.runId).toBe('second') + expect(f.state.mailboxTarget.value?.runId).toBe('first') + expect(f.state.delivery.value).toEqual({ mode: 'current', actorId: 'partner', target: { rootRunId: 'root', runId: 'first' } }) }) - it('uses normal message routing for resources while preserving the addressed role', () => { + it('keeps the actor target for rich input without pretending it is a mailbox answer', () => { const f = setup([execution('first')]) f.mention() f.content.value = { ...f.content.value, panelResourceIds: ['file'] } - expect(f.state.target.value).toBeNull() - expect(f.content.value.body[0]?.content[0]).toMatchObject({ actorId: 'partner' }) + expect(f.state.target.value?.runId).toBe('first') + expect(f.state.mailboxTarget.value).toBeNull() + expect(f.state.delivery.value?.target?.runId).toBe('first') }) - it('keeps NPCs and root-only conversations out of live actor input and clears the target on navigation', () => { + it('does not choose NPCs, closed runs or an ambiguous active actor', () => { const f = setup([execution('private', { kind: 'npc_child' })]) f.mention() expect(f.state.target.value).toBeNull() - f.mention('lead') + f.update([execution('closed', { phase: 'closed' })]) + expect(f.state.delivery.value?.target).toBeNull() + f.update([execution('one'), execution('two')]) expect(f.state.target.value).toBeNull() - f.update([execution('public')]) + f.update([execution('one')]) + expect(f.state.target.value?.runId).toBe('one') + }) + + it('retains a closing target for authority checks instead of treating it as a new turn', () => { + const f = setup([execution('closing', { phase: 'closing', stopping: true, canSend: false })]) f.mention() - expect(f.state.target.value?.runId).toBe('public') + expect(f.state.target.value?.runId).toBe('closing') + expect(f.state.mailboxTarget.value).toBeNull() + }) + + it('derives the default destination and follows recipient or draft scope changes', () => { + const f = setup([execution('first')]) + expect(f.state.execution.value?.runId).toBe('root') + expect(f.state.mailboxTarget.value).toBeNull() + expect(f.state.delivery.value).toEqual({ mode: 'current', actorId: 'lead', target: { rootRunId: 'root', runId: 'root' } }) + f.mention() + expect(f.state.delivery.value).toEqual({ mode: 'current', actorId: 'partner', target: { rootRunId: 'root', runId: 'first' } }) + f.mention('lead') + expect(f.state.delivery.value).toEqual({ mode: 'current', actorId: 'lead', target: { rootRunId: 'root', runId: 'root' } }) + f.mention('idle') + expect(f.state.delivery.value).toEqual({ mode: 'current', actorId: 'idle', target: null }) f.scopeKey.value = 'message-edit:conversation:branch:message' expect(f.state.target.value).toBeNull() + expect(f.state.delivery.value).toBeUndefined() + }) + + it('routes multiple mentions to the leader without using an actor mailbox', () => { + const f = setup([execution('first')]) + f.mention() + f.content.value = { ...f.content.value, body: [{ type: 'paragraph', content: [...f.content.value.body[0]!.content, { type: 'actor_ref', actorId: 'other', name: 'Other' }] }] } + expect(f.state.actorId.value).toBe('lead') + expect(f.state.target.value?.runId).toBe('root') + expect(f.state.mailboxTarget.value).toBeNull() }) }) diff --git a/apps/buddy/src/modules/tasks/state/composer/useActorInputTarget.ts b/apps/buddy/src/modules/tasks/state/composer/useActorInputTarget.ts index d9e5edb21..757eaa369 100644 --- a/apps/buddy/src/modules/tasks/state/composer/useActorInputTarget.ts +++ b/apps/buddy/src/modules/tasks/state/composer/useActorInputTarget.ts @@ -1,27 +1,47 @@ import type { BuddyUserContentV1 } from '@buddy-shared/conversation/buddyUserContent' +import type { BuddyInputDelivery } from '@buddy-shared/conversation/chatApi' import type { Ref } from 'vue' import type { TaskExecutionTree } from '../../contracts' import { getBuddyUserContentActorReferences } from '@buddy-shared/conversation/buddyUserContent' import { isExecutionTextInput } from '@buddy-shared/executions/executionComposerInput' import { computed } from 'vue' -import { projectTaskPartners } from '../../model/executions/executionGroups' export function useActorInputTarget(options: { content: Readonly> scopeKey: Readonly> + defaultActorId?: Readonly> tree: Pick }) { - const target = computed(() => { - if (!options.scopeKey.value.startsWith('conversation:') || !isExecutionTextInput(options.content.value)) + const actors = computed(() => [...new Set(getBuddyUserContentActorReferences(options.content.value).map(actor => actor.actorId))]) + const actorId = computed(() => actors.value.length === 1 ? actors.value[0]! : options.defaultActorId?.value ?? null) + const execution = computed(() => { + if (!options.scopeKey.value.startsWith('conversation:') || !actorId.value) return null - const actors = getBuddyUserContentActorReferences(options.content.value) - if (actors.length !== 1) - return null - const record = projectTaskPartners(options.tree.groups.value).byBuddy.get(actors[0]!.actorId)?.find(record => - (record.kind === 'actor_child' && record.canSend) - || (record.kind === 'root' && record.canAnswer && record.questions.some(question => question.open && question.audience === 'user')), + const candidates = [...options.tree.groups.value.byRun.values()].filter(record => + record.buddyId === actorId.value && (record.kind === 'root' || record.kind === 'actor_child') && record.phase !== 'closed', ) - return record ? options.tree.target(record) : null + return candidates.length === 1 ? candidates[0]! : null + }) + const target = computed(() => execution.value ? options.tree.target(execution.value) : null) + const mailboxTarget = computed(() => { + const record = execution.value + if (!record || actors.value.length > 1 || record.stopping || record.phase === 'closing' || !isExecutionTextInput(options.content.value)) + return null + return (record.kind === 'actor_child' && record.canSend) + || (record.canAnswer && record.questions.some(question => question.open && question.audience === 'user')) + ? target.value + : null }) - return { target } + const delivery = computed(() => { + if (!options.scopeKey.value.startsWith('conversation:') || !actorId.value) + return undefined + return { + mode: 'current', + actorId: actorId.value, + target: target.value ? { rootRunId: target.value.rootRunId, runId: target.value.runId } : null, + } + }) + return { actorId, execution, target, mailboxTarget, delivery } } + +export type ActorInputTarget = ReturnType diff --git a/apps/buddy/src/modules/tasks/state/drafts/__tests__/useTaskDraftModelBinding.spec.ts b/apps/buddy/src/modules/tasks/state/drafts/__tests__/useTaskDraftModelBinding.spec.ts index 1a87c638f..2ad3e12bf 100644 --- a/apps/buddy/src/modules/tasks/state/drafts/__tests__/useTaskDraftModelBinding.spec.ts +++ b/apps/buddy/src/modules/tasks/state/drafts/__tests__/useTaskDraftModelBinding.spec.ts @@ -33,46 +33,18 @@ describe('draft-owned task model configuration', () => { expect(drafts.acknowledgeSend({ draftId: submitted.draftId, sourceRevision: 0, committedRevision: 1 }, 'global')).toBe(true) expect(drafts.modelSelection.value).toBeNull() }) - it('displays the saved Draft configuration without changing the next conversation default', async () => { + it('restores saved scope models without overwriting defaults changed by the user', async () => { const fixture = await createFixture() - const saved = { ...modelSelection('model-2'), reasoning: 'high' as const, serviceTier: 'priority' as const } + const saved = { ...modelSelection('model-1'), reasoning: 'high' as const, serviceTier: 'priority' as const } fixture.confirmOpen(saved) await fixture.catalog.loadModelCatalog(true) expect(fixture.selection.currentSelection()).toEqual(saved) expect(fixture.drafts.modelSelection.value).toEqual(saved) - expect(fixture.selection.selectedModelId.value).toBe('provider:model-2') + expect(fixture.selection.selectedModelId.value).toBe('provider:model-1') expect(fixture.drafts.isPersisted(fixture.drafts.snapshot('global'))).toBe(true) expect(fixture.catalog.defaultModelId.value).toBe('provider:model-1') - expect(fixture.setDefaultModel).not.toHaveBeenCalled() - }) - - it('displays the inherited default without persisting an explicit draft override', async () => { - const fixture = await createFixture() - const initial = fixture.drafts.snapshot('global') - fixture.binding.restoreScope() - await fixture.catalog.loadModelCatalog(true) - - expect(fixture.drafts.snapshot('global')).toEqual(initial) - expect(fixture.binding.initialModelSelection('global')).toBeNull() - fixture.confirmOpen(fixture.binding.initialModelSelection('global')) - expect(fixture.drafts.modelSelection.value).toBeNull() - expect(fixture.selection.currentSelection()).toEqual(modelSelection('model-1')) - }) - - it('keeps the actor default when reopening a task without a member override', async () => { - const fixture = await createFixture(modelSelection('model-2')) - fixture.confirmOpen(null) - fixture.binding.restoreScope(null) - expect(fixture.selection.currentSelection()).toEqual(modelSelection('model-2')) - expect(fixture.drafts.modelSelection.value).toBeNull() - await fixture.commands.setChatEffort('high') - expect(fixture.drafts.modelSelection.value).toEqual({ ...modelSelection('model-2'), reasoning: 'high' }) - }) - it('restores each scope model while user choices still update the next conversation default', async () => { - const fixture = await createFixture() - fixture.confirmOpen(modelSelection('model-1')) fixture.targetKey.value = 'space:space-1' fixture.binding.restoreScope() await fixture.commands.selectChatModel('provider:model-2') @@ -80,8 +52,8 @@ describe('draft-owned task model configuration', () => { fixture.targetKey.value = 'global' fixture.binding.restoreScope() - expect(fixture.selection.currentSelection()).toEqual(modelSelection('model-1')) - expect(fixture.drafts.modelSelection.value).toEqual(modelSelection('model-1')) + expect(fixture.selection.currentSelection()).toEqual(saved) + expect(fixture.drafts.modelSelection.value).toEqual(saved) expect(fixture.catalog.defaultModelId.value).toBe('provider:model-2') fixture.targetKey.value = 'space:space-1' fixture.binding.restoreScope() @@ -89,6 +61,16 @@ describe('draft-owned task model configuration', () => { expect(fixture.drafts.modelSelection.value).toEqual(modelSelection('model-2')) }) + it('keeps the actor default when reopening a task without a member override', async () => { + const fixture = await createFixture(modelSelection('model-2')) + fixture.confirmOpen(null) + fixture.binding.restoreScope(null) + expect(fixture.selection.currentSelection()).toEqual(modelSelection('model-2')) + expect(fixture.drafts.modelSelection.value).toBeNull() + await fixture.commands.setChatEffort('high') + expect(fixture.drafts.modelSelection.value).toEqual({ ...modelSelection('model-2'), reasoning: 'high' }) + }) + it('preserves user model, effort and service-tier choices made while a remote open is pending', async () => { const fixture = await createFixture() fixture.confirmOpen(modelSelection('model-1')) @@ -132,20 +114,6 @@ describe('draft-owned task model configuration', () => { expect(fixture.drafts.modelSelection.value).toEqual(modelSelection('model-1')) expect(fixture.catalog.defaultModelId.value).toBe('provider:model-2') }) - - it('retains saved unavailable model parameters instead of substituting the catalog default', async () => { - const fixture = await createFixture() - const saved = { ...modelSelection('removed-model'), reasoning: 'high' as const, serviceTier: 'priority' as const } - fixture.confirmOpen(saved) - await fixture.catalog.loadModelCatalog(true) - - expect(fixture.selection.selectedModel.value).toBeNull() - expect(fixture.selection.selectedModelId.value).toBe('provider:removed-model') - expect(fixture.selection.currentSelection()).toEqual(saved) - expect(fixture.drafts.modelSelection.value).toEqual(saved) - expect(fixture.drafts.isPersisted(fixture.drafts.snapshot('global'))).toBe(true) - expect(fixture.catalog.defaultModelId.value).toBe('provider:model-1') - }) }) describe('input actor model selection', () => { @@ -207,6 +175,172 @@ describe('input actor model selection', () => { }) }) +describe('model persistence target isolation', () => { + it.each(['conversation', 'actor'] as const)('keeps failed saves with their %s and allows retrying that target', async (changedTarget) => { + const fixture = await createPendingPersistenceFixture() + const failedSave = fixture.commands.selectChatModel('provider:model-2') + await nextTick() + const error = new Error('Model save failed') + fixture.complete('conversation-a', 'actor-a', error) + await failedSave + expect(await fixture.commands.flushModelSelection()).toBe(false) + expect(fixture.next.drafts.modelSelection.value).toEqual(modelSelection('model-2')) + + fixture.open(changedTarget === 'conversation' ? 'conversation-b' : 'conversation-a', changedTarget === 'actor' ? 'actor-b' : 'actor-a') + expect(await fixture.commands.flushModelSelection()).toBe(true) + expect(fixture.error.value).toBeNull() + expect(fixture.commands.isUpdating.value).toBe(false) + + fixture.open('conversation-a', 'actor-a') + expect(await fixture.commands.flushModelSelection()).toBe(false) + expect(fixture.error.value).toBe(error) + expect(fixture.next.drafts.modelSelection.value).toEqual(modelSelection('model-2')) + const retry = fixture.commands.selectChatModel('provider:model-2') + await nextTick() + expect(fixture.commands.isUpdating.value).toBe(true) + fixture.complete('conversation-a', 'actor-a') + await retry + expect(await fixture.commands.flushModelSelection()).toBe(true) + expect(fixture.saved.get('conversation-a:actor-a')).toEqual(modelSelection('model-2')) + expect(fixture.commands.isUpdating.value).toBe(false) + }) + + it('binds a pending flush to its original target while another task saves independently', async () => { + const fixture = await createPendingPersistenceFixture() + const firstSave = fixture.commands.selectChatModel('provider:model-2') + const firstFlush = fixture.commands.flushModelSelection() + let firstFlushFinished = false + void firstFlush.then(() => firstFlushFinished = true) + fixture.open('conversation-b', 'actor-a') + expect(fixture.commands.isUpdating.value).toBe(false) + expect(await fixture.commands.flushModelSelection()).toBe(true) + const secondSave = fixture.commands.setChatEffort('high') + await nextTick() + fixture.complete('conversation-b', 'actor-a') + await secondSave + const secondModel = fixture.next.drafts.modelSelection.value + expect(await fixture.commands.flushModelSelection()).toBe(true) + expect(fixture.saved.get('conversation-b:actor-a')).toEqual(secondModel) + expect(firstFlushFinished).toBe(false) + expect(fixture.visibleConversation.value?.id).toBe('conversation-b') + expect(fixture.refreshed).toEqual(['conversation-b:actor-a']) + + fixture.complete('conversation-a', 'actor-a', new Error('First task failed')) + await firstSave + expect(await firstFlush).toBe(false) + expect(await fixture.commands.flushModelSelection()).toBe(true) + expect(fixture.next.drafts.modelSelection.value).toEqual(secondModel) + expect(fixture.error.value).toBeNull() + expect(fixture.visibleConversation.value?.id).toBe('conversation-b') + expect(fixture.refreshed).toEqual(['conversation-b:actor-a']) + }) + + it('does not apply a late success or clear the pending save of another actor', async () => { + const fixture = await createPendingPersistenceFixture() + const firstSave = fixture.commands.selectChatModel('provider:model-2') + await fixture.commands.selectChatModel('provider:model-1') + expect(fixture.next.drafts.modelSelection.value).toEqual(modelSelection('model-2')) + fixture.open('conversation-a', 'actor-b') + const secondSave = fixture.commands.setChatServiceTier('priority') + await nextTick() + fixture.complete('conversation-a', 'actor-a') + await firstSave + expect(fixture.commands.isUpdating.value).toBe(true) + expect(fixture.visibleConversation.value).toBeNull() + expect(fixture.refreshed).toEqual([]) + expect(fixture.next.drafts.modelSelection.value?.serviceTier).toBe('priority') + + fixture.complete('conversation-a', 'actor-b') + await secondSave + expect(await fixture.commands.flushModelSelection()).toBe(true) + expect(fixture.commands.isUpdating.value).toBe(false) + expect(fixture.saved.get('conversation-a:actor-a')).toEqual(modelSelection('model-2')) + expect(fixture.saved.get('conversation-a:actor-b')?.serviceTier).toBe('priority') + expect(fixture.refreshed).toEqual(['conversation-a:actor-b']) + }) + + it('keeps the save pending until the task model settles when remembering the default fails first', async () => { + const fixture = await createPendingPersistenceFixture() + const selectModel = fixture.next.selection.selectModel + vi.spyOn(fixture.next.selection, 'selectModel').mockImplementation(async (value) => { + await selectModel(value) + throw new Error('Default save failed') + }) + const pendingSave = fixture.commands.selectChatModel('provider:model-2') + const pendingFlush = fixture.commands.flushModelSelection() + let flushed = false + void pendingFlush.then(() => flushed = true) + await nextTick() + await nextTick() + expect(fixture.commands.isUpdating.value).toBe(true) + expect(flushed).toBe(false) + + fixture.complete('conversation-a', 'actor-a') + await pendingSave + expect(await pendingFlush).toBe(false) + expect(fixture.commands.isUpdating.value).toBe(false) + expect(fixture.error.value).toEqual(new Error('Default save failed')) + expect(fixture.saved.get('conversation-a:actor-a')).toEqual(modelSelection('model-2')) + }) +}) + +async function createPendingPersistenceFixture() { + const next = await createFixture() + const activeConversationId = shallowRef('conversation-a') + const actorId = shallowRef('actor-a') + const error = shallowRef(null) + const visibleConversation = shallowRef(null) + const pending = new Map>>() + const saved = new Map() + const refreshed: string[] = [] + const commands = useTaskModelPersistence({ + activeConversationId, + target: () => ({ actorId: actorId.value }), + drafts: next.drafts, + selection: next.selection, + onError: value => error.value = value, + applyConversation: value => visibleConversation.value = value, + onPersisted: async () => { refreshed.push(`${activeConversationId.value}:${actorId.value}`) }, + api: { + async setModelSelection(conversationId, selection, target) { + const key = `${conversationId}:${target?.actorId}` + const gate = Promise.withResolvers() + pending.set(key, gate) + await gate.promise + saved.set(key, selection) + return { id: conversationId, buddyId: target?.actorId ?? 'actor-a', activeBranchId: 'branch', approvalPolicy: 'policy', executionProfile: 'workspace_write', modelSelection: selection, title: null, spaceId: null, createdAt: '2026-10-11T10:00:00.000Z', updatedAt: '2026-10-11T10:00:00.000Z', deletedAt: null } + }, + }, + }) + function open(conversationId: string, nextActorId: string) { + activeConversationId.value = conversationId + actorId.value = nextActorId + next.targetKey.value = `${conversationId}:${nextActorId}` + next.binding.restoreScope() + error.value = null + } + open('conversation-a', 'actor-a') + cleanups.push(next.binding.dispose, next.selection.dispose, next.catalog.dispose) + return { + next, + commands, + error, + visibleConversation, + refreshed, + saved, + open, + complete(conversationId: string, targetActorId: string, failure?: Error) { + const gate = pending.get(`${conversationId}:${targetActorId}`) + if (!gate) + throw new Error('Missing pending model save') + if (failure) + gate.reject(failure) + else + gate.resolve() + }, + } +} + async function createControlsFixture(nextActorId = 'actor-b') { const next = await createFixture(modelSelection('model-2')) next.confirmOpen(null) @@ -265,7 +399,6 @@ async function createControlsFixture(nextActorId = 'actor-b') { } async function createFixture(actorModel: BuddyComposerDraftModelSelection | null = null) { - const setDefaultModel = vi.fn(async value => value) const catalog = useModelProvidersStore({ api: { getDefaultModel: async () => ({ modelId: 'model-1', providerId: 'provider', reasoning: 'medium' }), @@ -291,7 +424,7 @@ async function createFixture(actorModel: BuddyComposerDraftModelSelection | null listModels: async () => ['model-1', 'model-2'].map(model), onChanged: () => () => {}, onAuthChallenge: () => () => {}, - setDefaultModel, + setDefaultModel: async (value: Parameters[0]) => value, } as unknown as LocalChatApi['providers'], language: shallowRef('zh-CN'), }) @@ -325,7 +458,7 @@ async function createFixture(actorModel: BuddyComposerDraftModelSelection | null }) binding.restoreOpenedDraft(submitted.targetKey) } - return { binding, catalog, commands, confirmOpen, drafts, selection, setDefaultModel, targetKey } + return { binding, catalog, commands, confirmOpen, drafts, selection, targetKey } } function modelSelection(modelId: string): BuddyComposerDraftModelSelection { diff --git a/apps/buddy/src/modules/tasks/state/drafts/useTaskModelPersistence.ts b/apps/buddy/src/modules/tasks/state/drafts/useTaskModelPersistence.ts index 7fd0d1e37..e6d2f9b20 100644 --- a/apps/buddy/src/modules/tasks/state/drafts/useTaskModelPersistence.ts +++ b/apps/buddy/src/modules/tasks/state/drafts/useTaskModelPersistence.ts @@ -3,7 +3,7 @@ import type { LocalConversation } from '@buddy-shared/conversation/conversationA import type { Ref } from 'vue' import type { TaskModelSelection } from './typing' import type { useChatDrafts } from './useChatDrafts' -import { readonly, shallowRef } from 'vue' +import { computed, shallowReactive } from 'vue' interface TaskModelPersistenceOptions { target?: () => { actorId?: string } @@ -18,84 +18,91 @@ interface TaskModelPersistenceOptions { selection: TaskModelSelection } +type ModelPersistenceResult = { saved: true } | { saved: false, error: unknown } + +interface ModelPersistenceState { + pending: boolean + promise: Promise +} + export function useTaskModelPersistence(options: TaskModelPersistenceOptions) { - const isUpdating = shallowRef(false) - let conversationModelPersistenceRevision = 0 - let conversationModelPersistenceQueue = Promise.resolve(true) + const persistence = shallowReactive(new Map()) + const currentTarget = computed(() => { + const conversationId = options.activeConversationId.value + const target = options.target?.() + return { + conversationId, + target: target ? { ...target } : undefined, + key: JSON.stringify([conversationId ? 'conversation' : 'draft', conversationId ?? options.drafts.draftId.value, target?.actorId ?? null]), + } + }) + const isUpdating = computed(() => persistence.get(currentTarget.value.key)?.pending ?? false) - function persistConversationModelSelection( - conversationId: string | null, - modelSelection: NonNullable | null, - target?: { actorId?: string }, - ): Promise { - if (!conversationId || !modelSelection || options.persistTarget?.() === false) - return Promise.resolve(true) - const revision = ++conversationModelPersistenceRevision + function persistModelSelection( + captured: typeof currentTarget.value, + persistDefault: Promise = Promise.resolve(true), + ): Promise { + const { conversationId, target, key } = captured + const modelSelection = options.selection.currentSelection() + const persistConversation = options.persistTarget?.() !== false + options.drafts.setModelSelection(modelSelection) const operation = async () => { try { - const conversation = await options.api.setModelSelection( - conversationId, - modelSelection, - ...target ? [target] as const : [], - ) - if (revision === conversationModelPersistenceRevision) { + const [conversationResult, defaultResult] = await Promise.allSettled([ + conversationId && modelSelection && persistConversation + ? options.api.setModelSelection(conversationId, modelSelection, ...target ? [target] as const : []) + : Promise.resolve(null), + persistDefault, + ]) + if (conversationResult.status === 'rejected') + throw conversationResult.reason + if (defaultResult.status === 'rejected') + throw defaultResult.reason + const conversation = conversationResult.value + if (conversation && key === currentTarget.value.key) { options.applyConversation(conversation) await options.onPersisted?.() } - return true + return { saved: true } as const } catch (error) { - if (revision === conversationModelPersistenceRevision) + if (key === currentTarget.value.key) { options.onError(error) - await options.onPersisted?.() - return false + await options.onPersisted?.() + } + return { saved: false, error } as const } } - conversationModelPersistenceQueue = conversationModelPersistenceQueue.then( - operation, - operation, - ) - return conversationModelPersistenceQueue + const state: ModelPersistenceState = { + pending: true, + promise: Promise.resolve().then(operation).finally(() => { + persistence.set(key, { ...state, pending: false }) + }), + } + persistence.set(key, state) + return state.promise } async function selectChatModel(value: string) { if (isUpdating.value || options.canUpdate?.() === false) return const selection = options.selection - const conversationId = options.activeConversationId.value - const target = options.target?.() + const target = currentTarget.value const persistDefault = selection.selectModel(value) if (selection.selectedModelId.value !== value) { await persistDefault return } - const modelSelection = selection.currentSelection() - options.drafts.setModelSelection(modelSelection) - isUpdating.value = true - try { - await Promise.all([persistDefault, persistConversationModelSelection(conversationId, modelSelection, target)]) - } - finally { - isUpdating.value = false - } + await persistModelSelection(target, persistDefault) } async function setChatEffort(value: Parameters[0]) { if (isUpdating.value || options.canUpdate?.() === false) return const selection = options.selection - const conversationId = options.activeConversationId.value - const target = options.target?.() + const target = currentTarget.value const persistDefault = selection.setSelectedEffort(value) - const modelSelection = selection.currentSelection() - options.drafts.setModelSelection(modelSelection) - isUpdating.value = true - try { - await Promise.all([persistDefault, persistConversationModelSelection(conversationId, modelSelection, target)]) - } - finally { - isUpdating.value = false - } + await persistModelSelection(target, persistDefault) } async function setChatServiceTier( @@ -104,18 +111,21 @@ export function useTaskModelPersistence(options: TaskModelPersistenceOptions) { if (isUpdating.value || options.canUpdate?.() === false) return const selection = options.selection - const conversationId = options.activeConversationId.value - const target = options.target?.() + const target = currentTarget.value selection.setSelectedServiceTier(value) - options.drafts.setModelSelection(selection.currentSelection()) - isUpdating.value = true - try { - await persistConversationModelSelection(conversationId, selection.currentSelection(), target) - } - finally { - isUpdating.value = false - } + await persistModelSelection(target) + } + + async function flushModelSelection() { + const { key } = currentTarget.value + const state = persistence.get(key) + if (!state) + return true + const result = await state.promise + if (!result.saved && key === currentTarget.value.key) + options.onError(result.error) + return result.saved } - return { isUpdating: readonly(isUpdating), selectChatModel, setChatEffort, setChatServiceTier, flushModelSelection: () => conversationModelPersistenceQueue } + return { isUpdating, selectChatModel, setChatEffort, setChatServiceTier, flushModelSelection } } diff --git a/apps/buddy/src/modules/tasks/state/runs/__tests__/useChatExecutionTree.spec.ts b/apps/buddy/src/modules/tasks/state/runs/__tests__/useChatExecutionTree.spec.ts new file mode 100644 index 000000000..7f1d0d2fa --- /dev/null +++ b/apps/buddy/src/modules/tasks/state/runs/__tests__/useChatExecutionTree.spec.ts @@ -0,0 +1,106 @@ +import type { LocalChatApi } from '@buddy-electron/shared/localChatApi' +import type { LocalExecutionInputReceipt, LocalExecutionInputsPage } from '@buddy-shared/executions/executionInputReceipt' +import type { ExecutionsChanged, LocalExecution } from '@buddy-shared/executions/executionPresentationApi' +import { deferred } from '@buddy-tests/deferred' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { effectScope, nextTick, shallowRef } from 'vue' +import { useChatExecutionTree } from '../useChatExecutionTree' + +const now = '2026-10-12T00:00:00.000Z' +const cleanups: (() => void)[] = [] +afterEach(() => cleanups.splice(0).forEach(cleanup => cleanup())) +function execution(runId: string, kind: LocalExecution['kind'] = 'root'): LocalExecution { + return { runId, rootRunId: runId, parentRunId: null, buddyId: 'default', buddyName: 'Lexora', ordinal: null, kind, phase: 'working', status: 'running', startedAt: now, completedAt: null, updatedAt: now, progress: null, toolCount: 0, usage: null, stopping: false, task: 'Task', waitingFor: null, activeChildren: 0, canCancel: true, canAnswer: false, canSend: false, cleanup: 'pending', errorCode: null, affectedResources: [], questions: [] } +} +function receipt(runId: string, branchId = 'branch'): LocalExecutionInputReceipt { + return { conversationId: 'task', branchId, runId, rootRunId: runId, buddyId: 'default', buddyName: 'Lexora', task: 'Task', messageId: `input-${runId}`, kind: 'supplement', text: `Private ${runId}`, createdAt: now, state: 'pending' } +} +function setup(overrides: Partial = {}) { + let changed: ((notice: ExecutionsChanged) => void) | undefined + const api: LocalChatApi['executions'] = { + list: async () => [execution('root')], + inputs: async input => ({ inputs: [receipt(input.runIds[0]!, input.branchId)], nextOffset: null }), + detail: async () => { throw new Error('Not used') }, + events: async () => ({ events: [], nextSequence: 0, hasMore: false }), + answer: async () => ({ accepted: true }), + send: async () => ({ accepted: true }), + sendDraft: async () => null, + cancel: async () => ({ cancelled: true }), + cancelChildren: async () => ({ cancelledRunIds: [] }), + onChanged: (listener) => { + changed = listener + return () => {} + }, + ...overrides, + } + const conversationId = shallowRef('task') + const branchId = shallowRef('branch') + const ready = shallowRef(true) + const onError = vi.fn() + const scope = effectScope() + const state = scope.run(() => useChatExecutionTree({ api, conversationId, branchId, ready, onError }))! + cleanups.push(() => scope.stop()) + return { api, state, conversationId, branchId, ready, onError, changed: () => changed?.({ sourceId: 'source', revision: 1, conversationId: conversationId.value!, rootRunId: 'root' }) } +} + +describe('execution input receipt scope', () => { + it.each(['branch', 'conversation'] as const)('ignores a late receipt page after changing %s', async (target) => { + const late = deferred() + const pending = vi.fn(async (input: Parameters[0]) => input.conversationId === 'task' && input.branchId === 'branch' + ? late.promise + : { inputs: [{ ...receipt('root', input.branchId), conversationId: input.conversationId, text: 'New scope' }], nextOffset: null }) + const f = setup({ inputs: pending }) + await vi.waitFor(() => expect(pending).toHaveBeenCalledOnce()) + if (target === 'branch') + f.branchId.value = 'other-branch' + else + f.conversationId.value = 'other-task' + await nextTick() + late.resolve({ inputs: [receipt('root')], nextOffset: null }) + await vi.waitFor(() => expect(f.state.inputs.value[0]?.text).toBe('New scope')) + expect(f.state.inputs.value[0]).toMatchObject({ conversationId: f.conversationId.value, branchId: f.branchId.value }) + expect(f.onError).not.toHaveBeenCalled() + }) + + it('retains all previous receipts after a partial page failure and a temporary disconnect', async () => { + const f = setup() + await vi.waitFor(() => expect(f.state.inputs.value).toHaveLength(1)) + const previous = f.state.inputs.value + f.api.inputs = async input => input.offset === 0 + ? { inputs: [{ ...receipt('root'), text: 'Partial refresh' }], nextOffset: 100 } + : Promise.reject(new Error('Disconnected')) + await f.state.refresh() + expect(f.state.inputs.value).toEqual(previous) + expect(f.onError).toHaveBeenCalledWith(expect.objectContaining({ message: 'Disconnected' })) + f.ready.value = false + await nextTick() + expect(f.state.inputs.value).toEqual(previous) + f.api.inputs = async () => ({ inputs: [{ ...receipt('root'), state: 'consumed' }], nextOffset: null }) + f.ready.value = true + await vi.waitFor(() => expect(f.state.inputs.value[0]?.state).toBe('consumed')) + }) + + it('reads every bounded run batch and continues past filtered empty pages', async () => { + const records = [...Array.from({ length: 51 }, (_, index) => execution(`root-${index}`)), execution('npc', 'npc_child')] + const f = setup({ + list: async () => records, + inputs: async input => input.offset === 0 + ? { inputs: [], nextOffset: 100 } + : { inputs: input.runIds.map(runId => receipt(runId)), nextOffset: null }, + }) + await vi.waitFor(() => expect(f.state.inputs.value).toHaveLength(51)) + expect(f.state.inputs.value.map(input => input.runId)).toEqual(records.slice(0, 51).map(record => record.runId)) + }) + + it('refreshes delivery state from execution notices and rejects wrong-scope pages', async () => { + const f = setup() + await vi.waitFor(() => expect(f.state.inputs.value[0]?.state).toBe('pending')) + f.api.inputs = async () => ({ inputs: [{ ...receipt('root'), state: 'accepted' }], nextOffset: null }) + f.changed() + await vi.waitFor(() => expect(f.state.inputs.value[0]?.state).toBe('accepted')) + f.api.inputs = async () => ({ inputs: [receipt('root', 'unrelated-branch')], nextOffset: null }) + await f.state.refresh() + expect(f.state.inputs.value[0]?.state).toBe('accepted') + expect(f.onError).toHaveBeenCalledWith(expect.objectContaining({ message: 'Invalid execution input page' })) + }) +}) diff --git a/apps/buddy/src/modules/tasks/state/runs/__tests__/useChatTurnExecution.spec.ts b/apps/buddy/src/modules/tasks/state/runs/__tests__/useChatTurnExecution.spec.ts index 23ab642ef..ad6c64dc5 100644 --- a/apps/buddy/src/modules/tasks/state/runs/__tests__/useChatTurnExecution.spec.ts +++ b/apps/buddy/src/modules/tasks/state/runs/__tests__/useChatTurnExecution.spec.ts @@ -1,6 +1,7 @@ import type { BuddyUserContentV1 } from '@buddy-shared/conversation/buddyUserContent' +import type { BuddyInputDelivery } from '@buddy-shared/conversation/chatApi' import type { LocalConversation } from '@buddy-shared/conversation/conversationApi' -import type { ExecutionTarget } from '@buddy-shared/executions/executionPresentationApi' +import type { ExecutionTarget, LocalExecution } from '@buddy-shared/executions/executionPresentationApi' import type { LocalRuntimeModelOption } from '@buddy-shared/providers/providerApi' import type { LocalRun } from '@buddy-shared/runs/runApi' import { browserQuote } from '@buddy-shared/browser/__tests__/browserSelectionFixture' @@ -49,20 +50,6 @@ describe('useChatTurnExecution cancellation ownership', () => { expect(f.api.chat.steerQueued).not.toHaveBeenCalled() }) - it('applies cancellation to the current projection and preserves its Draft', async () => { - const fixture = createFixture() - const cancelling = fixture.execution.cancelActiveRun() - expect(fixture.execution.stoppingRunId.value).toBe(fixture.run.id) - expect(fixture.projectedRuns.value[0]?.status).toBe('running') - fixture.pending.resolve({ ...fixture.run, status: 'cancelled' }) - await cancelling - expect(fixture.execution.stoppingRunId.value).toBeNull() - - expect(fixture.projectedRuns.value).toEqual([{ ...fixture.run, status: 'cancelled' }]) - expect(fixture.drafts.draft.value).toBe('pending input') - expect(fixture.error.value).toBeNull() - }) - it('reports a cancellation failure only in the originating view', async () => { const fixture = createFixture() const cancelling = fixture.execution.cancelActiveRun() @@ -127,8 +114,10 @@ describe('turn submission conflicts', () => { f.drafts.confirmOpen(snapshot, { content, draftId: snapshot.draftId, executionConfig: { approvalPolicy: snapshot.approvalPolicy, executionProfile: snapshot.executionProfile }, modelSelection: null, revision: 1, scope: { kind: 'conversation_branch', conversationId: 'conversation-a', branchId: 'branch-a' }, updatedAt: '2026-10-02T00:00:00.000Z' }) f.api.chat.startTurn.mockResolvedValue({ conversationId: 'conversation-a', branchId: 'branch-a', run: { ...f.run, status: 'queued' }, draftReceipt: { draftId: snapshot.draftId, sourceRevision: 1, committedRevision: 2 } }) expect(await f.execution.send({ content: '', userContent: content })).toBe(true) - expect(f.api.chat.startTurn).toHaveBeenCalledWith(expect.objectContaining({ draftId: snapshot.draftId, expectedRevision: 1 })) + expect(f.drafts.snapshot(key).content).toEqual(createBuddyUserContent()) + expect(f.drafts.snapshot(key).revision).toBe(2) }) + it('keeps the confirmed review and resources when its revision changes before submission', async () => { const f = createFixture() f.projectedRuns.value = [] @@ -157,11 +146,162 @@ describe('turn submission conflicts', () => { }) }) +describe('model selection before submission', () => { + function prepare(route: 'start' | 'queue' | 'mailbox' = 'queue') { + const f = createFixture() + const key = 'conversation:conversation-a:branch-a' + f.selectedModel.value = { modelId: 'model-b', providerId: 'provider-a' } as LocalRuntimeModelOption + f.drafts.setModelSelection({ modelId: 'model-b', providerId: 'provider-a', reasoning: null, serviceTier: null }) + const snapshot = f.drafts.snapshot(key) + const remote = { + content: snapshot.content, + draftId: snapshot.draftId, + executionConfig: { approvalPolicy: snapshot.approvalPolicy, executionProfile: snapshot.executionProfile }, + modelSelection: snapshot.modelSelection, + revision: 1, + scope: { kind: 'conversation_branch' as const, conversationId: 'conversation-a', branchId: 'branch-a' }, + updatedAt: '2026-10-27T00:00:00.000Z', + } + f.drafts.confirmOpen(snapshot, remote) + const target = { conversationId: 'conversation-a', branchId: 'branch-a', rootRunId: 'run-a', runId: route === 'mailbox' ? 'partner-run' : 'run-a' } + f.actorInput.target.value = route === 'start' ? null : target + f.actorInput.mailboxTarget.value = route === 'mailbox' ? target : null + f.actorInput.delivery.value = { mode: 'current', actorId: route === 'mailbox' ? 'partner' : 'default', target: route === 'start' ? null : { rootRunId: target.rootRunId, runId: target.runId } } + if (route === 'start') + f.projectedRuns.value = [] + const result = { id: 'queued', conversationId: 'conversation-a', branchId: 'branch-a', run: { ...f.run, status: 'queued' }, draftReceipt: { draftId: snapshot.draftId, sourceRevision: 1, committedRevision: 2 } } + f.api.chat.enqueue.mockResolvedValue(result) + f.api.chat.startTurn.mockResolvedValue(result) + f.api.executions.sendDraft.mockResolvedValue({ ...remote, revision: 2, content: createBuddyUserContent() }) + const barrier = deferred() + f.flushModelSelection.mockImplementation(() => barrier.promise) + return { ...f, key, remote, target, barrier } + } + + it.each(['start', 'queue', 'mailbox'] as const)('waits for the selected model before accepting %s input', async (route) => { + const f = prepare(route) + const delivery = f.actorInput.delivery.value + const sending = f.execution.send('pending input') + await Promise.resolve() + expect(f.execution.isSending.value).toBe(true) + expect(f.drafts.draft.value).toBe('pending input') + expect([f.api.chat.startTurn, f.api.chat.enqueue, f.api.executions.sendDraft].flatMap(api => api.mock.calls)).toEqual([]) + f.actorInput.delivery.value = { mode: 'current', actorId: 'other', target: { rootRunId: 'other-root', runId: 'other-run' } } + f.actorInput.mailboxTarget.value = null + f.barrier.resolve(true) + + expect(await sending).toBe(true) + const request = (route === 'start' ? f.api.chat.startTurn : route === 'queue' ? f.api.chat.enqueue : f.api.executions.sendDraft).mock.calls[0]![0] + expect(request).toMatchObject({ draftId: f.remote.draftId, expectedRevision: 1, delivery }) + if (route === 'mailbox') + expect(request).toMatchObject(f.target) + expect(f.drafts.draft.value).toBe('') + expect(f.execution.isSending.value).toBe(false) + }) + + it.each(['reported', 'rejected'] as const)('retains the draft and failure when model persistence is %s', async (failure) => { + const f = prepare() + f.error.value = 'Model selection could not be saved' + const original = f.drafts.snapshot(f.key) + const sending = f.execution.send('pending input') + if (failure === 'reported') + f.barrier.resolve(false) + else + f.barrier.reject(new Error(formatLocalChatPublicError({ code: 'PROVIDER_UNAVAILABLE', retryable: false }))) + + expect(await sending).toBe(false) + expect(f.drafts.snapshot(f.key)).toEqual(original) + expect([f.api.chat.startTurn, f.api.chat.enqueue, f.api.executions.sendDraft].flatMap(api => api.mock.calls)).toEqual([]) + expect(f.execution.isSending.value).toBe(false) + expect(f.error.value).toBeTruthy() + if (failure === 'reported') + expect(f.error.value).toBe('Model selection could not be saved') + else + expect(f.error.value).not.toBe('Model selection could not be saved') + }) + + it('keeps the original task target and does not clear a new view while waiting for the model', async () => { + const f = prepare() + const sending = f.execution.send('pending input') + f.navigate('task') + f.barrier.resolve(true) + + expect(await sending).toBe(true) + expect(f.api.chat.enqueue.mock.calls[0]![0]).toMatchObject({ draftId: f.remote.draftId, expectedRevision: 1, delivery: { actorId: 'default', target: { rootRunId: 'run-a', runId: 'run-a' } } }) + expect(f.drafts.snapshot(f.key).content).toEqual(createBuddyUserContent()) + expect(f.drafts.draft.value).toBe('current view input') + expect(f.error.value).toBe('current view status') + }) + + it('does not submit a later saved draft revision after the model wait', async () => { + const f = prepare() + const sending = f.execution.send('pending input') + f.drafts.updateComposerContent('new unsent input', null) + const newer = f.drafts.snapshot(f.key) + f.drafts.confirmOpen(newer, { ...f.remote, content: newer.content, revision: 2 }) + f.barrier.resolve(true) + + expect(await sending).toBe(false) + expect(f.api.chat.enqueue.mock.calls).toEqual([]) + expect(f.drafts.draft.value).toBe('new unsent input') + expect(f.drafts.snapshot(f.key).revision).toBe(2) + expect(f.error.value).toBe('Draft changed') + }) + + it('submits the latest saved text when it changes after the model barrier', async () => { + const f = prepare() + const save = deferred() + f.persistWorkspaceState.mockImplementation(() => save.promise) + const sending = f.execution.send('pending input') + f.barrier.resolve(true) + await vi.waitUntil(() => f.persistWorkspaceState.mock.calls.length > 0) + f.drafts.updateComposerContent('latest saved input', null) + const newer = f.drafts.snapshot(f.key) + f.drafts.confirmOpen(newer, { ...f.remote, content: newer.content, revision: 2 }) + f.api.chat.enqueue.mockResolvedValue({ draftReceipt: { draftId: newer.draftId, sourceRevision: 2, committedRevision: 3 } }) + save.resolve(true) + + expect(await sending).toBe(true) + expect(f.api.chat.enqueue.mock.calls[0]![0]).toMatchObject({ draftId: newer.draftId, expectedRevision: 2, delivery: { actorId: 'default', target: { rootRunId: 'run-a', runId: 'run-a' } } }) + expect(f.drafts.draft.value).toBe('') + expect(f.drafts.snapshot(f.key).revision).toBe(3) + }) + + it.each(['model', 'actor', 'permissions', 'participants'] as const)('preserves the draft when %s changes during workspace save after the model barrier', async (change) => { + const f = prepare() + const save = deferred() + f.persistWorkspaceState.mockImplementation(() => save.promise) + const sending = f.execution.send('pending input') + f.barrier.resolve(true) + await vi.waitUntil(() => f.persistWorkspaceState.mock.calls.length > 0) + if (change === 'model') + f.drafts.setModelSelection({ ...f.remote.modelSelection!, modelId: 'model-c' }) + else if (change === 'actor') + f.drafts.setUserContent({ ...createBuddyUserContent(), body: [{ type: 'paragraph', content: [{ type: 'actor_ref', actorId: 'other', name: 'Other' }, { type: 'text', text: 'new target' }] }] }) + else if (change === 'permissions') + f.drafts.setPermissionSettings({ approvalPolicy: 'policy', executionProfile: 'full_access' }) + else + f.drafts.setParticipants({ leaderId: 'other', actorIds: ['other'] }) + const newer = f.drafts.snapshot(f.key) + f.drafts.confirmOpen(newer, { ...f.remote, content: newer.content, modelSelection: newer.modelSelection, participants: newer.participants, executionConfig: { approvalPolicy: newer.approvalPolicy, executionProfile: newer.executionProfile }, revision: 2 }) + save.resolve(true) + + expect(await sending).toBe(false) + expect(f.api.chat.enqueue.mock.calls).toEqual([]) + expect(f.drafts.snapshot(f.key).content).toEqual(newer.content) + expect(f.drafts.snapshot(f.key).modelSelection).toEqual(newer.modelSelection) + expect(f.drafts.snapshot(f.key).revision).toBe(2) + expect(f.error.value).toBe('Draft changed') + }) +}) + describe('addressed role input', () => { function prepare() { const f = createFixture() f.selectedModel.value = { modelId: 'model-a', providerId: 'provider-a' } as LocalRuntimeModelOption f.actorInput.target.value = { conversationId: 'conversation-a', branchId: 'branch-a', rootRunId: 'run-a', runId: 'partner-run' } + f.actorInput.mailboxTarget.value = f.actorInput.target.value + f.actorInput.delivery.value = { mode: 'current', actorId: 'partner', target: { rootRunId: 'run-a', runId: 'partner-run' } } const content: BuddyUserContentV1 = { ...createBuddyUserContent(), body: [{ type: 'paragraph', content: [{ type: 'actor_ref', actorId: 'partner', name: 'Partner' }, { type: 'text', text: ' Continue this work' }] }] } f.drafts.setUserContent(content) const key = 'conversation:conversation-a:branch-a' @@ -178,31 +318,72 @@ describe('addressed role input', () => { return { ...f, key, snapshot, content } } - it.each([true, false])('automatically submits the same addressed draft when the role ends while sending, root active: %s', async (active) => { + it.each([true, false])('retains an unavailable addressed draft without falling back to a new turn, root active: %s', async (active) => { const f = prepare() if (!active) f.projectedRuns.value = [] f.api.executions.sendDraft.mockResolvedValue(null) - const result = { id: 'queued', conversationId: 'conversation-a', branchId: 'branch-a', run: { ...f.run, id: 'next-run', buddyId: 'partner' }, draftReceipt: { draftId: f.snapshot.draftId, sourceRevision: 1, committedRevision: 2 } } - const destination = active ? f.api.chat.enqueue : f.api.chat.startTurn - destination.mockImplementation(async () => { - expect(f.drafts.snapshot(f.key).content).toEqual(f.content) - return result - }) + expect(await f.execution.send({ content: '@Partner Continue this work', userContent: f.content })).toBe(false) + expect(f.api.chat.enqueue.mock.calls).toEqual([]) + expect(f.api.chat.startTurn.mock.calls).toEqual([]) + expect(f.drafts.snapshot(f.key).content).toEqual(f.content) + expect(f.error.value).toContain('draft is kept') + }) + + it('queues next-round input bound to the older child even when no root is active', async () => { + const f = prepare() + f.projectedRuns.value = [] + f.actorInput.delivery.value = { ...f.actorInput.delivery.value!, mode: 'next' } + f.api.chat.enqueue.mockResolvedValue({ id: 'next', conversationId: 'conversation-a', branchId: 'branch-a', draftReceipt: { draftId: f.snapshot.draftId, sourceRevision: 1, committedRevision: 2 } }) expect(await f.execution.send({ content: '@Partner Continue this work', userContent: f.content })).toBe(true) - expect(destination.mock.calls).toEqual([[expect.objectContaining({ draftId: f.snapshot.draftId, expectedRevision: 1 })]]) + expect(f.api.chat.enqueue.mock.calls[0]?.[0]).toMatchObject({ delivery: { mode: 'next', actorId: 'partner', target: { rootRunId: 'run-a', runId: 'partner-run' } } }) + expect(f.api.executions.sendDraft.mock.calls).toEqual([]) + expect(f.api.chat.startTurn.mock.calls).toEqual([]) expect(f.drafts.draft.value).toBe('') - expect(f.error.value).toBeNull() }) - it('preserves the addressed draft without starting another turn when delivery acknowledgement is uncertain', async () => { + it('binds a current root supplement to its original target and gives changed intent a new request id', async () => { + const f = prepare() + f.actorInput.mailboxTarget.value = null + f.api.chat.enqueue.mockRejectedValue(new Error('Offline')) + expect(await f.execution.send({ content: '@Partner Continue this work', userContent: f.content })).toBe(false) + const first = f.api.chat.enqueue.mock.calls[0]![0] + expect(first.delivery).toEqual(f.actorInput.delivery.value) + f.actorInput.delivery.value = { ...f.actorInput.delivery.value!, mode: 'next' } + expect(await f.execution.send({ content: '@Partner Continue this work', userContent: f.content })).toBe(false) + const second = f.api.chat.enqueue.mock.calls[1]![0] + expect(second.requestId).not.toBe(first.requestId) + expect(second.delivery.mode).toBe('next') + expect(f.drafts.snapshot(f.key).content).toEqual(f.content) + }) + + it('retries the accepted identity after a lost acknowledgement even if the actor has already finished', async () => { const f = prepare() - f.api.executions.sendDraft.mockRejectedValue(new Error('Connection lost')) + f.api.executions.sendDraft.mockRejectedValueOnce(new Error('Connection lost')) expect(await f.execution.send({ content: '@Partner Continue this work', userContent: f.content })).toBe(false) expect(f.drafts.snapshot(f.key).content).toEqual(f.content) expect(f.api.chat.enqueue.mock.calls).toEqual([]) expect(f.api.chat.startTurn.mock.calls).toEqual([]) expect(f.error.value).toBeTruthy() + const first = f.api.executions.sendDraft.mock.calls[0]![0] + f.actorInput.target.value = null + f.actorInput.mailboxTarget.value = null + f.actorInput.delivery.value = { mode: 'current', actorId: 'partner', target: null } + f.projectedRuns.value = [] + f.api.executions.sendDraft.mockResolvedValue({ + draftId: f.snapshot.draftId, + revision: 2, + scope: { kind: 'conversation_branch', conversationId: 'conversation-a', branchId: 'branch-a' }, + executionConfig: { approvalPolicy: f.snapshot.approvalPolicy, executionProfile: f.snapshot.executionProfile }, + modelSelection: null, + content: createBuddyUserContent(), + updatedAt: '2026-10-13T00:01:00.000Z', + }) + expect(await f.execution.send({ content: '@Partner Continue this work', userContent: f.content })).toBe(true) + expect(f.api.executions.sendDraft.mock.calls[1]![0]).toEqual(first) + expect(f.api.chat.enqueue.mock.calls).toEqual([]) + expect(f.api.chat.startTurn.mock.calls).toEqual([]) + expect(f.drafts.draft.value).toBe('') }) }) @@ -236,9 +417,16 @@ function createFixture() { const error = shallowRef(null) const pending = deferred() const composerTarget = useComposerTarget({ drafts, conversationId: session.activeConversationId, branchId: session.activeBranchId, persist: async () => true }) - const actorInput = { target: shallowRef(null) } + const actorInput = { + target: shallowRef(null), + mailboxTarget: shallowRef(null), + delivery: shallowRef(), + execution: shallowRef(null), + } const api = { executions: { sendDraft: vi.fn() }, chat: { listQueue: async () => [], enqueue: vi.fn(), cancelQueued: vi.fn(), steerQueued: vi.fn(), cancel: () => pending.promise, executeCommand: vi.fn(), startTurn: vi.fn() } } const selectedModel = shallowRef(null) + const flushModelSelection = vi.fn(async () => true) + const persistWorkspaceState = vi.fn(async () => true) const execution = useChatTurnExecution({ actorInput, composerTarget, @@ -250,12 +438,13 @@ function createFixture() { draftChangedMessage: () => 'Draft changed', draftScopeKey: targetKey, executionProfile: drafts.executionProfile, + flushModelSelection, getRunTerminationMessage: () => 'Run ended', isUpdatingPermissionSettings: shallowRef(false), language: shallowRef('en-US'), modelSelection: { selectedModel }, onActionCommandRunStarted: () => {}, - persistWorkspaceState: async () => true, + persistWorkspaceState, runSync: { refreshActiveConversation: async () => {}, applyRunStart: () => {}, @@ -286,7 +475,7 @@ function createFixture() { drafts.updateComposerContent('current view input', null) error.value = 'current view status' } - return { actorInput, api, drafts, error, execution, navigate, pending, projectedRuns, run, scope, selectedModel } + return { actorInput, api, drafts, error, execution, flushModelSelection, navigate, pending, persistWorkspaceState, projectedRuns, run, scope, selectedModel } })! } diff --git a/apps/buddy/src/modules/tasks/state/runs/__tests__/useExecutionDetail.spec.ts b/apps/buddy/src/modules/tasks/state/runs/__tests__/useExecutionDetail.spec.ts index dd16bd33f..301524b20 100644 --- a/apps/buddy/src/modules/tasks/state/runs/__tests__/useExecutionDetail.spec.ts +++ b/apps/buddy/src/modules/tasks/state/runs/__tests__/useExecutionDetail.spec.ts @@ -30,6 +30,7 @@ function message(runId: string, sequence: number, text: string): LocalRunEvent { function setup() { const api: NonNullable>['api'] = { list: vi.fn(async () => []), + inputs: async () => ({ inputs: [], nextOffset: null }), detail: vi.fn(async input => detail(input.runId)), events: vi.fn(async input => ({ events: [message(input.runId, 1, `${input.runId} private output`)], nextSequence: 1, hasMore: false })), answer: vi.fn(async () => ({ accepted: true })), @@ -63,19 +64,26 @@ describe('execution detail lifecycle', () => { expect(JSON.stringify(f.state.rows.value)).not.toContain('Late first output') }) - it('retains paged history and its cursor across same-identity refreshes and hiding', async () => { + it('refreshes input receipts without losing paged history across same-target refreshes and hiding', async () => { const f = setup() + const input = { kind: 'supplement' as const, messageId: 'input', text: 'Additional direction', state: 'pending' as const, createdAt: now } + const value = { ...detail('first'), inputs: [input] } + vi.mocked(f.api.detail).mockResolvedValue(value) let finish = false vi.mocked(f.api.events).mockImplementation(async input => (input.afterSequence ?? 0) === 0 ? { events: [message(input.runId, 1, 'First page')], nextSequence: 1, hasMore: true } : input.afterSequence === 1 ? { events: [message(input.runId, 2, 'Second page')], nextSequence: 2, hasMore: false } : finish ? { events: [message(input.runId, 3, 'Resumed output')], nextSequence: 3, hasMore: false } : { events: [], nextSequence: input.afterSequence ?? 0, hasMore: false }) + await f.state.refresh() await vi.waitFor(() => expect(JSON.stringify(f.state.rows.value)).toContain('Second page')) + expect(f.state.detail.value?.inputs).toEqual([input]) f.selected.value = { ...f.selected.value } await nextTick() - await f.state.refresh() - expect(vi.mocked(f.api.events).mock.calls.at(-1)?.[0].afterSequence).toBe(2) + vi.mocked(f.api.detail).mockResolvedValue({ ...value, inputs: [{ ...input, state: 'consumed' }] }) + const changed = vi.mocked(f.api.onChanged).mock.calls[0]![0] + changed({ sourceId: 'source', revision: 1, conversationId: 'task', rootRunId: 'root' }) + await vi.waitFor(() => expect(f.state.detail.value?.inputs).toEqual([{ ...input, state: 'consumed' }])) f.visible.value = false await nextTick() finish = true diff --git a/apps/buddy/src/modules/tasks/state/runs/useChatExecution.ts b/apps/buddy/src/modules/tasks/state/runs/useChatExecution.ts index 8d14e53cb..45d245f62 100644 --- a/apps/buddy/src/modules/tasks/state/runs/useChatExecution.ts +++ b/apps/buddy/src/modules/tasks/state/runs/useChatExecution.ts @@ -29,6 +29,7 @@ export function useChatExecution(options: UseChatExecutionOptions) { draftScopeKey: options.draftScopeKey, draftChangedMessage: options.draftChangedMessage, executionProfile: options.executionProfile, + flushModelSelection: options.flushModelSelection, getRunTerminationMessage: options.getRunTerminationMessage, isUpdatingPermissionSettings: options.isUpdatingPermissionSettings, language: options.language, diff --git a/apps/buddy/src/modules/tasks/state/runs/useChatExecutionTree.ts b/apps/buddy/src/modules/tasks/state/runs/useChatExecutionTree.ts index 4e4688a15..c0defbbc6 100644 --- a/apps/buddy/src/modules/tasks/state/runs/useChatExecutionTree.ts +++ b/apps/buddy/src/modules/tasks/state/runs/useChatExecutionTree.ts @@ -1,7 +1,9 @@ import type { LocalChatApi } from '@buddy-electron/shared/localChatApi' +import type { LocalExecutionInputReceipt } from '@buddy-shared/executions/executionInputReceipt' import type { ExecutionTarget, LocalExecution } from '@buddy-shared/executions/executionPresentationApi' import type { Ref } from 'vue' import type { TaskExecutionTree } from '../../contracts' +import { EXECUTION_INPUT_RUN_BATCH_SIZE } from '@buddy-shared/executions/executionInputReceipt' import { computed, onScopeDispose, readonly, shallowRef, watch } from 'vue' import { projectExecutionGroups } from '../../model/executions/executionGroups' @@ -13,6 +15,7 @@ export function useChatExecutionTree(options: { onError: (error: unknown) => void }): TaskExecutionTree { const records = shallowRef([]) + const inputs = shallowRef([]) const revision = shallowRef(0) const groups = computed(() => projectExecutionGroups(records.value)) let generation = 0 @@ -44,15 +47,35 @@ export function useChatExecutionTree(options: { loading = true const request = ++generation const scope = { conversationId, branchId } + const current = () => !disposed && options.ready.value && request === generation + && scope.conversationId === options.conversationId.value && scope.branchId === options.branchId.value try { const value = await options.api.list(scope) - if (!disposed && request === generation && scope.conversationId === options.conversationId.value && scope.branchId === options.branchId.value) { - records.value = value - revision.value++ + if (!current()) + return + records.value = value + revision.value++ + const runIds = value.filter(record => record.kind === 'root' || record.kind === 'actor_child').map(record => record.runId) + const nextInputs: LocalExecutionInputReceipt[] = [] + for (let start = 0; start < runIds.length; start += EXECUTION_INPUT_RUN_BATCH_SIZE) { + const batch = runIds.slice(start, start + EXECUTION_INPUT_RUN_BATCH_SIZE) + let offset: number | null = 0 + do { + const page = await options.api.inputs({ ...scope, runIds: batch, offset }) + if (!current()) + return + if (page.inputs.some(input => input.conversationId !== conversationId || input.branchId !== branchId || !batch.includes(input.runId)) + || (page.nextOffset !== null && page.nextOffset <= offset)) { + throw new Error('Invalid execution input page') + } + nextInputs.push(...page.inputs) + offset = page.nextOffset + } while (offset !== null) } + inputs.value = nextInputs } catch (error) { - if (!disposed && request === generation) + if (current()) options.onError(error) } finally { @@ -72,9 +95,12 @@ export function useChatExecutionTree(options: { void refresh() }, 60) }) - watch(() => [options.conversationId.value, options.branchId.value, options.ready.value], () => { + watch(() => [options.conversationId.value, options.branchId.value, options.ready.value] as const, (scope, previous) => { generation++ - records.value = [] + if (scope[0] !== previous?.[0] || scope[1] !== previous?.[1]) { + records.value = [] + inputs.value = [] + } void refresh() }, { immediate: true }) onScopeDispose(() => { @@ -94,6 +120,7 @@ export function useChatExecutionTree(options: { const tree = { groups, records: readonly(records), + inputs: readonly(inputs), revision: readonly(revision), ready: options.ready, conversationId: options.conversationId, diff --git a/apps/buddy/src/modules/tasks/state/runs/useChatTurnExecution.ts b/apps/buddy/src/modules/tasks/state/runs/useChatTurnExecution.ts index dc218f14e..11b8b04e0 100644 --- a/apps/buddy/src/modules/tasks/state/runs/useChatTurnExecution.ts +++ b/apps/buddy/src/modules/tasks/state/runs/useChatTurnExecution.ts @@ -1,22 +1,25 @@ import type { LocalChatApi } from '@buddy-electron/shared/localChatApi' import type { ParsedBuddyChatCommand } from '@buddy-shared/conversation/buddyChatCommands' import type { BuddyUserContentV1 } from '@buddy-shared/conversation/buddyUserContent' -import type { LocalPromptContextItem } from '@buddy-shared/conversation/chatApi' +import type { BuddyInputDelivery, LocalPromptContextItem } from '@buddy-shared/conversation/chatApi' import type { ExecutionTarget } from '@buddy-shared/executions/executionPresentationApi' import type { BuddyApprovalPolicy } from '@buddy-shared/permissions/approvalPolicy' import type { BuddyExecutionProfile } from '@buddy-shared/permissions/executionProfile' import type { LocalRun } from '@buddy-shared/runs/runApi' +import type { ActorInputTarget } from '../composer/useActorInputTarget' import type { ComposerTarget } from '../composer/useComposerTarget' import type { ChatRunSync } from './typing' import type { BuddyLocale } from '@/i18n/buddyI18n' import type { ChatComposerSubmitPayload } from '@/modules/prompt-input' import type { ChatSession } from '@/modules/tasks/state/conversations/useChatSession' -import type { ChatDrafts, TaskModelSelection } from '@/modules/tasks/state/drafts/typing' +import type { ChatDrafts, ChatDraftSnapshot, TaskModelSelection } from '@/modules/tasks/state/drafts/typing' import type { TaskIndexData } from '@/modules/tasks/state/task-index/useTaskIndexData' import type { RuntimeSupervisorStore } from '@/platform/runtime/useRuntimeSupervisorStore' import { isBuddyRunChatCommand, parseBuddyChatCommand } from '@buddy-shared/conversation/buddyChatCommands' -import { getBuddyUserContentResourceIds } from '@buddy-shared/conversation/buddyUserContent' +import { getBuddyUserContentActorReferences, getBuddyUserContentResourceIds } from '@buddy-shared/conversation/buddyUserContent' +import { isExecutionTextInput } from '@buddy-shared/executions/executionComposerInput' +import { parseLocalChatPublicError } from '@buddy-shared/runtime/localChatError' import { computed, onScopeDispose, readonly, shallowReactive, shallowRef, watch } from 'vue' import { translateBuddy } from '@/i18n/buddyI18n' import { createRequestIdRegistry } from '@/modules/tasks/model/requests/chatRequestIdentity' @@ -29,7 +32,7 @@ interface ValueRef { } export interface UseChatTurnExecutionOptions { - actorInput?: { target: ValueRef } + actorInput?: { [Key in 'target' | 'execution' | 'mailboxTarget' | 'delivery']: ValueRef } getNewBuddyId?: () => string | undefined activeRun: ValueRef approvalPolicy: ValueRef @@ -54,6 +57,7 @@ export interface UseChatTurnExecutionOptions { draftScopeKey: ValueRef draftChangedMessage: () => string executionProfile: ValueRef + flushModelSelection: () => Promise getRunTerminationMessage: (errorCode: string | null) => string isUpdatingPermissionSettings: ValueRef language: ValueRef @@ -71,6 +75,7 @@ export function useChatTurnExecution(options: UseChatTurnExecutionOptions) { const queue = useChatQueue({ api: options.api.chat, session: options.session, runtime: options.runtimeSupervisor, refreshConversation: options.runSync.refreshActiveConversation, onError: error => options.setErrorMessage(resolveLocalChatErrorMessage(error, options.language.value)), onUnavailable: reason => options.setErrorMessage(translateBuddy(options.language.value, reason === 'incompatible' ? 'desktop.chat.queueIncompatible' : reason === 'unavailable' ? 'desktop.chat.queueUnavailable' : 'desktop.chat.queueActionFailed')) }) const isSending = shallowRef(false) const requestIds = createRequestIdRegistry() + const pendingDeliveries = new Map() const pendingCancellationWatches = new Set<() => void>() const pendingCancellations = shallowReactive(new Map()) const stoppingRunId = computed(() => { @@ -86,6 +91,7 @@ export function useChatTurnExecution(options: UseChatTurnExecutionOptions) { stop() pendingCancellationWatches.clear() pendingCancellations.clear() + pendingDeliveries.clear() }, true) const canSend = computed(() => options.runtimeSupervisor.runtimeState.value.status === 'ready' @@ -117,49 +123,105 @@ export function useChatTurnExecution(options: UseChatTurnExecutionOptions) { options.setErrorMessage(options.unavailableCommandMessage()) return false } - if (isRunCommand && (options.activeRun.value || stoppingRunId.value)) + if (isRunCommand && (options.activeRun.value || options.actorInput?.target.value || stoppingRunId.value)) return false if (isRunCommand && command) return executeActionCommand(command, contextItems) const buddyId = options.getNewBuddyId?.() - const supplementTarget = options.actorInput?.target.value + const inputDelivery = options.actorInput?.delivery.value + let delivery = inputDelivery + ? { + mode: inputDelivery.mode, + actorId: inputDelivery.actorId, + target: inputDelivery.target ? { ...inputDelivery.target } : null, + } + : undefined + if (delivery?.mode === 'current' && options.actorInput?.execution.value?.canAnswer && !isExecutionTextInput(currentContent)) { + options.setErrorMessage(translateBuddy(options.language.value, 'desktop.chat.inputDeliveryAnswerTextOnly')) + return false + } + let supplementTarget = delivery?.mode === 'current' ? options.actorInput?.mailboxTarget.value : null + let deliveryKey: string | undefined const sourceScopeKey = options.draftScopeKey.value + const submittedDraft = options.drafts.snapshot(sourceScopeKey) + const initialConfiguration = draftSubmissionConfiguration(submittedDraft) + const submittedConfiguration = draftSubmissionConfiguration({ + ...submittedDraft, + approvalPolicy: options.approvalPolicy.value, + executionProfile: options.executionProfile.value, + }) const navigationVersion = options.session.generation() const isSourceViewCurrent = () => options.session.isCurrent(navigationVersion) && options.draftScopeKey.value === sourceScopeKey isSending.value = true - options.setErrorMessage(null) try { + if (!await options.flushModelSelection() || isDisposed) + return false + const modelReadyDraft = options.drafts.snapshot(sourceScopeKey) + const modelReadyConfiguration = draftSubmissionConfiguration(modelReadyDraft) + if ( + modelReadyDraft.draftId !== submittedDraft.draftId + || modelReadyDraft.editorSessionId !== submittedDraft.editorSessionId + || JSON.stringify(modelReadyDraft.content) !== JSON.stringify(submittedDraft.content) + || (modelReadyConfiguration !== initialConfiguration && modelReadyConfiguration !== submittedConfiguration) + ) { + if (isSourceViewCurrent()) + options.setErrorMessage(options.draftChangedMessage()) + return false + } + if (isSourceViewCurrent()) + options.setErrorMessage(null) if (!await options.persistWorkspaceState()) return false const confirmedDraft = options.drafts.snapshot(sourceScopeKey) - if (!options.drafts.isPersisted(confirmedDraft)) { + if (!options.drafts.isPersisted(confirmedDraft) || draftSubmissionConfiguration(confirmedDraft) !== submittedConfiguration) { if (isSourceViewCurrent()) options.setErrorMessage(options.draftChangedMessage()) return false } const expectedRevision = confirmedDraft.revision! + if (delivery) { + deliveryKey = `${confirmedDraft.draftId}:${expectedRevision}:${delivery.actorId}:${delivery.mode}` + const pending = pendingDeliveries.get(deliveryKey) + if (pending) { + delivery = pending.delivery + supplementTarget = pending.mailboxTarget + } + else { + pendingDeliveries.set(deliveryKey, { delivery, mailboxTarget: supplementTarget ?? null }) + } + } if (supplementTarget) { if (!options.api.executions) throw new Error('Execution input service unavailable') - const remote = await options.api.executions.sendDraft({ ...supplementTarget, draftId: confirmedDraft.draftId, expectedRevision }) - if (remote) { - options.drafts.confirmOpen(confirmedDraft, remote) - return true + const remote = await options.api.executions.sendDraft({ ...supplementTarget, draftId: confirmedDraft.draftId, expectedRevision, delivery }) + if (!remote) { + if (deliveryKey) + pendingDeliveries.delete(deliveryKey) + if (isSourceViewCurrent()) + options.setErrorMessage(translateBuddy(options.language.value, 'desktop.chat.inputDeliveryTargetUnavailable')) + return false } + if (deliveryKey) + pendingDeliveries.delete(deliveryKey) + options.drafts.confirmOpen(confirmedDraft, remote) + return true } - const operationKey = `turn:${confirmedDraft.draftId}:${expectedRevision}:${buddyId ?? ''}` + const operationKey = `turn:${confirmedDraft.draftId}:${expectedRevision}:${buddyId ?? ''}:${JSON.stringify(delivery ?? null)}` const requestId = requestIds.resolve(operationKey) - if (stoppingRunId.value || options.activeRun.value || queue.queuedMessages.value.length) { - const result = await options.api.chat.enqueue({ draftId: confirmedDraft.draftId, expectedRevision, requestId }) + if (delivery?.target || stoppingRunId.value || options.activeRun.value || queue.queuedMessages.value.length) { + const result = await options.api.chat.enqueue({ draftId: confirmedDraft.draftId, expectedRevision, requestId, ...(delivery ? { delivery } : {}) }) requestIds.release(operationKey) + if (deliveryKey) + pendingDeliveries.delete(deliveryKey) options.composerTarget.complete(result.draftReceipt, sourceScopeKey, sourceScopeKey) await queue.refreshQueue() return true } const result = await options.api.chat.startTurn({ ...(buddyId ? { buddyId } : {}), + ...(delivery ? { delivery } : {}), draftId: confirmedDraft.draftId, expectedRevision, requestId, @@ -167,6 +229,8 @@ export function useChatTurnExecution(options: UseChatTurnExecutionOptions) { if (!result.draftReceipt) throw new Error('Turn did not commit its Composer draft') requestIds.release(operationKey) + if (deliveryKey) + pendingDeliveries.delete(deliveryKey) const sourceViewIsCurrent = isSourceViewCurrent() const targetScopeKey = `conversation:${result.conversationId}:${result.branchId}` const source = parseDraftScopeKey(confirmedDraft.targetKey) @@ -197,6 +261,9 @@ export function useChatTurnExecution(options: UseChatTurnExecutionOptions) { return true } catch (error) { + const code = error instanceof Error ? parseLocalChatPublicError(error.message)?.code : undefined + if (deliveryKey && (code === 'INPUT_DELIVERY_TARGET_UNAVAILABLE' || code === 'INPUT_DELIVERY_INCOMPATIBLE' || code === 'DRAFT_CONFLICT')) + pendingDeliveries.delete(deliveryKey) if (isSourceViewCurrent()) setNormalizedError(error) return false @@ -334,6 +401,18 @@ export function useChatTurnExecution(options: UseChatTurnExecutionOptions) { } } +function draftSubmissionConfiguration(draft: ChatDraftSnapshot): string { + const model = draft.modelSelection + const participants = draft.participants + return JSON.stringify([ + draft.approvalPolicy, + draft.executionProfile, + participants ? [participants.leaderId, participants.actorIds] : null, + getBuddyUserContentActorReferences(draft.content).map(actor => actor.actorId), + model ? [model.providerId, model.modelId, model.reasoning, model.serviceTier] : null, + ]) +} + function createActionCommandContent( command: ParsedBuddyChatCommand, ): BuddyUserContentV1 { diff --git a/apps/buddy/src/modules/tasks/state/useTaskCapability.ts b/apps/buddy/src/modules/tasks/state/useTaskCapability.ts index 1ee108d24..ecd117627 100644 --- a/apps/buddy/src/modules/tasks/state/useTaskCapability.ts +++ b/apps/buddy/src/modules/tasks/state/useTaskCapability.ts @@ -81,6 +81,7 @@ export function useTaskCapability(options: UseTaskCapabilityOptions): TaskCapabi } = chatSession const isSelectingFiles = shallowRef(false) const isClosing = shallowRef(false) + const cancellingInput = shallowRef<{ runId: string, scopeKey: string } | null>(null) const errorMessage = shallowRef(null) const dismissedChatBlockerKind = shallowRef(null) const { language } = applicationSettings @@ -175,7 +176,6 @@ export function useTaskCapability(options: UseTaskCapabilityOptions): TaskCapabi const actors = getBuddyUserContentActorReferences(drafts.load(drafts.targetKey.value).content) return actors.length === 1 ? actors[0]!.actorId : null }) - const actorInput = useActorInputTarget({ content: computed(() => drafts.load(drafts.targetKey.value).content), scopeKey: drafts.targetKey, tree: executionTree }) const resolvedActorModel = shallowRef<{ conversationId: string | null, actorId: string | null, model: LocalConversation['modelSelection'] } | null>(null) const draftModelBinding = useTaskDraftModelBinding({ drafts, @@ -214,6 +214,12 @@ export function useTaskCapability(options: UseTaskCapabilityOptions): TaskCapabi selectDefaultModel: () => draftModelBinding.restoreScope(), session: chatSession, }) + const actorInput = useActorInputTarget({ + content: computed(() => drafts.load(drafts.targetKey.value).content), + scopeKey: drafts.targetKey, + defaultActorId: computed(getDefaultActorId), + tree: executionTree, + }) const activeRun = computed(() => { const actorId = addressedActorId.value ?? activeConversation.value?.buddyId ?? draftBuddyId.value const active = runs.value.filter(run => run.status === 'queued' || run.status === 'running') @@ -508,6 +514,29 @@ export function useTaskCapability(options: UseTaskCapabilityOptions): TaskCapabi } } + async function cancelInputWork() { + const record = actorInput.execution.value + const target = actorInput.target.value + if (!target || record?.kind !== 'actor_child') { + await cancelActiveRun() + return + } + if (!record.canCancel || cancellingInput.value) + return + const pending = { runId: target.runId, scopeKey: draftScopeKey.value } + cancellingInput.value = pending + try { + await api.localChat.executions.cancel(target) + } + catch (error) { + if (!isClosing.value && draftScopeKey.value === pending.scopeKey) + setError(error) + } + finally { + cancellingInput.value = null + } + } + function setError(error: unknown) { errorMessage.value = resolveLocalChatErrorMessage(error, language.value) } @@ -643,16 +672,21 @@ export function useTaskCapability(options: UseTaskCapabilityOptions): TaskCapabi }, }, execution: { - isActorInput: computed(() => actorInput.target.value !== null), + isActorInput: computed(() => actorInput.mailboxTarget.value !== null), + inputExecution: actorInput.execution, activeRun: readonly(activeRun), approvalViews: readonly(approvalViews), canMutateBranch: readonly(canMutateBranch), canSend: readonly(canSend), queuedMessages, + queueRecipientNames: computed(() => new Map([ + ...executionTree.records.value.map(record => [record.buddyId, record.buddyName] as const), + ...(options.buddyCatalog?.value?.buddies ?? []).map(buddy => [buddy.id, buddy.name] as const), + ])), pendingQueueActions, cancelQueuedMessage, steerQueuedMessage, - cancelActiveRun, + cancelActiveRun: cancelInputWork, cancelEditUserMessage, editUserMessage, beginFollowup: execution.beginFollowup, @@ -660,7 +694,10 @@ export function useTaskCapability(options: UseTaskCapabilityOptions): TaskCapabi editingMessageId, isMutatingBranch: readonly(isMutatingBranch), isSending: readonly(isSending), - stoppingRunId: readonly(execution.stoppingRunId), + stoppingRunId: computed(() => cancellingInput.value?.scopeKey === draftScopeKey.value + && cancellingInput.value.runId === actorInput.target.value?.runId + ? cancellingInput.value.runId + : execution.stoppingRunId.value), regenerateAssistant, resolveApproval, resolvingApprovalActions: readonly(resolvingApprovalActions), diff --git a/apps/buddy/src/modules/tasks/widgets/composer/ChatMessageQueue.vue b/apps/buddy/src/modules/tasks/widgets/composer/ChatMessageQueue.vue index bbdf7bf37..750f02182 100644 --- a/apps/buddy/src/modules/tasks/widgets/composer/ChatMessageQueue.vue +++ b/apps/buddy/src/modules/tasks/widgets/composer/ChatMessageQueue.vue @@ -1,7 +1,7 @@ diff --git a/apps/buddy/src/modules/tasks/widgets/composer/typing.ts b/apps/buddy/src/modules/tasks/widgets/composer/typing.ts index b75b5eaa0..5bc9af035 100644 --- a/apps/buddy/src/modules/tasks/widgets/composer/typing.ts +++ b/apps/buddy/src/modules/tasks/widgets/composer/typing.ts @@ -56,7 +56,12 @@ export interface ChatComposerEditorOptions { onLocateQuote?: (reference: BuddyQuoteReference) => void } +export interface DesktopComposerInputDelivery { + answering: boolean +} + export interface DesktopChatComposerProps { + inputDelivery?: DesktopComposerInputDelivery isActorInput?: boolean isLoadingModel?: boolean isUpdatingModel?: boolean diff --git a/apps/buddy/src/modules/tasks/widgets/executions/BuddyExecutionInputs.vue b/apps/buddy/src/modules/tasks/widgets/executions/BuddyExecutionInputs.vue index ac65a50ec..05df738d5 100644 --- a/apps/buddy/src/modules/tasks/widgets/executions/BuddyExecutionInputs.vue +++ b/apps/buddy/src/modules/tasks/widgets/executions/BuddyExecutionInputs.vue @@ -11,10 +11,10 @@ const labels = computed(() => executionLabels(props.language)) diff --git a/apps/buddy/src/modules/tasks/widgets/transcript/BuddyChatActorProcess.vue b/apps/buddy/src/modules/tasks/widgets/transcript/BuddyChatActorProcess.vue index 70a98acc0..99f4c072b 100644 --- a/apps/buddy/src/modules/tasks/widgets/transcript/BuddyChatActorProcess.vue +++ b/apps/buddy/src/modules/tasks/widgets/transcript/BuddyChatActorProcess.vue @@ -7,7 +7,6 @@ import { computed, shallowRef } from 'vue' import DesktopIcon from '@/shared/ui/icon/DesktopIcon.vue' import { useExecutionTree } from '../../state/runs/chatExecutionContext' import { useExecutionDetail } from '../../state/runs/useExecutionDetail' -import BuddyExecutionInputs from '../executions/BuddyExecutionInputs.vue' import BuddyExecutionProcess from '../executions/BuddyExecutionProcess.vue' import { executionLabels } from '../executions/executionLabels' import BuddyChatTokenUsage from './BuddyChatTokenUsage.vue' @@ -55,7 +54,6 @@ const labels = computed(() => executionLabels(props.language))