From 5a65402a72b474b702a5d633cd32658d91982414 Mon Sep 17 00:00:00 2001 From: shanyuhai123 <864299347@qq.com> Date: Tue, 29 Sep 2026 09:18:02 +0800 Subject: [PATCH] =?UTF-8?q?feat(buddy):=20=E6=94=AF=E6=8C=81=E6=8F=92?= =?UTF-8?q?=E4=BB=B6=E5=8A=A8=E4=BD=9C=E4=B8=8E=E5=8A=A8=E6=80=81=E6=9D=A1?= =?UTF-8?q?=E4=BB=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../scripts/__tests__/extensionAgent.e2e.mjs | 305 +++++++++++++++++- .../scripts/__tests__/settings.e2e.mjs | 35 +- .../electron/main/app/DesktopIntegrations.ts | 21 ++ .../__tests__/ExtensionCapabilities.spec.ts | 2 +- .../main/extensions/registerExtensionIpc.ts | 17 +- .../runtime/__tests__/ExtensionEvents.spec.ts | 8 +- .../electron/main/extensions/runtime/host.js | 65 +++- .../electron/main/local-chat/notifications.ts | 8 +- .../electron/main/runtime/BuddyServicePeer.ts | 2 +- .../main/runtime/BuddyServiceSupervisor.ts | 2 +- apps/buddy/electron/preload/extensions.ts | 4 + .../preload/local-chat/conversation.ts | 1 + apps/buddy/electron/shared/localChatApi.ts | 4 +- .../extensions/ExtensionConditionEvaluator.ts | 198 ++++++++++++ .../extensions/ExtensionConditions.ts | 122 +++++++ .../platform/extensions/ExtensionService.ts | 69 +++- .../__tests__/ExtensionConditions.spec.ts | 218 +++++++++++++ .../ipc/__tests__/cancellation.spec.ts | 31 +- apps/buddy/platform/ipc/runtimeRpcPeer.ts | 13 +- .../resources/skills/plugin-creator/SKILL.md | 2 +- .../references/agent-settings.md | 61 +++- .../skills/plugin-creator/references/api.d.ts | 47 ++- .../plugin-creator/references/capabilities.md | 3 +- .../plugin-creator/references/conditions.md | 61 ++++ .../plugin-creator/references/events.md | 28 ++ .../plugin-creator/references/protocol.md | 7 +- apps/buddy/service/src/BuddyService.ts | 68 +++- .../ConversationMetadataService.ts | 4 +- .../src/conversations/ExtensionTaskContext.ts | 76 +++++ .../ConversationMetadataService.spec.ts | 2 +- .../extensionTaskCapabilities.spec.ts | 100 ++++++ .../conversationTimelineCursor.ts | 2 +- .../extensionTaskCapabilities.ts | 18 +- .../src/plugins/ExtensionActionService.ts | 149 +++++++++ .../src/plugins/ExtensionAgentEvents.ts | 6 +- .../src/plugins/ExtensionAgentRuntime.ts | 105 ++++-- .../src/plugins/ExtensionInvocationQueue.ts | 47 +++ .../__tests__/ExtensionActionService.spec.ts | 175 ++++++++++ .../__tests__/ExtensionAgentRuntime.spec.ts | 84 ++++- .../ExtensionInvocationQueue.spec.ts | 31 ++ .../pluginAuthoringCapability.spec.ts | 2 +- .../src/plugins/bindExtensionActions.ts | 35 ++ .../src/plugins/extensionAgentHandlers.ts | 8 +- .../observeExtensionAgentDiagnostics.ts | 4 +- .../plugins/registerExtensionConditionRpc.ts | 58 ++++ .../providers/extensionModelCapabilities.ts | 16 +- .../service/src/rpc/BuddyServiceRpcServer.ts | 2 +- .../src/runs/ConversationStatusService.ts | 9 +- .../ConversationStatusService.spec.ts | 69 +++- apps/buddy/service/src/runs/registerRunRpc.ts | 2 +- .../__tests__/conversationTitleState.spec.ts | 4 +- .../__tests__/extensionActionTimeline.spec.ts | 42 +++ .../src/storage/__tests__/schema.spec.ts | 44 +++ .../usageAnalyticsRepository.spec.ts | 1 + .../src/storage/conversationRepository.ts | 14 +- .../storage/conversationTimelineRepository.ts | 44 ++- apps/buddy/service/src/storage/database.ts | 2 + .../storage/extensionInvocationRepository.ts | 34 ++ .../storage/extensionTaskContextRepository.ts | 47 +++ .../src/storage/migrations/v22Extension.ts | 50 +++ apps/buddy/service/src/storage/schema.ts | 5 +- .../src/storage/usageAnalyticsRepository.ts | 5 +- .../service/src/storage/usageRepository.ts | 51 ++- apps/buddy/service/src/usage/UsageService.ts | 23 +- apps/buddy/service/src/usage/recordPiUsage.ts | 4 +- .../src/web/__tests__/webUsage.spec.ts | 2 +- .../shared/conversation/conversationApi.ts | 8 + apps/buddy/shared/conversation/taskEvents.ts | 12 + .../shared/extensions/extensionActionApi.ts | 32 ++ .../buddy/shared/extensions/extensionAgent.ts | 38 ++- .../extensions/extensionAgentCapabilities.ts | 5 +- apps/buddy/shared/extensions/extensionApi.ts | 11 + .../extensions/extensionConditionContext.ts | 49 +++ .../shared/extensions/extensionConditions.ts | 34 ++ .../shared/extensions/extensionManifest.ts | 23 +- .../shared/extensions/extensionSettings.ts | 3 +- apps/buddy/shared/runtime/rpcPeer.ts | 2 +- apps/buddy/shared/usage/usageApi.ts | 3 +- .../workbench/workbenchContributionCatalog.ts | 9 +- apps/buddy/src/i18n/locales/en-US/chat.ts | 1 + apps/buddy/src/i18n/locales/zh-CN/chat.ts | 1 + .../state/__tests__/useExtensionState.spec.ts | 4 +- .../extensions/state/useExtensionState.ts | 11 +- .../widgets/DesktopExtensionInstallReview.vue | 5 +- .../widgets/DesktopExtensionMenu.vue | 18 +- .../usePluginSettingConditions.spec.ts | 100 ++++++ .../state/__tests__/usePluginSettings.spec.ts | 2 + .../state/usePluginSettingConditions.ts | 85 +++++ .../settings/state/usePluginSettings.ts | 7 +- .../widgets/plugins/DesktopPluginSetting.vue | 18 +- .../plugins/DesktopPluginSettingField.vue | 20 +- .../DesktopPluginSettingField.spec.ts | 44 +++ .../__tests__/chatAgentActivities.spec.ts | 25 +- .../__tests__/chatTranscriptSegments.spec.ts | 49 +++ .../model/transcript/chatActivityLayout.ts | 4 +- .../tasks/model/transcript/chatAgentTurn.ts | 9 +- .../transcript/chatIncrementalTranscript.ts | 16 +- .../tasks/model/transcript/chatMessageTime.ts | 8 +- .../transcript/chatPersistedTranscriptRows.ts | 29 +- .../tasks/model/transcript/chatToolDisplay.ts | 14 +- .../model/transcript/chatToolRegistry.ts | 8 +- .../transcript/chatTranscriptActivities.ts | 60 ++++ .../transcript/chatTranscriptSegments.ts | 18 +- .../model/transcript/chatTranscriptTypes.ts | 16 +- .../state/__tests__/useTaskCapability.spec.ts | 3 + .../runs/__tests__/useChatRunSync.spec.ts | 44 +++ .../tasks/state/runs/useChatRunSync.ts | 7 +- .../modules/tasks/state/useTaskLifecycle.ts | 2 + .../widgets/canvas/ConversationNodeDetail.vue | 2 + .../transcript/BuddyChatActivityGroup.vue | 11 +- .../widgets/transcript/BuddyChatAgentTurn.vue | 2 +- .../transcript/BuddyChatMessageList.vue | 10 + .../transcript/BuddyChatToolDetails.vue | 2 +- .../widgets/transcript/BuddyChatToolIcon.vue | 3 +- .../widgets/transcript/BuddyChatToolRow.vue | 13 +- .../__tests__/BuddyChatActivities.spec.ts | 30 ++ .../workspace/DesktopChatWorkspaceHeader.vue | 3 +- .../widgets/workspace/DesktopTaskEditor.vue | 1 + .../shared/ui/contributions/WorkbenchMenu.vue | 2 +- .../ui/contributions/workbenchUiContext.ts | 1 + .../src/shared/ui/icon/assets/tool-use.svg | 7 + apps/buddy/src/shared/ui/icon/desktopIcons.ts | 2 + 122 files changed, 3468 insertions(+), 256 deletions(-) create mode 100644 apps/buddy/platform/extensions/ExtensionConditionEvaluator.ts create mode 100644 apps/buddy/platform/extensions/ExtensionConditions.ts create mode 100644 apps/buddy/platform/extensions/__tests__/ExtensionConditions.spec.ts create mode 100644 apps/buddy/service/resources/skills/plugin-creator/references/conditions.md create mode 100644 apps/buddy/service/src/conversations/ExtensionTaskContext.ts create mode 100644 apps/buddy/service/src/conversations/__tests__/extensionTaskCapabilities.spec.ts create mode 100644 apps/buddy/service/src/plugins/ExtensionActionService.ts create mode 100644 apps/buddy/service/src/plugins/ExtensionInvocationQueue.ts create mode 100644 apps/buddy/service/src/plugins/__tests__/ExtensionActionService.spec.ts create mode 100644 apps/buddy/service/src/plugins/__tests__/ExtensionInvocationQueue.spec.ts create mode 100644 apps/buddy/service/src/plugins/bindExtensionActions.ts create mode 100644 apps/buddy/service/src/plugins/registerExtensionConditionRpc.ts create mode 100644 apps/buddy/service/src/storage/__tests__/extensionActionTimeline.spec.ts create mode 100644 apps/buddy/service/src/storage/extensionInvocationRepository.ts create mode 100644 apps/buddy/service/src/storage/extensionTaskContextRepository.ts create mode 100644 apps/buddy/service/src/storage/migrations/v22Extension.ts create mode 100644 apps/buddy/shared/conversation/taskEvents.ts create mode 100644 apps/buddy/shared/extensions/extensionActionApi.ts create mode 100644 apps/buddy/shared/extensions/extensionConditionContext.ts create mode 100644 apps/buddy/shared/extensions/extensionConditions.ts create mode 100644 apps/buddy/src/modules/settings/state/__tests__/usePluginSettingConditions.spec.ts create mode 100644 apps/buddy/src/modules/settings/state/usePluginSettingConditions.ts create mode 100644 apps/buddy/src/modules/settings/widgets/plugins/__tests__/DesktopPluginSettingField.spec.ts create mode 100644 apps/buddy/src/modules/tasks/model/transcript/chatTranscriptActivities.ts create mode 100644 apps/buddy/src/shared/ui/icon/assets/tool-use.svg diff --git a/.playwright/scripts/__tests__/extensionAgent.e2e.mjs b/.playwright/scripts/__tests__/extensionAgent.e2e.mjs index 00e991fb..387335f3 100644 --- a/.playwright/scripts/__tests__/extensionAgent.e2e.mjs +++ b/.playwright/scripts/__tests__/extensionAgent.e2e.mjs @@ -155,6 +155,9 @@ test('agent plugin uses the originating model, persists settings and protects ma expect(JSON.stringify(requests[0].messages)).toContain('proactively') const pluginToolName = requests[0].tools.find(tool => tool.function.description.includes('Generate and save a concise title')).function.name await expect(page.getByText('生成标题 1', { exact: true }).first()).toBeVisible() + await expect(page.locator('.buddy-chat-tool__title').first()).toHaveText('工具调用') + await expect(page.locator('.buddy-chat-tool__summary').first()).toHaveText('标题自动生成 · 生成标题') + await page.screenshot({ path: path.join(instance.artifactDirectory, 'plugin-tool-row.png'), animations: 'disabled' }) await settings() const modelField = () => page.locator(`[data-setting-id="${pluginId}.model"]`) @@ -305,6 +308,306 @@ function activate(context) { return { applied: false } const configuration = await context.configuration.get() const result = await invocation.models.generateText({ prompt: input.summary, model: configuration.model }) - return invocation.task.rename({ title: result.text, expectedRevision: task.titleRevision }) + return invocation.task.rename({ title: result.text }) + }) +} + +test('background plugin actions preserve event context, protected titles and independent usage', async ({ buddy }) => { + test.setTimeout(180000) + const requests = [] + const pending = [] + let hold = true + let generated = 0 + const server = createServer(async (request, response) => { + if (request.url !== '/v1/chat/completions') { + response.writeHead(404).end() + return + } + const chunks = [] + for await (const chunk of request) chunks.push(chunk) + const body = JSON.parse(Buffer.concat(chunks).toString()) + requests.push(body) + const background = !body.tools?.length + const content = background ? `后台标题 ${++generated}` : '任务回答已完成。' + const finish = () => { + const common = { id: `action-${requests.length}`, model: body.model, object: 'chat.completion.chunk', created: 1 } + response.writeHead(200, { 'content-type': 'text/event-stream' }) + if (!background) + response.write(`data: ${JSON.stringify({ ...common, choices: [{ index: 0, delta: { role: 'assistant', reasoning_content: '检查任务目标和执行步骤。' }, finish_reason: null }] })}\n\n`) + response.write(`data: ${JSON.stringify({ ...common, choices: [{ index: 0, delta: { role: 'assistant', content }, finish_reason: null }] })}\n\n`) + response.write(`data: ${JSON.stringify({ ...common, choices: [{ index: 0, delta: {}, finish_reason: 'stop' }], usage: { prompt_tokens: 20, completion_tokens: 10, total_tokens: 30 } })}\n\n`) + response.end('data: [DONE]\n\n') + } + if (background && hold) + pending.push(finish) + else finish() + }) + server.listen(0, '127.0.0.1') + await once(server, 'listening') + const instance = await buddy.createInstance('background-title') + try { + const { app, page, diagnostics } = await instance.launch() + await syntheticCredentials(app, page) + await page.evaluate(async (baseUrl) => { + const providers = window.lexoraDesktop.localChat.providers + await providers.upsertCustom({ id: 'title-fixture', displayName: 'Title fixture', api: 'openai-completions', baseUrl, enabled: true, models: [{ id: 'primary', name: 'primary', input: ['text'], reasoning: false, contextWindow: 128000, maxTokens: 1024 }] }) + const stop = providers.onAuthChallenge((challenge) => { + if (challenge.providerId === 'title-fixture' && challenge.type === 'secret') + void providers.respondToAuth(challenge.challengeId, 'offline-fixture-key') + }) + try { + await providers.login('title-fixture', 'api_key') + } + finally { stop() } + await providers.setDefaultModel({ providerId: 'title-fixture', modelId: 'primary', reasoning: null }) + }, `http://127.0.0.1:${server.address().port}/v1`) + const source = process.env.LEXORA_TEST_ACTION_PLUGIN ?? path.join(instance.home, 'action-fixture') + if (!process.env.LEXORA_TEST_ACTION_PLUGIN) + await writeActionFixture(source) + const previous = process.env.LEXORA_TEST_ACTION_PLUGIN_PREVIOUS + if (previous) { + await app.evaluate(({ dialog }, previous) => { + dialog.showOpenDialog = async () => ({ canceled: false, filePaths: [previous] }) + }, previous) + await page.locator('.desktop-app-sidebar').getByRole('button', { name: '插件', exact: true }).click() + await page.getByTestId('extension-install-options').click() + await page.getByTestId((await fs.stat(previous)).isDirectory() ? 'extension-development' : 'extension-install').click() + await page.getByTestId('extension-confirm-install').click() + await expect.poll(() => page.evaluate(async () => (await window.lexoraDesktop.extensions.list()).length)).toBe(1) + await page.evaluate(async () => { + const plugin = (await window.lexoraDesktop.extensions.list())[0] + await window.lexoraDesktop.extensions.configure(plugin.manifest.id, { enabled: false, model: { providerId: 'title-fixture', modelId: 'primary' }, updateOnGoalChange: true }) + }) + } + const development = (await fs.stat(source)).isDirectory() + await page.reload() + await page.locator('.desktop-app-sidebar').getByRole('button', { name: '插件', exact: true }).click() + await app.evaluate(({ dialog }, source) => { + dialog.showOpenDialog = async () => ({ canceled: false, filePaths: [source] }) + }, source) + await page.getByTestId('extension-install-options').click() + await page.getByTestId(development ? 'extension-development' : 'extension-install').click() + await expect(page.getByText('读取任务消息', { exact: true })).toBeVisible() + await page.getByTestId('extension-confirm-install').click() + await expect.poll(() => page.evaluate(async () => (await window.lexoraDesktop.extensions.list()).length)).toBe(1) + const pluginId = await page.evaluate(async () => (await window.lexoraDesktop.extensions.list())[0].manifest.id) + if (previous) { + await expect.poll(() => page.evaluate(async () => !!(await window.lexoraDesktop.extensions.list())[0].pending)).toBe(true) + await page.locator(`[data-extension-id="${pluginId}"]`).getByTestId('extension-card-more').click() + await page.getByTestId('extension-restart').click() + await expect.poll(() => page.evaluate(async () => (await window.lexoraDesktop.extensions.list())[0].pending)).toBeNull() + expect(await page.evaluate(id => window.lexoraDesktop.extensions.configuration(id), pluginId)).toMatchObject({ enabled: false, model: { providerId: 'title-fixture', modelId: 'primary' }, updateOnGoalChange: true }) + await page.evaluate(id => window.lexoraDesktop.extensions.configure(id, { enabled: true, model: null, updateOnGoalChange: false }), pluginId) + } + + const declaredActions = await page.evaluate(async () => (await window.lexoraDesktop.extensions.list())[0].manifest.contributes.agent.actions) + const pluginSettings = await page.evaluate(async () => (await window.lexoraDesktop.extensions.list())[0].manifest.contributes.settings.items) + await page.locator(`[data-extension-id="${pluginId}"]`).getByRole('button', { name: '打开', exact: true }).click() + const master = page.locator(`[data-setting-id="${pluginId}.enabled"]`).getByRole('switch') + const model = page.locator(`[data-setting-id="${pluginId}.model"] .plugin-model-setting__select`) + const updates = page.locator(`[data-setting-id="${pluginSettings.find(item => item.key === 'updateOnGoalChange').id}"]`).getByRole('switch') + await page.evaluate(id => window.lexoraDesktop.extensions.configure(id, { updateOnGoalChange: true }), pluginId) + await expect(updates).toBeChecked() + const preservedConfiguration = await page.evaluate(id => window.lexoraDesktop.extensions.configuration(id), pluginId) + await expect(master).toBeChecked() + await expect(model).toBeEnabled() + await master.click() + await expect(model).toBeDisabled() + await expect(updates).toBeDisabled() + await expect(master).toBeEnabled() + const disabledConfiguration = await page.evaluate(id => window.lexoraDesktop.extensions.configuration(id), pluginId) + expect(disabledConfiguration).toEqual({ ...preservedConfiguration, enabled: false }) + await expect(updates).toBeChecked() + await page.screenshot({ path: path.join(instance.artifactDirectory, 'title-settings-disabled.png'), animations: 'disabled' }) + await page.reload() + await expect(model).toBeDisabled() + await master.click() + await expect(model).toBeEnabled() + await expect(updates).toBeEnabled() + expect(await page.evaluate(id => window.lexoraDesktop.extensions.configuration(id), pluginId)).toEqual(preservedConfiguration) + await page.evaluate(id => window.lexoraDesktop.extensions.configure(id, { updateOnGoalChange: false }), pluginId) + + await page.locator('.desktop-app-sidebar').getByRole('button', { name: '任务', exact: true }).click() + const latest = () => rows(instance.home, 'SELECT * FROM conversations ORDER BY created_at DESC LIMIT 1')[0] + async function send(text, fresh = false) { + if (fresh) { + await page.getByRole('button', { name: '新任务', exact: true }).click() + await expect(page.locator('.desktop-chat-workspace-header__copy:visible')).toHaveText('新任务') + await expect(page.locator('.desktop-chat-page.is-empty:not(.is-loading):visible')).toBeVisible() + } + const before = rows(instance.home, 'SELECT id FROM runs').length + await page.locator('.desktop-chat-composer__prosemirror:visible').fill(text) + await page.getByRole('button', { name: '发送消息', exact: true }).click() + await expect.poll(() => rows(instance.home, 'SELECT id FROM runs').length).toBe(before + 1) + await expect.poll(() => rows(instance.home, 'SELECT id FROM runs WHERE status IN (\'queued\', \'running\')').length).toBe(0) + } + const settled = () => expect.poll(() => rows(instance.home, 'SELECT id FROM extension_invocations WHERE status = \'running\'').length).toBe(0) + const backgroundRequests = () => requests.filter(body => !body.tools?.length).length + async function revealActions() { + const group = page.locator('.buddy-chat-message-list .buddy-chat-activity-group__header').first() + await expect(group).toContainText('工具调用') + if (await group.getAttribute('aria-expanded') === 'false') + await group.click() + } + await send('整理项目发布计划') + await expect.poll(() => pending.length).toBe(1) + await revealActions() + await expect(page.locator('[data-action-status="running"]').filter({ hasText: '重新生成标题' })).toBeVisible() + expect(latest().title_source).toBe('fallback') + expect(rows(instance.home, 'SELECT status FROM runs')).toEqual([{ status: 'completed' }]) + expect(requests.filter(body => body.tools?.length).every(body => !body.tools.some(tool => tool.function.name.startsWith('lexora_plugin_')))).toBe(true) + pending.shift()() + await settled() + expect(latest()).toMatchObject({ title: '后台标题 1', title_source: 'generated' }) + await expect(page.locator('[data-action-status="completed"]').filter({ hasText: '重新生成标题' })).toBeVisible() + const savedTimeline = await page.evaluate(conversationId => window.lexoraDesktop.localChat.conversations.listTimeline({ conversationId }), latest().id) + expect(savedTimeline.items.filter(item => item.kind === 'extension-action')).toEqual(expect.arrayContaining([expect.objectContaining({ extensionId: pluginId, title: '重新生成标题', status: 'completed', branchId: latest().active_branch_id })])) + await page.reload() + await revealActions() + const completedAction = page.locator('[data-action-status="completed"]').filter({ hasText: '重新生成标题' }) + await expect(completedAction).toBeVisible() + await expect(completedAction.locator('.buddy-chat-tool__title')).toHaveText('工具调用') + await expect(completedAction.locator('.buddy-chat-tool__summary')).toHaveText('重新生成标题') + const identities = page.locator('.buddy-chat-message-list .buddy-chat-agent-identity') + await expect(identities).toHaveCount(1) + const identityBounds = await identities.boundingBox() + const actionBounds = await completedAction.boundingBox() + const answerBounds = await page.locator('.buddy-chat-message-list .buddy-chat-message.is-assistant').boundingBox() + expect(identityBounds.y + identityBounds.height).toBeLessThanOrEqual(actionBounds.y) + expect(actionBounds.y + actionBounds.height).toBeLessThanOrEqual(answerBounds.y) + if (process.env.LEXORA_TEST_ACTION_PLUGIN) { + await completedAction.getByRole('button', { name: /重新生成标题/ }).click() + const actionId = await completedAction.getAttribute('data-action-id') + await expect(page.locator(`[data-action-detail-id="${actionId}"]`)).toContainText('后台标题 1') + await page.screenshot({ path: path.join(instance.artifactDirectory, 'title-action-details.png'), animations: 'disabled' }) + } + + expect(rows(instance.home, 'SELECT run_id, invocation_id, purpose FROM usage_records WHERE purpose = \'extension.action\'')).toEqual([{ run_id: null, invocation_id: expect.any(String), purpose: 'extension.action' }]) + const inputActions = declaredActions.filter(action => action.triggers.includes('task:input:committed')).map(action => action.id).sort() + expect(rows(instance.home, 'SELECT action_id, status FROM extension_invocations WHERE trigger = \'task:input:committed\' ORDER BY action_id')).toEqual(inputActions.map(action_id => ({ action_id, status: 'completed' }))) + const status = await page.evaluate(conversationId => window.lexoraDesktop.localChat.runs.status({ conversationId }), latest().id) + expect(status.tokens.totals).toMatchObject({ totalTokens: 60, recordCount: 2 }) + expect(status.tokens.byPurpose.find(entry => entry.purpose === 'extension.action')).toMatchObject({ totalTokens: 30, recordCount: 1 }) + expect(status.tokens.byModel).toMatchObject([{ modelId: 'primary', totalTokens: 60, runCount: 1 }]) + expect(status.activity.runs.chat.total).toBe(1) + await send('补充测试范围') + await settled() + expect(backgroundRequests()).toBe(1) + + await page.evaluate(id => window.lexoraDesktop.localChat.conversations.rename(id, '手动保留'), latest().id) + await send('继续完成细节') + await settled() + expect(backgroundRequests()).toBe(1) + const taskId = latest().id + const database = new DatabaseSync(path.join(instance.home, 'buddy/buddy.sqlite3')) + database.prepare('UPDATE conversations SET title_revision = 0 WHERE id = ?').run(taskId) + database.close() + await send('历史标题也应保留') + await settled() + expect(backgroundRequests()).toBe(1) + + hold = false + await page.locator('[data-workbench-menu="task.actions"]:visible').click() + await page.locator('.n-dropdown-menu').getByText('重新生成标题', { exact: true }).click() + await expect.poll(() => latest().title).toBe('后台标题 2') + await settled() + await page.evaluate(id => window.lexoraDesktop.extensions.configure(id, { updateOnGoalChange: true }), pluginId) + await send('把主目标调整为插件架构评审') + await expect.poll(() => latest().title).toBe('后台标题 3') + await settled() + await page.screenshot({ path: path.join(instance.artifactDirectory, 'generated-task-title.png'), animations: 'disabled' }) + + hold = true + await page.locator('[data-workbench-menu="task.actions"]:visible').click() + await page.locator('.n-dropdown-menu').getByText('重新生成标题', { exact: true }).click() + await expect.poll(() => pending.length).toBe(1) + await page.evaluate(id => window.lexoraDesktop.localChat.conversations.rename(id, '用户最终命名'), taskId) + pending.shift()() + await settled() + expect(latest().title).toBe('用户最终命名') + + await send('停用插件时保留输入标题', true) + await expect.poll(() => pending.length).toBe(1) + const original = latest().title + await page.evaluate(id => window.lexoraDesktop.extensions.enable(id, false), pluginId) + pending.shift()() + await settled() + expect(latest().title).toBe(original) + const stoppedActions = rows(instance.home, `SELECT action_id, status FROM extension_invocations WHERE conversation_id = '${latest().id}'`) + const namingActionIds = new Set(declaredActions.filter(action => action.triggers.includes('user')).map(action => action.id)) + const stoppedNaming = stoppedActions.filter(action => namingActionIds.has(action.action_id)) + expect(stoppedNaming.length).toBeGreaterThan(0) + expect(stoppedNaming.every(action => action.status === 'cancelled')).toBe(true) + expect(stoppedActions.every(action => ['cancelled', 'completed', 'skipped'].includes(action.status))).toBe(true) + await expect(page.locator('[data-workbench-menu="task.actions"]:visible')).toHaveCount(0) + expect(rows(instance.home, 'SELECT id FROM runs WHERE status = \'failed\'')).toEqual([]) + await page.screenshot({ path: path.join(instance.artifactDirectory, 'background-actions.png'), animations: 'disabled' }) + expect(diagnostics.console.filter(item => item.type === 'pageerror')).toEqual([]) + const persistedTaskId = latest().id + const beforeRestart = await page.evaluate(conversationId => window.lexoraDesktop.localChat.conversations.listTimeline({ conversationId }), persistedTaskId) + await instance.stop() + const restarted = await instance.launch() + const afterRestart = await restarted.page.evaluate(conversationId => window.lexoraDesktop.localChat.conversations.listTimeline({ conversationId }), persistedTaskId) + expect(afterRestart.items.filter(item => item.kind === 'extension-action')).toEqual(beforeRestart.items.filter(item => item.kind === 'extension-action')) + expect(afterRestart.runs).toEqual(beforeRestart.runs) + expect(restarted.diagnostics.console.filter(item => item.type === 'pageerror')).toEqual([]) + } + finally { + await instance.stop() + server.closeAllConnections() + await new Promise(resolve => server.close(resolve)) + } +}) + +async function writeActionFixture(directory) { + const id = 'tests.actions' + await fs.mkdir(directory, { recursive: true }) + await fs.writeFile(path.join(directory, 'extension.json'), JSON.stringify({ + schemaVersion: 1, + id, + name: '后台动作测试', + version: '1.0.0', + apiVersion: 3, + engines: { lexora: '*' }, + entry: 'extension.js', + permissions: { agent: true, models: true, tasks: 'title', taskMessages: true }, + contributes: { + conditions: [{ id: `${id}.available`, inputs: ['configuration', 'form'] }], + settings: { items: [ + { id: `${id}.enabled`, key: 'enabled', group: 'settings.general.general', type: 'boolean', title: '启用', default: true }, + { id: `${id}.updates`, key: 'updateOnGoalChange', group: 'settings.general.general', type: 'boolean', title: '允许更新', default: false, enabledWhen: { condition: `${id}.available` } }, + { id: `${id}.model`, key: 'model', group: 'settings.general.general', type: 'model', title: '模型', default: null, enabledWhen: { condition: `${id}.available` } }, + ] }, + agent: { enabledWhen: 'enabled', actions: [ + { id: `${id}.generate`, title: '重新生成标题', triggers: ['task:input:committed', 'task:turn:completed', 'user'] }, + ...Array.from({ length: 15 }, (_, index) => ({ id: `${id}.read${index}`, title: `Read ${index}`, triggers: ['task:input:committed'] })), + ] }, + }, + })) + await fs.writeFile(path.join(directory, 'extension.js'), `export const activate = ${activateActionFixture.toString()}`) +} + +function activateActionFixture(context) { + context.conditions.register(`${context.extension.id}.available`, ctx => ctx.configuration.status === 'available' && ctx.configuration.values.enabled === true) + for (let index = 0; index < 15; index++) { + context.agent.registerAction(`${context.extension.id}.read${index}`, async (invocation) => { + await invocation.task.messages() + return { status: 'completed' } + }) + } + context.agent.registerAction(`${context.extension.id}.generate`, async (invocation) => { + const config = await context.configuration.get() + const task = await invocation.task.get() + const cause = invocation.cause.type + if (cause !== 'user' && ['manual', 'legacy'].includes(task.titleSource)) + return { status: 'skipped' } + if (cause === 'task:input:committed' && task.titleSource !== 'fallback') + return { status: 'skipped' } + const messages = await invocation.task.messages() + if (cause === 'task:turn:completed' && (!config.updateOnGoalChange || task.titleSource !== 'generated' || messages.filter(message => message.role === 'user').length < 2)) + return { status: 'skipped' } + const result = await invocation.models.generateText({ prompt: JSON.stringify(messages) }) + const saved = await invocation.task.rename({ title: result.text }) + return { status: saved.applied ? 'completed' : 'skipped' } }) } diff --git a/.playwright/scripts/__tests__/settings.e2e.mjs b/.playwright/scripts/__tests__/settings.e2e.mjs index f5fb1dd1..10fe59f5 100644 --- a/.playwright/scripts/__tests__/settings.e2e.mjs +++ b/.playwright/scripts/__tests__/settings.e2e.mjs @@ -52,7 +52,7 @@ test('registered plugin settings share groups, recover read/save errors and with const instance = await buddy.createInstance('settings-registry') let { app, page, diagnostics } = await instance.launch() const directory = path.join(instance.home, 'settings-plugin') - await writeSettingsPlugin(directory) + await writeSettingsPlugin(directory, true) await app.evaluate(({ ipcMain, dialog }, directory) => { dialog.showOpenDialog = async () => ({ canceled: false, filePaths: [directory] }) const original = ipcMain._invokeHandlers.get('lexora:extensions:request') @@ -60,6 +60,10 @@ test('registered plugin settings share groups, recover read/save errors and with globalThis.settingsFixtureSaves = [] ipcMain.removeHandler('lexora:extensions:request') ipcMain.handle('lexora:extensions:request', async (event, request) => { + if (request.action === 'settingConditions') { + globalThis.settingsFixtureConditionReads = (globalThis.settingsFixtureConditionReads ?? 0) + 1 + await globalThis.settingsFixtureConditionGate + } if (request.action === 'configure') { globalThis.settingsFixtureSaves.push(request.patch) await globalThis.settingsFixtureSaveGate @@ -101,8 +105,16 @@ test('registered plugin settings share groups, recover read/save errors and with const label = () => page.locator('[data-setting-id="tests.settings.label"] input') await expect(label()).toBeEnabled() - await label().pressSequentially('editable') + await app.evaluate(() => { + globalThis.settingsFixtureConditionReads = 0 + globalThis.settingsFixtureConditionGate = new Promise(resolve => globalThis.releaseSettingsFixtureCondition = resolve) + }) + await label().pressSequentially('editable', { delay: 75 }) await expect(label()).toHaveValue('editable') + await expect(label()).toBeFocused() + await expect(label()).toBeEnabled() + await expect.poll(() => app.evaluate(() => globalThis.settingsFixtureConditionReads)).toBeGreaterThan(0) + await app.evaluate(() => globalThis.releaseSettingsFixtureCondition()) expect(await page.evaluate(() => window.lexoraDesktop.extensions.configuration('tests.settings'))).toMatchObject({ label: '' }) await app.evaluate(() => { globalThis.settingsFixtureSaves = [] @@ -122,6 +134,17 @@ test('registered plugin settings share groups, recover read/save errors and with await expect(label()).toHaveValue('editable') await expect(label()).toBeEnabled() await app.evaluate(() => globalThis.settingsFixtureFailures.save = false) + await label().fill('') + const ime = await page.context().newCDPSession(page) + await ime.send('Input.imeSetComposition', { text: '组合输入', selectionStart: 4, selectionEnd: 4 }) + await expect(label()).toHaveValue('组合输入') + await expect(label()).toBeFocused() + await expect(label()).toBeEnabled() + expect(await page.evaluate(() => window.lexoraDesktop.extensions.configuration('tests.settings'))).toMatchObject({ label: 'editable' }) + await page.keyboard.insertText('组合输入') + await expect(label()).toHaveValue('组合输入') + await expect(label()).toBeFocused() + await ime.detach() await label().fill('粘贴后的名称') await expect(label()).toHaveValue('粘贴后的名称') await label().press('Enter') @@ -379,7 +402,7 @@ function panelSettings(page) { return { global: desktop.contextPanelGlobal, mode: desktop.contextPanelMode } }) } -async function writeSettingsPlugin(directory) { +async function writeSettingsPlugin(directory, conditional = false) { await fs.mkdir(directory, { recursive: true }) await fs.writeFile(path.join(directory, 'extension.json'), JSON.stringify({ schemaVersion: 1, @@ -389,7 +412,7 @@ async function writeSettingsPlugin(directory) { apiVersion: 3, engines: { lexora: '*' }, entry: 'extension.js', - contributes: { commands: [{ id: 'tests.settings.open', title: 'Open fixture' }], views: [{ id: 'tests.settings.view', title: 'Fixture view', resource: 'none', entry: 'view.js' }], settings: { + contributes: { conditions: conditional ? [{ id: 'tests.settings.available', inputs: ['form'] }] : [], commands: [{ id: 'tests.settings.open', title: 'Open fixture' }], views: [{ id: 'tests.settings.view', title: 'Fixture view', resource: 'none', entry: 'view.js' }], settings: { modules: [{ id: 'tests.settings.module', title: '注册设置' }], groups: [ { id: 'tests.settings.group', module: 'tests.settings.module', title: '插件分组' }, @@ -398,13 +421,13 @@ async function writeSettingsPlugin(directory) { ], items: [ { id: 'tests.settings.enabled', key: 'enabled', group: 'tests.settings.group', type: 'boolean', title: '启用', default: false }, - { id: 'tests.settings.label', key: 'label', group: 'tests.settings.group', type: 'string', title: '名称', default: '' }, + { id: 'tests.settings.label', key: 'label', group: 'tests.settings.group', type: 'string', title: '名称', default: '', ...(conditional ? { enabledWhen: { condition: 'tests.settings.available' } } : {}) }, { id: 'tests.settings.inline', key: 'inline', group: 'settings.general.general', type: 'boolean', title: '分组内单项', default: false }, { id: 'tests.settings.extra-item', key: 'extra', group: 'tests.settings.extra', type: 'number', title: '数值', default: 0 }, { id: 'tests.settings.logs-item', key: 'log', group: 'tests.settings.logs', type: 'boolean', title: '日志附加项', default: false }, ], } }, })) - await fs.writeFile(path.join(directory, 'extension.js'), `export function activate(context) { context.commands.register('tests.settings.open', async () => { await context.storage.set({ retained: true }); await context.views.open('tests.settings.view', { state: { position: 17 } }); }); }\n`) + await fs.writeFile(path.join(directory, 'extension.js'), `export function activate(context) { ${conditional ? 'context.conditions.register(\'tests.settings.available\', () => true);' : ''} context.commands.register('tests.settings.open', async () => { await context.storage.set({ retained: true }); await context.views.open('tests.settings.view', { state: { position: 17 } }); }); }\n`) await fs.writeFile(path.join(directory, 'view.js'), 'export function render(context, container) { container.textContent = "Fixture view"; }\n') } diff --git a/apps/buddy/electron/main/app/DesktopIntegrations.ts b/apps/buddy/electron/main/app/DesktopIntegrations.ts index cb227615..9ad02f80 100644 --- a/apps/buddy/electron/main/app/DesktopIntegrations.ts +++ b/apps/buddy/electron/main/app/DesktopIntegrations.ts @@ -10,9 +10,14 @@ import process from 'node:process' import { app, Notification, shell } from 'electron' import { z } from 'zod' import buddyVersion from '../../../buddy.version.json' +import { CONVERSATION_CHANGED, conversationSchema } from '../../../shared/conversation/conversationApi' +import { extensionActionRpc } from '../../../shared/extensions/extensionActionApi' import { extensionAgentRpc } from '../../../shared/extensions/extensionAgent' import { extensionJsonSchema } from '../../../shared/extensions/extensionApi' import { EXTENSION_REVIEW_REQUEST } from '../../../shared/extensions/extensionAuthoring' +import { extensionConditionSnapshotRpc } from '../../../shared/extensions/extensionConditionContext' +import { providerNotifications } from '../../../shared/providers/providerApi' +import { runNotifications } from '../../../shared/runs/runApi' import { spaceTextDocumentSchema } from '../../../shared/spaces/spaceFileApi' import { DESKTOP_IPC_CHANNELS } from '../../shared/desktopApi' import { registerBrowserDesktopIpc } from '../browser/registerBrowserDesktopIpc' @@ -84,11 +89,15 @@ export class DesktopIntegrations { getWindow: () => windows.window, get: runtime.network.get, notificationsEnabled: () => runtime.config?.desktop.notificationsEnabled ?? true, + conditionRuntime: async (input, signal) => extensionConditionSnapshotRpc.response.parse(await service.request(extensionConditionSnapshotRpc.method, input, { signal, timeoutMs: 5000 })), + taskActions: async () => extensionActionRpc.list.response.parse(await service.request(extensionActionRpc.list.method, {})), + invokeTaskAction: async input => extensionActionRpc.invoke.response.parse(await service.request(extensionActionRpc.invoke.method, input, { timeoutMs: null })), agentChanged: () => service.notify(extensionAgentRpc.changed, {}), agentRequest: async (input, signal) => extensionJsonSchema.parse(await service.request(extensionAgentRpc.request, input, { signal, timeoutMs: 120000 })), readText: async (target, signal) => spaceTextDocumentSchema.parse(await service.request('spaceFiles.readDocument', target, { signal })).text, }) this.#subscriptions.push(extensions.dispose) + this.#subscriptions.push(service.onStateChange(() => extensions.conditions.invalidate({ inputs: ['runtime.models', 'runtime.task'] }))) runtime.inspectExtension = extensions.inspect runtime.extensionAgent = extensions.agent this.#subscriptions.push(() => { @@ -122,6 +131,18 @@ export class DesktopIntegrations { request: service.request.bind(service), }) this.#subscriptions.push(service.onNotification((notification) => { + if (notification.method === providerNotifications.changed.method) + extensions.conditions.invalidate({ inputs: ['runtime.models'] }) + if (notification.method === CONVERSATION_CHANGED) { + const task = conversationSchema.safeParse(notification.params) + if (task.success) + extensions.conditions.invalidate({ inputs: ['runtime.task', 'runtime.models'], taskId: task.data.id }) + } + if (notification.method === runNotifications.event.method) { + const event = runNotifications.event.params.safeParse(notification.params) + if (event.success && /^(?:run\.(?:started|completed|failed|cancelled)|approval\.(?:requested|resolved))$/.test(event.data.type)) + extensions.conditions.invalidate({ inputs: ['runtime.task'] }) + } if (notification.method === EXTENSION_REVIEW_REQUEST) { const input = z.object({ path: z.string().min(1).max(4096) }).strict().safeParse(notification.params) if (input.success) { diff --git a/apps/buddy/electron/main/extensions/__tests__/ExtensionCapabilities.spec.ts b/apps/buddy/electron/main/extensions/__tests__/ExtensionCapabilities.spec.ts index b2d0254a..d9ff93be 100644 --- a/apps/buddy/electron/main/extensions/__tests__/ExtensionCapabilities.spec.ts +++ b/apps/buddy/electron/main/extensions/__tests__/ExtensionCapabilities.spec.ts @@ -28,7 +28,7 @@ it('discovers a compact host index and exact target contracts without loading pl expect(extensionPlacementSchema.parse({ ...placement, enabled: false })).toMatchObject({ height: detail.targets[0]!.height!.default, enabled: false }) expect(extensionPlacementSchema.safeParse({ ...placement, height: detail.targets[0]!.height!.max + 1 }).success).toBe(false) expect(extensionCapabilitiesSchema.parse(await query({ target: 'document.body' })).targets).toEqual([]) - expect(extensionCapabilitiesSchema.parse(await query({ kind: 'runtime' })).targets.map(target => target.target)).toEqual(['events', 'agent.tools', 'agent.models', 'agent.task', 'settings', 'commands', 'workbench.panes', 'workbench.interactions']) + expect(extensionCapabilitiesSchema.parse(await query({ kind: 'runtime' })).targets.map(target => target.target)).toEqual(['events', 'agent.tools', 'agent.actions', 'agent.models', 'agent.task', 'agent.taskMessages', 'conditions', 'settings', 'commands', 'workbench.panes', 'workbench.interactions']) await expect(query({ kind: 'arbitrary' })).rejects.toThrow() } finally { dispose() } diff --git a/apps/buddy/electron/main/extensions/registerExtensionIpc.ts b/apps/buddy/electron/main/extensions/registerExtensionIpc.ts index 2e88b802..83106524 100644 --- a/apps/buddy/electron/main/extensions/registerExtensionIpc.ts +++ b/apps/buddy/electron/main/extensions/registerExtensionIpc.ts @@ -1,8 +1,10 @@ import type { BrowserWindow, IpcMainEvent } from 'electron' import type { ApplicationDiagnosticReporter } from '../../../shared/diagnostics/applicationDiagnostic' +import type { ExtensionTaskAction, ExtensionTaskActionInput, ExtensionTaskActionResult } from '../../../shared/extensions/extensionActionApi' import type { ExtensionAgentDescriptor, ExtensionAgentInvocation } from '../../../shared/extensions/extensionAgent' import type { ExtensionWorkbenchEvent } from '../../../shared/extensions/extensionApi' import type { ExtensionInspection } from '../../../shared/extensions/extensionAuthoring' +import type { ExtensionConditionRuntime } from '../../../shared/extensions/extensionConditionContext' import type { SpaceFileTarget } from '../../../shared/spaces/spaceFileApi' import type { JsonValue } from '../../../shared/workbench/workbenchState' import { join } from 'node:path' @@ -25,10 +27,13 @@ export function registerExtensionIpc(options: { get: (url: string, init: { signal: AbortSignal }) => Promise developmentDirectory?: string notificationsEnabled?: () => boolean + taskActions: () => Promise + invokeTaskAction: (input: ExtensionTaskActionInput) => Promise agentChanged?: () => void record?: ApplicationDiagnosticReporter + conditionRuntime?: (input: { models: boolean, task: boolean, taskId: string | null, runId: string | null }, signal: AbortSignal) => Promise agentRequest?: (input: { invocationId: string, method: string, params: JsonValue }, signal: AbortSignal) => Promise -}): { dispose: () => Promise, reviewPackage: (path: string) => Promise, inspect: (id: string) => Promise, agent: { list: () => Promise, invoke: (input: ExtensionAgentInvocation, signal: AbortSignal) => Promise } } { +}): { conditions: ExtensionService['conditions'], dispose: () => Promise, reviewPackage: (path: string) => Promise, inspect: (id: string) => Promise, agent: { list: () => Promise, invoke: (input: ExtensionAgentInvocation, signal: AbortSignal) => Promise } } { const store = new ExtensionPackageStore(join(options.home, 'extensions'), options.version) const protocol = new ExtensionProtocol(store) const stopProtocol = protocol.install(session.defaultSession, 'view') @@ -52,6 +57,7 @@ export function registerExtensionIpc(options: { createView: pkg => protocol.register(pkg, 'view'), readText: options.readText, agentRequest: options.agentRequest, + conditionRuntime: options.conditionRuntime, get: options.get, compile: compileExtension, selectResources: async (name, selection, signal) => { @@ -176,9 +182,12 @@ export function registerExtensionIpc(options: { await prepared await service.initialize() switch (input.action) { + case 'taskActions': return await options.taskActions() + case 'invokeTaskAction': return await options.invokeTaskAction(input.input) case 'list': return await service.list() case 'configuration': return await service.configuration(input.id) case 'configurationSnapshot': return await service.configurationSnapshot(input.id) + case 'settingConditions': return await service.conditions.settings(input.id, input.items, input.form) case 'configure': return await service.configure(input.id, input.patch) case 'installations': return service.installations.list() case 'catalog': return service.catalog.list(input.refresh) @@ -257,7 +266,13 @@ export function registerExtensionIpc(options: { ipcMain.off(EXTENSION_IPC.workbenchReply, onWorkbenchReply) } } + subscriptions.push(service.conditions.onDidInvalidate((event) => { + const owner = window() + if (owner && !owner.isDestroyed()) + owner.webContents.send(EXTENSION_IPC.conditionsChanged, event) + })) return { + conditions: service.conditions, dispose, agent: { list: async () => { diff --git a/apps/buddy/electron/main/extensions/runtime/__tests__/ExtensionEvents.spec.ts b/apps/buddy/electron/main/extensions/runtime/__tests__/ExtensionEvents.spec.ts index 19711f3f..2bca696b 100644 --- a/apps/buddy/electron/main/extensions/runtime/__tests__/ExtensionEvents.spec.ts +++ b/apps/buddy/electron/main/extensions/runtime/__tests__/ExtensionEvents.spec.ts @@ -1,5 +1,8 @@ import type * as Sdk from '../../../../../service/resources/skills/plugin-creator/references/api' -import type { EventMessage, EventPattern, EventSubscriber } from '../../../../../shared/events/eventTypes' +import type { TaskActionEvents } from '../../../../../shared/conversation/taskEvents' +import type { EventMessage, EventPattern, EventSnapshot, EventSubscriber } from '../../../../../shared/events/eventTypes' +import type { ExtensionActionCause } from '../../../../../shared/extensions/extensionAgent' +import type { ExtensionConditionContext } from '../../../../../shared/extensions/extensionConditionContext' import type { ExtensionHostEvents, ExtensionViewEvents } from '../../../../../shared/extensions/extensionEvents' import { deferred } from '@buddy-tests/deferred' import { expect, expectTypeOf, it } from 'vitest' @@ -7,7 +10,10 @@ import { ExtensionHostEvents as HostSource } from '../ExtensionHostEvents' import { ExtensionViewState } from '../ExtensionViewState' it('keeps the self-contained public SDK and internal event contracts equivalent', () => { + expectTypeOf>().toEqualTypeOf>() expectTypeOf().toEqualTypeOf() + expectTypeOf().toEqualTypeOf() + expectTypeOf().toEqualTypeOf() expectTypeOf().toEqualTypeOf() expectTypeOf>().toEqualTypeOf>() expectTypeOf>().toEqualTypeOf>() diff --git a/apps/buddy/electron/main/extensions/runtime/host.js b/apps/buddy/electron/main/extensions/runtime/host.js index 1abc0c3a..6e8ebc8f 100644 --- a/apps/buddy/electron/main/extensions/runtime/host.js +++ b/apps/buddy/electron/main/extensions/runtime/host.js @@ -1,8 +1,12 @@ +import { copyEventSnapshot } from '../../../../shared/events/eventSnapshot' import { ExtensionHostEvents } from './ExtensionHostEvents' const bridge = window.lexoraExtensionHost const commands = new Map() const agentTools = new Map() +const agentActions = new Map() +const conditions = new Map() +const conditionEvaluations = new Map() const agentInvocations = new Map() const source = new ExtensionHostEvents() const events = source.events @@ -73,6 +77,17 @@ bridge.subscribe(async ({ id, method, params }) => { placements: { show: (id, options) => request('placements.show', { id, ...(typeof options === 'string' ? { instanceId: options } : options ?? {}) }), hide: (id, options) => request('placements.hide', { id, ...(typeof options === 'string' ? { instanceId: options } : options ?? {}) }) }, resources: { readText: resource => request('resources.readText', { id: resource.id }) }, storage: { get: () => request('storage.get'), set: value => request('storage.set', { value, version: manifest.dataVersion }) }, + conditions: { + register(id, callback) { + if (activated || !manifest.contributes.conditions?.some(condition => condition.id === id) || conditions.has(id) || typeof callback !== 'function') { + registrationError = new Error('EXTENSION_CONDITION_INVALID') + throw registrationError + } + conditions.set(id, callback) + return disposable(() => conditions.delete(id)) + }, + invalidate: (input = {}) => request('conditions.invalidate', input), + }, configuration: { get: () => request('configuration.get'), onChange: listener => source.registerConfigurationApplier(listener), @@ -84,6 +99,13 @@ bridge.subscribe(async ({ id, method, params }) => { } agentTools.set(id, callback) return disposable(() => agentTools.delete(id)) + }, registerAction(id, callback) { + if (activated || !manifest.contributes.agent?.actions.some(action => action.id === id) || agentActions.has(id) || typeof callback !== 'function') { + registrationError = new Error('EXTENSION_ACTION_INVALID') + throw registrationError + } + agentActions.set(id, callback) + return disposable(() => agentActions.delete(id)) } }, network: { get: url => request('network.get', { url }) }, notifications: { show: notification => request('notifications.show', notification) }, @@ -111,6 +133,10 @@ bridge.subscribe(async ({ id, method, params }) => { throw new Error('EXTENSION_COMMAND_MISSING') if (manifest.contributes.agent?.tools.some(tool => !agentTools.has(tool.id))) throw new Error('EXTENSION_TOOL_MISSING') + if (manifest.contributes.agent?.actions.some(action => !agentActions.has(action.id))) + throw new Error('EXTENSION_ACTION_MISSING') + if (manifest.contributes.conditions?.some(condition => !conditions.has(condition.id))) + throw new Error('EXTENSION_CONDITION_MISSING') activated = true } else if (method === 'configuration.changed') { @@ -122,8 +148,36 @@ bridge.subscribe(async ({ id, method, params }) => { } catch { throw new Error('EXTENSION_CONFIGURATION_UPDATE_FAILED') } } + else if (method === 'conditions.evaluate') { + if (!activated || !conditions.has(params.condition) || conditionEvaluations.size >= 32) + throw new Error('EXTENSION_CONDITION_UNAVAILABLE') + const controller = new AbortController() + conditionEvaluations.set(params.evaluationId, controller) + const timeout = setTimeout(() => controller.abort(), 2000) + let cancel + try { + const cancelled = new Promise((_, reject) => { + cancel = () => reject(new Error('EXTENSION_CONDITION_CANCELLED')) + controller.signal.addEventListener('abort', cancel, { once: true }) + }) + result = await Promise.race([ + Promise.resolve().then(() => conditions.get(params.condition)(Object.freeze({ ...copyEventSnapshot(params.context), signal: controller.signal }), copyEventSnapshot(params.params))), + cancelled, + ]) + controller.signal.throwIfAborted() + } + catch { throw new Error('EXTENSION_CONDITION_FAILED') } + finally { + clearTimeout(timeout) + controller.signal.removeEventListener('abort', cancel) + conditionEvaluations.delete(params.evaluationId) + } + } + else if (method === 'conditions.cancel') { + conditionEvaluations.get(params.evaluationId)?.abort() + } else if (method === 'agent.invoke') { - if (!activated || !agentTools.has(params.tool)) + if (!activated || !(params.action ? agentActions.has(params.action) : agentTools.has(params.tool))) throw new Error('EXTENSION_AGENT_UNAVAILABLE') const controller = new AbortController() agentInvocations.set(params.invocationId, controller) @@ -132,11 +186,12 @@ bridge.subscribe(async ({ id, method, params }) => { return request('agent.request', { invocationId: params.invocationId, method, params: value }) } try { - result = await agentTools.get(params.tool)(params.input, Object.freeze({ + const invocation = Object.freeze({ signal: controller.signal, - task: Object.freeze({ get: () => call('task.get'), rename: value => call('task.rename', value) }), + task: Object.freeze({ get: () => call('task.get'), messages: () => call('task.messages'), rename: value => call('task.rename', value) }), models: Object.freeze({ generateText: value => call('models.generateText', value) }), - })) ?? null + }) + result = (params.action ? await agentActions.get(params.action)(Object.freeze({ ...invocation, cause: copyEventSnapshot(params.cause) })) : await agentTools.get(params.tool)(params.input, invocation)) ?? null } finally { agentInvocations.delete(params.invocationId) } } @@ -156,6 +211,8 @@ bridge.subscribe(async ({ id, method, params }) => { interactions.delete(params.id) } else if (method === 'deactivate') { + for (const controller of conditionEvaluations.values()) controller.abort() + conditionEvaluations.clear() for (const controller of agentInvocations.values()) controller.abort() agentInvocations.clear() for (const controller of interactions.values()) controller.abort() diff --git a/apps/buddy/electron/main/local-chat/notifications.ts b/apps/buddy/electron/main/local-chat/notifications.ts index c9d7d397..7ef71f9f 100644 --- a/apps/buddy/electron/main/local-chat/notifications.ts +++ b/apps/buddy/electron/main/local-chat/notifications.ts @@ -5,7 +5,7 @@ import { automationNotifications } from '../../../shared/automation/automationAp import { changesChanged } from '../../../shared/changes/changeApi' import { connectorNotifications } from '../../../shared/connectors/connectorApi' import { composerResourcesChanged } from '../../../shared/conversation/composerApi' -import { CONVERSATION_CHANGED, conversationSchema } from '../../../shared/conversation/conversationApi' +import { CONVERSATION_CHANGED, conversationSchema, conversationTimelineChanged } from '../../../shared/conversation/conversationApi' import { webSettingsChanged } from '../../../shared/network/webApi' import { notificationsChanged } from '../../../shared/notifications/notificationApi' import { providerNotifications } from '../../../shared/providers/providerApi' @@ -54,6 +54,12 @@ export function registerLocalChatNotifications(options: RegisterLocalChatIpcOpti sendToRenderer(options.getWindow(), LOCAL_CHAT_IPC_CHANNELS.webSettingsChanged, changed.data) return } + if (notification.method === conversationTimelineChanged.method) { + const result = conversationTimelineChanged.params.safeParse(notification.params) + if (result.success) + sendToRenderer(options.getWindow(), LOCAL_CHAT_IPC_CHANNELS.conversationsTimelineChanged, result.data) + return + } if (notification.method === CONVERSATION_CHANGED) { const result = conversationSchema.safeParse(notification.params) if (result.success) diff --git a/apps/buddy/electron/main/runtime/BuddyServicePeer.ts b/apps/buddy/electron/main/runtime/BuddyServicePeer.ts index dec9d72d..f97ba3f5 100644 --- a/apps/buddy/electron/main/runtime/BuddyServicePeer.ts +++ b/apps/buddy/electron/main/runtime/BuddyServicePeer.ts @@ -44,7 +44,7 @@ export class BuddyServicePeer implements RuntimeRpcPeerContract { return this.#peer.onRequest(method, handler) } - request(method: string, params: unknown, timeoutMs?: number, signal?: AbortSignal, requestId?: string): Promise { + request(method: string, params: unknown, timeoutMs?: number | null, signal?: AbortSignal, requestId?: string): Promise { return this.#peer.request(method, params, timeoutMs, signal, requestId) } diff --git a/apps/buddy/electron/main/runtime/BuddyServiceSupervisor.ts b/apps/buddy/electron/main/runtime/BuddyServiceSupervisor.ts index ee23146b..1eb3c7e8 100644 --- a/apps/buddy/electron/main/runtime/BuddyServiceSupervisor.ts +++ b/apps/buddy/electron/main/runtime/BuddyServiceSupervisor.ts @@ -40,7 +40,7 @@ export interface BuddyServiceNotification { export interface BuddyServiceRequestOptions { requestId?: string signal?: AbortSignal - timeoutMs?: number + timeoutMs?: number | null } export interface BuddyServiceSupervisorOptions { diff --git a/apps/buddy/electron/preload/extensions.ts b/apps/buddy/electron/preload/extensions.ts index 9d3ccb10..8d12cba5 100644 --- a/apps/buddy/electron/preload/extensions.ts +++ b/apps/buddy/electron/preload/extensions.ts @@ -6,9 +6,13 @@ import { subscribe } from './subscribe' export function createExtensionApi(): ExtensionApi { const request = (input: ExtensionManagementRequest) => ipcRenderer.invoke(EXTENSION_IPC.request, input) return Object.freeze({ + taskActions: () => request({ action: 'taskActions' }), + invokeTaskAction: input => request({ action: 'invokeTaskAction', input }), list: () => request({ action: 'list' }), configuration: id => request({ action: 'configuration', id }), configurationSnapshot: id => request({ action: 'configurationSnapshot', id }), + settingConditions: (id, items, form) => request({ action: 'settingConditions', id, items, form }), + onConditionsChanged: listener => subscribe(EXTENSION_IPC.conditionsChanged, listener), configure: (id, patch) => request({ action: 'configure', id, patch }), installations: () => request({ action: 'installations' }), catalog: (refresh = false) => request({ action: 'catalog', refresh }), diff --git a/apps/buddy/electron/preload/local-chat/conversation.ts b/apps/buddy/electron/preload/local-chat/conversation.ts index 8f504f77..0425257a 100644 --- a/apps/buddy/electron/preload/local-chat/conversation.ts +++ b/apps/buddy/electron/preload/local-chat/conversation.ts @@ -16,6 +16,7 @@ export function createConversationApi(): Pick subscribe(LOCAL_CHAT_IPC_CHANNELS.conversationsChanged, listener), + onTimelineChanged: listener => subscribe(LOCAL_CHAT_IPC_CHANNELS.conversationsTimelineChanged, listener), getNodeDetail: input => ipcRenderer.invoke(LOCAL_CHAT_IPC_CHANNELS.conversationsGetNodeDetail, input), getTree: conversationId => ipcRenderer.invoke(LOCAL_CHAT_IPC_CHANNELS.conversationsGetTree, { conversationId }), list: limit => ipcRenderer.invoke(LOCAL_CHAT_IPC_CHANNELS.conversationsList, { limit }), diff --git a/apps/buddy/electron/shared/localChatApi.ts b/apps/buddy/electron/shared/localChatApi.ts index 4c128566..ebd4c180 100644 --- a/apps/buddy/electron/shared/localChatApi.ts +++ b/apps/buddy/electron/shared/localChatApi.ts @@ -18,7 +18,7 @@ import type { BuddyComposerSpaceFileSelect, } from '../../shared/conversation/composerResource' import type { LocalContextUsageSnapshot, LocalContextUsageSnapshotRequest } from '../../shared/conversation/contextApi' -import type { LocalConversation, LocalConversationBranch, LocalConversationSummary, LocalConversationTimelinePage, LocalMessagePage } from '../../shared/conversation/conversationApi' +import type { ConversationTimelineChangeNotice, LocalConversation, LocalConversationBranch, LocalConversationSummary, LocalConversationTimelinePage, LocalMessagePage } from '../../shared/conversation/conversationApi' 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' @@ -127,6 +127,7 @@ export const LOCAL_CHAT_IPC_CHANNELS = { conversationsListMessages: 'lexora:buddy:conversations:list-messages', conversationsRename: 'lexora:buddy:conversations:rename', conversationsChanged: 'lexora:buddy:conversations:changed', + conversationsTimelineChanged: 'lexora:buddy:conversations:timeline-changed', conversationsSetPermissionSettings: 'lexora:buddy:conversations:set-permission-settings', conversationsSetModelSelection: 'lexora:buddy:conversations:set-model-selection', conversationsListTimeline: 'lexora:buddy:conversations:list-timeline', @@ -382,6 +383,7 @@ export interface LocalChatApi { } conversations: { onChanged: (listener: (conversation: LocalConversation) => void) => () => void + onTimelineChanged: (listener: (event: ConversationTimelineChangeNotice) => void) => () => void getNodeDetail: (input: ConversationNodeDetailRequest) => Promise getTree: (conversationId: string) => Promise list: (limit?: number) => Promise> diff --git a/apps/buddy/platform/extensions/ExtensionConditionEvaluator.ts b/apps/buddy/platform/extensions/ExtensionConditionEvaluator.ts new file mode 100644 index 00000000..e4f1a750 --- /dev/null +++ b/apps/buddy/platform/extensions/ExtensionConditionEvaluator.ts @@ -0,0 +1,198 @@ +import type { ExtensionConditionContext } from '../../shared/extensions/extensionConditionContext' +import type { ExtensionConditionDefinition, ExtensionConditionInput, ExtensionConditionReference, ExtensionConditionsChanged, ExtensionConditionState } from '../../shared/extensions/extensionConditions' +import type { JsonValue } from '../../shared/workbench/workbenchState' +import type { ExtensionHost } from './ExtensionService' +import { createHash, randomUUID } from 'node:crypto' +import { Emitter } from '../../shared/events/Emitter' +import { extensionConditionResultSchema } from '../../shared/extensions/extensionConditions' + +interface Identity { + extensionId: string + condition: string + scopeKey: string + taskId?: string + inputs: readonly ExtensionConditionInput[] +} +interface Operation extends Identity { controller: AbortController } +interface Evaluation { controller: AbortController, consumers: number, result: Promise } +interface Snapshot { + generation: string + context: ExtensionConditionContext + host: ExtensionHost + signal: AbortSignal +} +interface Invalidation { + extensionId?: string + condition?: string + scopeKey?: string + taskId?: string + inputs?: readonly ExtensionConditionInput[] +} +const unavailable: ExtensionConditionState = { status: 'unavailable', value: false } + +export class ExtensionConditionEvaluator { + readonly #changes = new Emitter(() => {}) + readonly onDidInvalidate = this.#changes.event + readonly #pending = new Set() + readonly #waiting = new Map void>() + readonly #cache = new Map() + readonly #evaluations = new Map() + readonly #stop = new AbortController() + #revision = 0 + #active = 0 + + async evaluate(input: { + extensionId: string + definition: ExtensionConditionDefinition + reference: ExtensionConditionReference + scopeKey: string + taskId?: string + signal?: AbortSignal + cache: boolean + snapshot: (signal: AbortSignal) => Promise + }): Promise { + if (this.#stop.signal.aborted) + return unavailable + const operation: Operation = { extensionId: input.extensionId, condition: input.definition.id, scopeKey: input.scopeKey, taskId: input.taskId, inputs: input.definition.inputs, controller: new AbortController() } + const signal = AbortSignal.any([operation.controller.signal, this.#stop.signal, ...input.signal ? [input.signal] : []]) + this.#pending.add(operation) + let evaluation: Evaluation | undefined + let release: (() => void) | undefined + let timeout: ReturnType | undefined + try { + release = await this.#acquire(operation, signal) + signal.throwIfAborted() + timeout = setTimeout(() => operation.controller.abort(), 5000) + const snapshot = await waitFor(input.snapshot(signal), signal) + const abort = AbortSignal.any([signal, snapshot.signal]) + abort.throwIfAborted() + const key = conditionRevision({ extensionId: input.extensionId, generation: snapshot.generation, reference: input.reference, context: snapshot.context }) + const cached = input.cache ? this.#cache.get(key) : undefined + if (cached) + return cached.state + evaluation = input.cache ? this.#evaluations.get(key) : undefined + if (!evaluation || evaluation.controller.signal.aborted) { + const controller = new AbortController() + const result = this.#execute(snapshot, input.reference, AbortSignal.any([controller.signal, this.#stop.signal, snapshot.signal])) + evaluation = { controller, consumers: 0, result } + if (input.cache) { + this.#evaluations.set(key, evaluation) + const current = evaluation + void result.finally(() => { + if (this.#evaluations.get(key) === current) + this.#evaluations.delete(key) + }).catch(() => {}) + } + } + evaluation.consumers++ + const state = await waitFor(evaluation.result, abort) + abort.throwIfAborted() + if (input.cache) { + this.#cache.set(key, { identity: operation, state }) + if (this.#cache.size > 512) + this.#cache.delete(this.#cache.keys().next().value!) + } + return state + } + catch { + return unavailable + } + finally { + clearTimeout(timeout) + this.#pending.delete(operation) + if (evaluation && --evaluation.consumers === 0) + evaluation.controller.abort() + release?.() + } + } + + #acquire(operation: Operation, signal: AbortSignal): Promise<() => void> { + signal.throwIfAborted() + if (this.#waiting.size >= 1024) + return Promise.reject(new Error('EXTENSION_REQUEST_LIMIT')) + return new Promise((resolve, reject) => { + const cancel = () => { + this.#waiting.delete(operation) + reject(signal.reason) + } + const start = () => { + this.#waiting.delete(operation) + signal.removeEventListener('abort', cancel) + this.#active++ + resolve(() => { + this.#active-- + this.#waiting.values().next().value?.() + }) + } + if (this.#active < 32) { + start() + } + else { + this.#waiting.set(operation, start) + signal.addEventListener('abort', cancel, { once: true }) + } + }) + } + + async #execute(snapshot: Snapshot, reference: ExtensionConditionReference, signal: AbortSignal): Promise { + const evaluationId = randomUUID() + const cancel = () => { + void snapshot.host.call('conditions.cancel', { evaluationId }).catch(() => {}) + } + signal.addEventListener('abort', cancel, { once: true }) + try { + signal.throwIfAborted() + const result = await waitFor(snapshot.host.call('conditions.evaluate', { evaluationId, condition: reference.condition, context: snapshot.context, params: reference.params } as JsonValue), signal) + signal.throwIfAborted() + return { status: 'ready', ...extensionConditionResultSchema.parse(result) } + } + finally { signal.removeEventListener('abort', cancel) } + } + + invalidate(input: Invalidation, extensions: readonly string[] = []): void { + const matches = (identity: Identity) => (!input.extensionId || identity.extensionId === input.extensionId) + && (!input.condition || identity.condition === input.condition) + && (!input.scopeKey || identity.scopeKey === input.scopeKey) + && (!input.taskId || identity.taskId === input.taskId) + && (!input.inputs || identity.inputs.some(source => input.inputs!.includes(source))) + const affected = new Set(input.extensionId ? [input.extensionId] : extensions) + for (const operation of this.#pending) { + if (matches(operation)) { + affected.add(operation.extensionId) + operation.controller.abort() + } + } + for (const [key, cached] of this.#cache) { + if (matches(cached.identity)) { + affected.add(cached.identity.extensionId) + this.#cache.delete(key) + } + } + for (const extensionId of affected) + this.#changes.fire({ extensionId, revision: ++this.#revision, ...(input.condition ? { condition: input.condition } : {}), ...(input.scopeKey ? { scopeKey: input.scopeKey } : {}) }) + } + + dispose(): void { + this.#stop.abort() + this.#cache.clear() + this.#changes.dispose() + } +} + +export function conditionRevision(value: unknown): string { + return createHash('sha256').update(JSON.stringify(value)).digest('hex') +} + +async function waitFor(pending: Promise, signal: AbortSignal): Promise { + let abort!: () => void + const cancelled = new Promise((_, reject) => { + abort = () => reject(signal.reason) + signal.addEventListener('abort', abort, { once: true }) + if (signal.aborted) + abort() + }) + try { + return await Promise.race([pending, cancelled]) + } + finally { signal.removeEventListener('abort', abort) } +} diff --git a/apps/buddy/platform/extensions/ExtensionConditions.ts b/apps/buddy/platform/extensions/ExtensionConditions.ts new file mode 100644 index 00000000..272dbbfa --- /dev/null +++ b/apps/buddy/platform/extensions/ExtensionConditions.ts @@ -0,0 +1,122 @@ +import type { ExtensionConditionContext, ExtensionConditionInvocationScope, ExtensionConditionRuntime } from '../../shared/extensions/extensionConditionContext' +import type { ExtensionConditionInput, ExtensionConditionReference, ExtensionConditionState } from '../../shared/extensions/extensionConditions' +import type { ExtensionConfiguration } from '../../shared/extensions/extensionSettings' +import type { ExtensionPackage } from './ExtensionPackageStore' +import type { ExtensionHost } from './ExtensionService' +import { extensionConditionContextSchema, extensionConditionRuntimeSchema } from '../../shared/extensions/extensionConditionContext' +import { conditionRevision, ExtensionConditionEvaluator } from './ExtensionConditionEvaluator' + +interface Options { + package: (id: string) => Promise + packages: () => readonly ExtensionPackage[] + configuration: (id: string) => Promise<{ values: ExtensionConfiguration, invalidKeys: string[] }> + activate: (id: string) => Promise<{ generation: string, host: ExtensionHost, signal: AbortSignal }> + runtime?: (input: { models: boolean, task: boolean, taskId: string | null, runId: string | null }, signal: AbortSignal) => Promise + workbench: () => Array<{ id: string, active: boolean, visible: boolean }> +} + +export class ExtensionConditions { + readonly #options: Options + readonly #evaluator = new ExtensionConditionEvaluator() + readonly onDidInvalidate = this.#evaluator.onDidInvalidate + + constructor(options: Options) { this.#options = options } + + async settings(id: string, items: readonly string[], form?: ExtensionConfiguration): Promise> { + const pkg = await this.#options.package(id) + if (form && Object.keys(form).some(key => !pkg.manifest.contributes.settings.items.some(item => item.key === key))) + throw new Error('EXTENSION_CONDITION_INVALID') + return Object.fromEntries(await Promise.all(items.map(async (itemId) => { + const item = pkg.manifest.contributes.settings.items.find(item => item.id === itemId) + if (!item) + throw new Error('EXTENSION_CONDITION_INVALID') + const group = pkg.manifest.contributes.settings.groups.find(group => group.id === item.group) + const state = item.enabledWhen + ? await this.evaluate(pkg, item.enabledWhen, { kind: 'setting', id: item.id }, { kind: 'settings', groupId: item.group, moduleId: group?.module ?? item.group.slice(0, item.group.lastIndexOf('.')) }, { form, cache: true }) + : { status: 'ready' as const, value: true } + return [itemId, state] + }))) + } + + evaluate(pkg: ExtensionPackage, reference: ExtensionConditionReference, target: ExtensionConditionContext['target'], invocation: ExtensionConditionInvocationScope, options: { cache: boolean, signal?: AbortSignal, form?: ExtensionConfiguration }): Promise { + const definition = pkg.manifest.contributes.conditions.find(condition => condition.id === reference.condition) + if (!definition) + return Promise.resolve({ status: 'unavailable', value: false }) + const scopeKey = conditionRevision(invocation) + return this.#evaluator.evaluate({ + extensionId: pkg.manifest.id, + definition, + reference, + scopeKey, + cache: options.cache, + signal: options.signal, + ...(invocation.kind === 'task' ? { taskId: invocation.taskId } : {}), + snapshot: async (signal) => { + const active = await this.#options.activate(pkg.manifest.id) + signal.throwIfAborted() + const current = await this.#options.package(pkg.manifest.id) + if (current.revision !== pkg.revision) + throw new Error('EXTENSION_CONDITION_STALE') + const uses = (input: ExtensionConditionInput) => definition.inputs.includes(input) + const configuration = await this.#options.configuration(pkg.manifest.id) + signal.throwIfAborted() + const runtime: ExtensionConditionRuntime = { models: { status: 'not_requested' }, task: { status: 'not_requested' } } + const models = uses('runtime.models') && pkg.manifest.permissions.models + const task = uses('runtime.task') && pkg.manifest.permissions.tasks !== 'none' + if (models || task) { + const value = this.#options.runtime + ? await this.#options.runtime({ models, task, taskId: invocation.kind === 'task' ? invocation.taskId : null, runId: invocation.kind === 'task' ? invocation.runId : null }, signal).catch(() => ({ models: { status: 'loading' as const }, task: { status: 'loading' as const } })) + : { models: { status: 'unsupported' as const }, task: { status: 'unsupported' as const } } + const parsed = extensionConditionRuntimeSchema.parse(value) + if (models) + runtime.models = parsed.models + if (task) + runtime.task = parsed.task + } + if (uses('runtime.models') && !models) + runtime.models = { status: 'denied' } + if (uses('runtime.task') && !task) + runtime.task = { status: 'denied' } + const panes = uses('workbench') ? this.#options.workbench() : [] + const formValues = { ...configuration.values, ...options.form } + const context = extensionConditionContextSchema.parse({ + version: 1, + scope: { key: scopeKey, configuration: { kind: 'global' }, invocation }, + target, + configuration: !uses('configuration') + ? { status: 'not_requested' } + : configuration.invalidKeys.length + ? { status: 'invalid' } + : { + status: 'available', + revision: conditionRevision(configuration.values), + values: configuration.values, + sources: Object.fromEntries(Object.keys(configuration.values).map(key => [key, { kind: 'global' }])), + }, + runtime, + workbench: uses('workbench') ? { status: 'available', revision: conditionRevision(panes), panes } : { status: 'not_requested' }, + form: !uses('form') ? { status: 'not_requested' } : invocation.kind !== 'settings' ? { status: 'no_context' } : { status: 'available', revision: conditionRevision(formValues), values: formValues, dirtyKeys: Object.keys(options.form ?? {}) }, + }) + signal.throwIfAborted() + return { ...active, context } + }, + }) + } + + invalidate(input: { extensionId?: string, condition?: string, scopeKey?: string, taskId?: string, inputs?: readonly ExtensionConditionInput[] }): void { + if (!input.inputs) { + this.#evaluator.invalidate(input) + return + } + for (const pkg of this.#options.packages()) { + if (input.extensionId && pkg.manifest.id !== input.extensionId) + continue + for (const definition of pkg.manifest.contributes.conditions) { + if (definition.inputs.some(source => input.inputs!.includes(source))) + this.#evaluator.invalidate({ ...input, extensionId: pkg.manifest.id, condition: definition.id }) + } + } + } + + dispose(): void { this.#evaluator.dispose() } +} diff --git a/apps/buddy/platform/extensions/ExtensionService.ts b/apps/buddy/platform/extensions/ExtensionService.ts index e4c0495f..80ab1aa6 100644 --- a/apps/buddy/platform/extensions/ExtensionService.ts +++ b/apps/buddy/platform/extensions/ExtensionService.ts @@ -1,6 +1,7 @@ import type { ExtensionAgentDescriptor, ExtensionAgentInvocation } from '../../shared/extensions/extensionAgent' import type { ExtensionMenuInvocation, ExtensionResource, ExtensionStatus, ExtensionViewInput, ExtensionViewSession, ExtensionWorkbenchEvent } from '../../shared/extensions/extensionApi' import type { ExtensionInspection } from '../../shared/extensions/extensionAuthoring' +import type { ExtensionConditionRuntime } from '../../shared/extensions/extensionConditionContext' import type { ExtensionResourceSelection } from '../../shared/extensions/extensionResources' import type { ExtensionConfiguration, ExtensionConfigurationSnapshot } from '../../shared/extensions/extensionSettings' import type { SpaceFileTarget } from '../../shared/spaces/spaceFileApi' @@ -14,10 +15,11 @@ import { basename } from 'node:path' import { z } from 'zod' import { Emitter } from '../../shared/events/Emitter' import { copyEventSnapshot } from '../../shared/events/eventSnapshot' -import { extensionAgentRequestSchema } from '../../shared/extensions/extensionAgent' +import { extensionAgentInvocationLimits, extensionAgentRequestSchema } from '../../shared/extensions/extensionAgent' import { extensionAgentCapabilities } from '../../shared/extensions/extensionAgentCapabilities' import { extensionError, extensionJsonSchema, extensionResourceSchema } from '../../shared/extensions/extensionApi' import { EXTENSION_CATALOG_URL } from '../../shared/extensions/extensionCatalog' +import { extensionConditionInvalidationSchema } from '../../shared/extensions/extensionConditions' import { extensionCompatible, extensionManifestSchema } from '../../shared/extensions/extensionManifest' import { extensionDirectoryScanSchema, extensionResourceSelectionSchema } from '../../shared/extensions/extensionResources' import { extensionNotificationSchema, extensionScheduleIdSchema, extensionScheduleInputSchema } from '../../shared/extensions/extensionSchedule' @@ -26,6 +28,7 @@ import { workbenchHitRegionsSchema } from '../../shared/workbench/workbenchInter import { controlProposalSchema, extensionPresentationRequestSchema } from '../../shared/workbench/workbenchUi' import { publicWebUrl, readResponseBytes } from '../network/publicWebTransport' import { ExtensionCatalogService } from './ExtensionCatalogService' +import { ExtensionConditions } from './ExtensionConditions' import { sha256, unpackExtension } from './extensionFiles' import { ExtensionInstallations } from './ExtensionInstallations' import { extensionActivationOrder } from './ExtensionPackageStore' @@ -45,6 +48,7 @@ export interface ExtensionServicePorts { get: (url: string, init: { signal: AbortSignal }) => Promise changed?: () => void agentChanged?: () => void + conditionRuntime?: (input: { models: boolean, task: boolean, taskId: string | null, runId: string | null }, signal: AbortSignal) => Promise agentRequest?: (input: { invocationId: string, method: string, params: JsonValue }, signal: AbortSignal) => Promise notify?: (id: string, notification: { title: string, body: string }) => boolean compile?: ExtensionCompiler @@ -78,6 +82,7 @@ export class ExtensionService { readonly onDidChange = this.#changes.event readonly #configurationApplications = new Map() #revision = 0 + readonly conditions: ExtensionConditions readonly store: ExtensionPackageStore readonly scheduler: ExtensionScheduler readonly installations: ExtensionInstallations @@ -110,6 +115,22 @@ export class ExtensionService { constructor(store: ExtensionPackageStore, ports: ExtensionServicePorts) { this.store = store this.#ports = ports + this.conditions = new ExtensionConditions({ + package: async (id) => { + await this.initialize() + await this.#mutating + this.#order(id) + return this.store.installed[id]!.current + }, + packages: () => Object.values(this.store.installed).filter(record => record.enabled).map(record => record.current), + configuration: id => this.store.configurationSnapshot(id), + activate: async (id) => { + const running = await this.#activate(id) + return { generation: running.generation, host: running.host, signal: running.abort.signal } + }, + runtime: ports.conditionRuntime, + workbench: () => this.#panes.map(({ id, active, visible }) => ({ id, active, visible })), + }) this.onDidChange((change) => { ports.changed?.() if (change.kind === 'contributions' && !change.initial) @@ -229,6 +250,7 @@ export class ExtensionService { if (!changedKeys.length) return await this.store.saveConfiguration(id, current) + this.conditions.invalidate({ extensionId: id, inputs: ['configuration', 'form'] }) const operationId = randomUUID() const configurationRevision = sha256(JSON.stringify(current)) const running = this.#running.get(id) @@ -289,7 +311,7 @@ export class ExtensionService { try { this.#order(id) const configuration = await this.store.configuration(id) - if (agent.enabledWhen && configuration[agent.enabledWhen] !== true) + if (typeof agent.enabledWhen === 'string' && configuration[agent.enabledWhen] !== true) continue result.push({ id, name: manifest.name, revision, configurationRevision: sha256(JSON.stringify(configuration)), agent }) } @@ -315,16 +337,34 @@ export class ExtensionService { async invokeAgent(input: ExtensionAgentInvocation, signal: AbortSignal): Promise { signal.throwIfAborted() const contribution = (await this.agentContributions()).find(item => item.id === input.extensionId) - if (!contribution || contribution.revision !== input.revision || contribution.configurationRevision !== input.configurationRevision || !contribution.agent.tools.some(tool => tool.id === input.tool)) - throw new Error('EXTENSION_AGENT_UNAVAILABLE') + if (!contribution || contribution.revision !== input.revision || contribution.configurationRevision !== input.configurationRevision || !('tool' in input ? contribution.agent.tools.some(tool => tool.id === input.tool) : contribution.agent.actions.some(action => action.id === input.action && action.triggers.includes(input.cause.type)))) + throw Object.assign(new Error('EXTENSION_AGENT_UNAVAILABLE'), { code: 'EXTENSION_AGENT_UNAVAILABLE' }) const running = await this.#activate(input.extensionId) const current = (await this.agentContributions()).find(item => item.id === input.extensionId) if (!current || current.revision !== input.revision || current.configurationRevision !== input.configurationRevision) + throw Object.assign(new Error('EXTENSION_AGENT_UNAVAILABLE'), { code: 'EXTENSION_AGENT_UNAVAILABLE' }) + const references = [ + ...(typeof current.agent.enabledWhen === 'object' ? [{ reference: current.agent.enabledWhen, target: { kind: 'agent' as const, id: current.id } }] : []), + ...('action' in input ? current.agent.actions.filter(action => action.id === input.action && action.enabledWhen).map(action => ({ reference: action.enabledWhen!, target: { kind: 'action' as const, id: action.id } })) : []), + ] + for (const { reference, target } of references) { + if (!input.context) + throw new Error('EXTENSION_CONDITION_UNAVAILABLE') + const state = await this.conditions.evaluate(running.package, reference, target, { kind: 'task', ...input.context }, { cache: false, signal }) + signal.throwIfAborted() + this.#assertCurrent(running) + if (!state.value) { + if (state.status === 'ready' && 'action' in input) + return { status: 'skipped', ...(state.reason ? { message: state.reason } : {}) } + throw new Error('EXTENSION_CONDITION_UNAVAILABLE') + } + } + if (references.length && (await this.agentContributions()).find(item => item.id === input.extensionId)?.configurationRevision !== input.configurationRevision) throw new Error('EXTENSION_AGENT_UNAVAILABLE') const controller = new AbortController() const abort = AbortSignal.any([signal, running.abort.signal, controller.signal, AbortSignal.timeout(120000)]) abort.throwIfAborted() - if (this.#agentInvocations.has(input.invocationId) || [...this.#agentInvocations.values()].filter(item => item.running === running).length >= 4) + if (this.#agentInvocations.has(input.invocationId) || [...this.#agentInvocations.values()].filter(item => item.running === running).length >= extensionAgentInvocationLimits.concurrentPerExtension) throw new Error('EXTENSION_REQUEST_LIMIT') this.#agentInvocations.set(input.invocationId, { running, signal: abort, abort: controller }) const cancel = () => { @@ -332,11 +372,16 @@ export class ExtensionService { } abort.addEventListener('abort', cancel, { once: true }) try { - const result = await running.host.call('agent.invoke', { invocationId: input.invocationId, tool: input.tool, input: input.input }) + const result = await running.host.call('agent.invoke', { invocationId: input.invocationId, ...('tool' in input ? { tool: input.tool, input: input.input } : { action: input.action, cause: input.cause }) }) abort.throwIfAborted() this.#assertCurrent(running) return extensionJsonSchema.parse(result) } + catch (error) { + if (abort.aborted) + throw Object.assign(new Error('EXTENSION_AGENT_CANCELLED'), { code: 'EXTENSION_AGENT_CANCELLED' }) + throw error + } finally { abort.removeEventListener('abort', cancel) this.#agentInvocations.delete(input.invocationId) @@ -500,7 +545,10 @@ export class ExtensionService { } updatePanes(panes: WorkbenchPaneSnapshot[]): void { + const changed = JSON.stringify(this.#panes.map(({ id, active, visible }) => ({ id, active, visible }))) !== JSON.stringify(panes.map(({ id, active, visible }) => ({ id, active, visible }))) this.#panes = structuredClone(panes) + if (changed) + this.conditions.invalidate({ inputs: ['workbench'] }) for (const running of this.#running.values()) { if (running.active) this.#publishPanes(running) @@ -847,6 +895,7 @@ export class ExtensionService { return this.#disposal this.#disposed = true this.#disposal = Promise.resolve().then(async () => { + this.conditions.dispose() this.scheduler.dispose() this.catalog.dispose() const stopping = this.resetHosts() @@ -960,6 +1009,13 @@ export class ExtensionService { if (method === 'configuration.get') { result = await this.store.configuration(id) } + else if (method === 'conditions.invalidate') { + const input = extensionConditionInvalidationSchema.parse(params) + if (input.condition && !running.package.manifest.contributes.conditions.some(condition => condition.id === input.condition)) + throw new Error('EXTENSION_CONDITION_INVALID') + this.conditions.invalidate({ extensionId: id, ...input }) + result = null + } else if (method === 'agent.request') { const input = extensionAgentRequestSchema.parse(params) const invocation = this.#agentInvocations.get(input.invocationId) @@ -1151,6 +1207,7 @@ export class ExtensionService { } async #stopHost(id: string): Promise { + this.conditions.invalidate({ extensionId: id }) const running = this.#running.get(id) if (!running) return diff --git a/apps/buddy/platform/extensions/__tests__/ExtensionConditions.spec.ts b/apps/buddy/platform/extensions/__tests__/ExtensionConditions.spec.ts new file mode 100644 index 00000000..f189dd07 --- /dev/null +++ b/apps/buddy/platform/extensions/__tests__/ExtensionConditions.spec.ts @@ -0,0 +1,218 @@ +import type { ExtensionConditionContext } from '../../../shared/extensions/extensionConditionContext' +import type { JsonValue } from '../../../shared/workbench/workbenchState' +import type { ExtensionHost, ExtensionServicePorts } from '../ExtensionService' +import { randomUUID } from 'node:crypto' +import { rm } from 'node:fs/promises' +import { deferred } from '@buddy-tests/deferred' +import { expect, it, vi } from 'vitest' +import { extensionConditionContextSchema } from '../../../shared/extensions/extensionConditionContext' +import { extensionManifestSchema } from '../../../shared/extensions/extensionManifest' +import { ExtensionConditionEvaluator } from '../ExtensionConditionEvaluator' +import { ExtensionConditions } from '../ExtensionConditions' +import { ExtensionService } from '../ExtensionService' +import { createStore, manifest, reviewPackage } from './fixtures' + +const condition = 'tests.reader.available' +function definition() { + return manifest({ apiVersion: 3, permissions: { agent: true, models: true, tasks: 'read' }, contributes: { + conditions: [{ id: condition, inputs: ['configuration', 'form', 'runtime.models', 'runtime.task', 'workbench'] }], + agent: { enabledWhen: { condition }, actions: [{ id: 'tests.reader.run', title: 'Run', triggers: ['user'] }] }, + settings: { items: [ + { id: 'tests.reader.enabled', key: 'enabled', group: 'settings.general.general', type: 'boolean', title: 'Enabled', default: true }, + { id: 'tests.reader.model', key: 'model', group: 'settings.general.general', type: 'model', title: 'Model', default: null, enabledWhen: { condition, params: { purpose: 'configure', nested: { supported: [true, null, 1] } } } }, + ] }, + } }) +} +it('validates owned references, declared inputs, permissions and bounded parameters', () => { + const valid = definition() + expect(valid.contributes.agent?.enabledWhen).toEqual({ condition, params: {} }) + for (const conditions of [[], [{ id: 'another.plugin.available', inputs: [] }], [{ id: condition, inputs: ['configuration', 'configuration'] }]]) + expect(extensionManifestSchema.safeParse({ ...valid, contributes: { ...valid.contributes, conditions } }).success).toBe(false) + for (const permissions of [{ ...valid.permissions, models: false }, { ...valid.permissions, tasks: 'none' }]) + expect(extensionManifestSchema.safeParse({ ...valid, permissions }).success).toBe(false) + expect(extensionManifestSchema.safeParse({ ...valid, contributes: { ...valid.contributes, agent: { ...valid.contributes.agent, enabledWhen: { condition, params: { large: 'x'.repeat(17000) } } } } }).success).toBe(false) +}) + +it('refreshes saved configuration and scopes runtime rechecks to the invoking task without trusting the settings cache', async () => { + const { store, root } = await createStore() + const pkg = definition() + await store.install((await reviewPackage(root, store, pkg)).token) + const seen: ExtensionConditionContext[] = [] + const requests: unknown[] = [] + let invoked = 0 + const ports: ExtensionServicePorts = { + createHost: () => ({ call: async (method, params): Promise => { + if (method === 'conditions.evaluate') { + const context = extensionConditionContextSchema.parse((params as Record).context) + seen.push(context) + return context.configuration.status === 'available' && context.configuration.values.enabled === true && context.scope.invocation.kind === 'settings' + } + if (method === 'agent.invoke') { + invoked++ + return { status: 'completed' } + } + if (method === 'configuration.changed') + return { ...(params as object), applied: true } + return null + }, dispose: async () => {}, devtools() {} }), + createView: () => { throw new Error('unused') }, + workbench: async () => null, + get: async () => new Response(''), + readText: async () => '', + conditionRuntime: async (input) => { + requests.push(input) + return { models: { status: 'available', revision: 'models-1', models: [], selection: null }, task: { status: 'no_context' } } + }, + } + const service = new ExtensionService(store, ports) + try { + const field = 'tests.reader.model' + expect(await service.conditions.settings(pkg.id, [field], { enabled: false })).toEqual({ [field]: { status: 'ready', value: true } }) + expect(seen.at(-1)).toMatchObject({ version: 1, scope: { configuration: { kind: 'global' }, invocation: { kind: 'settings', groupId: 'settings.general.general' } }, configuration: { values: { enabled: true, model: null }, sources: { enabled: { kind: 'global' } } }, form: { values: { enabled: false, model: null }, dirtyKeys: ['enabled'] }, runtime: { task: { status: 'no_context' } } }) + await service.configure(pkg.id, { enabled: false }) + expect(await service.conditions.settings(pkg.id, [field])).toEqual({ [field]: { status: 'ready', value: false } }) + await service.configure(pkg.id, { enabled: true }) + const [descriptor] = await service.agentContributions() + const result = await service.invokeAgent({ extensionId: pkg.id, revision: descriptor!.revision, configurationRevision: descriptor!.configurationRevision, invocationId: randomUUID(), action: 'tests.reader.run', cause: { type: 'user' }, context: { taskId: 'origin-task', runId: null } }, new AbortController().signal) + expect(result).toEqual({ status: 'skipped' }) + expect(invoked).toBe(0) + expect(seen.at(-1)).toMatchObject({ scope: { invocation: { kind: 'task', taskId: 'origin-task', runId: null } }, form: { status: 'no_context' } }) + expect(requests.at(-1)).toEqual({ models: true, task: true, taskId: 'origin-task', runId: null }) + } + finally { + await service.dispose() + await rm(root, { recursive: true, force: true }) + } +}) + +function evaluationFixture() { + const engine = new ExtensionConditionEvaluator() + const pending: ReturnType>[] = [] + const stopped = new AbortController() + const host: ExtensionHost = { call: async (method) => { + if (method !== 'conditions.evaluate') + return null + const value = deferred() + pending.push(value) + return value.promise + }, dispose: async () => {}, devtools() {} } + const context = extensionConditionContextSchema.parse({ version: 1, scope: { key: 'scope', configuration: { kind: 'global' }, invocation: { kind: 'settings', moduleId: 'settings.general', groupId: 'settings.general.general' } }, target: { kind: 'setting', id: 'tests.reader.model' }, configuration: { status: 'available', revision: 'configuration-1', values: { enabled: true }, sources: { enabled: { kind: 'global' } } }, runtime: { models: { status: 'not_requested' }, task: { status: 'not_requested' } }, workbench: { status: 'not_requested' }, form: { status: 'not_requested' } }) + const input = { extensionId: 'tests.reader', definition: { id: condition, inputs: ['configuration' as const] }, reference: { condition, params: {} }, scopeKey: 'scope', cache: true, snapshot: async () => ({ generation: 'host-1', context, host, signal: stopped.signal }) } + return { engine, pending, input, context, stopped } +} +it('shares identical pending reads, rejects invalidated results and isolates context revisions', async () => { + const f = evaluationFixture() + try { + const first = f.engine.evaluate(f.input) + const duplicate = f.engine.evaluate(f.input) + await vi.waitFor(() => expect(f.pending).toHaveLength(1)) + f.pending[0]!.resolve({ value: true, reason: 'ready' }) + expect(await first).toEqual(await duplicate) + expect(await f.engine.evaluate(f.input)).toEqual({ status: 'ready', value: true, reason: 'ready' }) + expect(f.pending).toHaveLength(1) + f.engine.invalidate({ extensionId: 'tests.reader', condition, scopeKey: 'scope' }) + const stale = f.engine.evaluate(f.input) + await vi.waitFor(() => expect(f.pending).toHaveLength(2)) + f.engine.invalidate({ extensionId: 'tests.reader', condition }) + expect(await stale).toEqual({ status: 'unavailable', value: false }) + f.pending[1]!.resolve(true) + const fresh = f.engine.evaluate(f.input) + await vi.waitFor(() => expect(f.pending).toHaveLength(3)) + f.pending[2]!.resolve(false) + expect(await fresh).toEqual({ status: 'ready', value: false }) + f.context.configuration = { status: 'invalid' } + const invalid = f.engine.evaluate(f.input) + await vi.waitFor(() => expect(f.pending).toHaveLength(4)) + f.pending[3]!.resolve({ unexpected: true }) + expect(await invalid).toEqual({ status: 'unavailable', value: false }) + } + finally { f.engine.dispose() } +}) +it('releases hung snapshots on cancellation and fences host replacement', async () => { + const f = evaluationFixture() + const stop = new AbortController() + const waiting = f.engine.evaluate({ ...f.input, signal: stop.signal, snapshot: () => new Promise(() => {}) }) + stop.abort() + expect(await waiting).toEqual({ status: 'unavailable', value: false }) + const oldHost = f.engine.evaluate(f.input) + await vi.waitFor(() => expect(f.pending).toHaveLength(1)) + f.stopped.abort() + expect(await oldHost).toEqual({ status: 'unavailable', value: false }) + f.pending[0]!.resolve(true) + f.engine.dispose() +}) + +it('queues a legal 64-field batch within 32 slots and caches every result', async () => { + vi.useFakeTimers() + const { store, root } = await createStore() + const pkg = manifest({ apiVersion: 3, contributes: { + conditions: [{ id: condition, inputs: ['form'] }], + settings: { items: Array.from({ length: 64 }, (_, index) => ({ id: `tests.reader.field${index}`, key: `field${index}`, title: `Field ${index}`, group: 'settings.general.general', type: 'string', default: '', enabledWhen: { condition } })) }, + } }) + await store.install((await reviewPackage(root, store, pkg)).token) + const installed = store.installed[pkg.id]!.current + let active = 0 + let peak = 0 + let evaluated = 0 + const host: ExtensionHost = { + call: async (method) => { + if (method !== 'conditions.evaluate') + return null + active++ + peak = Math.max(peak, active) + evaluated++ + await new Promise(resolve => setTimeout(resolve, 4000)) + active-- + return true + }, + dispose: async () => {}, + devtools() {}, + } + const conditions = new ExtensionConditions({ + package: async () => installed, + packages: () => [installed], + configuration: async () => ({ values: {}, invalidKeys: [] }), + activate: async () => ({ generation: 'host', host, signal: new AbortController().signal }), + workbench: () => [], + }) + try { + const fields = pkg.contributes.settings.items.map(item => item.id) + const result = conditions.settings(pkg.id, fields) + await vi.advanceTimersByTimeAsync(0) + expect(active).toBe(32) + await vi.advanceTimersByTimeAsync(8000) + const expected = Object.fromEntries(fields.map(id => [id, { status: 'ready', value: true }])) + expect(await result).toEqual(expected) + expect(peak).toBe(32) + expect(active).toBe(0) + expect(await conditions.settings(pkg.id, fields)).toEqual(expected) + expect(evaluated).toBe(64) + } + finally { + conditions.dispose() + vi.useRealTimers() + await rm(root, { recursive: true, force: true }) + } +}) + +it('cancels invalidated queued conditions before reading snapshots and releases queued work on disposal', async () => { + const f = evaluationFixture() + const evaluate = (index: number) => f.engine.evaluate({ ...f.input, scopeKey: `scope-${index}`, reference: { condition, params: { index } } }) + try { + const running = Array.from({ length: 32 }, (_, index) => evaluate(index)) + await vi.waitFor(() => expect(f.pending).toHaveLength(32)) + const invalidated = evaluate(32) + const retained = evaluate(33) + f.engine.invalidate({ scopeKey: 'scope-32' }) + expect(await invalidated).toEqual({ status: 'unavailable', value: false }) + f.pending[0]!.resolve(true) + await vi.waitFor(() => expect(f.pending).toHaveLength(33)) + f.pending[32]!.resolve(true) + expect(await retained).toEqual({ status: 'ready', value: true }) + const waiting = Array.from({ length: 4 }, (_, index) => evaluate(index + 34)) + f.engine.dispose() + expect(await Promise.all(waiting)).toEqual(Array.from({ length: 4 }, () => ({ status: 'unavailable', value: false }))) + await Promise.all(running) + } + finally { f.engine.dispose() } +}) diff --git a/apps/buddy/platform/ipc/__tests__/cancellation.spec.ts b/apps/buddy/platform/ipc/__tests__/cancellation.spec.ts index 47c7b9ff..788bc2ae 100644 --- a/apps/buddy/platform/ipc/__tests__/cancellation.spec.ts +++ b/apps/buddy/platform/ipc/__tests__/cancellation.spec.ts @@ -1,5 +1,5 @@ import type { RuntimeMessageTransport } from '../../../shared/runtime/rpcPeer' -import { expect, it } from 'vitest' +import { expect, it, vi } from 'vitest' import { RuntimeRpcPeer } from '../runtimeRpcPeer' function peers() { @@ -15,7 +15,7 @@ function peers() { } } return [new RuntimeRpcPeer({ transport: first }), new RuntimeRpcPeer({ transport: second })] as const } -it('cancels remote work and discards late replies without cancelling later requests', async () => { +it.each([5000, null])('cancels remote work and discards late replies with timeout %s without cancelling later requests', async (timeout) => { const [client, server] = peers() let signal: AbortSignal | undefined let complete!: (value: string) => void @@ -25,7 +25,7 @@ it('cancels remote work and discards late replies without cancelling later reque }) server.onRequest('next', () => 'next result') const controller = new AbortController() - const promise = client.request('slow', {}, 5000, controller.signal) + const promise = client.request('slow', {}, timeout, controller.signal) const rejected = expect(promise).rejects.toMatchObject({ name: 'AbortError' }) controller.abort() await rejected @@ -35,14 +35,14 @@ it('cancels remote work and discards late replies without cancelling later reque client.close(new Error('closed')) server.close(new Error('closed')) }) -it('aborts handlers when their connection closes', async () => { +it.each([undefined, null])('aborts handlers when their connection closes with timeout %s', async (timeout) => { const [client, server] = peers() let signal: AbortSignal | undefined server.onRequest('slow', (_, received) => { signal = received return new Promise(resolve => received?.addEventListener('abort', () => resolve(null))) }) - const promise = client.request('slow', {}) + const promise = client.request('slow', {}, timeout) const rejected = expect(promise).rejects.toThrow('closed') server.close(new Error('closed')) client.close(new Error('closed')) @@ -50,6 +50,27 @@ it('aborts handlers when their connection closes', async () => { expect(signal?.aborted).toBe(true) }) +it('waits for lifecycle-owned requests without a transport deadline while preserving default deadlines', async () => { + vi.useFakeTimers() + const [client, server] = peers() + let complete!: (value: string) => void + server.onRequest('queued', () => new Promise(resolve => complete = resolve)) + server.onRequest('timed', () => new Promise(() => {})) + try { + const queued = client.request('queued', {}, null) + const timed = expect(client.request('timed', {})).rejects.toMatchObject({ code: 'RUNTIME_REQUEST_TIMEOUT' }) + await vi.advanceTimersByTimeAsync(150000) + await timed + complete('completed') + expect(await queued).toBe('completed') + } + finally { + client.close(new Error('closed')) + server.close(new Error('closed')) + vi.useRealTimers() + } +}) + it('keeps notification observers independent from request outcomes and preserves delivery order under reentry', async () => { const [client, server] = peers() const seen: string[] = [] diff --git a/apps/buddy/platform/ipc/runtimeRpcPeer.ts b/apps/buddy/platform/ipc/runtimeRpcPeer.ts index 997f45e2..aec46b67 100644 --- a/apps/buddy/platform/ipc/runtimeRpcPeer.ts +++ b/apps/buddy/platform/ipc/runtimeRpcPeer.ts @@ -13,7 +13,6 @@ interface PendingRequest { dispose: () => void reject: (error: Error) => void resolve: (result: unknown) => void - timeout: ReturnType } export class RuntimeProtocolError extends Error { @@ -82,7 +81,7 @@ export class RuntimeRpcPeer implements RuntimeRpcPeerContract { return () => this.#handlers.delete(method) } - request(method: string, params: unknown, timeoutMs = this.#defaultTimeoutMs, signal?: AbortSignal, requestId?: string): Promise { + request(method: string, params: unknown, timeoutMs: number | null = this.#defaultTimeoutMs, signal?: AbortSignal, requestId?: string): Promise { if (this.#closed) return Promise.reject(new Error('Runtime RPC peer is closed')) if (signal?.aborted) @@ -107,14 +106,16 @@ export class RuntimeRpcPeer implements RuntimeRpcPeerContract { } } const aborted = () => cancel(signal?.reason ?? new DOMException('Request cancelled', 'AbortError')) - const timeout = setTimeout(() => { - cancel(new RuntimeRequestTimeoutError(method)) - }, timeoutMs) + const timeout = timeoutMs === null + ? undefined + : setTimeout(() => { + cancel(new RuntimeRequestTimeoutError(method)) + }, timeoutMs) const dispose = () => { clearTimeout(timeout) signal?.removeEventListener('abort', aborted) } - this.#pending.set(id, { reject, resolve, timeout, dispose }) + this.#pending.set(id, { reject, resolve, dispose }) signal?.addEventListener('abort', aborted, { once: true }) try { this.#transport.postMessage({ jsonrpc: '2.0', id, method, params }) diff --git a/apps/buddy/service/resources/skills/plugin-creator/SKILL.md b/apps/buddy/service/resources/skills/plugin-creator/SKILL.md index 1807bcd8..7426e654 100644 --- a/apps/buddy/service/resources/skills/plugin-creator/SKILL.md +++ b/apps/buddy/service/resources/skills/plugin-creator/SKILL.md @@ -30,7 +30,7 @@ description: 创建、修改并验证可安装的 Lexora 桌面插件,包括 继续旧对话创作时,重新读取当前技能与协议。历史消息、旧工具结果和压缩摘要中的命名示例不作为新插件的身份依据;维护已有插件时读取实际源码清单,并保留其中的 ID。 1. 读取 [协议与清单](references/protocol.md) 和 [完整 API 类型](references/api.d.ts)。这些文件与当前 Lexora 一起分发;不要根据其他产品的插件格式猜测接口。 - 需要 Agent 指令、模型调用、任务标题或原生设置时,追加读取 [Agent 与设置](references/agent-settings.md),并确认当前宿主能力目录支持。 + 需要实时配置、表单依赖或运行状态判断时,读取 [条件函数](references/conditions.md),声明数据输入与作用域,不将函数序列化进清单。需要 Agent 指令、后台任务动作、模型调用、任务标题或原生设置时,追加读取 [Agent 与设置](references/agent-settings.md),并确认当前宿主能力目录支持。事件驱动动作与订阅按 [事件与触发列表](references/events.md) 选择精确入口和载荷,不把普通事件监听器当作任务能力调用。 2. 在当前可写工作区创建独立源码目录,例如 `plugins/<插件名>/`。通过文件工具写入真实文件,保留源码以便后续对话修改。不需要用户另外安装 Node、Bun 或 SDK。 3. 新建独立插件时通过 `lexora_tool_search` 查找 `lexora_plugin_identity`,传入按功能选定的简短英文 `slug`,将返回的 `id`、`author` 和 `engines` 写入 `extension.json`。作者未指定时省略参数,使用已保存的默认署名;明确不署名时传空字符串。作者名支持中文,不要求用户提供英文名、平台账号或唯一名称,也不从系统用户名、Git 配置或目录推断署名。不要自行编造 ID 或继续生成 `local.*`。修改、升级、换电脑维护及分发沿用源码内已有 ID,不重新调用身份生成工具;包括已有的 `local.*`。只有创建独立副本时生成新 ID 并同步内部引用。 4. 按选定组合声明视图、命令、导航和挂载点,明确每项能力由宿主入口还是视图入口调用;限定页面时使用公开上下文和声明条件。仅声明实际需要的权限;没有对应能力时解释限制,不绕过隔离访问宿主 DOM、内部接口或文件。明确用户第一次如何启动、关闭后如何再次打开;普通挂载需要显式打开;内容插槽和替换控件的启用、回退与配置归属按 [内容插槽](references/slots.md) 实现。现有入口足够时不额外增加设置页。 diff --git a/apps/buddy/service/resources/skills/plugin-creator/references/agent-settings.md b/apps/buddy/service/resources/skills/plugin-creator/references/agent-settings.md index 6b544c79..49e6ce56 100644 --- a/apps/buddy/service/resources/skills/plugin-creator/references/agent-settings.md +++ b/apps/buddy/service/resources/skills/plugin-creator/references/agent-settings.md @@ -1,6 +1,6 @@ -# Agent 工具与声明式设置 +# Agent 工具、任务动作与设置 -这些是 API 3 的增量能力。先查询当前宿主的 `runtime` 能力目录,确认 `agent.tools`、`agent.models`、`agent.task` 或 `settings` 可用,再声明对应的最低 `engines.lexora`。 +这些是 API 3 的增量能力。先查询当前宿主的 `runtime` 能力目录,确认所需的 `agent.tools`、`agent.actions`、`agent.models`、`agent.task`、`agent.taskMessages` 或 `settings` 可用,再声明对应的最低 `engines.lexora`。相同 API 版本的旧宿主不一定提供全部增量能力。 ## 设置 @@ -8,21 +8,24 @@ - `modules: [{id,title,order?}]`:设置侧栏中的独立模块。 - `groups: [{id,module,title,order?}]`:模块中的分组。module 引用自己的模块或能力目录列出的内置模块。 -- `items: [{id,key,group,title,description?,type,default,order?}]`:分组中的单项。group 引用自己的分组或能力目录开放的内置分组。 +- `items: [{id,key,group,title,description?,type,default,order?,enabledWhen?}]`:分组中的单项。group 引用自己的分组或能力目录开放的内置分组。 所有 id 以插件 ID 加 `.` 开头且全包唯一;key 在本插件内唯一,使用以小写字母开头的字母数字名称。order 范围 -1000–1000,默认 0。插件不能覆写宿主设置或插入其他插件的设置。 -type 支持 boolean、string(最长 8192 字符)、number(可声明 min/max)、select(声明 `options:[{label,value}]`)、model。默认值必须符合类型;model 值为 `{providerId,modelId}` 或 null,空值表示采用调用工具的本轮模型。指定模型不可用时会失败,不会静默改用其他模型。 +type 支持 boolean、string(最长 8192 字符)、number(可声明 min/max)、select(声明 `options:[{label,value}]`)、model。默认值必须符合类型;model 值为 `{providerId,modelId}` 或 null。模型选择规则见下文,指定模型不可用时会失败,不会静默改用其他模型。 宿主自动保存设置,插件用 `context.configuration.get()` 读取,使用 `configuration.onChange` 注册可等待的热应用处理器;`context.events.on('configuration:changed', listener)` 仅观察已经保存的配置;订阅与失败回退见 [作用域事件](events.md)。修改设置取消当前插件调用,Agent 贡献变化从下一轮生效;关闭功能后旧工具也被拒绝。配置与 storage 私有业务数据独立,不维护第二份持久设置真源。已发布 key 保持类型和含义稳定,新增 key 使用 default。升级后不符合新选项或范围的旧值会被保留并在设置中标记,用户可以逐项修改或恢复默认值;修复前 Agent 贡献不可用。禁用和普通卸载保留配置;用户选择“卸载并清理”时删除配置、私有数据和视图状态。 +设置项的 `enabledWhen: {condition,params?}` 引用插件注册的[条件函数](conditions.md)。关闭条件仅禁用输入并保留原值;控制开关自身保持可编辑。 + ## Agent 入口 声明 `permissions.agent: true`、宿主 entry,并添加 `contributes.agent`: -- enabledWhen:可选,引用本插件 boolean 设置,值为 true 时贡献生效。 +- enabledWhen:可选,引用本插件 boolean 设置,或使用[条件函数](conditions.md)引用;函数按原调用任务在执行前求值。 - instructions:可选使用指引,最长 8000 字符;`{{.tool}}` 替换为实际工具名,不硬编码运行时名称。 - tools:`[{id,title,description,parameters}]`,最多 16 个,必须在 activate 中全部注册。 +- actions:`[{id,title,triggers,enabledWhen?}]`,最多 16 个,必须在 activate 中全部注册。只提供动作时可以省略 tools;tools 与 actions 至少提供一项。 parameters 为封闭对象,例如:`{type:"object",properties:{summary:{type:"string",description:"任务摘要"}},required:["summary"],additionalProperties:false}`。支持最多 16 个 string/number/boolean 字段,不支持嵌套 schema 或外部引用。 @@ -30,21 +33,59 @@ parameters 为封闭对象,例如:`{type:"object",properties:{summary:{type: context.agent.registerTool(`${context.extension.id}.tool`, async (input, invocation) => { const configuration = await context.configuration.get() const task = await invocation.task.get() - if (task.titleSource === 'manual') + if (task.titleSource === 'manual' || task.titleSource === 'legacy') return { applied: false } const result = await invocation.models.generateText({ prompt: String(input.summary), model: typeof configuration.model === 'object' ? configuration.model : null, maxTokens: 256, }) - return invocation.task.rename({ title: result.text.trim(), expectedRevision: task.titleRevision }) + return invocation.task.rename({ title: result.text.trim() }) }) ``` -回调返回 JSON。invocation 仅在本次回调内有效;回调结束、任务取消、插件禁用、更新或崩溃后不能继续使用。遵守 `invocation.signal`,不保留句柄给其他任务或后台定时器。工具失败不应阻断原任务。 +工具由主模型选择调用,回调返回 JSON,生命周期属于原运行。需要随输入提交、对话完成或菜单点击执行时,使用下面的任务动作。 + +## 任务动作 + +动作不依赖主模型决定是否调用,不占用主对话的工具消息。以下是一个只读取任务信息的最小声明;将 `example.helper` 替换为插件实际 ID: + +```json +{ + "permissions": { "agent": true, "tasks": "read" }, + "contributes": { + "agent": { + "actions": [{ + "id": "example.helper.inspect", + "title": "检查任务", + "triggers": ["task:input:committed", "task:turn:completed", "user"] + }] + } + } +} +``` + +```ts +context.agent.registerAction(`${context.extension.id}.inspect`, async (invocation) => { + const task = await invocation.task.get() + return { status: 'completed', message: task.title ?? '未命名任务' } +}) +``` + +每种触发的字段、时机和类型收窄方式只在 [任务动作事件列表](events.md#任务动作事件) 维护。包含 `user` 时宿主自动将动作放入任务菜单,不必另外声明普通菜单命令;插件不能自行制造 `user` 调用或传入另一个任务 ID。actions 的 title 是 1–100 字符的菜单显示文本。 + +回调返回 `{status:'completed'|'skipped', message?:string}`,message 最长 500 字符。`skipped` 表示当前无需执行,不是失败。动作在触发任务的历史中显示独立记录,包含插件名称、动作标题、状态及结果;自动 skipped 不显示,用户主动触发的 skipped 保留原因。新记录随所属分支持久化,不产生主模型工具消息;升级前缺少分支信息的调用不会补造历史。主对话结束不会取消独立动作;动作失败也不使已完成的主对话失败。所有调用句柄仅在本次回调内有效;遵守 `invocation.signal`,不保留给其他任务或后台定时器。配置变化、插件禁用、更新或崩溃会撤销该插件的相关调用。 + +自动命名可以在输入提交后处理 `fallback` 标题,成功后保留 `generated` 标题;上下文不足时返回 skipped,下一次输入仍可重试。需要随目标变化更新时,通过插件设置控制完成事件的处理;默认成功命名一次即可,避免轻微追问反复改名。`user` 动作可实现“重新生成标题”,允许替换原有手动或历史标题。 ## 模型与任务权限 -`permissions.models: true` 开放 `invocation.models.generateText({prompt,system?,model?,maxTokens?})`,返回 `{text,model}`。未指定模型时使用本轮模型,显式指定不可用模型则失败。请求仅有文本,没有工具或隐式对话历史;不允许自定义凭据、URL 或 Provider 参数。凭据留在宿主,用量计入当前运行。最多 4 次模型请求,每次最多 4096 输出 token,整个工具调用限时 120 秒。 +`permissions.models: true` 开放 `invocation.models.generateText({prompt,system?,model?,maxTokens?})`,返回 `{text,model}`。未指定模型时,工具继承原运行的模型,动作使用调用开始时的任务模型选择或对应输入的对话模型;没有可用模型时失败。请求仅有文本,没有工具或隐式对话历史;不允许自定义凭据、URL 或 Provider 参数。凭据留在宿主。工具用量计入原运行,动作用量属于独立调用,同样出现在任务用量统计中。每次调用最多 4 次模型请求,同时最多 1 次,每次最多 4096 输出 token,整次调用限时 120 秒。 + +`permissions.tasks: "read"` 开放 `task.get()`,返回当前任务的 id/title/titleSource。titleSource 为 `fallback`(消息截取)、`generated`(生成)、`manual`(用户明确命名)或 `legacy`(历史来源未确认)。`"title"` 额外开放 `task.rename({title})`,标题为单行 1–80 字符,返回 `{applied}`。宿主捕获本次调用开始时的标题版本,只接受该版本上的更新;重新读取标题不会刷新写入资格。自动动作与工具不能覆盖 manual/legacy,只有从任务菜单显式发起的 user 动作可以替换,期间的并发修改仍受保护。改名不更新任务活动时间。 + +过期标题写入返回 `{applied:false}`,任务或调用已失效则拒绝请求。版本比较由宿主负责,插件不需要读取或传递版本号。 + +`permissions.taskMessages: true` 单独开放 `task.messages()`,返回当前任务可见分支的有界 `{role:'user'|'assistant',text}` 数组,最多 16 条、每条最多 1500 字符。当前保留首条用户输入与最近文本;不包含工具输出、隐藏分支、附件正文或隐式文件读取。仅在任务消息确实是功能输入时申请此权限,不因需要标题就申请。 -`permissions.tasks: "read"` 开放 task.get,返回当前任务的 id/title/titleSource/titleRevision;`"title"` 额外开放 rename。标题为单行 1–80 字符,比较版本后返回 `{applied}`。手动标题、历史来源不明的标题、已删除任务和过期版本不被覆盖;改名不更新任务活动时间。API 不接受调用者指定的任务 ID,不读取其他任务或聊天历史,不提供任意字段写入。 +以上接口均绑定原任务,不接受任意任务 ID,也不提供任意字段或数据库写入。宿主和其他插件仍可通过各自授权入口修改同一任务,过期结果不能覆盖新状态。 diff --git a/apps/buddy/service/resources/skills/plugin-creator/references/api.d.ts b/apps/buddy/service/resources/skills/plugin-creator/references/api.d.ts index 29bad66b..92b4c73b 100644 --- a/apps/buddy/service/resources/skills/plugin-creator/references/api.d.ts +++ b/apps/buddy/service/resources/skills/plugin-creator/references/api.d.ts @@ -62,16 +62,53 @@ export interface WorkbenchPaneEvents { 'workbench:panes:changed': { readonly panes: readonly PaneSnapshot[] } } export interface ExtensionEvents extends ConfigurationEvents, WorkbenchPaneEvents {} +export interface TaskTitle { + id: string + title: string | null + titleSource: 'legacy' | 'manual' | 'fallback' | 'generated' +} export interface AgentToolContext { readonly signal: AbortSignal readonly task: { - get: () => Promise<{ id: string, title: string | null, titleSource: 'manual' | 'fallback' | 'generated', titleRevision: number }> - rename: (input: { title: string, expectedRevision: number }) => Promise<{ applied: boolean }> + get: () => Promise + messages: () => Promise<{ role: 'user' | 'assistant', text: string }[]> + rename: (input: { title: string }) => Promise<{ applied: boolean }> } readonly models: { generateText: (input: { prompt: string, system?: string, model?: ModelSelection | null, maxTokens?: number }) => Promise<{ text: string, model: ModelSelection }> } } +export interface TaskActionEvents { + 'task:input:committed': { readonly conversationId: string, readonly branchId: string, readonly runId: string, readonly messageId: string, readonly commitId: string } + 'task:turn:completed': { readonly conversationId: string, readonly branchId: string, readonly runId: string, readonly triggeringMessageId: string, readonly completedAt: string } +} +export interface AgentActionContext extends AgentToolContext { + readonly cause: EventMessage | Readonly<{ type: 'user' }> +} +export interface AgentActionResult { status: 'completed' | 'skipped', message?: string } +export type ConditionInput = 'configuration' | 'runtime.models' | 'runtime.task' | 'workbench' | 'form' +export interface ConditionReference { condition: string, params?: ReadonlyJsonObject } +export interface ConditionResult { value: boolean, reason?: string } +export type ConfigurationScope = { kind: 'global' } | { kind: 'space', spaceId: string } | { kind: 'task', taskId: string } +export type ConditionInvocationScope = { kind: 'settings', moduleId: string, groupId: string } | { kind: 'task', taskId: string, runId: string | null } +export type ConditionData = EventSnapshot<{ status: 'available', revision: string } & T> | Readonly<{ status: 'not_requested' | 'no_context' | 'denied' | 'unsupported' | 'loading' | 'invalid' }> +export interface ConditionContext { + readonly version: 1 + readonly signal: AbortSignal + readonly scope: EventSnapshot<{ key: string, configuration: ConfigurationScope, invocation: ConditionInvocationScope }> + readonly target: Readonly<{ kind: 'setting' | 'agent' | 'action', id: string }> + readonly configuration: ConditionData<{ values: Record, sources: Record }> + readonly runtime: Readonly<{ + models: ConditionData<{ selection: ModelSelection | null, models: { providerId: string, modelId: string, name: string, available: boolean, capabilities: string[] }[] }> + task: ConditionData<{ id: string, spaceId: string | null, branchId: string, title: string | null, titleSource: 'fallback' | 'generated' | 'manual' | 'legacy', activity: 'idle' | 'running' | 'awaiting_approval', modelSelection: ModelSelection | null }> + }> + readonly workbench: ConditionData<{ panes: { id: string, active: boolean, visible: boolean }[] }> + readonly form: ConditionData<{ values: Record, dirtyKeys: string[] }> +} +export interface ConditionApi { + register: (id: string, evaluate: (context: ConditionContext, params: ReadonlyJsonObject) => boolean | ConditionResult | Promise) => Disposable + invalidate: (input?: { condition?: string, scopeKey?: string }) => Promise +} export interface ExtensionContext { readonly extension: { readonly id: string, readonly version: string, readonly apiVersion: 1 | 2 | 3 } readonly events: EventSubscriber @@ -79,7 +116,11 @@ export interface ExtensionContext { get: () => Promise> onChange: (listener: (configuration: Readonly>) => void | Promise) => Disposable } - readonly agent: { registerTool: (id: string, execute: (input: Record, context: AgentToolContext) => Json | void | Promise) => Disposable } + readonly conditions: ConditionApi + readonly agent: { + registerTool: (id: string, execute: (input: Record, context: AgentToolContext) => Json | void | Promise) => Disposable + registerAction: (id: string, execute: (context: AgentActionContext) => AgentActionResult | Promise) => Disposable + } readonly subscriptions: { add: (disposable: T) => T } readonly commands: { register: (id: string, execute: (context: { resource: Resource | null, arguments: Json, invocation: CommandInvocation | null }) => Json | void | Promise) => Disposable } readonly placements: { show: (id: string, options?: string | PlacementOptions) => Promise, hide: (id: string, options?: string | PlacementOptions) => Promise } diff --git a/apps/buddy/service/resources/skills/plugin-creator/references/capabilities.md b/apps/buddy/service/resources/skills/plugin-creator/references/capabilities.md index 845de123..df7851d8 100644 --- a/apps/buddy/service/resources/skills/plugin-creator/references/capabilities.md +++ b/apps/buddy/service/resources/skills/plugin-creator/references/capabilities.md @@ -5,6 +5,7 @@ | 用户需要 | 清单与入口 | 状态和边界 | | --- | --- | --- | | 由主模型主动调用插件、请求模型或修改任务标题 | API 3;contributes.agent 和 agent/models/tasks 权限,见 [Agent 与设置](agent-settings.md) | 工具入口绑定原任务与本轮模型,不导出凭据,不覆盖手动标题 | +| 输入提交、对话成功完成或任务菜单点击后执行 | API 3;contributes.agent.actions,见 [任务动作事件](events.md#任务动作事件) 与 [Agent 与设置](agent-settings.md) | 独立调用绑定原任务;正文需要 taskMessages 权限,标题更新由宿主保护并发修改 | | 在原生设置页追加模块、分组或单项 | API 3;contributes.settings,见 [Agent 与设置](agent-settings.md) | 宿主保存配置并渲染控件;插件读取 configuration,不另存设置 | | 对当前文件提供阅读、统计或专用展示 | `selectedResource: "read"`;`resource: "selected-file"` 的视图;宿主可见命令将收到的 `resource` 传给 `views.open`,视图用 `resources.readText(context.resource)` 读取 | 用户先打开文件,再执行插件命令。句柄不包含任意路径,每次打开是独立实例;不能枚举目录、读其他视图的文件或写回文件 | | 输入 `/插件名:命令` 执行插件动作 | API 3 命令声明 `slash`,并在 `activate` 注册处理函数;见 [注册命令](commands.md) | 精确匹配,包内校验注册,跨插件命名空间隔离,不请求模型 | @@ -39,4 +40,4 @@ 验收从用户行为出发,不以采用某个样例的文件名或界面作为成功依据。例如文件阅读工具应验证两份不同文件、空内容、各自筛选状态及重开恢复;网络信息条应验证允许来源、请求失败后的状态、关闭与再次显示;自定义控件应验证当前快照、禁用或隐藏时提交被拒绝,以及故障后内置控件可用。 -对每项需求明确已有 API 是否覆盖。只有当前宿主能力查询返回的正式位置,没有任意 DOM 插槽、父页面 DOM、静默覆写用户文件或通用 IPC;Agent 能力仅通过声明权限的受控工具入口开放。核心要求超出已开放能力时先明确缺口与可实现范围,不能把样例资源或相近布局当作已经满足核心要求,也不用内部接口模拟完整实现。 +对每项需求明确已有 API 是否覆盖。只有当前宿主能力查询返回的正式位置,没有任意 DOM 插槽、父页面 DOM、静默覆写用户文件或通用 IPC;Agent 能力仅通过声明权限的受控工具或动作入口开放。核心要求超出已开放能力时先明确缺口与可实现范围,不能把样例资源或相近布局当作已经满足核心要求,也不用内部接口模拟完整实现。 diff --git a/apps/buddy/service/resources/skills/plugin-creator/references/conditions.md b/apps/buddy/service/resources/skills/plugin-creator/references/conditions.md new file mode 100644 index 00000000..79e582d1 --- /dev/null +++ b/apps/buddy/service/resources/skills/plugin-creator/references/conditions.md @@ -0,0 +1,61 @@ +# 条件函数 + +API 3 的 `conditions` 能力用于声明式设置及 Agent 入口的可用性判断。先查询宿主 runtime 能力目录;旧宿主即使同为 API 3,也可能不支持此能力。 + +## 声明与注册 + +清单只引用函数 ID 和 JSON 参数,不放函数字符串或表达式代码。函数在插件沙箱的 `activate` 中注册,不能在视图或条件回调中动态注册。 + +```json +{ + "contributes": { + "conditions": [{ "id": "example.helper.available", "inputs": ["configuration", "form"] }], + "settings": { + "items": [{ + "id": "example.helper.mode", + "key": "mode", + "group": "settings.general.general", + "title": "模式", + "type": "string", + "default": "", + "enabledWhen": { "condition": "example.helper.available", "params": { "requiredKey": "enabled" } } + }] + } + } +} +``` + +示例中的 enabled 需另声明为 boolean 设置;启用开关自身不要依赖这个条件。 + +```ts +context.conditions.register('example.helper.available', (ctx, params) => { + const current = ctx.form.status === 'available' ? ctx.form : ctx.configuration + return current.status === 'available' + && typeof params.requiredKey === 'string' + && current.values[params.requiredKey] === true +}) +``` + +回调接受只读 `ConditionContext` 与只读 params,可同步或异步返回 boolean 或 `{value:boolean, reason?:string}`。reason 最长 300 字符,供用户理解不可用原因,不放诊断堆栈或敏感信息。最多声明 32 个条件,参数总大小不超过 16 KiB。没有外部数据依赖时 inputs 可以为空。 + +设置项、`agent.enabledWhen` 和单个 `agent.actions[].enabledWhen` 接受相同引用。Agent 的字符串 enabledWhen 仍是 boolean 配置 key 的简写,可直接控制贡献是否出现在目录中;函数条件依赖调用作用域,在实际执行前求值,不按设置页或全局焦点过滤工具目录。函数返回 false 时动作 skipped、工具拒绝执行;条件求值失败同样不执行。UI 的禁用状态不授予权限,也不能替代任务写入时的版本、分支与授权检查。 + +## 上下文 + +`version: 1` 标识上下文契约。`scope.configuration` 表示配置归属;目前仅提供 global,类型预留 space/task,尚无覆盖层级。`scope.invocation` 独立表示 settings(moduleId/groupId)或 task(taskId/runId);后台动作的 runId 为 null。`target` 是本次求值的 setting/agent/action 及 ID。`scope.key` 是不透明的作用域标识,可用于精确失效,不解析其格式。 + +每个数据源均带 status;available 才能读取 revision 和数据。revision 为不透明内容版本,不保证递增。未声明输入为 not_requested;无任务或表单上下文为 no_context;权限不足为 denied;宿主缺少能力为 unsupported;Runtime 暂不可用为 loading;无效配置或失效任务为 invalid。它们都不是 false,也不是空列表,插件需要显式决定这些状态下的行为。 + +| inputs | available 数据 | 权限与范围 | +| --- | --- | --- | +| configuration | values、每个 key 的 sources | 自己当前已保存的有效配置,含默认值;当前 sources 均为 global | +| form | values、dirtyKeys | 设置页已保存值叠加当前编辑草稿;不是持久配置,任务调用没有 form | +| runtime.models | selection、models | 需要 models 权限;模型 ID、显示名、可用性、能力。工具使用原运行模型、动作使用原任务当前模型,设置页使用默认模型;无凭据或请求地址 | +| runtime.task | id、spaceId、branchId、title、titleSource、activity、modelSelection | 需要 tasks 权限;仅原调用任务,不随用户切换焦点;设置页不隐式读取最近任务,无消息正文 | +| workbench | panes 的 id、active、visible | 仅分屏概要,无 DOM、文件路径、输入内容或任务关联 | + +异步函数收到的是本次一致的只读快照,不是可变全局对象。配置、模型目录、任务状态、分屏或草稿变化后,宿主使相关结果失效;设置页自动重算,实际任务入口重新判断。`signal` 在本次结果过期、取消或宿主停止时撤销。应尽快返回,只做判断,不在条件中改设置、发通知或调用模型。 + +插件自己的内存或业务状态变化后,可调用 `context.conditions.invalidate({condition?,scopeKey?})`;省略参数使本插件所有条件失效,不影响其他插件。不要在求值函数内调用 invalidate,也不要轮询刷新。 + +宿主合并相同的在途设置求值并有界缓存结果,数据版本、参数、目标与作用域不同不会共享结果。沙箱函数预算 2 秒,包含快照读取的宿主等待最多 5 秒;超时、异常或非法返回值使该项暂不可用,并允许用户重试。数据变化、禁用、更新和宿主退出会撤销过期结果。不符合新版本的旧配置保留原值,并允许逐项修复或恢复默认值。 diff --git a/apps/buddy/service/resources/skills/plugin-creator/references/events.md b/apps/buddy/service/resources/skills/plugin-creator/references/events.md index 26e59489..f5b90117 100644 --- a/apps/buddy/service/resources/skills/plugin-creator/references/events.md +++ b/apps/buddy/service/resources/skills/plugin-creator/references/events.md @@ -40,3 +40,31 @@ subscription.dispose() 需要热应用时显式注册 `context.configuration.onChange(configuration => applyConfiguration(configuration))`,返回的 Promise 应在应用完成后解决。宿主先保存配置并撤销旧调用,再等待这些应用处理器。没有处理器、应用失败或超时会回退到重启,已经保存的配置保留;新的工具调用等待应用或重启完成。迟到的旧代结果不会恢复已撤销的调用。 视图初始化使用当前作用域的只读快照,随后接收增量。宿主在权限与实例过滤后确定顺序;初始化期间发生变化、出现缺口或缓冲溢出时,SDK 重新读取快照并通知实际差异,不要求插件实现补偿。一次恢复最多尝试三次,失败后较新的增量会触发下一轮有界恢复。输入活动、私有消息和点击不提供历史重放;已初始化实例收到但尚未交付的这些通知会在恢复后交付一次,旧实例与初始化快照之前的通知不重放。命令超时表示结果尚未确认,读取当前状态后再决定下一步,不自动重做可能已经执行的操作。 + +## 任务动作事件 + +任务动作通过 `contributes.agent.actions[].triggers` 声明,并由 `context.agent.registerAction` 注册。完整声明、权限与示例见 [Agent 工具、任务动作与设置](agent-settings.md)。它们不会出现在 `context.events.on` 或 `**` 订阅中;动作由宿主创建独立调用,提供只属于原任务的 `invocation.task`、`invocation.models` 与 `invocation.signal`。 + +| trigger / cause.type | cause.data | 触发时机 | +| --- | --- | --- | +| `task:input:committed` | `{conversationId, branchId, runId, messageId, commitId}` | 用户输入已经提交到当前任务分支,包括已提交的后续输入;未发送草稿和仍在等待的队列项不触发。无需等待主模型回复。 | +| `task:turn:completed` | `{conversationId, branchId, runId, triggeringMessageId, completedAt}` | 该轮对话成功完成且执行占用已经释放;失败、取消与历史恢复不触发。`completedAt` 是 ISO 时间字符串。 | +| `user` | 无 `data`,cause 为 `{type: 'user'}` | 用户点击任务菜单中该动作的 title,目标固定为菜单所属任务。不是可订阅、可发布的事件。 | + +表中的 ID 均为不透明字符串,用于关联事实;不是文件路径或操作其他任务的授权。事件自身不含消息正文,读取消息需要单独的 `taskMessages` 权限。`TaskActionEvents` 映射精确事件名和各自载荷,按 `cause.type` 分支后 TypeScript 自动收窄 `cause.data`;不要把不同事件的字段复制到一个含大量可选字段的对象中。 + +```ts +import type { AgentActionContext } from './lexora' + +function triggeringInput({ cause }: AgentActionContext): string | null { + if (cause.type === 'task:input:committed') + return cause.data.messageId + if (cause.type === 'task:turn:completed') + return cause.data.triggeringMessageId + return null +} +``` + +triggers 只接受表中的精确值,不支持通配符。动作不会作为主模型工具出现,也不向主对话插入工具结果;模型用量单独记录。输入事件不意味着只调用一次,是否首次命名、何时重试由插件按任务元数据决定。同一任务的同一动作串行执行;用户显式调用会取代仍在进行的同一动作,新输入会撤销旧输入对应的调用。事件不重放,重启后不自动重试中断动作。 + +动作的源输入、分支、标题、模型选择或任务有效性发生变化时,宿主拒绝过期操作;任务手动改名、删除,以及插件配置变化、禁用或更新会撤销相关调用。检查 `invocation.signal` 并处理 `task.rename()` 的 `{applied:false}`。`cause.data` 保存触发时的事实,读取接口返回读取时的状态;不要假定异步模型完成后任务仍未变化。 diff --git a/apps/buddy/service/resources/skills/plugin-creator/references/protocol.md b/apps/buddy/service/resources/skills/plugin-creator/references/protocol.md index 0c98a774..37749e57 100644 --- a/apps/buddy/service/resources/skills/plugin-creator/references/protocol.md +++ b/apps/buddy/service/resources/skills/plugin-creator/references/protocol.md @@ -35,7 +35,7 @@ | `views` | 数组,每项必填 `id`、`title`、`entry`;`resource` 为 `selected-file`(默认)或 `none`;`location` 默认 `context`;`stateVersion` 为正整数,默认 1 | | `placements` | 数组,每项必填 `id`、`view`、`kind`,并按 kind 添加后文规定的 `anchor`、`target` 或 `presentation`;不要把它们写入视图定义 | | `navigation` | 单个对象,必填 `title`、`view`,引用自己的无资源视图 | -| `agent` | API 3;指令、工具和可选的 enabledWhen,见 [Agent 与设置](agent-settings.md) | +| `agent` | API 3;指令、工具、任务动作和可选的 enabledWhen,见 [Agent 与设置](agent-settings.md) | | `settings` | API 3;声明 modules/groups/items,由宿主渲染并保存,见 [Agent 与设置](agent-settings.md) | 插件 ID 使用小写字母开头的两段名称,中间以 `.` 分隔,每段可含小写字母、数字与 `-`;贡献 ID 在插件 ID 后追加由小写字母、数字、`.`、`-` 组成的后缀。声明文件路径使用包内相对路径,`.ts` 文件名直接指向源码,编译工具负责产出运行文件。 @@ -60,9 +60,10 @@ | permissions 字段 | 能力 | | --- | --- | -| `agent: true` | 追加 Agent 指令和可调用工具 | -| `models: true` | 工具调用期间请求已配置模型,凭据留在宿主 | +| `agent: true` | 追加 Agent 指令、可调用工具或任务动作 | +| `models: true` | 工具或动作调用期间请求已配置模型,凭据留在宿主 | | `tasks: "read" / "title"` | 读取当前任务标题元数据;title 额外允许安全改名 | +| `taskMessages: true` | 工具或动作调用期间读取原任务可见分支的有限消息文本,详见 [任务权限](agent-settings.md#模型与任务权限) | | `windowEffects: true` | 锚点装饰、窗口效果与无内容的对话输入活动 | | `controls: ["model.reasoning"]` | 提供可由用户选用的思考等级控件 | | `notifications: true` | 宿主 `context.notifications.show({title,body})` | diff --git a/apps/buddy/service/src/BuddyService.ts b/apps/buddy/service/src/BuddyService.ts index 88c1b211..909098a3 100644 --- a/apps/buddy/service/src/BuddyService.ts +++ b/apps/buddy/service/src/BuddyService.ts @@ -20,7 +20,8 @@ import { changesChanged } from '../../shared/changes/changeApi' import { connectorNotifications } from '../../shared/connectors/connectorApi' import { contextPanelRpc, contextPanelStateSchema } from '../../shared/context-panel/contextPanel' import { composerResourcesChanged } from '../../shared/conversation/composerApi' -import { CONVERSATION_CHANGED } from '../../shared/conversation/conversationApi' +import { CONVERSATION_CHANGED, conversationTimelineChanged } from '../../shared/conversation/conversationApi' +import { extensionActionRpc } from '../../shared/extensions/extensionActionApi' import { extensionAgentRpc } from '../../shared/extensions/extensionAgent' import { ServiceHost } from '../../shared/lifecycle/ServiceHost' import { ServiceLifecycleSource } from '../../shared/lifecycle/ServiceLifecycleSource' @@ -89,6 +90,7 @@ import { registerContextRpc } from './context/registerContextRpc' import { ConversationLifecycleService } from './conversations/ConversationLifecycleService' import { ConversationMetadataService } from './conversations/ConversationMetadataService' import { createExtensionTaskCapabilities } from './conversations/extensionTaskCapabilities' +import { ExtensionTaskContext } from './conversations/ExtensionTaskContext' import { registerConversationRpc } from './conversations/registerConversationRpc' import { registerConversationTreeRpc } from './conversations/registerConversationTreeRpc' import { registerTaskMarkRpc } from './conversations/registerTaskMarkRpc' @@ -105,16 +107,19 @@ import { NotificationProjection } from './notifications/NotificationProjection' import { registerNotificationRpc } from './notifications/registerNotificationRpc' import { observePetActionDiagnostics } from './pet/observePetActionDiagnostics' import { PetActionService } from './pet/PetActionService' +import { bindExtensionActions } from './plugins/bindExtensionActions' +import { ExtensionActionService } from './plugins/ExtensionActionService' import { ExtensionAgentRuntime } from './plugins/ExtensionAgentRuntime' import { observeExtensionAgentDiagnostics } from './plugins/observeExtensionAgentDiagnostics' import { PluginAuthoringService } from './plugins/PluginAuthoringService' +import { registerExtensionConditionRpc } from './plugins/registerExtensionConditionRpc' import { createProviderService } from './providers/createProviderService' -import { createExtensionModelCapabilities } from './providers/extensionModelCapabilities' +import { createExtensionModelCapabilities } from './providers/extensionModelCapabilities' import { ProviderDependents } from './providers/ProviderDependents' import { registerProviderRpc } from './providers/registerProviderRpc' import { resolveInteractiveModelSelection } from './providers/resolveInteractiveModelSelection' -import { BuddyServiceError } from './rpc/runtimeRequest' +import { BuddyServiceError, registerRuntimeRequest } from './rpc/runtimeRequest' import { registerRunRpc } from './runs/registerRunRpc' import { RunContinuityService } from './runs/RunContinuityService' import { RunLifecycleService } from './runs/RunLifecycleService' @@ -145,8 +150,11 @@ import { createComposerResourceRepository } from './storage/composerResourceRepo import { createConnectorRepository } from './storage/connectorRepository' import { createConversationDeletionRepository } from './storage/conversationDeletionRepository' import { createConversationDirectoryGrantRepository } from './storage/conversationDirectoryGrantRepository' +import { createConversationHistoryStore } from './storage/conversationHistoryRepository' import { createConversationRepository } from './storage/conversationRepository' import { createConversationTreeRepository } from './storage/conversationTreeRepository' +import { createExtensionInvocationRepository } from './storage/extensionInvocationRepository' +import { createExtensionTaskContextRepository } from './storage/extensionTaskContextRepository' import { createNotificationAttentionRepository } from './storage/notificationAttentionRepository' import { createProviderRepository } from './storage/providerRepository' import { createRunInputRepository } from './storage/runInputRepository' @@ -319,10 +327,16 @@ export async function startBuddyService( }) return service }) - const usageService = await host.start('runtime.usage', () => { + const usageService = await host.start('runtime.usage', ({ defer }) => { const service = new UsageService({ eventLog: options.eventLog, repository: usageRepository, + onObserverError: () => record({ event: 'usage.observer_failed', level: 'warn' }), + }) + const diagnostics = service.onDidRecord(event => record({ event: 'usage.committed', level: 'info', operationId: event.invocationId ?? event.id, runId: event.runId ?? undefined })) + defer(() => { + service.dispose() + diagnostics.dispose() }) return service }) @@ -471,19 +485,17 @@ export async function startBuddyService( }) return service }) + const extensionHistory = createExtensionTaskContextRepository(options.database, createConversationHistoryStore(options.database).lineage) + const extensionTaskContext = new ExtensionTaskContext(conversations, extensionHistory, runs, runInputs) const extensionAgent = await host.start('runtime.plugins', ({ defer }) => { const service = new ExtensionAgentRuntime({ rpc: options.rpc, - handlers: { - ...createExtensionTaskCapabilities(conversationMetadata), - ...createExtensionModelCapabilities(executionModels, usageService), - }, - context: (scope) => { - const run = runs.findById(scope.runId) - const task = conversations.findById(scope.conversationId) - if (!run || run.conversationId !== scope.conversationId || run.status !== 'running' || !task || task.deletedAt !== null) - throw new Error('EXTENSION_TASK_UNAVAILABLE') - return { ...scope, model: { providerId: run.provider, modelId: run.model } } + createHandlers: (scope) => { + const task = extensionTaskContext.open(scope) + return { + ...createExtensionTaskCapabilities(conversationMetadata, extensionHistory, task, scope), + ...createExtensionModelCapabilities(executionModels, usageService, task), + } }, onObserverError: () => record({ event: 'plugins.observer_failed', level: 'warn' }), }) @@ -934,6 +946,28 @@ export async function startBuddyService( }) return service }) + const extensionActions = await host.start('runtime.plugin_actions', ({ defer }) => { + const service = new ExtensionActionService({ + runtime: extensionAgent, + repository: createExtensionInvocationRepository(options.database), + capture: (conversationId, source) => extensionTaskContext.captureAction(conversationId, source), + onObserverError: () => record({ event: 'plugins.action.observer_failed', level: 'warn' }), + }) + const binding = bindExtensionActions(service, { turns: turnRequests, execution: runner, metadata: conversationMetadata, deletions: conversationLifecycle, runs }) + const diagnostics = service.onDidFail(event => record({ event: 'plugins.action.failed', level: 'warn', ...event })) + const notification = service.onDidChange(event => options.rpc.notify(conversationTimelineChanged.method, event)) + const list = registerRuntimeRequest(options.rpc, extensionActionRpc.list, (_input, signal) => service.list(signal)) + const invoke = registerRuntimeRequest(options.rpc, extensionActionRpc.invoke, (input, signal) => service.invoke(input, signal)) + defer(async () => { + binding.dispose() + list() + invoke() + await service.dispose() + diagnostics.dispose() + notification.dispose() + }) + return service + }, ['runtime.plugins', 'runtime.task_metadata', 'runtime.usage']) taskAttention.start(conversationLifecycle, runLifecycleService) const automationOccurrenceLifecycle = await host.start('runtime.automation_deletion', ({ defer }) => { const service = new AutomationOccurrenceLifecycleService({ @@ -1037,6 +1071,10 @@ export async function startBuddyService( }) chatQueueService = await host.start('runtime.chat-queue', async ({ defer }) => { const service = new ChatQueueService({ queue: createChatQueueRepository(options.database), turns: chatTurnService, requests: turnRequests, launcher: turnLauncher, runner, runs, runInputs, eventLog: options.eventLog, onObserverError: () => record({ event: 'queue.observer_failed', level: 'error' }) }) + const actionInputs = service.onDidChange((event) => { + if (event.committed?.messageId && event.committed.runId) + extensionActions.dispatch({ type: 'task:input:committed', data: { conversationId: event.scope.conversationId, branchId: event.scope.branchId, runId: event.committed.runId, messageId: event.committed.messageId, commitId: event.committed.commitId } }) + }) const continuation = new QueueContinuation({ queue: service, runner, runs, eventLog: options.eventLog, record }) const diagnostics = service.onDidChange((event) => { const committed = event.committed @@ -1051,6 +1089,7 @@ export async function startBuddyService( record({ event: `attachments.${committed.attachmentOwnership.kind}_bound`, level: 'info', operationId: committed.commitId, conversationId: event.scope.conversationId, count: committed.attachmentOwnership.attachmentIds.length, ...(committed.runId ? { runId: committed.runId } : {}) }) }) defer(async () => { + actionInputs.dispose() const stopped = continuation.dispose() service.dispose() await stopped @@ -1128,6 +1167,7 @@ export async function startBuddyService( await host.start('runtime.rpc', ({ defer }) => { const register = (dispose: () => void) => defer(dispose) + register(registerExtensionConditionRpc({ rpc: options.rpc, tasks: conversations, history: extensionHistory, runs, inputs: runInputs, providers: providerService })) register( registerWebSettingsRpc(options.rpc, webSettings), ) diff --git a/apps/buddy/service/src/conversations/ConversationMetadataService.ts b/apps/buddy/service/src/conversations/ConversationMetadataService.ts index 5b416de9..3f92fc03 100644 --- a/apps/buddy/service/src/conversations/ConversationMetadataService.ts +++ b/apps/buddy/service/src/conversations/ConversationMetadataService.ts @@ -11,7 +11,7 @@ export type ConversationMetadataCommit = EventSnapshot<{ kind: 'title' | 'permissions' | 'model' | 'branch' conversation: ConversationRecord titleRevision?: number - titleSource?: 'manual' | 'fallback' | 'generated' + titleSource?: 'legacy' | 'manual' | 'fallback' | 'generated' }> export interface ConversationMetadataServiceOptions { @@ -46,7 +46,7 @@ export class ConversationMetadataService { return conversation } - renameGenerated(input: RenameConversationInput & { expectedRevision: number }): ConversationRecord | null { + renameGenerated(input: RenameConversationInput & { expectedRevision: number, userInitiated?: boolean }): ConversationRecord | null { if (this.#disposed) throw new BuddyServiceError('VALIDATION_FAILED') const conversation = this.#options.repository.renameGenerated(input) diff --git a/apps/buddy/service/src/conversations/ExtensionTaskContext.ts b/apps/buddy/service/src/conversations/ExtensionTaskContext.ts new file mode 100644 index 00000000..2ec7d9f0 --- /dev/null +++ b/apps/buddy/service/src/conversations/ExtensionTaskContext.ts @@ -0,0 +1,76 @@ +import type { ExtensionActionCause } from '../../../shared/extensions/extensionAgent' +import type { ExtensionInvocationScope } from '../plugins/extensionAgentHandlers' +import type { ConversationRepository } from '../storage/conversationRepository' +import type { ExtensionTaskContextRepository } from '../storage/extensionTaskContextRepository' +import type { RunInputRepository } from '../storage/runInputRepository' +import type { RunRepository } from '../storage/runRepository' + +export interface ExtensionTaskSession { + conversationId: string + branchId: string + model: { providerId: string, modelId: string } | null + assertCurrent: () => void +} + +export class ExtensionTaskContext { + readonly tasks: Pick + readonly history: ExtensionTaskContextRepository + readonly runs: Pick + readonly inputs: Pick + + constructor( + tasks: Pick, + history: ExtensionTaskContextRepository, + runs: Pick, + inputs: Pick, + ) { + this.tasks = tasks + this.history = history + this.runs = runs + this.inputs = inputs + } + + valid(conversationId: string, cause: ExtensionActionCause): boolean { + const task = this.tasks.findById(conversationId) + if (!task || task.deletedAt !== null || !task.activeBranchId) + return false + if (cause.type === 'user') + return true + if (cause.data.conversationId !== conversationId || task.activeBranchId !== cause.data.branchId) + return false + const inputId = this.history.latestInput(conversationId, task.activeBranchId) + return cause.type === 'task:input:committed' + ? inputId === cause.data.messageId + : inputId === cause.data.triggeringMessageId || (!!inputId && this.inputs.findByMessageId(inputId)?.runId === cause.data.runId) + } + + open(scope: ExtensionInvocationScope): ExtensionTaskSession { + scope.signal.throwIfAborted() + const conversationId = scope.conversationId + const task = this.tasks.findById(conversationId) + if (!task || task.deletedAt !== null || !task.activeBranchId || (scope.action && !this.valid(conversationId, scope.action.cause))) + throw new Error('EXTENSION_TASK_UNAVAILABLE') + const branchId = task.activeBranchId + const inputMessageId = this.history.latestInput(conversationId, branchId) + const runId = scope.runId ?? (scope.action && scope.action.cause.type !== 'user' ? scope.action.cause.data.runId : inputMessageId ? this.inputs.findByMessageId(inputMessageId)?.runId : null) + const run = runId ? this.runs.findById(runId) : null + const runModel = run ? { providerId: run.provider, modelId: run.model } : null + const model = scope.action ? task.modelSelection ?? runModel : runModel + if (!scope.action && (!run || run.status !== 'running' || run.conversationId !== conversationId || run.branchId !== branchId)) + throw new Error('EXTENSION_TASK_UNAVAILABLE') + const assertCurrent = () => { + scope.signal.throwIfAborted() + const current = this.tasks.findById(conversationId) + if (!current || current.deletedAt !== null || current.activeBranchId !== branchId || this.history.latestInput(conversationId, branchId) !== inputMessageId || (!scope.action && this.runs.findById(scope.runId!)?.status !== 'running')) + throw new Error('EXTENSION_TASK_UNAVAILABLE') + } + return { conversationId, branchId, model: model ? { providerId: model.providerId, modelId: model.modelId } : null, assertCurrent } + } + + captureAction(conversationId: string, cause: ExtensionActionCause) { + if (!this.valid(conversationId, cause)) + return null + const branchId = this.tasks.findById(conversationId)!.activeBranchId! + return { branchId, sourceMessageId: this.history.latestInput(conversationId, branchId) } + } +} diff --git a/apps/buddy/service/src/conversations/__tests__/ConversationMetadataService.spec.ts b/apps/buddy/service/src/conversations/__tests__/ConversationMetadataService.spec.ts index 1ec0bfc6..36c997a3 100644 --- a/apps/buddy/service/src/conversations/__tests__/ConversationMetadataService.spec.ts +++ b/apps/buddy/service/src/conversations/__tests__/ConversationMetadataService.spec.ts @@ -68,7 +68,7 @@ function fixture() { databases.push(database) const repository = createConversationRepository(database) repository.create({ id: 'task-1', branchId: 'branch-1', spaceId: null, title: 'Original', approvalPolicy: 'policy', executionProfile: 'workspace_write', createdAt: '2026-09-28T00:00:00.000Z' }) - database.prepare('UPDATE conversations SET title_source = ? WHERE id = ?').run('fallback', 'task-1') + database.prepare('UPDATE conversations SET title_source = ?, title_revision = 0 WHERE id = ?').run('fallback', 'task-1') const invalidate = vi.fn(async (_id: string) => ({ pending: 0, degraded: 0 })) const resolveModel = vi.fn(async (selection: NonNullable>['modelSelection']) => selection!) const service = new ConversationMetadataService({ repository, resolveModelSelection: resolveModel, sessions: { invalidateConversation: invalidate } }) diff --git a/apps/buddy/service/src/conversations/__tests__/extensionTaskCapabilities.spec.ts b/apps/buddy/service/src/conversations/__tests__/extensionTaskCapabilities.spec.ts new file mode 100644 index 00000000..cfefec08 --- /dev/null +++ b/apps/buddy/service/src/conversations/__tests__/extensionTaskCapabilities.spec.ts @@ -0,0 +1,100 @@ +import type { DatabaseSync } from 'node:sqlite' +import type { ExtensionInvocationScope } from '../../plugins/extensionAgentHandlers' +import { afterEach, describe, expect, it } from 'vitest' +import { createConversationHistoryStore } from '../../storage/conversationHistoryRepository' +import { createConversationRepository } from '../../storage/conversationRepository' +import { openBuddyDatabase } from '../../storage/database' +import { createExtensionTaskContextRepository } from '../../storage/extensionTaskContextRepository' +import { createRunInputRepository } from '../../storage/runInputRepository' +import { createRunRepository } from '../../storage/runRepository' +import { ConversationMetadataService } from '../ConversationMetadataService' +import { createExtensionTaskCapabilities } from '../extensionTaskCapabilities' +import { ExtensionTaskContext } from '../ExtensionTaskContext' + +const databases: DatabaseSync[] = [] +afterEach(() => databases.splice(0).forEach(database => database.close())) +function fixture() { + const database = openBuddyDatabase({ databasePath: ':memory:' }) + databases.push(database) + const tasks = createConversationRepository(database) + const createdAt = '2026-09-28T00:00:00.000Z' + tasks.create({ id: 'task', branchId: 'branch', title: 'Original', spaceId: null, approvalPolicy: 'policy', executionProfile: 'workspace_write', createdAt }) + tasks.createMessage({ id: 'input', conversationId: 'task', branchId: 'branch', runId: null, role: 'user', content: 'Initial goal', createdAt }) + tasks.setModelSelection({ id: 'task', modelSelection: { providerId: 'fixture', modelId: 'fixture', reasoning: null, serviceTier: null }, updatedAt: createdAt }) + const history = createExtensionTaskContextRepository(database, createConversationHistoryStore(database).lineage) + const domain = new ExtensionTaskContext(tasks, history, createRunRepository(database), createRunInputRepository(database)) + const metadata = new ConversationMetadataService({ repository: tasks, resolveModelSelection: async value => value, sessions: { invalidateConversation: async () => ({ pending: 0, degraded: 0 }) } }) + const controller = new AbortController() + function invocation(user = false) { + const scope: ExtensionInvocationScope = { conversationId: 'task', runId: null, extensionId: 'tests.actions', invocationId: 'invocation', signal: controller.signal, action: { id: 'tests.actions.generate', cause: user ? { type: 'user' } : { type: 'task:input:committed', data: { conversationId: 'task', branchId: 'branch', runId: 'run', messageId: 'input', commitId: 'commit' } } } } + return { handlers: createExtensionTaskCapabilities(metadata, history, domain.open(scope), scope), context: { ...scope, callNumber: 1 } } + } + return { database, tasks, metadata, controller, invocation, history, domain } +} + +describe('invocation-owned task capabilities', () => { + it('accepts completion after an in-run follow-up but rejects it after a newer run input', () => { + const f = fixture() + f.database.prepare(`INSERT INTO runs (id, conversation_id, branch_id, triggering_message_id, provider, model, purpose, status, started_at, completed_at) + VALUES ('run', 'task', 'branch', 'input', 'fixture', 'fixture', 'chat', 'completed', '2026-09-28T00:00:00.000Z', '2026-09-28T00:01:00.000Z')`).run() + const cause = { type: 'task:turn:completed', data: { conversationId: 'task', branchId: 'branch', runId: 'run', triggeringMessageId: 'input', completedAt: '2026-09-28T00:01:00.000Z' } } as const + expect(f.domain.valid('task', cause)).toBe(true) + f.tasks.createMessage({ id: 'follow-up', conversationId: 'task', branchId: 'branch', runId: null, role: 'user', content: 'New goal in the same run', createdAt: '2026-09-28T00:00:30.000Z' }) + f.database.prepare(`INSERT INTO chat_queue (id, conversation_id, branch_id, request_id, request_fingerprint, prepared_json, state, run_id, created_at) + VALUES ('follow-up', 'task', 'branch', 'request', 'fingerprint', ?, 'sent', 'run', '2026-09-28T00:00:30.000Z')`).run(JSON.stringify({ runInput: { prompt: 'New goal', attachmentIds: [], contextItems: [], reasoning: null, serviceTier: null } })) + expect(f.domain.valid('task', cause)).toBe(true) + f.tasks.createMessage({ id: 'later', conversationId: 'task', branchId: 'branch', runId: null, role: 'user', content: 'A different input after completion', createdAt: '2026-09-28T00:01:30.000Z' }) + expect(f.domain.valid('task', cause)).toBe(false) + }) + + it('protects historical titles and lets an explicit user action replace exactly the captured revision', async () => { + const f = fixture() + f.database.prepare('UPDATE conversations SET title_revision = 0 WHERE id = \'task\'').run() + const auto = f.invocation() + expect(await auto.handlers['task.get'](null, auto.context)).toEqual({ id: 'task', title: 'Original', titleSource: 'legacy' }) + expect(await auto.handlers['task.rename']({ title: 'Automatic' }, auto.context)).toEqual({ applied: false }) + const user = f.invocation(true) + expect(await user.handlers['task.rename']({ title: 'Requested' }, user.context)).toEqual({ applied: true }) + expect(f.tasks.getTitleState('task')).toEqual({ title: 'Requested', source: 'generated', revision: 1 }) + expect(await user.handlers['task.rename']({ title: 'Second write' }, user.context)).toEqual({ applied: false }) + }) + + it('keeps a manual change even when the plugin rereads the latest title', async () => { + const f = fixture() + const invocation = f.invocation(true) + f.metadata.rename({ id: 'task', title: 'User wins' }) + expect(await invocation.handlers['task.get'](null, invocation.context)).toEqual({ id: 'task', title: 'User wins', titleSource: 'manual' }) + expect(await invocation.handlers['task.rename']({ title: 'Stale result' }, invocation.context)).toEqual({ applied: false }) + expect(f.tasks.findById('task')?.title).toBe('User wins') + }) + + it.each(['new input', 'branch', 'delete', 'abort'])('rejects writes and message reads after %s', async (change) => { + const f = fixture() + const invocation = f.invocation(true) + if (change === 'new input') + f.tasks.createMessage({ id: 'next', conversationId: 'task', branchId: 'branch', runId: null, role: 'user', content: 'New goal', createdAt: '2026-09-28T00:00:01.000Z' }) + if (change === 'branch') + f.tasks.createBranch({ id: 'other', conversationId: 'task', parentBranchId: 'branch', forkedFromMessageId: 'input', createdAt: '2026-09-28T00:00:01.000Z', activate: true }) + if (change === 'delete') + f.tasks.markDeleted('task', '2026-09-28T00:00:01.000Z') + if (change === 'abort') + f.controller.abort() + await expect(invocation.handlers['task.rename']({ title: 'Late' }, invocation.context)).rejects.toThrow() + await expect(invocation.handlers['task.messages'](null, invocation.context)).rejects.toThrow() + expect(f.tasks.findById('task')?.title).toBe('Original') + }) + + it('returns bounded visible text while excluding tool output and inactive branches', () => { + const f = fixture() + f.tasks.createBranch({ id: 'hidden', conversationId: 'task', parentBranchId: 'branch', forkedFromMessageId: 'input', createdAt: '2026-09-28T00:00:01.000Z', activate: false }) + for (let index = 0; index < 30; index++) + f.tasks.createMessage({ id: `reply-${index}`, conversationId: 'task', branchId: 'branch', runId: null, role: 'assistant', content: { text: 'x'.repeat(3000) }, createdAt: new Date(Date.UTC(2026, 8, 28, 0, 1, index)).toISOString() }) + f.tasks.createMessage({ id: 'tool', conversationId: 'task', branchId: 'branch', runId: null, role: 'tool', content: 'tool-private', createdAt: '2026-09-28T00:02:00.000Z' }) + f.tasks.createMessage({ id: 'hidden-message', conversationId: 'task', branchId: 'hidden', runId: null, role: 'user', content: 'hidden-private', createdAt: '2026-09-28T00:02:00.000Z' }) + const messages = f.history.messages('task', 'branch') + expect(messages).toHaveLength(16) + expect(messages[0]).toEqual({ role: 'user', text: 'Initial goal' }) + expect(messages.every(message => message.text.length <= 1500)).toBe(true) + expect(JSON.stringify(messages)).not.toContain('private') + }) +}) diff --git a/apps/buddy/service/src/conversations/conversationTimelineCursor.ts b/apps/buddy/service/src/conversations/conversationTimelineCursor.ts index 089eeb7c..5ea42427 100644 --- a/apps/buddy/service/src/conversations/conversationTimelineCursor.ts +++ b/apps/buddy/service/src/conversations/conversationTimelineCursor.ts @@ -15,7 +15,7 @@ interface CreateConversationTimelineCursorInput extends ConversationTimelineCurs const timelineBoundarySchema = z.object({ branchId: z.string().trim().min(1).max(256), id: z.string().trim().min(1).max(256), - kind: z.enum(['message', 'compaction']), + kind: z.enum(['message', 'compaction', 'extension-action']), occurredAt: z.iso.datetime(), }).strict() diff --git a/apps/buddy/service/src/conversations/extensionTaskCapabilities.ts b/apps/buddy/service/src/conversations/extensionTaskCapabilities.ts index fb35e8f8..9f13465d 100644 --- a/apps/buddy/service/src/conversations/extensionTaskCapabilities.ts +++ b/apps/buddy/service/src/conversations/extensionTaskCapabilities.ts @@ -1,17 +1,29 @@ +import type { ExtensionInvocationScope } from '../plugins/extensionAgentHandlers' +import type { ExtensionTaskContextRepository } from '../storage/extensionTaskContextRepository' import type { ConversationMetadataService } from './ConversationMetadataService' +import type { ExtensionTaskSession } from './ExtensionTaskContext' import { extensionAgentHandler } from '../plugins/extensionAgentHandlers' -export function createExtensionTaskCapabilities(conversations: Pick) { +export function createExtensionTaskCapabilities(conversations: Pick, history: ExtensionTaskContextRepository, task: ExtensionTaskSession, scope: ExtensionInvocationScope) { + const titleRevision = conversations.getTitleState(scope.conversationId)?.revision return { 'task.get': extensionAgentHandler('task.get', (_input, context) => { + task.assertCurrent() const title = conversations.getTitleState(context.conversationId) if (!title) throw new Error('EXTENSION_TASK_UNAVAILABLE') - return { id: context.conversationId, title: title.title, titleSource: title.source, titleRevision: title.revision } + return { id: context.conversationId, title: title.title, titleSource: title.source } + }), + 'task.messages': extensionAgentHandler('task.messages', (_input, context) => { + task.assertCurrent() + return history.messages(context.conversationId, task.branchId) }), 'task.rename': extensionAgentHandler('task.rename', (input, context) => { context.signal.throwIfAborted() - const result = conversations.renameGenerated({ id: context.conversationId, ...input }) + task.assertCurrent() + if (titleRevision === undefined) + return { applied: false } + const result = conversations.renameGenerated({ id: context.conversationId, title: input.title, expectedRevision: titleRevision, userInitiated: context.action?.cause.type === 'user' }) return { applied: !!result } }), } diff --git a/apps/buddy/service/src/plugins/ExtensionActionService.ts b/apps/buddy/service/src/plugins/ExtensionActionService.ts new file mode 100644 index 00000000..c3117ad8 --- /dev/null +++ b/apps/buddy/service/src/plugins/ExtensionActionService.ts @@ -0,0 +1,149 @@ +import type { ConversationTimelineChangeNotice } from '../../../shared/conversation/conversationApi' +import type { TaskActionEvent } from '../../../shared/conversation/taskEvents' +import type { ListenerErrorHandler } from '../../../shared/events/Emitter' +import type { ExtensionActionCause, ExtensionAgentDescriptor } from '../../../shared/extensions/extensionAgent' +import type { ExtensionInvocationRepository } from '../storage/extensionInvocationRepository' +import type { ExtensionAgentRuntime } from './ExtensionAgentRuntime' +import { randomUUID } from 'node:crypto' +import { Emitter } from '../../../shared/events/Emitter' +import { extensionActionResultSchema } from '../../../shared/extensions/extensionAgent' + +interface Options { + runtime: Pick + repository: ExtensionInvocationRepository + capture: (conversationId: string, cause: ExtensionActionCause) => { branchId: string, sourceMessageId: string | null } | null + onObserverError?: ListenerErrorHandler +} + +export class ExtensionActionService { + readonly #options: Options + readonly #stop = new AbortController() + readonly #pending = new Map, { conversationId: string, controller: AbortController }>() + readonly #actions = new Map }>() + readonly #failed: Emitter> + readonly #changed: Emitter + readonly #sourceId = randomUUID() + #revision = 0 + readonly onDidFail + readonly onDidChange + + constructor(options: Options) { + this.#options = options + this.#failed = new Emitter(options.onObserverError ?? (() => {})) + this.onDidFail = this.#failed.event + this.#changed = new Emitter(options.onObserverError ?? (() => {})) + this.onDidChange = this.#changed.event + options.repository.recover() + } + + async list(signal = this.#stop.signal) { + return (await this.#options.runtime.descriptors(signal)).flatMap(plugin => plugin.agent.actions + .filter(action => action.triggers.includes('user')) + .map(action => ({ extensionId: plugin.id, actionId: action.id, title: action.title }))) + } + + dispatch(event: TaskActionEvent): void { + const conversationId = event.data.conversationId + if (event.type === 'task:input:committed') + this.cancel(conversationId) + void this.#track(conversationId, async (signal) => { + const plugins = await this.#options.runtime.descriptors(signal) + signal.throwIfAborted() + const results = await Promise.allSettled(plugins.flatMap(plugin => plugin.agent.actions.filter(action => action.triggers.includes(event.type)).map(action => + this.#invoke(plugin, action.id, conversationId, event, signal)))) + signal.throwIfAborted() + const failed = results.find(result => result.status === 'rejected') + if (failed?.status === 'rejected') + throw failed.reason + }).catch((error) => { + if (error instanceof Error && error.name === 'AbortError') + return + this.#failed.fire({ conversationId, errorCode: error instanceof Error ? error.message.match(/EXTENSION_[A-Z_]+/)?.[0] ?? 'EXTENSION_ACTION_FAILED' : 'EXTENSION_ACTION_FAILED' }) + }) + } + + invoke(input: { conversationId: string, extensionId: string, actionId: string }, signal?: AbortSignal) { + return this.#track(input.conversationId, async (signal) => { + const plugin = (await this.#options.runtime.descriptors(signal)).find(plugin => plugin.id === input.extensionId && plugin.agent.actions.some(action => action.id === input.actionId && action.triggers.includes('user'))) + if (!plugin) + throw new Error('EXTENSION_ACTION_UNAVAILABLE') + return this.#invoke(plugin, input.actionId, input.conversationId, { type: 'user' }, signal) + }, signal) + } + + cancel(conversationId?: string): void { + for (const pending of this.#pending.values()) { + if (!conversationId || pending.conversationId === conversationId) + pending.controller.abort() + } + } + + async dispose(): Promise { + this.#stop.abort() + await Promise.allSettled([...this.#pending.keys()]) + this.#failed.dispose() + this.#changed.dispose() + } + + #track(conversationId: string, execute: (signal: AbortSignal) => Promise, signal?: AbortSignal): Promise { + if (this.#pending.size >= 64) + return Promise.reject(new Error('EXTENSION_REQUEST_LIMIT')) + const controller = new AbortController() + const abort = AbortSignal.any([this.#stop.signal, controller.signal, ...signal ? [signal] : []]) + const pending = Promise.resolve().then(() => { + abort.throwIfAborted() + return execute(abort) + }).finally(() => this.#pending.delete(pending)) + this.#pending.set(pending, { conversationId, controller }) + return pending + } + + async #invoke(plugin: ExtensionAgentDescriptor, actionId: string, conversationId: string, cause: ExtensionActionCause, signal: AbortSignal) { + const key = JSON.stringify([conversationId, plugin.id, actionId]) + while (this.#actions.has(key)) { + const previous = this.#actions.get(key)! + if (cause.type === 'user') + previous.controller.abort() + await previous.settled + signal.throwIfAborted() + } + signal.throwIfAborted() + const context = this.#options.capture(conversationId, cause) + if (!context) + return { status: 'skipped' as const } + const controller = new AbortController() + const abort = AbortSignal.any([controller.signal, signal]) + let settle!: () => void + const state = { controller, settled: new Promise(resolve => settle = resolve) } + this.#actions.set(key, state) + const invocationId = randomUUID() + let status: 'completed' | 'skipped' | 'failed' | 'cancelled' = 'failed' + let message: string | null = null + const changed = () => this.#changed.fire({ sourceId: this.#sourceId, revision: ++this.#revision, conversationId, branchId: context.branchId }) + try { + this.#options.repository.start({ id: invocationId, extensionId: plugin.id, extensionName: plugin.name, title: plugin.agent.actions.find(action => action.id === actionId)!.title, actionId, conversationId, ...context, trigger: cause.type, startedAt: new Date().toISOString() }) + changed() + const result = extensionActionResultSchema.parse(await this.#options.runtime.invoke(plugin, { action: actionId, cause }, { conversationId, runId: null, action: { id: actionId, cause } }, abort, invocationId)) + status = result.status + message = result.message ?? null + return result + } + catch (error) { + if (error instanceof Error && error.name === 'AbortError') + status = 'cancelled' + throw error + } + finally { + if (abort.aborted) + status = 'cancelled' + try { + this.#options.repository.finish(invocationId, status, message) + changed() + } + finally { + this.#actions.delete(key) + settle() + } + } + } +} diff --git a/apps/buddy/service/src/plugins/ExtensionAgentEvents.ts b/apps/buddy/service/src/plugins/ExtensionAgentEvents.ts index 77c63d25..f0cd4837 100644 --- a/apps/buddy/service/src/plugins/ExtensionAgentEvents.ts +++ b/apps/buddy/service/src/plugins/ExtensionAgentEvents.ts @@ -1,5 +1,5 @@ import type { EventSnapshot } from '../../../shared/events/eventTypes' -import type { ExtensionAgentDescriptor } from '../../../shared/extensions/extensionAgent' +import type { ExtensionActionTrigger, ExtensionAgentDescriptor } from '../../../shared/extensions/extensionAgent' import type { ExtensionAgentMethod } from '../../../shared/extensions/extensionAgentCapabilities' export interface ExtensionCapabilityProjection { @@ -14,7 +14,9 @@ interface InvocationIdentity { readonly invocationId: string readonly extensionId: string readonly conversationId: string - readonly runId: string + readonly runId: string | null + readonly actionId?: string + readonly trigger?: ExtensionActionTrigger } export type ExtensionAgentFact diff --git a/apps/buddy/service/src/plugins/ExtensionAgentRuntime.ts b/apps/buddy/service/src/plugins/ExtensionAgentRuntime.ts index 77fe1494..1357b648 100644 --- a/apps/buddy/service/src/plugins/ExtensionAgentRuntime.ts +++ b/apps/buddy/service/src/plugins/ExtensionAgentRuntime.ts @@ -1,12 +1,12 @@ import type { ListenerErrorHandler } from '../../../shared/events/Emitter' import type { EventSnapshot } from '../../../shared/events/eventTypes' -import type { ExtensionAgentDescriptor } from '../../../shared/extensions/extensionAgent' +import type { ExtensionAgentDescriptor, ExtensionAgentInvocation } from '../../../shared/extensions/extensionAgent' import type { ExtensionAgentMethod } from '../../../shared/extensions/extensionAgentCapabilities' import type { RuntimeRpcPeerContract } from '../../../shared/runtime/rpcPeer' import type { JsonValue } from '../../../shared/workbench/workbenchState' import type { BuddyCapability, BuddyCapabilityContext } from '../agent/extensions/BuddyCapability' import type { ExtensionAgentChange, ExtensionAgentFact, ExtensionCapabilityProjection } from './ExtensionAgentEvents' -import type { ExtensionAgentHandlers, ExtensionCapabilityContext, ExtensionInvocationScope } from './extensionAgentHandlers' +import type { ExtensionAgentHandlers, ExtensionInvocationScope } from './extensionAgentHandlers' import { createHash, randomUUID } from 'node:crypto' import { defineTool } from '@earendil-works/pi-coding-agent' import { Type } from 'typebox' @@ -18,9 +18,11 @@ import { extensionAgentDescriptorSchema, extensionAgentRequestSchema, extensionA import { extensionAgentCapabilities } from '../../../shared/extensions/extensionAgentCapabilities' import { extensionJsonSchema } from '../../../shared/extensions/extensionApi' import { extensionIdSchema } from '../../../shared/extensions/extensionManifest' +import { ExtensionInvocationQueue } from './ExtensionInvocationQueue' interface Invocation { scope: ExtensionInvocationScope + handlers: ExtensionAgentHandlers requests: Map pending: Set> accepting: boolean @@ -34,14 +36,15 @@ interface ProjectionState { } interface ExtensionAgentRuntimeOptions { rpc: RuntimeRpcPeerContract - handlers: ExtensionAgentHandlers - context: (scope: ExtensionInvocationScope) => Omit + createHandlers: (scope: ExtensionInvocationScope) => ExtensionAgentHandlers onObserverError?: ListenerErrorHandler } +type InvocationRequest = { tool: string, input: Record } | { action: string, cause: NonNullable['cause'] } export class ExtensionAgentRuntime { readonly #options: ExtensionAgentRuntimeOptions readonly #invocations = new Map() + readonly #queue = new ExtensionInvocationQueue() readonly #projections = new Map() readonly #changes: Emitter readonly onDidChange @@ -63,17 +66,73 @@ export class ExtensionAgentRuntime { return this.#options.rpc.onRequest(extensionAgentRpc.request, (input, signal) => this.#request(input, signal)) } + async descriptors(signal: AbortSignal): Promise { + signal.throwIfAborted() + return z.array(extensionAgentDescriptorSchema.extend({ id: extensionIdSchema })).parse(await this.#options.rpc.request(extensionAgentRpc.list, {}, 10000, AbortSignal.any([signal, this.#stop.signal]))) + } + + async invoke(descriptor: ExtensionAgentDescriptor, request: InvocationRequest, scope: Omit, signal: AbortSignal, invocationId = randomUUID()): Promise { + const controller = new AbortController() + const abort = AbortSignal.any([signal, this.#stop.signal, controller.signal]) + abort.throwIfAborted() + let settle!: () => void + const invocationScope = { ...scope, extensionId: descriptor.id, invocationId, signal: abort } + const invocation: Invocation = { scope: invocationScope, handlers: this.#options.createHandlers(invocationScope), requests: new Map(), pending: new Set(), accepting: true, settled: new Promise(resolve => settle = resolve), settle: () => settle() } + const release = await this.#queue.acquire(descriptor.id, invocationId, abort) + try { + abort.throwIfAborted() + return await this.#execute(descriptor, request, invocation, controller) + } + finally { + release() + } + } + + async #execute(descriptor: ExtensionAgentDescriptor, request: InvocationRequest, invocation: Invocation, controller: AbortController): Promise { + const scope = invocation.scope + const { invocationId, signal: abort } = scope + const timeout = setTimeout(() => controller.abort(new DOMException('Extension invocation timed out', 'TimeoutError')), 120000) + this.#invocations.set(invocationId, invocation) + const identity = { invocationId, extensionId: descriptor.id, conversationId: scope.conversationId, runId: scope.runId, ...(scope.action ? { actionId: scope.action.id, trigger: scope.action.cause.type } : {}) } + const startedAt = performance.now() + let outcome: 'completed' | 'failed' | 'cancelled' = 'completed' + this.#publish({ kind: 'invocation', stage: 'started', ...identity }) + try { + const input: ExtensionAgentInvocation = { extensionId: descriptor.id, revision: descriptor.revision, configurationRevision: descriptor.configurationRevision, invocationId, context: { taskId: scope.conversationId, runId: scope.runId }, ...request } + const result = extensionJsonSchema.parse(await this.#options.rpc.request(extensionAgentRpc.invoke, input, 125000, abort)) + abort.throwIfAborted() + return result + } + catch (error) { + const revoked = z.object({ data: z.object({ code: z.enum(['EXTENSION_AGENT_CANCELLED', 'EXTENSION_AGENT_UNAVAILABLE']) }) }).safeParse(error).success + outcome = abort.aborted || revoked ? 'cancelled' : 'failed' + if (revoked) + throw new DOMException('Extension invocation revoked', 'AbortError') + throw error + } + finally { + clearTimeout(timeout) + invocation.accepting = false + this.#publish({ kind: 'invocation', stage: 'returned', outcome, durationMs: Math.round(performance.now() - startedAt), ...identity }) + controller.abort() + await Promise.allSettled([...invocation.pending]) + this.#invocations.delete(invocationId) + this.#publish({ kind: 'invocation', stage: 'settled', outcome, durationMs: Math.round(performance.now() - startedAt), ...identity }) + invocation.settle() + } + } + async capabilities(context: BuddyCapabilityContext): Promise { context.signal.throwIfAborted() this.#stop.signal.throwIfAborted() const projection = this.#projection(context) const request = ++projection.request try { - const descriptors = z.array(extensionAgentDescriptorSchema.extend({ id: extensionIdSchema })).parse(await this.#options.rpc.request(extensionAgentRpc.list, {}, 10000, AbortSignal.any([context.signal, this.#stop.signal]))) + const descriptors = await this.descriptors(context.signal) context.signal.throwIfAborted() this.#stop.signal.throwIfAborted() this.#acceptProjection(projection, request, 'accepted', descriptors) - return descriptors.map(descriptor => this.#capability(context, descriptor)) + return descriptors.filter(descriptor => descriptor.agent.tools.length).map(descriptor => this.#capability(context, descriptor)) } catch { context.signal.throwIfAborted() @@ -95,8 +154,8 @@ export class ExtensionAgentRuntime { return this.#disposing } - #capability(context: BuddyCapabilityContext, input: ExtensionAgentDescriptor): BuddyCapability { - const descriptor: EventSnapshot = copyEventSnapshot(input) + #capability(context: BuddyCapabilityContext, inputDescriptor: ExtensionAgentDescriptor): BuddyCapability { + const descriptor: EventSnapshot = copyEventSnapshot(inputDescriptor) const prefix = `plugin_${createHash('sha256').update(descriptor.id).digest('hex').slice(0, 12)}` const names = new Map(descriptor.agent.tools.map(tool => [tool.id, `lexora_plugin_${createHash('sha256').update(tool.id).digest('hex').slice(0, 16)}`])) const instructions = descriptor.agent.instructions.replace(/\{\{([^}]+)\}\}/g, (_match, id: string) => names.get(id) ?? id) @@ -119,38 +178,16 @@ export class ExtensionAgentRuntime { parameters: tool.schema, execute: async (_toolCallId, input, signal) => { const runId = context.getRunId() - if (this.#stop.signal.aborted || context.signal.aborted || !runId || !Check(tool.schema, input)) + if (!runId || !Check(tool.schema, input)) throw new Error('EXTENSION_AGENT_UNAVAILABLE') - const controller = new AbortController() - const abort = AbortSignal.any([context.signal, this.#stop.signal, controller.signal, ...signal ? [signal] : [], AbortSignal.timeout(120000)]) - const invocationId = randomUUID() - let settle!: () => void - const invocation: Invocation = { scope: { conversationId: context.conversationId, runId, extensionId: descriptor.id, invocationId, signal: abort }, requests: new Map(), pending: new Set(), accepting: true, settled: new Promise(resolve => settle = resolve), settle: () => settle() } - this.#invocations.set(invocationId, invocation) - const identity = { invocationId, extensionId: descriptor.id, conversationId: context.conversationId, runId } - const startedAt = performance.now() - let outcome: 'completed' | 'failed' | 'cancelled' = 'completed' - this.#publish({ kind: 'invocation', stage: 'started', ...identity }) try { - abort.throwIfAborted() - const result = extensionJsonSchema.parse(await this.#options.rpc.request(extensionAgentRpc.invoke, { extensionId: descriptor.id, revision: descriptor.revision, configurationRevision: descriptor.configurationRevision, invocationId, tool: tool.id, input }, 125000, abort)) - abort.throwIfAborted() + const result = await this.invoke(inputDescriptor, { tool: tool.id, input }, { conversationId: context.conversationId, runId }, signal ?? new AbortController().signal) return { content: [{ type: 'text', text: JSON.stringify(result) }], details: { ok: true, result } } } catch (error) { - outcome = abort.aborted ? 'cancelled' : 'failed' const code = error instanceof Error ? error.message.match(/EXTENSION_[A-Z_]+/)?.[0] : null return { content: [{ type: 'text', text: code ?? 'EXTENSION_AGENT_FAILED' }], details: { ok: false, result: null }, isError: true } } - finally { - invocation.accepting = false - this.#publish({ kind: 'invocation', stage: 'returned', outcome, durationMs: Math.round(performance.now() - startedAt), ...identity }) - controller.abort() - await Promise.allSettled([...invocation.pending]) - this.#invocations.delete(invocationId) - this.#publish({ kind: 'invocation', stage: 'settled', outcome, durationMs: Math.round(performance.now() - startedAt), ...identity }) - invocation.settle() - } }, })) } @@ -171,7 +208,7 @@ export class ExtensionAgentRuntime { const signal = requestSignal ? AbortSignal.any([invocation.scope.signal, requestSignal]) : invocation.scope.signal signal.throwIfAborted() const contract = extensionAgentCapabilities[input.method] - const context = this.#options.context({ ...invocation.scope, signal }) + const context = { ...invocation.scope, signal } const requests = invocation.requests.get(input.method) ?? { calls: 0, active: 0 } if (contract.limits && (requests.calls >= contract.limits.calls || requests.active >= contract.limits.concurrent)) throw new Error(contract.limits.error) @@ -184,7 +221,7 @@ export class ExtensionAgentRuntime { let handler: 'completed' | 'failed' = 'failed' const pending = Promise.resolve().then(async () => { signal.throwIfAborted() - const result = await this.#options.handlers[input.method](input.params, { ...context, callNumber }) + const result = await invocation.handlers[input.method](input.params, { ...context, callNumber }) handler = 'completed' signal.throwIfAborted() return result diff --git a/apps/buddy/service/src/plugins/ExtensionInvocationQueue.ts b/apps/buddy/service/src/plugins/ExtensionInvocationQueue.ts new file mode 100644 index 00000000..8313648b --- /dev/null +++ b/apps/buddy/service/src/plugins/ExtensionInvocationQueue.ts @@ -0,0 +1,47 @@ +import { extensionAgentInvocationLimits } from '../../../shared/extensions/extensionAgent' + +interface WaitingInvocation { + extensionId: string + start: (release: () => void) => void +} + +export class ExtensionInvocationQueue { + readonly #active = new Map() + readonly #waiting = new Map() + + acquire(extensionId: string, invocationId: string, signal: AbortSignal): Promise<() => void> { + signal.throwIfAborted() + if (this.#active.has(invocationId) || this.#waiting.has(invocationId) || this.#waiting.size >= extensionAgentInvocationLimits.queued) + return Promise.reject(new Error('EXTENSION_REQUEST_LIMIT')) + return new Promise((resolve, reject) => { + const cancel = () => { + this.#waiting.delete(invocationId) + reject(signal.reason) + } + this.#waiting.set(invocationId, { + extensionId, + start: (release) => { + signal.removeEventListener('abort', cancel) + resolve(release) + }, + }) + signal.addEventListener('abort', cancel, { once: true }) + this.#drain() + }) + } + + #drain(): void { + for (const [invocationId, waiting] of this.#waiting) { + if (this.#active.size >= extensionAgentInvocationLimits.concurrent) + break + if ([...this.#active.values()].filter(id => id === waiting.extensionId).length >= extensionAgentInvocationLimits.concurrentPerExtension) + continue + this.#waiting.delete(invocationId) + this.#active.set(invocationId, waiting.extensionId) + waiting.start(() => { + if (this.#active.delete(invocationId)) + this.#drain() + }) + } + } +} diff --git a/apps/buddy/service/src/plugins/__tests__/ExtensionActionService.spec.ts b/apps/buddy/service/src/plugins/__tests__/ExtensionActionService.spec.ts new file mode 100644 index 00000000..03505b5d --- /dev/null +++ b/apps/buddy/service/src/plugins/__tests__/ExtensionActionService.spec.ts @@ -0,0 +1,175 @@ +import type { ExtensionAgentDescriptor, ExtensionAgentInvocation } from '../../../../shared/extensions/extensionAgent' +import type { RuntimeRpcPeerContract } from '../../../../shared/runtime/rpcPeer' +import { deferred } from '@buddy-tests/deferred' +import { describe, expect, it, vi } from 'vitest' +import { RuntimeRpcPeer } from '../../../../platform/ipc/runtimeRpcPeer' +import { extensionActionRpc } from '../../../../shared/extensions/extensionActionApi' +import { extensionAgentRpc } from '../../../../shared/extensions/extensionAgent' +import { createConversationRepository } from '../../storage/conversationRepository' +import { openBuddyDatabase } from '../../storage/database' +import { createExtensionInvocationRepository } from '../../storage/extensionInvocationRepository' +import { ExtensionActionService } from '../ExtensionActionService' +import { ExtensionAgentRuntime } from '../ExtensionAgentRuntime' + +describe('extension action dispatch', () => { + it.each([90000, 121000])('gives manual actions their execution budget after queueing over RPC for duration %s', async (duration) => { + vi.useFakeTimers() + const database = openBuddyDatabase({ databasePath: ':memory:' }) + const descriptor: ExtensionAgentDescriptor = { + id: 'tests.actions', + name: 'Actions', + revision: 'package', + configurationRevision: 'config', + agent: { instructions: '', tools: [], actions: [{ id: 'tests.actions.manual', title: 'Manual', triggers: ['user'] }] }, + } + createConversationRepository(database).create({ id: 'task', branchId: 'branch', title: null, spaceId: null, approvalPolicy: 'policy', executionProfile: 'workspace_write', createdAt: new Date().toISOString() }) + let toHost: (message: unknown) => void = () => {} + let toService: (message: unknown) => void = () => {} + const host = new RuntimeRpcPeer({ transport: { postMessage: message => toService(message), subscribe: (listener) => { + toHost = listener + return () => { + toHost = () => {} + } + } } }) + const peer = new RuntimeRpcPeer({ transport: { postMessage: message => toHost(message), subscribe: (listener) => { + toService = listener + return () => { + toService = () => {} + } + } } }) + host.onRequest(extensionAgentRpc.list, () => [descriptor]) + let manualSignal: AbortSignal | undefined + host.onRequest(extensionAgentRpc.invoke, (raw, signal) => { + const manual = 'action' in (raw as ExtensionAgentInvocation) + if (manual) + manualSignal = signal + return new Promise((resolve, reject) => { + let timer: ReturnType + const cancel = () => { + clearTimeout(timer) + reject(signal!.reason) + } + timer = setTimeout(() => { + signal!.removeEventListener('abort', cancel) + resolve({ status: 'completed' }) + }, manual ? duration : 60000) + signal!.addEventListener('abort', cancel, { once: true }) + }) + }) + const runtime = new ExtensionAgentRuntime({ rpc: peer, createHandlers: () => ({ 'task.get': async () => null, 'task.messages': async () => [], 'task.rename': async () => null, 'models.generateText': async () => null }) }) + const service = new ExtensionActionService({ runtime, repository: createExtensionInvocationRepository(database), capture: () => ({ branchId: 'branch', sourceMessageId: null }) }) + peer.onRequest(extensionActionRpc.invoke.method, (input, signal) => service.invoke(extensionActionRpc.invoke.input.parse(input), signal)) + try { + const tools = Array.from({ length: 4 }, () => runtime.invoke(descriptor, { tool: 'tests.actions.tool', input: {} }, { conversationId: 'task', runId: 'run' }, new AbortController().signal)) + let result: unknown + const manual = host.request(extensionActionRpc.invoke.method, { conversationId: 'task', extensionId: descriptor.id, actionId: 'tests.actions.manual' }, null).then(value => result = value, error => result = error) + await vi.advanceTimersByTimeAsync(0) + expect(manualSignal).toBeUndefined() + await vi.advanceTimersByTimeAsync(60000) + await Promise.all(tools) + expect(manualSignal?.aborted).toBe(false) + await vi.advanceTimersByTimeAsync(75000) + expect(result).toBeUndefined() + expect(manualSignal?.aborted).toBe(false) + await vi.advanceTimersByTimeAsync(duration <= 120000 ? 15000 : 45000) + await manual + if (duration <= 120000) { + expect(result).toEqual({ status: 'completed' }) + expect(database.prepare('SELECT status FROM extension_invocations').all()).toEqual([{ status: 'completed' }]) + } + else { + expect(manualSignal?.aborted).toBe(true) + expect(result).toBeInstanceOf(Error) + expect(database.prepare('SELECT status FROM extension_invocations').all()).toEqual([{ status: 'failed' }]) + } + } + finally { + await service.dispose() + await runtime.dispose() + host.close(new Error('closed')) + peer.close(new Error('closed')) + database.close() + vi.useRealTimers() + } + }) + + it('runs all sixteen automatic actions alongside a tool within the host quota and gives each its execution deadline', async () => { + vi.useFakeTimers() + const database = openBuddyDatabase({ databasePath: ':memory:' }) + const descriptor: ExtensionAgentDescriptor = { + id: 'tests.actions', + name: 'Actions', + revision: 'package', + configurationRevision: 'config', + agent: { + instructions: '', + tools: [{ id: 'tests.actions.tool', title: 'Tool', description: 'Fixture', parameters: { type: 'object', properties: {}, required: [], additionalProperties: false } }], + actions: Array.from({ length: 16 }, (_, index) => ({ id: `tests.actions.action${index}`, title: `Action ${index}`, triggers: ['task:input:committed'] })), + }, + } + createConversationRepository(database).create({ id: 'task', branchId: 'branch', title: null, spaceId: null, approvalPolicy: 'policy', executionProfile: 'workspace_write', createdAt: new Date().toISOString() }) + const accepted: ExtensionAgentInvocation[] = [] + let active = 0 + let peak = 0 + const firstBatch = deferred() + const rpc: RuntimeRpcPeerContract = { + notify() {}, + close() {}, + onRequest: () => () => {}, + onNotification: () => () => {}, + request: async (method, input, _timeout, signal) => { + if (method === extensionAgentRpc.list) + return [descriptor] + if (active >= 4) + throw new Error('EXTENSION_REQUEST_LIMIT') + accepted.push(input as ExtensionAgentInvocation) + active++ + peak = Math.max(peak, active) + if (accepted.length === 4) + firstBatch.resolve() + try { + await new Promise((resolve, reject) => { + let timer: ReturnType + const cancel = () => { + clearTimeout(timer) + reject(signal?.reason) + } + timer = setTimeout(() => { + signal?.removeEventListener('abort', cancel) + resolve() + }, 90000) + signal?.addEventListener('abort', cancel, { once: true }) + }) + return { status: 'completed' } + } + finally { active-- } + }, + } + const runtime = new ExtensionAgentRuntime({ rpc, createHandlers: () => ({ 'task.get': async () => null, 'task.messages': async () => [], 'task.rename': async () => null, 'models.generateText': async () => null }) }) + const service = new ExtensionActionService({ runtime, repository: createExtensionInvocationRepository(database), capture: () => ({ branchId: 'branch', sourceMessageId: null }) }) + const errors: string[] = [] + service.onDidFail(event => errors.push(event.errorCode)) + try { + const tool = runtime.invoke(descriptor, { tool: 'tests.actions.tool', input: {} }, { conversationId: 'task', runId: 'run' }, new AbortController().signal) + service.dispatch({ type: 'task:input:committed', data: { conversationId: 'task', branchId: 'branch', runId: 'run', messageId: 'message', commitId: 'commit' } }) + await firstBatch.promise + expect(accepted.filter(input => 'action' in input)).toHaveLength(3) + for (let batch = 1; batch <= 5; batch++) { + await vi.advanceTimersByTimeAsync(90000) + expect(database.prepare('SELECT id FROM extension_invocations WHERE status = \'completed\'').all()).toHaveLength(Math.min(16, batch * 4 - 1)) + } + await tool + expect(peak).toBe(4) + expect(active).toBe(0) + expect(accepted.filter(input => 'action' in input).map(input => input.action)).toEqual(descriptor.agent.actions.map(action => action.id)) + expect(errors).toEqual([]) + expect(runtime.snapshot.activeInvocations).toEqual([]) + } + finally { + await service.dispose() + await runtime.dispose() + database.close() + vi.useRealTimers() + } + }) +}) diff --git a/apps/buddy/service/src/plugins/__tests__/ExtensionAgentRuntime.spec.ts b/apps/buddy/service/src/plugins/__tests__/ExtensionAgentRuntime.spec.ts index d926cfa1..48f16917 100644 --- a/apps/buddy/service/src/plugins/__tests__/ExtensionAgentRuntime.spec.ts +++ b/apps/buddy/service/src/plugins/__tests__/ExtensionAgentRuntime.spec.ts @@ -4,12 +4,13 @@ import type { RuntimeRequestHandler, RuntimeRpcPeerContract } from '../../../../ import type { BuddyCapabilityContext } from '../../agent/extensions/BuddyCapability' import type { ExtensionAgentChange } from '../ExtensionAgentEvents' import type { ExtensionAgentHandlers } from '../extensionAgentHandlers' +import { randomUUID } from 'node:crypto' import { deferred } from '@buddy-tests/deferred' import { describe, expect, it } from 'vitest' import { extensionAgentRpc } from '../../../../shared/extensions/extensionAgent' import { ExtensionAgentRuntime } from '../ExtensionAgentRuntime' -const descriptor: ExtensionAgentDescriptor = { id: 'tests.reader', name: 'Fixture', revision: 'package-revision', configurationRevision: 'config-revision', agent: { instructions: 'fixture-private-instructions', tools: [{ id: 'tests.reader.read', title: 'Read', description: 'fixture-private-description', parameters: { type: 'object', properties: {}, required: [], additionalProperties: false } }] } } +const descriptor: ExtensionAgentDescriptor = { id: 'tests.reader', name: 'Fixture', revision: 'package-revision', configurationRevision: 'config-revision', agent: { actions: [], instructions: 'fixture-private-instructions', tools: [{ id: 'tests.reader.read', title: 'Read', description: 'fixture-private-description', parameters: { type: 'object', properties: {}, required: [], additionalProperties: false } }] } } function fixture() { const requests = new Map() const stop = new AbortController() @@ -17,7 +18,7 @@ function fixture() { let descriptors: ExtensionAgentDescriptor[] = [descriptor] let unavailable = false let invoke: (input: unknown, signal?: AbortSignal) => Promise = async () => null - const handlers: ExtensionAgentHandlers = { 'task.get': async () => null, 'task.rename': async () => null, 'models.generateText': async () => null } + const handlers: ExtensionAgentHandlers = { 'task.messages': async () => [], 'task.get': async () => null, 'task.rename': async () => null, 'models.generateText': async () => null } const rpc: RuntimeRpcPeerContract = { notify() {}, close() {}, @@ -38,7 +39,7 @@ function fixture() { return invoke(input, signal) }, } - const runtime = new ExtensionAgentRuntime({ rpc, handlers, context: scope => ({ ...scope, model: { providerId: 'provider-1', modelId: 'model-1' } }) }) + const runtime = new ExtensionAgentRuntime({ rpc, createHandlers: () => handlers }) const facts: ExtensionAgentChange[] = [] runtime.onDidChange(event => facts.push(event)) runtime.bind() @@ -59,6 +60,83 @@ function fixture() { } describe('extension agent capability and invocation lifetimes', () => { + it('holds quota through child draining, admits other plugins, and removes cancelled queued calls before execution', async () => { + const f = fixture() + const children = new Map>>() + const drainingIds = Array.from({ length: 4 }, () => randomUUID()) + const draining = new Set(drainingIds) + const accepted: string[] = [] + const childResults: Promise[] = [] + f.handlers['task.rename'] = async (_input, scope) => { + const committed = deferred() + children.set(scope.invocationId, committed) + await committed.promise + return { applied: true } + } + f.invoke(async (raw) => { + const { invocationId } = raw as { invocationId: string } + accepted.push(invocationId) + if (draining.has(invocationId)) { + childResults.push(f.request({ invocationId, method: 'task.rename', params: { title: 'Committed' } }).catch(error => error)) + await Promise.resolve() + } + return { done: true } + }) + const invoke = (id: ReturnType, plugin = descriptor, signal = new AbortController().signal) => f.runtime.invoke(plugin, { tool: 'tests.reader.read', input: {} }, { conversationId: 'conversation-1', runId: 'run-1' }, signal, id) + const running = drainingIds.map(id => invoke(id)) + try { + await expect.poll(() => f.runtime.snapshot.activeInvocations.filter(invocation => !invocation.accepting && invocation.pendingRequests === 1).length).toBe(4) + const cancel = new AbortController() + const cancelledId = randomUUID() + const cancelled = invoke(cancelledId, descriptor, cancel.signal).catch(error => error) + const nextId = randomUUID() + const next = invoke(nextId) + const otherId = randomUUID() + expect(await invoke(otherId, { ...descriptor, id: 'tests.other' })).toEqual({ done: true }) + expect(accepted).toEqual([...drainingIds, otherId]) + cancel.abort() + expect(await cancelled).toMatchObject({ name: 'AbortError' }) + children.get(drainingIds[0]!)!.resolve() + expect(await next).toEqual({ done: true }) + expect(accepted).toEqual([...drainingIds, otherId, nextId]) + const finished = f.facts.flatMap(fact => fact.kind === 'invocation' && fact.stage === 'settled' ? [fact.invocationId] : []) + expect(finished).toContain(drainingIds[0]) + } + finally { + for (const child of children.values()) child.resolve() + await Promise.allSettled([...running, ...childResults]) + await f.runtime.dispose() + } + }) + + it('cancels queued invocations on disposal without sending them to the host', async () => { + const f = fixture() + const accepted: string[] = [] + f.invoke(async (raw, signal) => { + accepted.push((raw as { invocationId: string }).invocationId) + await new Promise((_resolve, reject) => signal!.addEventListener('abort', () => reject(signal!.reason), { once: true })) + return null + }) + const ids = Array.from({ length: 8 }, () => randomUUID()) + const results = Promise.allSettled(ids.map(id => f.runtime.invoke(descriptor, { tool: 'tests.reader.read', input: {} }, { conversationId: 'conversation-1', runId: 'run-1' }, new AbortController().signal, id))) + await expect.poll(() => accepted.length).toBe(4) + await f.runtime.dispose() + expect(await results).toEqual(ids.map(() => expect.objectContaining({ status: 'rejected', reason: expect.objectContaining({ name: 'AbortError' }) }))) + expect(accepted).toEqual(ids.slice(0, 4)) + expect(f.runtime.snapshot.activeInvocations).toEqual([]) + }) + + it.each(['EXTENSION_AGENT_CANCELLED', 'EXTENSION_AGENT_UNAVAILABLE'])('preserves remote lifecycle revocation %s as cancellation', async (code) => { + const f = fixture() + f.invoke(async () => { + throw Object.assign(new Error('Runtime request failed'), { data: { code } }) + }) + await expect(f.runtime.invoke(descriptor, { tool: 'tests.reader.read', input: {} }, { conversationId: 'conversation-1', runId: 'run-1' }, new AbortController().signal)).rejects.toMatchObject({ name: 'AbortError' }) + expect(f.facts.filter(fact => fact.kind === 'invocation' && fact.stage !== 'started')).toMatchObject([{ stage: 'returned', outcome: 'cancelled' }, { stage: 'settled', outcome: 'cancelled' }]) + expect(f.runtime.snapshot.activeInvocations).toEqual([]) + await f.runtime.dispose() + }) + it('distinguishes an accepted empty catalog from an unavailable source and releases its scope snapshot', async () => { const f = fixture() f.setDescriptors([]) diff --git a/apps/buddy/service/src/plugins/__tests__/ExtensionInvocationQueue.spec.ts b/apps/buddy/service/src/plugins/__tests__/ExtensionInvocationQueue.spec.ts new file mode 100644 index 00000000..4a731a41 --- /dev/null +++ b/apps/buddy/service/src/plugins/__tests__/ExtensionInvocationQueue.spec.ts @@ -0,0 +1,31 @@ +import { describe, expect, it } from 'vitest' +import { ExtensionInvocationQueue } from '../ExtensionInvocationQueue' + +describe('extension invocation admission', () => { + it('keeps the global execution and waiting bounds and reuses cancelled queue capacity', async () => { + const queue = new ExtensionInvocationQueue() + const stop = new AbortController() + const active = await Promise.all(Array.from({ length: 32 }, (_, index) => queue.acquire(`plugin-${index}`, `active-${index}`, stop.signal))) + let admitted = 0 + const waiting = Promise.allSettled(Array.from({ length: 1024 }, (_, index) => queue.acquire('plugin-0', `waiting-${index}`, stop.signal).then((release) => { + admitted++ + return release + }))) + await expect(queue.acquire('plugin-0', 'overflow', stop.signal)).rejects.toThrow('EXTENSION_REQUEST_LIMIT') + await expect(queue.acquire('plugin-1', 'active-0', stop.signal)).rejects.toThrow('EXTENSION_REQUEST_LIMIT') + expect(admitted).toBe(0) + active[0]!() + await Promise.resolve() + expect(admitted).toBe(1) + stop.abort() + const results = await waiting + expect(results.filter(result => result.status === 'rejected')).toHaveLength(1023) + for (const result of results) { + if (result.status === 'fulfilled') + result.value() + } + for (const release of active) release() + const release = await queue.acquire('plugin-0', 'waiting-1', new AbortController().signal) + release() + }) +}) diff --git a/apps/buddy/service/src/plugins/__tests__/pluginAuthoringCapability.spec.ts b/apps/buddy/service/src/plugins/__tests__/pluginAuthoringCapability.spec.ts index b5904315..e5945986 100644 --- a/apps/buddy/service/src/plugins/__tests__/pluginAuthoringCapability.spec.ts +++ b/apps/buddy/service/src/plugins/__tests__/pluginAuthoringCapability.spec.ts @@ -27,7 +27,7 @@ async function fixture() { let identityTool: ToolDefinition | undefined let rejectReview = false const peer = { - request: async (_method: string, input: unknown, _timeout?: number, signal?: AbortSignal) => buildExtensionPackage(input, async (files, manifest, _signal, report) => compileExtensionSource(files, manifest, report), signal!), + request: async (_method: string, input: unknown, _timeout?: number | null, signal?: AbortSignal) => buildExtensionPackage(input, async (files, manifest, _signal, report) => compileExtensionSource(files, manifest, report), signal!), notify: (_method: string, input: unknown) => { if (rejectReview) throw new Error('fixture-private-review-failure') diff --git a/apps/buddy/service/src/plugins/bindExtensionActions.ts b/apps/buddy/service/src/plugins/bindExtensionActions.ts new file mode 100644 index 00000000..bdad6204 --- /dev/null +++ b/apps/buddy/service/src/plugins/bindExtensionActions.ts @@ -0,0 +1,35 @@ +import type { BuddyAgentRunner } from '../agent/execution/BuddyAgentRunner' +import type { TurnRequestService } from '../chat/TurnRequestService' +import type { ConversationLifecycleService } from '../conversations/ConversationLifecycleService' +import type { ConversationMetadataService } from '../conversations/ConversationMetadataService' +import type { RunRepository } from '../storage/runRepository' +import type { ExtensionActionService } from './ExtensionActionService' + +export function bindExtensionActions(service: ExtensionActionService, sources: { + turns: Pick + execution: Pick + metadata: Pick + deletions: Pick + runs: Pick +}) { + const subscriptions = [ + sources.turns.onDidCommit((event) => { + const message = event.facts.find(fact => fact.kind === 'message.created') + if (message?.kind === 'message.created' && sources.runs.findById(event.runId)?.purpose === 'chat') + service.dispatch({ type: 'task:input:committed', data: { conversationId: event.conversationId, branchId: event.branchId, runId: event.runId, messageId: message.messageId, commitId: event.commitId } }) + else if (event.facts.some(fact => fact.kind === 'task.branch_activated')) + service.cancel(event.conversationId) + }), + sources.execution.onDidSettle((event) => { + const run = sources.runs.findById(event.runId) + if (!event.stopping && run?.status === 'completed' && run.purpose === 'chat' && run.completedAt) + service.dispatch({ type: 'task:turn:completed', data: { conversationId: event.conversationId, branchId: event.branchId, runId: event.runId, triggeringMessageId: run.triggeringMessageId, completedAt: run.completedAt } }) + }), + sources.metadata.onDidCommit((event) => { + if (event.kind === 'branch' || event.kind === 'model' || (event.kind === 'title' && event.titleSource === 'manual')) + service.cancel(event.conversation.id) + }), + sources.deletions.onDidCommit(event => service.cancel(event.conversationId)), + ] + return { dispose: () => subscriptions.forEach(subscription => subscription.dispose()) } +} diff --git a/apps/buddy/service/src/plugins/extensionAgentHandlers.ts b/apps/buddy/service/src/plugins/extensionAgentHandlers.ts index 3996f45b..787a7a3a 100644 --- a/apps/buddy/service/src/plugins/extensionAgentHandlers.ts +++ b/apps/buddy/service/src/plugins/extensionAgentHandlers.ts @@ -1,17 +1,21 @@ import type { z } from 'zod' +import type { ExtensionActionCause } from '../../../shared/extensions/extensionAgent' import type { ExtensionAgentMethod } from '../../../shared/extensions/extensionAgentCapabilities' import type { JsonValue } from '../../../shared/workbench/workbenchState' import { extensionAgentCapabilities } from '../../../shared/extensions/extensionAgentCapabilities' export interface ExtensionInvocationScope { conversationId: string - runId: string + runId: string | null + action?: { + id: string + cause: ExtensionActionCause + } extensionId: string invocationId: string signal: AbortSignal } export interface ExtensionCapabilityContext extends ExtensionInvocationScope { - model: { providerId: string, modelId: string } callNumber: number } export type ExtensionAgentHandlers = Record Promise> diff --git a/apps/buddy/service/src/plugins/observeExtensionAgentDiagnostics.ts b/apps/buddy/service/src/plugins/observeExtensionAgentDiagnostics.ts index 0850b1a7..a35a8691 100644 --- a/apps/buddy/service/src/plugins/observeExtensionAgentDiagnostics.ts +++ b/apps/buddy/service/src/plugins/observeExtensionAgentDiagnostics.ts @@ -7,10 +7,10 @@ export function observeExtensionAgentDiagnostics(service: ExtensionAgentRuntime, report({ event: `plugins.capabilities.${change.projection.status}`, level: change.projection.status === 'unavailable' ? 'warn' : 'info', operationId: change.projection.id, conversationId: change.projection.conversationId, revision: change.projection.revision, count: change.projection.descriptors.length }) } else if (change.kind === 'invocation') { - report({ event: `plugins.invocation.${change.stage}`, level: change.outcome === 'failed' ? 'warn' : 'info', extensionId: change.extensionId, operationId: change.invocationId, runId: change.runId, conversationId: change.conversationId, ...(change.durationMs === undefined ? {} : { durationMs: change.durationMs }), ...(change.outcome === 'failed' ? { errorCode: 'EXTENSION_AGENT_FAILED' } : change.outcome === 'cancelled' ? { errorCode: 'EXTENSION_AGENT_CANCELLED' } : {}) }) + report({ event: `plugins.invocation.${change.stage}`, level: change.outcome === 'failed' ? 'warn' : 'info', extensionId: change.extensionId, operationId: change.invocationId, runId: change.runId ?? undefined, conversationId: change.conversationId, ...(change.durationMs === undefined ? {} : { durationMs: change.durationMs }), ...(change.outcome === 'failed' ? { errorCode: 'EXTENSION_AGENT_FAILED' } : change.outcome === 'cancelled' ? { errorCode: 'EXTENSION_AGENT_CANCELLED' } : {}) }) } else if (change.kind === 'request') { - report({ event: `plugins.request.${change.stage}`, level: change.response === 'failed' ? 'warn' : 'info', extensionId: change.extensionId, operationId: change.invocationId, requestId: change.requestId, runId: change.runId, conversationId: change.conversationId, method: change.method, ...(change.durationMs === undefined ? {} : { durationMs: change.durationMs }), ...(change.handler === 'failed' ? { errorCode: 'EXTENSION_REQUEST_FAILED' } : change.response === 'cancelled' ? { errorCode: 'EXTENSION_RESPONSE_CANCELLED' } : {}) }) + report({ event: `plugins.request.${change.stage}`, level: change.response === 'failed' ? 'warn' : 'info', extensionId: change.extensionId, operationId: change.invocationId, requestId: change.requestId, runId: change.runId ?? undefined, conversationId: change.conversationId, method: change.method, ...(change.durationMs === undefined ? {} : { durationMs: change.durationMs }), ...(change.handler === 'failed' ? { errorCode: 'EXTENSION_REQUEST_FAILED' } : change.response === 'cancelled' ? { errorCode: 'EXTENSION_RESPONSE_CANCELLED' } : {}) }) } }) } diff --git a/apps/buddy/service/src/plugins/registerExtensionConditionRpc.ts b/apps/buddy/service/src/plugins/registerExtensionConditionRpc.ts new file mode 100644 index 00000000..8dd05d06 --- /dev/null +++ b/apps/buddy/service/src/plugins/registerExtensionConditionRpc.ts @@ -0,0 +1,58 @@ +import type { ExtensionConditionRuntime } from '../../../shared/extensions/extensionConditionContext' +import type { ProviderService } from '../providers/ProviderService' +import type { RuntimeRequestRegistrar } from '../rpc/runtimeRequest' +import type { ConversationRepository } from '../storage/conversationRepository' +import type { ExtensionTaskContextRepository } from '../storage/extensionTaskContextRepository' +import type { RunInputRepository } from '../storage/runInputRepository' +import type { RunRepository } from '../storage/runRepository' +import { createHash } from 'node:crypto' +import { extensionConditionSnapshotRpc } from '../../../shared/extensions/extensionConditionContext' +import { registerRuntimeRequest } from '../rpc/runtimeRequest' + +export function registerExtensionConditionRpc(options: { + rpc: RuntimeRequestRegistrar + tasks: Pick + history: ExtensionTaskContextRepository + runs: Pick + inputs: Pick + providers: Pick +}): () => void { + return registerRuntimeRequest(options.rpc, extensionConditionSnapshotRpc, async (input, signal) => { + const result: ExtensionConditionRuntime = { models: { status: 'not_requested' }, task: { status: 'not_requested' } } + const catalog = input.models ? await Promise.all([options.providers.listModels(), options.providers.listProviders(), options.providers.getDefaultModel()]) : null + signal?.throwIfAborted() + const task = input.taskId ? options.tasks.findById(input.taskId) : null + const available = task && task.deletedAt === null && task.activeBranchId + const latestInput = available ? options.history.latestInput(task.id, task.activeBranchId!) : null + const runId = input.runId ?? (latestInput ? options.inputs.findByMessageId(latestInput)?.runId : null) + const run = runId ? options.runs.findById(runId) : null + const validRun = available && run?.conversationId === task.id && run.branchId === task.activeBranchId ? run : null + const runSelection = validRun ? { providerId: validRun.provider, modelId: validRun.model } : null + const taskSelection = available ? task.modelSelection ?? runSelection : null + const selection = input.runId ? runSelection : taskSelection + if (input.task) { + if (available) { + const state = options.tasks.getTitleState(task.id)! + const value = { id: task.id, spaceId: task.spaceId, branchId: task.activeBranchId!, title: task.title, titleSource: state.source, activity: options.history.activity(task.id), modelSelection: taskSelection ? { providerId: taskSelection.providerId, modelId: taskSelection.modelId } : null } + result.task = { status: 'available', revision: revision(value), ...value } + } + else { + result.task = { status: input.taskId ? 'invalid' : 'no_context' } + } + } + if (catalog) { + const [models, providers, fallback] = catalog + const chosen = input.taskId ? selection : fallback + const value = { + selection: chosen ? { providerId: chosen.providerId, modelId: chosen.modelId } : null, + models: models.map(model => ({ providerId: model.providerId, modelId: model.id, name: model.displayName, available: model.available && model.enabled && providers.some(provider => provider.id === model.providerId && provider.enabled && provider.status === 'available'), capabilities: model.capabilities })), + } + result.models = { status: 'available', revision: revision(value), ...value } + } + return result + }) +} + +function revision(value: unknown): string { + return createHash('sha256').update(JSON.stringify(value)).digest('hex') +} diff --git a/apps/buddy/service/src/providers/extensionModelCapabilities.ts b/apps/buddy/service/src/providers/extensionModelCapabilities.ts index 6fb39c02..48295444 100644 --- a/apps/buddy/service/src/providers/extensionModelCapabilities.ts +++ b/apps/buddy/service/src/providers/extensionModelCapabilities.ts @@ -1,16 +1,26 @@ +import type { ExtensionTaskSession } from '../conversations/ExtensionTaskContext' import type { UsageService } from '../usage/UsageService' import type { ProviderExecutionModelResolver } from './ProviderExecutionModelResolver' import { extensionAgentHandler } from '../plugins/extensionAgentHandlers' -export function createExtensionModelCapabilities(models: Pick, usage: Pick) { +export function createExtensionModelCapabilities(models: Pick, usage: Pick, task: Pick) { return { 'models.generateText': extensionAgentHandler('models.generateText', async (input, context) => { - const selection = input.model ?? context.model + task.assertCurrent() + const selection = input.model ?? task.model + if (!selection) + throw new Error('EXTENSION_MODEL_UNAVAILABLE') const { model, runtime } = await models.resolveSession({ ...selection, contextWindow: null, maxTokens: null }) context.signal.throwIfAborted() + task.assertCurrent() const result = await runtime.completeSimple(model, { systemPrompt: input.system, messages: [{ role: 'user', content: input.prompt, timestamp: Date.now() }] }, { signal: context.signal, maxTokens: input.maxTokens }) - await usage.record({ createdAt: new Date().toISOString(), model: result.model, provider: result.provider, purpose: 'tool', runId: context.runId, sourceEntryId: `plugin:${context.extensionId}:${context.invocationId}:${context.callNumber}`, usage: result.usage }) + const record = { createdAt: new Date().toISOString(), model: result.model, provider: result.provider, sourceEntryId: `plugin:${context.extensionId}:${context.invocationId}:${context.callNumber}`, usage: result.usage } + if (context.action) + usage.recordInvocation({ ...record, invocationId: context.invocationId }) + else if (context.runId) + await usage.record({ ...record, purpose: 'tool', runId: context.runId }) context.signal.throwIfAborted() + task.assertCurrent() if (result.stopReason === 'error' || result.stopReason === 'aborted' || result.stopReason === 'length') throw new Error('EXTENSION_MODEL_FAILED') return { text: result.content.filter(block => block.type === 'text').map(block => block.text).join(''), model: { providerId: model.provider, modelId: model.id } } diff --git a/apps/buddy/service/src/rpc/BuddyServiceRpcServer.ts b/apps/buddy/service/src/rpc/BuddyServiceRpcServer.ts index fb5fb354..6497b8ae 100644 --- a/apps/buddy/service/src/rpc/BuddyServiceRpcServer.ts +++ b/apps/buddy/service/src/rpc/BuddyServiceRpcServer.ts @@ -88,7 +88,7 @@ export class BuddyServiceRpcServer implements RuntimeRpcPeerContract { }) } - request(method: string, params: unknown, timeoutMs?: number, signal?: AbortSignal, requestId?: string): Promise { + request(method: string, params: unknown, timeoutMs?: number | null, signal?: AbortSignal, requestId?: string): Promise { return this.#peer.request(method, params, timeoutMs, signal, requestId) } diff --git a/apps/buddy/service/src/runs/ConversationStatusService.ts b/apps/buddy/service/src/runs/ConversationStatusService.ts index 4a16a203..a274c004 100644 --- a/apps/buddy/service/src/runs/ConversationStatusService.ts +++ b/apps/buddy/service/src/runs/ConversationStatusService.ts @@ -8,7 +8,7 @@ export interface ConversationStatusOptions { getCacheWarmingStatus?: (conversationId: string) => LocalConversationStatus['cacheWarming'] events: Pick repository: Pick - usage: Pick + usage: Pick } interface ConversationStatusEvent { @@ -51,7 +51,7 @@ export class ConversationStatusService { const runs = this.#options.repository.listForConversation(conversationId, RUN_LIMIT) const events = await this.#options.events.listForConversation(conversationId, { limit: EVENT_LIMIT }) const ordered = [...events].sort((left, right) => left.sequence - right.sequence) - const usage = runs.flatMap(run => this.#options.usage.listForRun(run.id)) + const usage = this.#options.usage.listForConversation(conversationId) return { cacheWarming: this.#options.getCacheWarmingStatus?.(conversationId) ?? null, @@ -106,7 +106,8 @@ function foldTokens(usage: readonly UsageRecord[]) { const modelKey = `${record.provider}:${record.model}` const model = byModel.get(modelKey) ?? { ...emptyCounts(), modelId: record.model, providerId: record.provider, runIds: new Set() } addRecord(model, record) - model.runIds.add(record.runId) + if (record.runId) + model.runIds.add(record.runId) byModel.set(modelKey, model) const purpose = byPurpose.get(record.purpose) ?? emptyCounts() addRecord(purpose, record) @@ -273,7 +274,7 @@ function foldTiming( } } const outputTokens = usage - .reduce((sum, record) => sum + (record.purpose === 'turn' ? record.outputTokens : 0), 0) + .reduce((sum, record) => sum + (record.purpose === 'turn' && record.runId && chatRunIds.has(record.runId) ? record.outputTokens : 0), 0) const wall = foldWall(events) return { modelMs, diff --git a/apps/buddy/service/src/runs/__tests__/ConversationStatusService.spec.ts b/apps/buddy/service/src/runs/__tests__/ConversationStatusService.spec.ts index 1362f77d..ff7c0ed5 100644 --- a/apps/buddy/service/src/runs/__tests__/ConversationStatusService.spec.ts +++ b/apps/buddy/service/src/runs/__tests__/ConversationStatusService.spec.ts @@ -1,6 +1,11 @@ import type { RunRecord } from '../../storage/runRecord' import type { UsageRecord } from '../../storage/usageRepository' import { describe, expect, it } from 'vitest' +import { createConversationRepository } from '../../storage/conversationRepository' +import { openBuddyDatabase } from '../../storage/database' +import { createExtensionInvocationRepository } from '../../storage/extensionInvocationRepository' +import { createRunRepository } from '../../storage/runRepository' +import { createUsageRepository } from '../../storage/usageRepository' import { ConversationStatusService } from '../ConversationStatusService' const conversationId = 'conversation-1' @@ -62,11 +67,73 @@ function service(runs: readonly RunRecord[], events: readonly unknown[], records return new ConversationStatusService({ events: { listForConversation: async () => events } as never, repository: { listForConversation: () => runs } as never, - usage: { listForRun: (runId: string) => records.filter(record => record.runId === runId) } as never, + usage: { listForConversation: () => records } as never, }) } describe('conversation status fold', () => { + it('includes the task independent ledger, including cancelled consumption, without inventing runs or mixing other tasks', async () => { + const database = openBuddyDatabase({ databasePath: ':memory:' }) + try { + const conversations = createConversationRepository(database) + const runs = createRunRepository(database) + const invocations = createExtensionInvocationRepository(database) + const records = createUsageRepository(database) + for (const id of [conversationId, 'other-task', 'action-only']) + conversations.create({ id, branchId: `${id}-branch`, title: null, spaceId: null, approvalPolicy: 'policy', executionProfile: 'workspace_write', createdAt: '2026-09-20T10:00:00.000Z' }) + for (const id of [conversationId, 'other-task']) { + const runId = id === conversationId ? 'run-1' : 'other-run' + runs.create({ ...run(runId, 'chat', 'completed', 'message-1'), conversationId: id, branchId: `${id}-branch` }) + database.prepare(`INSERT INTO usage_records ( + id, run_id, source_entry_id, provider, model, purpose, + input_tokens, output_tokens, cache_read_tokens, cache_write_tokens, reasoning_tokens, total_tokens, + input_cost, output_cost, cache_read_cost, cache_write_cost, total_cost, created_at + ) VALUES (?, ?, 'turn', 'provider-a', 'model-a', 'turn', 10, 20, 0, 0, NULL, 30, 0, 0, 0, 0, 0.1, '2026-09-20T10:00:10.000Z')`).run(runId, runId) + } + const events = [ + event(1, 'message.started', { messageId: 'assistant', role: 'assistant' }, '2026-09-20T10:00:00.000Z'), + event(2, 'message.block.started', { messageId: 'assistant', kind: 'text' }, '2026-09-20T10:00:01.000Z'), + event(3, 'message.completed', { messageId: 'assistant', role: 'assistant' }, '2026-09-20T10:00:11.000Z'), + ] + const statusService = new ConversationStatusService({ repository: runs, usage: records, events: { listForConversation: async (id: string) => id === conversationId ? events : [] } as never }) + const before = await statusService.status(conversationId) + for (const id of [conversationId, 'other-task', 'action-only']) { + invocations.start({ id, conversationId: id, extensionId: 'tests.action', actionId: 'tests.action.generate', trigger: 'user', startedAt: '2026-09-20T10:00:20.000Z' }) + records.recordInvocation({ ...usage(id, 'provider-b', 'model-b', 'extension.action', { cacheReadTokens: 300, inputTokens: 100, outputTokens: 200, totalCost: 0.4 }), runId: null, invocationId: id, sourceEntryId: 'generation', reasoningTokens: 50 }) + invocations.finish(id, 'cancelled') + } + const status = await statusService.status(conversationId) + expect(records.listForConversation(conversationId).map(record => record.id)).toEqual([conversationId, 'run-1']) + expect(status.tokens.totals).toEqual({ inputTokens: 110, outputTokens: 220, cacheReadTokens: 300, cacheWriteTokens: 0, reasoningTokens: 50, totalTokens: 630, totalCost: 0.5, recordCount: 2 }) + expect(status.tokens.byModel).toMatchObject([{ modelId: 'model-b', runCount: 0, totalTokens: 600 }, { modelId: 'model-a', runCount: 1, totalTokens: 30 }]) + expect(status.tokens.byPurpose).toMatchObject([{ purpose: 'extension.action', totalTokens: 600 }, { purpose: 'turn', totalTokens: 30 }]) + expect(status.activity).toEqual(before.activity) + expect(status.timing).toEqual(before.timing) + expect(status.timing.throughput.tokensPerSecond).toBe(2) + + const actionOnly = await statusService.status('action-only') + expect(actionOnly.tokens.totals).toMatchObject({ totalTokens: 600, totalCost: 0.4, recordCount: 1 }) + expect(actionOnly.activity.runs.chat.total).toBe(0) + expect(actionOnly.timing.throughput).toEqual({ samples: 0, tokensPerSecond: 0 }) + expect((await statusService.status('unknown-task')).tokens.totals.totalTokens).toBe(0) + } + finally { database.close() } + }) + + it('keeps lifetime usage outside the run sampling window out of sampled throughput', async () => { + const events = [ + event(1, 'message.started', { messageId: 'assistant' }, '2026-09-20T10:00:00.000Z'), + event(2, 'message.block.started', { messageId: 'assistant', kind: 'text' }, '2026-09-20T10:00:01.000Z'), + event(3, 'message.completed', { messageId: 'assistant' }, '2026-09-20T10:00:11.000Z'), + ] + const status = await service([run('run-1', 'chat', 'completed', 'message-1')], events, [ + usage('run-1', 'provider', 'model', 'turn', { cacheReadTokens: 0, inputTokens: 10, outputTokens: 20, totalCost: 0.1 }), + usage('old-run', 'provider', 'model', 'turn', { cacheReadTokens: 0, inputTokens: 10, outputTokens: 2000, totalCost: 10 }), + ]).status(conversationId) + expect(status.tokens.totals).toMatchObject({ totalTokens: 2040, totalCost: 10.1 }) + expect(status.timing.throughput.tokensPerSecond).toBe(2) + }) + it('folds activity, timing, and per-model tokens from runs, events, and usage records', async () => { const runs = [ run('run-1', 'chat', 'completed', 'message-1'), diff --git a/apps/buddy/service/src/runs/registerRunRpc.ts b/apps/buddy/service/src/runs/registerRunRpc.ts index 635c990b..869638ff 100644 --- a/apps/buddy/service/src/runs/registerRunRpc.ts +++ b/apps/buddy/service/src/runs/registerRunRpc.ts @@ -20,7 +20,7 @@ export interface RegisterRunRpcOptions { RunRepository, 'findById' | 'listForConversation' | 'listRecent' > - usage: Pick + usage: Pick rpc: RuntimeRequestRegistrar } diff --git a/apps/buddy/service/src/storage/__tests__/conversationTitleState.spec.ts b/apps/buddy/service/src/storage/__tests__/conversationTitleState.spec.ts index b31e87b8..89b5cc49 100644 --- a/apps/buddy/service/src/storage/__tests__/conversationTitleState.spec.ts +++ b/apps/buddy/service/src/storage/__tests__/conversationTitleState.spec.ts @@ -11,14 +11,14 @@ describe('task title metadata', () => { for (const id of ['older', 'newer']) repository.create({ id, branchId: `${id}-branch`, title: id, spaceId: null, approvalPolicy: 'policy', executionProfile: 'workspace_write', createdAt: id === 'older' ? createdAt : '2026-09-28T01:00:00.000Z' }) expect(repository.renameGenerated({ id: 'older', title: 'Protected', expectedRevision: 0 })).toBeNull() - database.prepare('UPDATE conversations SET title_source = ? WHERE id = ?').run('fallback', 'older') + database.prepare('UPDATE conversations SET title_source = ?, title_revision = 0 WHERE id = ?').run('fallback', 'older') expect(repository.renameGenerated({ id: 'older', title: 'Generated', expectedRevision: 0 })).toMatchObject({ title: 'Generated', updatedAt: createdAt }) expect(repository.renameGenerated({ id: 'older', title: 'Late', expectedRevision: 0 })).toBeNull() expect(repository.rename({ id: 'older', title: 'Chosen by user' })).toMatchObject({ title: 'Chosen by user', updatedAt: createdAt }) expect(repository.getTitleState('older')).toEqual({ title: 'Chosen by user', source: 'manual', revision: 2 }) expect(repository.renameGenerated({ id: 'older', title: 'Overwrite', expectedRevision: 2 })).toBeNull() expect(repository.listRecent().map(task => task.id)).toEqual(['newer', 'older']) - database.prepare('UPDATE conversations SET title_source = ? WHERE id = ?').run('fallback', 'newer') + database.prepare('UPDATE conversations SET title_source = ?, title_revision = 0 WHERE id = ?').run('fallback', 'newer') repository.markDeleted('newer', '2026-09-28T02:00:00.000Z') expect(repository.getTitleState('newer')).toBeNull() expect(repository.renameGenerated({ id: 'newer', title: 'Deleted', expectedRevision: 0 })).toBeNull() diff --git a/apps/buddy/service/src/storage/__tests__/extensionActionTimeline.spec.ts b/apps/buddy/service/src/storage/__tests__/extensionActionTimeline.spec.ts new file mode 100644 index 00000000..668625cb --- /dev/null +++ b/apps/buddy/service/src/storage/__tests__/extensionActionTimeline.spec.ts @@ -0,0 +1,42 @@ +import { expect, it } from 'vitest' +import { createConversationHistoryStore } from '../conversationHistoryRepository' +import { createConversationRepository } from '../conversationRepository' +import { createConversationTimelineRepository } from '../conversationTimelineRepository' +import { openBuddyDatabase } from '../database' +import { createExtensionInvocationRepository } from '../extensionInvocationRepository' + +it('persists independent action results, pages late actions without expanding old turns and isolates branches', () => { + const database = openBuddyDatabase({ databasePath: ':memory:' }) + try { + createConversationRepository(database).create({ id: 'task', branchId: 'root', title: null, spaceId: null, approvalPolicy: 'policy', executionProfile: 'workspace_write', createdAt: '2026-09-28T00:00:00.000Z' }) + const history = createConversationHistoryStore(database) + history.repository.createMessage({ id: 'question', conversationId: 'task', branchId: 'root', runId: null, role: 'user', content: 'Fixture', createdAt: '2026-09-28T00:01:00.000Z' }) + const actions = createExtensionInvocationRepository(database) + const timeline = createConversationTimelineRepository(database, history.lineage) + const start = (id: string, trigger: 'user' | 'task:input:committed' = 'user', branchId = 'root') => actions.start({ id, extensionId: 'tests.action', extensionName: 'Naming', actionId: 'tests.action.generate', title: 'Generate title', conversationId: 'task', branchId, sourceMessageId: 'question', trigger, startedAt: '2026-09-29T00:00:00.000Z' }) + start('complete') + actions.finish('complete', 'completed', 'Title updated') + start('skipped', 'task:input:committed') + expect(timeline.listTimelinePage('task', 'root', { limit: 100 }).items.find(item => item.id === 'skipped')).toMatchObject({ status: 'running' }) + actions.finish('skipped', 'skipped') + start('manual-skip') + actions.finish('manual-skip', 'skipped', 'Kept title') + start('interrupted') + actions.recover() + history.repository.createBranch({ id: 'child', conversationId: 'task', parentBranchId: 'root', forkedFromMessageId: 'question', createdAt: '2026-09-28T00:02:00.000Z', activate: false }) + start('child-action', 'user', 'child') + actions.finish('child-action', 'failed') + expect(timeline.listTimelinePage('task', 'child', { limit: 100 }).items.map(item => item.id)).toEqual(['question', 'child-action']) + const items = timeline.listTimelinePage('task', 'root', { limit: 100 }).items + expect(items.map(item => item.id)).toEqual(['question', 'complete', 'interrupted', 'manual-skip', 'skipped']) + expect(items.find(item => item.id === 'skipped')).toMatchObject({ status: 'skipped', trigger: 'task:input:committed' }) + expect(items.find(item => item.id === 'complete')).toMatchObject({ kind: 'extension-action', extensionName: 'Naming', title: 'Generate title', status: 'completed', message: 'Title updated', sourceMessageId: 'question' }) + expect(items.find(item => item.id === 'interrupted')).toMatchObject({ status: 'interrupted' }) + const latest = timeline.listTimelinePage('task', 'root', { limit: 1 }) + expect(latest.items.map(item => item.id)).toEqual(['skipped']) + expect(timeline.listTimelinePage('task', 'root', { limit: 1, before: latest.nextBefore }).items.map(item => item.id)).toEqual(['manual-skip']) + expect(database.prepare('SELECT COUNT(*) AS count FROM runs').get()).toEqual({ count: 0 }) + expect(database.prepare('PRAGMA foreign_key_check').all()).toEqual([]) + } + finally { database.close() } +}) diff --git a/apps/buddy/service/src/storage/__tests__/schema.spec.ts b/apps/buddy/service/src/storage/__tests__/schema.spec.ts index 5ec423e8..52044d57 100644 --- a/apps/buddy/service/src/storage/__tests__/schema.spec.ts +++ b/apps/buddy/service/src/storage/__tests__/schema.spec.ts @@ -8,8 +8,10 @@ import { afterEach, describe, expect, it } from 'vitest' import { createComposerResourceRepository } from '../composerResourceRepository' import { openBuddyDatabase } from '../database' +import { createExtensionInvocationRepository } from '../extensionInvocationRepository' import { BUDDY_V15_CAPABILITY_OVERRIDES_SCHEMA_SQL, BUDDY_V15_CATALOG_MODEL_ID_SCHEMA_SQL, BUDDY_V15_CATALOG_SELECTION_SCHEMA_SQL, BUDDY_V15_MODEL_SERVICES_SCHEMA_SQL, BUDDY_V15_PROVIDER_INSTANCES_SCHEMA_SQL, BUDDY_V15_REQUEST_HEADERS_SCHEMA_SQL } from '../migrations/v15ModelServices' import { BUDDY_SCHEMA_MIGRATIONS, BUDDY_SCHEMA_VERSION } from '../schema' +import { createUsageAnalyticsRepository } from '../usageAnalyticsRepository' import { createUsageRepository } from '../usageRepository' import { MIGRATION_TEST_TIMEOUT, openMigrationFixtureDatabase } from './migrationFixture' @@ -78,6 +80,48 @@ function seedRun( } describe('buddy schema', { timeout: MIGRATION_TEST_TIMEOUT }, () => { + it('preserves v21 usage and attributes independent actions without modifying completed runs', () => { + const directory = mkdtempSync(join(tmpdir(), 'buddy-action-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 <= 21)) + previous.exec(migration.sql) + seedRun(previous) + previous.exec(` + UPDATE runs SET status = 'completed', completed_at = '2026-08-14T00:01:00.000Z'; + INSERT INTO usage_records ( + id, run_id, source_entry_id, provider, model, purpose, + input_tokens, output_tokens, cache_read_tokens, cache_write_tokens, reasoning_tokens, total_tokens, + input_cost, output_cost, cache_read_cost, cache_write_cost, total_cost, created_at + ) VALUES ('usage-1', 'run-1', 'source-1', 'provider', 'model', 'turn', 10, 5, 3, 2, 1, 20, 0.1, 0.2, 0.3, 0.4, 1, '2026-08-14T00:00:00.000Z'); + PRAGMA user_version = 21; + `) + const before = previous.prepare('SELECT * FROM usage_records').all() + const runs = previous.prepare('SELECT * FROM runs').all() + previous.close() + const upgraded = openBuddyDatabase({ databasePath }) + databases.push(upgraded) + expect(upgraded.prepare('SELECT * FROM usage_records').all()).toEqual(before.map(record => ({ ...record, invocation_id: null }))) + expect(upgraded.prepare('PRAGMA table_info(extension_invocations)').all().map(column => column.name)).toEqual(expect.arrayContaining(['branch_id', 'source_message_id', 'extension_name', 'action_title', 'result_message'])) + const invocations = createExtensionInvocationRepository(upgraded) + invocations.start({ id: 'invocation', extensionId: 'test.action', actionId: 'test.action.generate', conversationId: 'conversation-1', trigger: 'user', startedAt: '2026-08-14T00:02:00.000Z' }) + const usage = createUsageRepository(upgraded) + const original = usage.listForRun('run-1')[0]! + const action = { ...original, id: 'usage-2', runId: null, invocationId: 'invocation', purpose: 'extension.action' } + expect(usage.recordInvocation(action)).toBe(true) + expect(usage.recordInvocation({ ...action, id: 'duplicate' })).toBe(false) + expect(usage.listForRun('run-1')).toEqual([original]) + expect(usage.summarize()).toMatchObject({ recordCount: 2, totalTokens: 40, totalCost: 2 }) + expect(createUsageAnalyticsRepository(upgraded).topTasks({ startDate: '2026-08-14', endDate: '2026-08-14', timeZone: 'UTC', model: null })).toMatchObject([{ conversationId: 'conversation-1', totalTokens: 40, recordCount: 2 }]) + expect(() => upgraded.prepare('UPDATE usage_records SET run_id = \'run-1\' WHERE id = \'usage-2\'').run()).toThrow(/CHECK constraint/) + expect(() => upgraded.prepare('UPDATE usage_records SET invocation_id = NULL WHERE id = \'usage-2\'').run()).toThrow(/CHECK constraint/) + invocations.recover() + expect(upgraded.prepare('SELECT status FROM extension_invocations').get()).toEqual({ status: 'interrupted' }) + expect(upgraded.prepare('SELECT * FROM runs').all()).toEqual(runs) + expect(upgraded.prepare('PRAGMA foreign_key_check').all()).toEqual([]) + }) + it('upgrades v18 without rewriting legacy sources and permits independent local reference identities', () => { const directory = mkdtempSync(join(tmpdir(), 'buddy-local-reference-migration-')) directories.push(directory) diff --git a/apps/buddy/service/src/storage/__tests__/usageAnalyticsRepository.spec.ts b/apps/buddy/service/src/storage/__tests__/usageAnalyticsRepository.spec.ts index 68209f49..ba732864 100644 --- a/apps/buddy/service/src/storage/__tests__/usageAnalyticsRepository.spec.ts +++ b/apps/buddy/service/src/storage/__tests__/usageAnalyticsRepository.spec.ts @@ -119,6 +119,7 @@ describe('usage analytics', () => { f.usage('retained') const before = f.repository.analytics(period) f.database.exec(` + DROP TABLE extension_invocations; DROP TABLE connector_tool_catalogs; DROP TABLE skill_file_cleanup; DROP TABLE skill_space_exclusions; diff --git a/apps/buddy/service/src/storage/conversationRepository.ts b/apps/buddy/service/src/storage/conversationRepository.ts index b943dc4b..7792d903 100644 --- a/apps/buddy/service/src/storage/conversationRepository.ts +++ b/apps/buddy/service/src/storage/conversationRepository.ts @@ -36,7 +36,7 @@ export interface RenameConversationInput { export interface ConversationTitleState { title: string | null - source: 'manual' | 'fallback' | 'generated' + source: 'legacy' | 'manual' | 'fallback' | 'generated' revision: number } @@ -62,7 +62,7 @@ export interface ConversationRepository markDeleted: (id: string, deletedAt: string) => boolean rename: (input: RenameConversationInput) => ConversationRecord getTitleState: (id: string) => ConversationTitleState | null - renameGenerated: (input: RenameConversationInput & { expectedRevision: number }) => ConversationRecord | null + renameGenerated: (input: RenameConversationInput & { expectedRevision: number, userInitiated?: boolean }) => ConversationRecord | null setPermissionSettings: ( input: SetConversationPermissionSettingsInput, ) => ConversationRecord | null @@ -76,18 +76,18 @@ export function createConversationRepository(database: DatabaseSync): Conversati const insertConversation = database.prepare(` INSERT INTO conversations ( id, space_id, title, active_branch_id, created_at, updated_at, - approval_policy, execution_profile, origin, deleted_at - ) VALUES (?, ?, ?, NULL, ?, ?, ?, ?, ?, NULL) + approval_policy, execution_profile, origin, deleted_at, title_source, title_revision + ) VALUES (?, ?, ?, NULL, ?, ?, ?, ?, ?, NULL, 'manual', 1) `) const renameConversation = database.prepare(` UPDATE conversations SET title = ?, title_source = 'manual', title_revision = title_revision + 1 WHERE id = ? AND deleted_at IS NULL `) - const findTitle = database.prepare('SELECT title, title_source AS source, title_revision AS revision FROM conversations WHERE id = ? AND deleted_at IS NULL') + const findTitle = database.prepare(`SELECT title, CASE WHEN title_source = 'manual' AND title_revision = 0 THEN 'legacy' ELSE title_source END AS source, title_revision AS revision FROM conversations WHERE id = ? AND deleted_at IS NULL`) const renameGenerated = database.prepare(` UPDATE conversations SET title = ?, title_source = 'generated', title_revision = title_revision + 1 - WHERE id = ? AND title_revision = ? AND title_source != 'manual' AND deleted_at IS NULL + WHERE id = ? AND title_revision = ? AND (title_source != 'manual' OR ? = 1) AND deleted_at IS NULL `) const setPermissionSettings = database.prepare(` UPDATE conversations @@ -178,7 +178,7 @@ export function createConversationRepository(database: DatabaseSync): Conversati return findTitle.get(id) as unknown as ConversationTitleState | undefined ?? null }, renameGenerated(input) { - if (Number(renameGenerated.run(input.title, input.id, input.expectedRevision).changes) !== 1) + if (Number(renameGenerated.run(input.title, input.id, input.expectedRevision, input.userInitiated ? 1 : 0).changes) !== 1) return null return requireConversationRecord(findConversation.get(input.id), input.id) }, diff --git a/apps/buddy/service/src/storage/conversationTimelineRepository.ts b/apps/buddy/service/src/storage/conversationTimelineRepository.ts index 6501dd40..d12508e5 100644 --- a/apps/buddy/service/src/storage/conversationTimelineRepository.ts +++ b/apps/buddy/service/src/storage/conversationTimelineRepository.ts @@ -1,4 +1,5 @@ import type { DatabaseSync } from 'node:sqlite' +import type { ExtensionActionTimelineItem } from '../../../shared/extensions/extensionActionApi' import type { ConversationBranchLineage, MessageRecord, @@ -6,6 +7,7 @@ import type { VisibleConversationBranchSegment, } from './conversationHistoryRepository' import type { RunStatus } from './runRecord' +import { extensionActionTimelineSchema } from '../../../shared/extensions/extensionActionApi' export interface ConversationTimelineMessageRecord extends MessageRecord { kind: 'message' @@ -27,6 +29,7 @@ export interface ConversationTimelineCompactionRecord { export type ConversationTimelineItemRecord = | ConversationTimelineMessageRecord | ConversationTimelineCompactionRecord + | ExtensionActionTimelineItem export interface ConversationTimelineBoundaryRecord { branchId: string @@ -64,7 +67,7 @@ interface MessageRow { } interface ConversationTimelineRow { - kind: 'message' | 'compaction' + kind: ConversationTimelineItemRecord['kind'] id: string conversation_id: string branch_id: string @@ -77,6 +80,7 @@ interface ConversationTimelineRow { compaction_payload_json: string | null occurred_at: string sort_rank: number + action_json?: string | null } interface ConversationTimelineRowsPage { @@ -95,6 +99,7 @@ export function createConversationTimelineRepository( SELECT * FROM runs WHERE id = ? AND purpose = 'conversation.compaction' `) + const findAction = database.prepare('SELECT id, conversation_id, branch_id, started_at FROM extension_invocations WHERE id = ? AND branch_id IS NOT NULL') const findTriggeringMessageForRun = database.prepare(` SELECT messages.* FROM runs INNER JOIN messages ON messages.id = runs.triggering_message_id @@ -114,7 +119,8 @@ export function createConversationTimelineRepository( NULL AS completed_at, NULL AS compaction_payload_json, created_at AS occurred_at, - 0 AS sort_rank + 0 AS sort_rank, + NULL AS action_json FROM messages WHERE conversation_id = ? AND branch_id = ? UNION ALL @@ -136,10 +142,29 @@ export function createConversationTimelineRepository( LIMIT 1 ) AS compaction_payload_json, runs.started_at AS occurred_at, - 1 AS sort_rank + 1 AS sort_rank, + NULL AS action_json FROM runs WHERE conversation_id = ? AND branch_id = ? AND purpose = 'conversation.compaction' + UNION ALL + SELECT + 'extension-action' AS kind, + id, conversation_id, branch_id, + NULL AS run_id, NULL AS role, NULL AS content_json, + NULL AS status, NULL AS error_code, completed_at, + NULL AS compaction_payload_json, started_at AS occurred_at, + 2 AS sort_rank, + json_object( + 'kind', 'extension-action', 'id', id, 'conversationId', conversation_id, + 'branchId', branch_id, 'sourceMessageId', source_message_id, + 'extensionId', extension_id, 'extensionName', COALESCE(extension_name, extension_id), + 'actionId', action_id, 'title', COALESCE(action_title, action_id), + 'trigger', trigger, 'status', status, 'message', result_message, + 'createdAt', started_at, 'completedAt', completed_at + ) AS action_json + FROM extension_invocations + WHERE conversation_id = ? AND branch_id = ? ` const listNewestTimelineForBranch = database.prepare(` WITH timeline AS (${timelineProjection}) @@ -188,6 +213,8 @@ export function createConversationTimelineRepository( segment.branchId, conversationId, segment.branchId, + conversationId, + segment.branchId, ] as const const rows = boundary && index === segmentIndex ? listTimelineForBranchBefore.all( @@ -227,7 +254,7 @@ export function createConversationTimelineRepository( return { items: [], nextBefore: null } const segments = branches.listVisibleSegments(conversationId, branchId) const boundary = options.before - ? resolveTimelineBoundary(options.before, findMessage, findCompactionRun) + ? resolveTimelineBoundary(options.before, findMessage, findCompactionRun, findAction) : null if (options.before) { const segmentIndex = boundary @@ -395,6 +422,7 @@ function resolveTimelineBoundary( boundary: ConversationTimelineBoundaryRecord, findMessage: ReturnType, findCompactionRun: ReturnType, + findAction: ReturnType, ): ConversationTimelineRow | null { if (boundary.kind === 'message') { const message = findMessage.get(boundary.id) as MessageRow | undefined @@ -407,7 +435,7 @@ function resolveTimelineBoundary( } return toMessageTimelineRow(message) } - const run = findCompactionRun.get(boundary.id) as { + const run = (boundary.kind === 'extension-action' ? findAction : findCompactionRun).get(boundary.id) as { id: string conversation_id: string branch_id: string @@ -428,11 +456,11 @@ function resolveTimelineBoundary( conversation_id: run.conversation_id, error_code: null, id: run.id, - kind: 'compaction', + kind: boundary.kind, occurred_at: run.started_at, role: null, run_id: run.id, - sort_rank: 1, + sort_rank: boundary.kind === 'extension-action' ? 2 : 1, status: null, } } @@ -475,6 +503,8 @@ function compareTimelineOrder( } function toTimelineItem(row: ConversationTimelineRow): ConversationTimelineItemRecord { + if (row.kind === 'extension-action') + return extensionActionTimelineSchema.parse(JSON.parse(row.action_json!)) if (row.kind === 'message') { if (!row.role || row.content_json === null) throw new Error(`Lexora Buddy timeline message is invalid: ${row.id}`) diff --git a/apps/buddy/service/src/storage/database.ts b/apps/buddy/service/src/storage/database.ts index bc7ab893..d2210afd 100644 --- a/apps/buddy/service/src/storage/database.ts +++ b/apps/buddy/service/src/storage/database.ts @@ -17,6 +17,8 @@ export interface OpenBuddyDatabaseOptions { } const BUDDY_CURRENT_SCHEMA_COLUMNS = { + extension_invocations: ['id', 'extension_id', 'action_id', 'conversation_id', 'trigger', 'status', 'branch_id', 'source_message_id', 'extension_name', 'action_title', 'result_message'], + usage_records: ['run_id', 'invocation_id'], conversations: ['title_source', 'title_revision'], connector_tool_catalogs: ['connector_id', 'tools_json', 'updated_at'], provider_states: ['provider_id', 'request_headers_json'], diff --git a/apps/buddy/service/src/storage/extensionInvocationRepository.ts b/apps/buddy/service/src/storage/extensionInvocationRepository.ts new file mode 100644 index 00000000..f1b27eeb --- /dev/null +++ b/apps/buddy/service/src/storage/extensionInvocationRepository.ts @@ -0,0 +1,34 @@ +import type { DatabaseSync } from 'node:sqlite' +import type { ExtensionActionTrigger } from '../../../shared/extensions/extensionAgent' + +export interface ExtensionActionRecord { + id: string + extensionId: string + actionId: string + conversationId: string + trigger: ExtensionActionTrigger + startedAt: string + branchId?: string + sourceMessageId?: string | null + extensionName?: string + title?: string +} + +export function createExtensionInvocationRepository(database: DatabaseSync) { + const start = database.prepare(`INSERT INTO extension_invocations + (id, extension_id, action_id, conversation_id, trigger, status, started_at, branch_id, source_message_id, extension_name, action_title) + VALUES (?, ?, ?, ?, ?, 'running', ?, ?, ?, ?, ?)`) + const finish = database.prepare(`UPDATE extension_invocations SET status = ?, completed_at = ?, result_message = ? WHERE id = ? AND status = 'running'`) + return { + recover() { + database.prepare(`UPDATE extension_invocations SET status = 'interrupted', completed_at = ? WHERE status = 'running'`).run(new Date().toISOString()) + }, + start(input: ExtensionActionRecord) { + start.run(input.id, input.extensionId, input.actionId, input.conversationId, input.trigger, input.startedAt, input.branchId ?? null, input.sourceMessageId ?? null, input.extensionName ?? null, input.title ?? null) + }, + finish(id: string, status: 'completed' | 'skipped' | 'failed' | 'cancelled', message: string | null = null) { + finish.run(status, new Date().toISOString(), message, id) + }, + } +} +export type ExtensionInvocationRepository = ReturnType diff --git a/apps/buddy/service/src/storage/extensionTaskContextRepository.ts b/apps/buddy/service/src/storage/extensionTaskContextRepository.ts new file mode 100644 index 00000000..7985d7bf --- /dev/null +++ b/apps/buddy/service/src/storage/extensionTaskContextRepository.ts @@ -0,0 +1,47 @@ +import type { DatabaseSync } from 'node:sqlite' +import type { ConversationBranchLineage } from './conversationHistoryRepository' +import { buddyUserContentToText, readBuddyUserMessageContent } from '../../../shared/conversation/buddyUserContent' + +interface ContextMessage { id: string, role: 'user' | 'assistant', content_json: string, created_at: string } + +export function createExtensionTaskContextRepository(database: DatabaseSync, lineage: ConversationBranchLineage) { + const query = (conversationId: string, branchId: string, usersOnly: boolean, first: boolean, limit: number): ContextMessage[] => { + const order = first ? 'ASC' : 'DESC' + const messages = lineage.listVisibleSegments(conversationId, branchId).flatMap((segment) => { + const through = segment.throughMessage + return database.prepare(`SELECT id, role, content_json, created_at FROM messages + WHERE conversation_id = ? AND branch_id = ? AND ${usersOnly ? 'role = \'user\'' : 'role IN (\'user\', \'assistant\')'} + ${through ? 'AND (created_at < ? OR (created_at = ? AND id <= ?))' : ''} + ORDER BY created_at ${order}, id ${order} LIMIT ?`).all(conversationId, segment.branchId, ...through ? [through.createdAt, through.createdAt, through.id] : [], limit) as unknown as ContextMessage[] + }) + messages.sort((a, b) => a.created_at.localeCompare(b.created_at) || a.id.localeCompare(b.id)) + return first ? messages.slice(0, limit) : messages.slice(-limit) + } + const activity = database.prepare(`SELECT CASE + WHEN EXISTS (SELECT 1 FROM runs JOIN approvals ON approvals.run_id = runs.id WHERE runs.conversation_id = ? AND approvals.status = 'pending') THEN 'awaiting_approval' + WHEN EXISTS (SELECT 1 FROM runs WHERE runs.conversation_id = ? AND runs.status IN ('queued', 'running')) THEN 'running' + ELSE 'idle' END AS activity`) + return { + activity(conversationId: string): 'idle' | 'running' | 'awaiting_approval' { + return (activity.get(conversationId, conversationId) as { activity: 'idle' | 'running' | 'awaiting_approval' }).activity + }, + latestInput(conversationId: string, branchId: string) { + return query(conversationId, branchId, true, false, 1)[0]?.id ?? null + }, + messages(conversationId: string, branchId: string) { + const first = query(conversationId, branchId, true, true, 1) + const recent = query(conversationId, branchId, false, false, 15) + return [...first, ...recent.filter(message => message.id !== first[0]?.id)].map((message) => { + const content: unknown = JSON.parse(message.content_json) + const structured = readBuddyUserMessageContent(content) + const text = structured + ? buddyUserContentToText(structured.userContent, () => '@file') + : typeof content === 'string' + ? content + : content && typeof content === 'object' && 'text' in content && typeof content.text === 'string' ? content.text : '' + return { role: message.role, text: text.slice(0, 1500) } + }).filter(message => message.text.trim()) + }, + } +} +export type ExtensionTaskContextRepository = ReturnType diff --git a/apps/buddy/service/src/storage/migrations/v22Extension.ts b/apps/buddy/service/src/storage/migrations/v22Extension.ts new file mode 100644 index 00000000..24b6e44c --- /dev/null +++ b/apps/buddy/service/src/storage/migrations/v22Extension.ts @@ -0,0 +1,50 @@ +export const BUDDY_V22_EXTENSION_SCHEMA_SQL = ` +CREATE TABLE extension_invocations ( + id TEXT PRIMARY KEY, + extension_id TEXT NOT NULL, + action_id TEXT NOT NULL, + conversation_id TEXT NOT NULL REFERENCES conversations(id) ON DELETE CASCADE, + branch_id TEXT REFERENCES conversation_branches(id) ON DELETE CASCADE, + source_message_id TEXT REFERENCES messages(id) ON DELETE SET NULL, + extension_name TEXT, + action_title TEXT, + result_message TEXT, + trigger TEXT NOT NULL, + status TEXT NOT NULL CHECK (status IN ('running', 'completed', 'skipped', 'failed', 'cancelled', 'interrupted')), + started_at TEXT NOT NULL, + completed_at TEXT +); +CREATE INDEX idx_extension_invocations_branch ON extension_invocations(conversation_id, branch_id, started_at); +CREATE INDEX idx_extension_invocations_task ON extension_invocations(conversation_id, started_at); +CREATE TABLE usage_records_next ( + id TEXT PRIMARY KEY, + run_id TEXT REFERENCES runs(id) ON DELETE CASCADE, + invocation_id TEXT REFERENCES extension_invocations(id) ON DELETE CASCADE, + source_entry_id TEXT NOT NULL, + provider TEXT NOT NULL, + model TEXT NOT NULL, + purpose TEXT NOT NULL, + input_tokens INTEGER NOT NULL, + output_tokens INTEGER NOT NULL, + cache_read_tokens INTEGER NOT NULL, + cache_write_tokens INTEGER NOT NULL, + reasoning_tokens INTEGER, + total_tokens INTEGER NOT NULL, + input_cost REAL NOT NULL, + output_cost REAL NOT NULL, + cache_read_cost REAL NOT NULL, + cache_write_cost REAL NOT NULL, + total_cost REAL NOT NULL, + created_at TEXT NOT NULL, + CHECK ((run_id IS NULL) <> (invocation_id IS NULL)), + UNIQUE (run_id, source_entry_id, purpose), + UNIQUE (invocation_id, source_entry_id, purpose) +); + +INSERT INTO usage_records_next (id, run_id, source_entry_id, provider, model, purpose, input_tokens, output_tokens, cache_read_tokens, cache_write_tokens, reasoning_tokens, total_tokens, input_cost, output_cost, cache_read_cost, cache_write_cost, total_cost, created_at) +SELECT id, run_id, source_entry_id, provider, model, purpose, input_tokens, output_tokens, cache_read_tokens, cache_write_tokens, reasoning_tokens, total_tokens, input_cost, output_cost, cache_read_cost, cache_write_cost, total_cost, created_at FROM usage_records; +DROP TABLE usage_records; +ALTER TABLE usage_records_next RENAME TO usage_records; +CREATE INDEX idx_usage_run_created ON usage_records(run_id, created_at); +CREATE INDEX idx_usage_created_at ON usage_records(created_at); +` diff --git a/apps/buddy/service/src/storage/schema.ts b/apps/buddy/service/src/storage/schema.ts index 2a4e0d20..d348b05c 100644 --- a/apps/buddy/service/src/storage/schema.ts +++ b/apps/buddy/service/src/storage/schema.ts @@ -22,13 +22,15 @@ import { BUDDY_V19_LOCAL_RESOURCES_SCHEMA_SQL } from './migrations/v19LocalResou import { BUDDY_V20_TASK_DRAFTS_SCHEMA_SQL } from './migrations/v20TaskDrafts' import { BUDDY_V21_TITLE_SCHEMA_SQL } from './migrations/v21Title' +import { BUDDY_V22_EXTENSION_SCHEMA_SQL } from './migrations/v22Extension' + export interface BuddySchemaMigration { foreignKeys?: 'off' sql: string version: number } -export const BUDDY_SCHEMA_VERSION = 21 as const +export const BUDDY_SCHEMA_VERSION = 22 as const export const BUDDY_SCHEMA_MIGRATIONS: readonly BuddySchemaMigration[] = [ { sql: BUDDY_V1_INITIAL_SCHEMA_SQL, version: 1 }, @@ -52,4 +54,5 @@ export const BUDDY_SCHEMA_MIGRATIONS: readonly BuddySchemaMigration[] = [ { sql: BUDDY_V19_LOCAL_RESOURCES_SCHEMA_SQL, version: 19 }, { foreignKeys: 'off', sql: BUDDY_V20_TASK_DRAFTS_SCHEMA_SQL, version: 20 }, { sql: BUDDY_V21_TITLE_SCHEMA_SQL, version: 21 }, + { sql: BUDDY_V22_EXTENSION_SCHEMA_SQL, version: 22 }, ] diff --git a/apps/buddy/service/src/storage/usageAnalyticsRepository.ts b/apps/buddy/service/src/storage/usageAnalyticsRepository.ts index bd2b66be..672cae4d 100644 --- a/apps/buddy/service/src/storage/usageAnalyticsRepository.ts +++ b/apps/buddy/service/src/storage/usageAnalyticsRepository.ts @@ -45,8 +45,9 @@ export function createUsageAnalyticsRepository(database: DatabaseSync): UsageAna SELECT c.id AS conversationId, c.title, s.name AS spaceName, SUM(u.total_tokens) AS totalTokens, COUNT(*) AS recordCount FROM usage_records u - JOIN runs r ON r.id = u.run_id - JOIN conversations c ON c.id = r.conversation_id AND c.deleted_at IS NULL + LEFT JOIN runs r ON r.id = u.run_id + LEFT JOIN extension_invocations invocation ON invocation.id = u.invocation_id + JOIN conversations c ON c.id = COALESCE(r.conversation_id, invocation.conversation_id) AND c.deleted_at IS NULL LEFT JOIN spaces s ON s.id = c.space_id AND s.revoked_at IS NULL WHERE u.created_at >= $startAt AND u.created_at < $endAt AND ($provider IS NULL OR (u.provider = $provider AND u.model = $model)) diff --git a/apps/buddy/service/src/storage/usageRepository.ts b/apps/buddy/service/src/storage/usageRepository.ts index a46d9930..4c3670be 100644 --- a/apps/buddy/service/src/storage/usageRepository.ts +++ b/apps/buddy/service/src/storage/usageRepository.ts @@ -2,7 +2,8 @@ import type { DatabaseSync } from 'node:sqlite' export interface UsageRecord { id: string - runId: string + runId: string | null + invocationId?: string | null sourceEntryId: string provider: string model: string @@ -33,15 +34,19 @@ export interface UsageTotals { } export interface UsageRepository { + recordInvocation: (record: UsageRecord & { runId: null, invocationId: string }) => boolean + findBySource: (runId: string, sourceEntryId: string, purpose: string) => UsageRecord | null listRecent: (limit?: number) => UsageRecord[] listForRun: (runId: string) => UsageRecord[] + listForConversation: (conversationId: string) => UsageRecord[] summarize: () => UsageTotals } interface UsageRecordRow { id: string - run_id: string + run_id: string | null + invocation_id: string | null source_entry_id: string provider: string model: string @@ -82,6 +87,16 @@ export function createUsageRepository(database: DatabaseSync): UsageRepository { const listRecent = database.prepare(` SELECT * FROM usage_records ORDER BY created_at DESC, id DESC LIMIT ? `) + const listForConversation = database.prepare(` + SELECT usage.* FROM usage_records usage + JOIN runs ON runs.id = usage.run_id + WHERE runs.conversation_id = ? + UNION ALL + SELECT usage.* FROM usage_records usage + JOIN extension_invocations invocations ON invocations.id = usage.invocation_id + WHERE invocations.conversation_id = ? + ORDER BY created_at, id + `) const summarize = database.prepare(` SELECT COALESCE(SUM(cache_read_tokens), 0) AS cache_read_tokens, @@ -96,6 +111,34 @@ export function createUsageRepository(database: DatabaseSync): UsageRepository { `) return { + recordInvocation(record) { + return Number(database.prepare(`INSERT INTO usage_records ( + id, run_id, invocation_id, source_entry_id, provider, model, purpose, + input_tokens, output_tokens, cache_read_tokens, cache_write_tokens, + reasoning_tokens, total_tokens, input_cost, output_cost, + cache_read_cost, cache_write_cost, total_cost, created_at + ) VALUES (?, NULL, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(invocation_id, source_entry_id, purpose) DO NOTHING`).run( + record.id, + record.invocationId, + record.sourceEntryId, + record.provider, + record.model, + record.purpose, + record.inputTokens, + record.outputTokens, + record.cacheReadTokens, + record.cacheWriteTokens, + record.reasoningTokens, + record.totalTokens, + record.inputCost, + record.outputCost, + record.cacheReadCost, + record.cacheWriteCost, + record.totalCost, + record.createdAt, + ).changes) === 1 + }, findBySource(runId, sourceEntryId, purpose) { const row = findBySource.get(runId, sourceEntryId, purpose) as unknown as UsageRecordRow | undefined @@ -104,6 +147,9 @@ export function createUsageRepository(database: DatabaseSync): UsageRepository { listForRun(runId) { return (list.all(runId) as unknown as UsageRecordRow[]).map(toUsageRecord) }, + listForConversation(conversationId) { + return (listForConversation.all(conversationId, conversationId) as unknown as UsageRecordRow[]).map(toUsageRecord) + }, listRecent(limit = 500) { return (listRecent.all(limit) as unknown as UsageRecordRow[]).map(toUsageRecord) }, @@ -127,6 +173,7 @@ function toUsageRecord(row: UsageRecordRow): UsageRecord { return { id: row.id, runId: row.run_id, + invocationId: row.invocation_id, sourceEntryId: row.source_entry_id, provider: row.provider, model: row.model, diff --git a/apps/buddy/service/src/usage/UsageService.ts b/apps/buddy/service/src/usage/UsageService.ts index 6ae9b325..3437f57b 100644 --- a/apps/buddy/service/src/usage/UsageService.ts +++ b/apps/buddy/service/src/usage/UsageService.ts @@ -4,12 +4,15 @@ import type { RunEventWriter } from '../events/RunEventPorts' import type { UsageRecord, UsageRepository } from '../storage/usageRepository' import type { BuddyUsagePurpose } from './recordPiUsage' import { randomUUID } from 'node:crypto' +import { Emitter } from '../../../shared/events/Emitter' +import { copyEventSnapshot } from '../../../shared/events/eventSnapshot' import { recordPiUsage } from './recordPiUsage' export interface UsageServiceOptions { eventLog: Pick repository: UsageRepository + onObserverError?: (error: unknown) => void } export interface RecordBuddyUsageInput { @@ -35,11 +38,28 @@ export class UsageService { readonly #eventLog: Pick readonly #repository: UsageRepository + readonly #recorded: Emitter> + readonly onDidRecord + constructor(options: UsageServiceOptions) { + this.#recorded = new Emitter(options.onObserverError ?? (() => {})) + this.onDidRecord = this.#recorded.event this.#eventLog = options.eventLog this.#repository = options.repository } + dispose(): void { + this.#recorded.dispose() + } + + recordInvocation(input: Omit & { invocationId: string }): UsageRecord | null { + const record = { ...recordPiUsage({ ...input, id: randomUUID(), runId: null, purpose: 'extension.action' }), runId: null, invocationId: input.invocationId } + if (!this.#repository.recordInvocation(record)) + return null + this.#recorded.fire(copyEventSnapshot(record)) + return record + } + async record(input: RecordBuddyUsageInput): Promise { if (this.#repository.findBySource(input.runId, input.sourceEntryId, input.purpose)) return null @@ -50,9 +70,10 @@ export class UsageService { await this.#eventLog.append({ createdAt: record.createdAt, payload: durableUsagePayload(record), - runId: record.runId, + runId: input.runId, type: 'usage.recorded', }) + this.#recorded.fire(copyEventSnapshot(record)) return record } diff --git a/apps/buddy/service/src/usage/recordPiUsage.ts b/apps/buddy/service/src/usage/recordPiUsage.ts index 032b9770..06a3b885 100644 --- a/apps/buddy/service/src/usage/recordPiUsage.ts +++ b/apps/buddy/service/src/usage/recordPiUsage.ts @@ -1,7 +1,7 @@ import type { Usage } from '@earendil-works/pi-ai' import type { UsageRecord } from '../storage/usageRepository' -export type BuddyUsagePurpose = 'cache_warm' | 'compaction' | 'tool' | 'turn' +export type BuddyUsagePurpose = 'cache_warm' | 'compaction' | 'tool' | 'turn' | 'extension.action' export interface RecordPiUsageInput { createdAt: string @@ -9,7 +9,7 @@ export interface RecordPiUsageInput { model: string provider: string purpose: BuddyUsagePurpose - runId: string + runId: string | null sourceEntryId: string usage: Usage } diff --git a/apps/buddy/service/src/web/__tests__/webUsage.spec.ts b/apps/buddy/service/src/web/__tests__/webUsage.spec.ts index 4f1d90c5..7316e348 100644 --- a/apps/buddy/service/src/web/__tests__/webUsage.spec.ts +++ b/apps/buddy/service/src/web/__tests__/webUsage.spec.ts @@ -34,7 +34,7 @@ describe('native search usage projection', () => { const result = await tools[0]!.execute('search-1', { query: 'fixture' }, new AbortController().signal, undefined, { model } as never) const events: unknown[] = [] const usageService = new UsageService({ - repository: { findBySource: () => null, listRecent: () => [], listForRun: () => [], summarize: () => { throw new Error('Unused') } }, + repository: { recordInvocation: () => false, findBySource: () => null, listRecent: () => [], listForRun: () => [], listForConversation: () => [], summarize: () => { throw new Error('Unused') } }, eventLog: { append: async (event) => { events.push(event) return event as never diff --git a/apps/buddy/shared/conversation/conversationApi.ts b/apps/buddy/shared/conversation/conversationApi.ts index 72266302..45aeec8e 100644 --- a/apps/buddy/shared/conversation/conversationApi.ts +++ b/apps/buddy/shared/conversation/conversationApi.ts @@ -2,6 +2,7 @@ import type { RuntimeRequestContract } from '../runtime/apiContract' import type { DeepReadonly } from '../runtime/apiValidation' import { z } from 'zod' import { changeSetSummarySchema } from '../changes/changeApi' +import { extensionActionTimelineSchema } from '../extensions/extensionActionApi' import { runEventSchema, runOutputSchema, runSchema, runStatusSchema } from '../runs/runApi' import { approvalPolicySchema, executionProfileSchema, idSchema, nullableTimestampSchema, optionalCursorSchema, optionalLimitSchema, timestampSchema, validationRequestSchemas, validationResponseSchemas } from '../runtime/apiValidation' import { attachmentSchema } from './attachmentApi' @@ -9,6 +10,12 @@ import { BUDDY_SERVICE_TIERS, BUDDY_THINKING_LEVELS } from './modelSelection' export const CONVERSATION_CHANGED = 'conversations.changed' +export const conversationTimelineChanged = { + method: 'conversations.timeline.changed', + params: z.object({ sourceId: z.uuid(), revision: z.number().int().positive(), conversationId: idSchema, branchId: idSchema }).strict(), +} as const +export type ConversationTimelineChangeNotice = z.infer + export const modelSelectionSchema = z.object({ modelId: idSchema, providerId: idSchema, @@ -59,6 +66,7 @@ export const messageSchema = z.object({ }).strict() export const conversationTimelineItemSchema = z.discriminatedUnion('kind', [ + extensionActionTimelineSchema, messageSchema.extend({ kind: z.literal('message') }).strict(), z.object({ branchId: idSchema, diff --git a/apps/buddy/shared/conversation/taskEvents.ts b/apps/buddy/shared/conversation/taskEvents.ts new file mode 100644 index 00000000..8016e34d --- /dev/null +++ b/apps/buddy/shared/conversation/taskEvents.ts @@ -0,0 +1,12 @@ +import type { EventMessage, EventSnapshot } from '../events/eventTypes' +import { z } from 'zod' +import { idSchema, timestampSchema } from '../runtime/apiValidation' + +const taskRun = { conversationId: idSchema, branchId: idSchema, runId: idSchema } +export const taskActionEventSchema = z.discriminatedUnion('type', [ + z.object({ type: z.literal('task:input:committed'), data: z.object({ ...taskRun, messageId: idSchema, commitId: idSchema }).strict() }).strict(), + z.object({ type: z.literal('task:turn:completed'), data: z.object({ ...taskRun, triggeringMessageId: idSchema, completedAt: timestampSchema }).strict() }).strict(), +]) +type ActionEvent = z.infer +export type TaskActionEvents = { [Event in ActionEvent as Event['type']]: EventSnapshot } +export type TaskActionEvent = EventMessage diff --git a/apps/buddy/shared/extensions/extensionActionApi.ts b/apps/buddy/shared/extensions/extensionActionApi.ts new file mode 100644 index 00000000..fa354efd --- /dev/null +++ b/apps/buddy/shared/extensions/extensionActionApi.ts @@ -0,0 +1,32 @@ +import type { RuntimeRequestContract } from '../runtime/apiContract' +import { z } from 'zod' +import { idSchema, nullableTimestampSchema, timestampSchema, validationRequestSchemas } from '../runtime/apiValidation' +import { extensionActionResultSchema, extensionActionTriggerSchema } from './extensionAgent' + +export const extensionActionTimelineSchema = z.object({ + kind: z.literal('extension-action'), + id: idSchema, + conversationId: idSchema, + branchId: idSchema, + sourceMessageId: idSchema.nullable(), + extensionId: z.string(), + extensionName: z.string(), + actionId: z.string(), + title: z.string(), + trigger: extensionActionTriggerSchema, + status: z.enum(['running', 'completed', 'skipped', 'failed', 'cancelled', 'interrupted']), + message: z.string().nullable(), + createdAt: timestampSchema, + completedAt: nullableTimestampSchema, +}).strict() +export type ExtensionActionTimelineItem = z.infer + +export const extensionTaskActionSchema = z.object({ extensionId: z.string(), actionId: z.string(), title: z.string() }).strict() +export const extensionActionRpc = { + list: { method: 'extensions.actions.list', input: validationRequestSchemas.empty, response: z.array(extensionTaskActionSchema) }, + invoke: { method: 'extensions.actions.invoke', input: z.object({ conversationId: idSchema, extensionId: z.string().max(120), actionId: z.string().max(180) }).strict(), response: extensionActionResultSchema }, +} as const satisfies Record + +export type ExtensionTaskAction = z.infer +export type ExtensionTaskActionInput = z.infer +export type ExtensionTaskActionResult = z.infer diff --git a/apps/buddy/shared/extensions/extensionAgent.ts b/apps/buddy/shared/extensions/extensionAgent.ts index 7efdcba0..55c81401 100644 --- a/apps/buddy/shared/extensions/extensionAgent.ts +++ b/apps/buddy/shared/extensions/extensionAgent.ts @@ -1,5 +1,8 @@ +import type { EventSnapshot } from '../events/eventTypes' import { z } from 'zod' +import { taskActionEventSchema } from '../conversation/taskEvents' import { extensionAgentMethodSchema } from './extensionAgentCapabilities' +import { extensionConditionReferenceSchema } from './extensionConditions' import { extensionSettingIdSchema, extensionSettingKeySchema } from './extensionSettings' export const extensionAgentRpc = { @@ -8,6 +11,11 @@ export const extensionAgentRpc = { request: 'extensions.agent.request', changed: 'extensions.agent.changed', } as const +export const extensionAgentInvocationLimits = { + concurrent: 32, + concurrentPerExtension: 4, + queued: 1024, +} as const export const extensionToolSchema = z.object({ id: extensionSettingIdSchema, title: z.string().min(1).max(100), @@ -19,11 +27,23 @@ export const extensionToolSchema = z.object({ additionalProperties: z.literal(false).default(false), }).strict().refine(value => value.required.every(key => Object.hasOwn(value.properties, key))), }).strict() +export const extensionActionTriggerSchema = z.enum(['task:input:committed', 'task:turn:completed', 'user']) +export const extensionActionSchema = z.object({ + enabledWhen: extensionConditionReferenceSchema.optional(), + id: extensionSettingIdSchema, + title: z.string().min(1).max(100), + triggers: z.array(extensionActionTriggerSchema).min(1).max(3).refine(value => new Set(value).size === value.length), +}).strict() +export const extensionActionResultSchema = z.object({ status: z.enum(['completed', 'skipped']), message: z.string().max(500).optional() }).strict() +export const extensionActionCauseSchema = z.union([taskActionEventSchema, z.object({ type: z.literal('user') }).strict()]) +export type ExtensionActionCause = EventSnapshot> +export type ExtensionActionTrigger = z.infer export const extensionAgentSchema = z.object({ - enabledWhen: extensionSettingKeySchema.optional(), + enabledWhen: z.union([extensionSettingKeySchema, extensionConditionReferenceSchema]).optional(), instructions: z.string().max(8000).default(''), - tools: z.array(extensionToolSchema).min(1).max(16), -}).strict() + tools: z.array(extensionToolSchema).max(16).default([]), + actions: z.array(extensionActionSchema).max(16).default([]), +}).strict().refine(value => value.tools.length + value.actions.length > 0) export const extensionAgentDescriptorSchema = z.object({ id: z.string(), name: z.string(), @@ -32,14 +52,22 @@ export const extensionAgentDescriptorSchema = z.object({ agent: extensionAgentSchema, }).strict() export type ExtensionAgentDescriptor = z.infer -export const extensionAgentInvocationSchema = z.object({ +const invocationIdentity = { + context: z.object({ taskId: z.string(), runId: z.string().nullable() }).strict().optional(), extensionId: z.string(), revision: z.string(), configurationRevision: z.string(), invocationId: z.string().uuid(), +} +export const extensionAgentInvocationSchema = z.union([z.object({ + ...invocationIdentity, tool: z.string().max(180), input: z.record(z.string(), z.union([z.string().max(32768), z.number().finite(), z.boolean()])), -}).strict() +}).strict(), z.object({ + ...invocationIdentity, + action: z.string().max(180), + cause: extensionActionCauseSchema, +}).strict()]) export type ExtensionAgentInvocation = z.infer export const extensionAgentRequestSchema = z.object({ invocationId: z.string().uuid(), diff --git a/apps/buddy/shared/extensions/extensionAgentCapabilities.ts b/apps/buddy/shared/extensions/extensionAgentCapabilities.ts index e3499043..1286dc6c 100644 --- a/apps/buddy/shared/extensions/extensionAgentCapabilities.ts +++ b/apps/buddy/shared/extensions/extensionAgentCapabilities.ts @@ -8,8 +8,9 @@ function capability(input: I, output: } export const extensionAgentCapabilities = { - 'task.get': capability(z.null(), z.object({ id: z.string(), title: z.string().nullable(), titleSource: z.enum(['manual', 'fallback', 'generated']), titleRevision: z.number().int().nonnegative() }).strict(), permissions => permissions.tasks !== 'none'), - 'task.rename': capability(z.object({ title: z.string().trim().min(1).max(80).refine(value => [...value].every(character => character.charCodeAt(0) >= 32)), expectedRevision: z.number().int().nonnegative() }).strict(), z.object({ applied: z.boolean() }).strict(), permissions => permissions.tasks === 'title'), + 'task.get': capability(z.null(), z.object({ id: z.string(), title: z.string().nullable(), titleSource: z.enum(['legacy', 'manual', 'fallback', 'generated']) }).strict(), permissions => permissions.tasks !== 'none'), + 'task.messages': capability(z.null(), z.array(z.object({ role: z.enum(['user', 'assistant']), text: z.string().max(2000) }).strict()).max(16), permissions => permissions.taskMessages), + 'task.rename': capability(z.object({ title: z.string().trim().min(1).max(80).refine(value => [...value].every(character => character.charCodeAt(0) >= 32)) }).strict(), z.object({ applied: z.boolean() }).strict(), permissions => permissions.tasks === 'title'), 'models.generateText': capability(z.object({ model: extensionModelSelectionSchema.nullable().default(null), system: z.string().max(8192).default(''), diff --git a/apps/buddy/shared/extensions/extensionApi.ts b/apps/buddy/shared/extensions/extensionApi.ts index 512646ae..76a4647f 100644 --- a/apps/buddy/shared/extensions/extensionApi.ts +++ b/apps/buddy/shared/extensions/extensionApi.ts @@ -1,7 +1,9 @@ import type { WorkbenchHitRegion, WorkbenchPaneSnapshot } from '../workbench/workbenchInteraction' import type { JsonValue } from '../workbench/workbenchState' import type { ControlProposal, WorkbenchPresentation } from '../workbench/workbenchUi' +import type { ExtensionTaskAction, ExtensionTaskActionInput, ExtensionTaskActionResult } from './extensionActionApi' import type { ExtensionCatalogSnapshot } from './extensionCatalog' +import type { ExtensionConditionsChanged, ExtensionConditionState } from './extensionConditions' import type { ExtensionInstallation } from './extensionInstallation' import type { ExtensionManifest } from './extensionManifest' import type { ExtensionConfiguration, ExtensionConfigurationSnapshot } from './extensionSettings' @@ -9,12 +11,14 @@ import { z } from 'zod' import { spaceFileTargetSchema } from '../spaces/spaceFileApi' import { workbenchPanesSchema } from '../workbench/workbenchInteraction' import { workbenchMenuSchema } from '../workbench/workbenchUi' +import { extensionActionRpc } from './extensionActionApi' import { extensionIdSchema } from './extensionManifest' import { extensionConfigurationSchema } from './extensionSettings' export const EXTENSION_IPC = { request: 'lexora:extensions:request', changed: 'lexora:extensions:changed', + conditionsChanged: 'lexora:extensions:conditions-changed', review: 'lexora:extensions:review', workbench: 'lexora:extensions:workbench', workbenchReply: 'lexora:extensions:workbench-reply', @@ -100,6 +104,7 @@ export const extensionManagementSchema = z.discriminatedUnion('action', [ z.object({ action: z.literal('list') }).strict(), z.object({ action: z.literal('configuration'), id: extensionIdSchema }).strict(), z.object({ action: z.literal('configurationSnapshot'), id: extensionIdSchema }).strict(), + z.object({ action: z.literal('settingConditions'), id: extensionIdSchema, items: z.array(z.string().max(180)).max(64), form: extensionConfigurationSchema.optional() }).strict(), z.object({ action: z.literal('configure'), id: extensionIdSchema, patch: extensionConfigurationSchema }).strict(), z.object({ action: z.literal('installations') }).strict(), z.object({ action: z.literal('catalog'), refresh: z.boolean().default(false) }).strict(), @@ -114,6 +119,8 @@ export const extensionManagementSchema = z.discriminatedUnion('action', [ z.object({ action: z.literal('devtools'), id: extensionIdSchema }).strict(), z.object({ action: z.literal('revokeResources'), id: extensionIdSchema }).strict(), z.object({ action: z.literal('execute'), id: extensionIdSchema, command: z.string().max(180), resource: spaceFileTargetSchema.nullable() }).strict(), + z.object({ action: z.literal('taskActions') }).strict(), + z.object({ action: z.literal('invokeTaskAction'), input: extensionActionRpc.invoke.input }).strict(), z.object({ action: z.literal('executeMenu'), id: extensionIdSchema, menu: z.string().max(180), invocation: extensionMenuInvocationSchema }).strict(), z.object({ action: z.literal('executeSlash'), id: extensionIdSchema, command: z.string().max(180), arguments: z.string().max(8192), instanceId: z.string().uuid().optional() }).strict(), z.object({ action: z.literal('updatePanes'), panes: workbenchPanesSchema }).strict(), @@ -124,6 +131,10 @@ export const extensionManagementSchema = z.discriminatedUnion('action', [ ]) export type ExtensionManagementRequest = z.infer export interface ExtensionApi { + settingConditions: (id: string, items: string[], form?: ExtensionConfiguration) => Promise> + onConditionsChanged: (listener: (event: ExtensionConditionsChanged) => void) => () => void + taskActions: () => Promise + invokeTaskAction: (input: ExtensionTaskActionInput) => Promise list: () => Promise configuration: (id: string) => Promise configurationSnapshot: (id: string) => Promise diff --git a/apps/buddy/shared/extensions/extensionConditionContext.ts b/apps/buddy/shared/extensions/extensionConditionContext.ts new file mode 100644 index 00000000..241276d3 --- /dev/null +++ b/apps/buddy/shared/extensions/extensionConditionContext.ts @@ -0,0 +1,49 @@ +import { z } from 'zod' +import { extensionConfigurationSchema, extensionModelSelectionSchema } from './extensionSettings' + +const unavailableSchema = z.object({ status: z.enum(['not_requested', 'no_context', 'denied', 'unsupported', 'loading', 'invalid']) }).strict() +const revision = { revision: z.string().max(128) } +const configurationScopeSchema = z.discriminatedUnion('kind', [ + z.object({ kind: z.literal('global') }).strict(), + z.object({ kind: z.literal('space'), spaceId: z.string() }).strict(), + z.object({ kind: z.literal('task'), taskId: z.string() }).strict(), +]) +export const extensionConditionInvocationScopeSchema = z.discriminatedUnion('kind', [ + z.object({ kind: z.literal('settings'), moduleId: z.string(), groupId: z.string() }).strict(), + z.object({ kind: z.literal('task'), taskId: z.string(), runId: z.string().nullable() }).strict(), +]) +export type ExtensionConditionInvocationScope = z.infer +export const extensionConditionModelsSchema = z.union([unavailableSchema, z.object({ + status: z.literal('available'), + ...revision, + selection: extensionModelSelectionSchema.nullable(), + models: z.array(z.object({ providerId: z.string(), modelId: z.string(), name: z.string(), available: z.boolean(), capabilities: z.array(z.string()) }).strict()), +}).strict()]) +export const extensionConditionTaskSchema = z.union([unavailableSchema, z.object({ + status: z.literal('available'), + ...revision, + id: z.string(), + spaceId: z.string().nullable(), + branchId: z.string(), + title: z.string().nullable(), + titleSource: z.enum(['fallback', 'generated', 'manual', 'legacy']), + activity: z.enum(['idle', 'running', 'awaiting_approval']), + modelSelection: extensionModelSelectionSchema.nullable(), +}).strict()]) +export const extensionConditionRuntimeSchema = z.object({ models: extensionConditionModelsSchema, task: extensionConditionTaskSchema }).strict() +export type ExtensionConditionRuntime = z.infer +export const extensionConditionContextSchema = z.object({ + version: z.literal(1), + scope: z.object({ key: z.string(), configuration: configurationScopeSchema, invocation: extensionConditionInvocationScopeSchema }).strict(), + target: z.object({ kind: z.enum(['setting', 'agent', 'action']), id: z.string() }).strict(), + configuration: z.union([unavailableSchema, z.object({ status: z.literal('available'), ...revision, values: extensionConfigurationSchema, sources: z.record(z.string(), configurationScopeSchema) }).strict()]), + runtime: extensionConditionRuntimeSchema, + workbench: z.union([unavailableSchema, z.object({ status: z.literal('available'), ...revision, panes: z.array(z.object({ id: z.string(), active: z.boolean(), visible: z.boolean() }).strict()) }).strict()]), + form: z.union([unavailableSchema, z.object({ status: z.literal('available'), ...revision, values: extensionConfigurationSchema, dirtyKeys: z.array(z.string()) }).strict()]), +}).strict() +export type ExtensionConditionContext = z.infer +export const extensionConditionSnapshotRpc = { + method: 'extensions.conditions.snapshot', + input: z.object({ models: z.boolean(), task: z.boolean(), taskId: z.string().nullable(), runId: z.string().nullable() }).strict(), + response: extensionConditionRuntimeSchema, +} as const diff --git a/apps/buddy/shared/extensions/extensionConditions.ts b/apps/buddy/shared/extensions/extensionConditions.ts new file mode 100644 index 00000000..2f30196e --- /dev/null +++ b/apps/buddy/shared/extensions/extensionConditions.ts @@ -0,0 +1,34 @@ +import { z } from 'zod' + +export const extensionConditionIdSchema = z.string().max(180).regex(/^[a-z][a-z0-9.-]+$/) +export const extensionConditionInputSchema = z.enum(['configuration', 'runtime.models', 'runtime.task', 'workbench', 'form']) +export type ExtensionConditionInput = z.infer +export type ConditionParameter = string | number | boolean | null | ConditionParameterArray | ConditionParameterObject +export interface ConditionParameterArray extends Array {} +export interface ConditionParameterObject { [key: string]: ConditionParameter } +const parameterSchema: z.ZodType = z.json() +export const extensionConditionParamsSchema = z.record(z.string().max(80), parameterSchema).refine(value => new TextEncoder().encode(JSON.stringify(value)).byteLength <= 16384, 'Condition parameters are too large') +export const extensionConditionReferenceSchema = z.object({ condition: extensionConditionIdSchema, params: extensionConditionParamsSchema.default({}) }).strict() +export type ExtensionConditionReference = z.infer +export const extensionConditionDefinitionSchema = z.object({ + id: extensionConditionIdSchema, + inputs: z.array(extensionConditionInputSchema).max(5).refine(inputs => new Set(inputs).size === inputs.length), +}).strict() +export type ExtensionConditionDefinition = z.infer +export const extensionConditionResultSchema = z.union([ + z.boolean().transform(value => ({ value })), + z.object({ value: z.boolean(), reason: z.string().max(300).optional() }).strict(), +]) +export interface ExtensionConditionResult { value: boolean, reason?: string } +export const extensionConditionStateSchema = z.object({ + status: z.enum(['ready', 'unavailable']), + value: z.boolean(), + reason: z.string().max(300).optional(), +}).strict() +export type ExtensionConditionState = z.infer +export const extensionConditionInvalidationSchema = z.object({ + condition: extensionConditionIdSchema.optional(), + scopeKey: z.string().regex(/^[a-f0-9]{64}$/).optional(), +}).strict() +export const extensionConditionsChangedSchema = extensionConditionInvalidationSchema.extend({ extensionId: z.string(), revision: z.number().int().positive() }).strict() +export type ExtensionConditionsChanged = z.infer diff --git a/apps/buddy/shared/extensions/extensionManifest.ts b/apps/buddy/shared/extensions/extensionManifest.ts index 7b28f3ff..a2ab2d10 100644 --- a/apps/buddy/shared/extensions/extensionManifest.ts +++ b/apps/buddy/shared/extensions/extensionManifest.ts @@ -4,6 +4,7 @@ import { workbenchSlashSchema } from '../workbench/workbenchCommand' import { workbenchConditionSchema } from '../workbench/workbenchContext' import { workbenchAnchorSchema, workbenchControls, workbenchControlSchema, workbenchMenuSchema, workbenchMountTargetSchema, workbenchPresentationSchema, workbenchSlots, workbenchSlotSchema } from '../workbench/workbenchUi' import { extensionAgentSchema } from './extensionAgent' +import { extensionConditionDefinitionSchema } from './extensionConditions' import { extensionAuthorSchema } from './extensionIdentity' import { extensionSettingsGroups, extensionSettingsModules, extensionSettingsSchema, validateExtensionSetting } from './extensionSettings' @@ -58,6 +59,7 @@ export const extensionPermissionsSchema = z.object({ agent: z.boolean().default(false), models: z.boolean().default(false), tasks: z.enum(['none', 'read', 'title']).default('none'), + taskMessages: z.boolean().default(false), windowEffects: z.boolean().default(false), controls: z.array(workbenchControlSchema).max(workbenchControlSchema.options.length).default([]), notifications: z.boolean().default(false), @@ -89,6 +91,7 @@ export const extensionManifestSchema = z.object({ dependencies: z.record(extensionIdSchema, z.string().max(100).refine(value => validRange(value) !== null)).default({}), permissions: extensionPermissionsSchema.prefault({}), contributes: z.object({ + conditions: z.array(extensionConditionDefinitionSchema).max(32).default([]), agent: extensionAgentSchema.optional(), settings: extensionSettingsSchema.prefault({}), commands: z.array(z.object({ id: contributionId, title: z.string().min(1).max(100), hidden: z.boolean().default(false), slash: workbenchSlashSchema.optional(), when: workbenchConditionSchema.optional() }).strict()).max(64).default([]), @@ -101,18 +104,31 @@ export const extensionManifestSchema = z.object({ const ids = new Set() const settings = manifest.contributes.settings const agent = manifest.contributes.agent - for (const contribution of [...manifest.contributes.commands, ...manifest.contributes.views, ...manifest.contributes.placements, ...manifest.contributes.menus, ...settings.modules, ...settings.groups, ...settings.items, ...agent?.tools ?? []]) { + for (const contribution of [...manifest.contributes.conditions, ...manifest.contributes.commands, ...manifest.contributes.views, ...manifest.contributes.placements, ...manifest.contributes.menus, ...settings.modules, ...settings.groups, ...settings.items, ...agent?.tools ?? [], ...agent?.actions ?? []]) { if (!contribution.id.startsWith(`${manifest.id}.`) || ids.has(contribution.id)) context.addIssue({ code: 'custom', message: 'Contribution IDs must be unique and owned by the extension' }) ids.add(contribution.id) } const issue = (message: string) => context.addIssue({ code: 'custom', message }) - if (manifest.apiVersion < 3 && (agent || settings.modules.length || settings.groups.length || settings.items.length || manifest.permissions.agent || manifest.permissions.models || manifest.permissions.tasks !== 'none')) + if (manifest.apiVersion < 3 && (manifest.contributes.conditions.length || agent || settings.modules.length || settings.groups.length || settings.items.length || manifest.permissions.agent || manifest.permissions.models || manifest.permissions.taskMessages || manifest.permissions.tasks !== 'none')) issue('Agent capabilities and settings require API 3') if (agent && (!manifest.entry || !manifest.permissions.agent)) issue('Agent contributions require an entry and agent permission') - if (agent?.enabledWhen && !settings.items.some(item => item.key === agent.enabledWhen && item.type === 'boolean')) + if (typeof agent?.enabledWhen === 'string' && !settings.items.some(item => item.key === agent.enabledWhen && item.type === 'boolean')) issue('Agent enabledWhen must reference a boolean setting') + const conditionReferences = [...settings.items.map(item => item.enabledWhen), ...agent?.actions.map(action => action.enabledWhen) ?? [], typeof agent?.enabledWhen === 'object' ? agent.enabledWhen : undefined].filter(reference => reference !== undefined) + if (manifest.contributes.conditions.length && !manifest.entry) + issue('Conditions require an extension entry') + for (const reference of conditionReferences) { + if (!manifest.contributes.conditions.some(condition => condition.id === reference.condition)) + issue('Condition references must be declared by the extension') + } + for (const condition of manifest.contributes.conditions) { + if (condition.inputs.includes('runtime.models') && !manifest.permissions.models) + issue('Model conditions require models permission') + if (condition.inputs.includes('runtime.task') && manifest.permissions.tasks === 'none') + issue('Task conditions require task permission') + } const keys = new Set() for (const group of settings.groups) { if (!settings.modules.some(module => module.id === group.module) && !extensionSettingsModules.includes(group.module)) @@ -182,6 +198,7 @@ export function extensionCompatible(manifest: ExtensionManifest, version: string export function addedExtensionPermissions(previous: ExtensionPermissions | undefined, next: ExtensionPermissions): string[] { return [ + ...(next.taskMessages && !previous?.taskMessages ? ['taskMessages'] : []), ...(next.agent && !previous?.agent ? ['agent'] : []), ...(next.models && !previous?.models ? ['models'] : []), ...(next.tasks !== 'none' && next.tasks !== previous?.tasks && previous?.tasks !== 'title' ? [`tasks:${next.tasks}`] : []), diff --git a/apps/buddy/shared/extensions/extensionSettings.ts b/apps/buddy/shared/extensions/extensionSettings.ts index 3a13c176..f2335d4f 100644 --- a/apps/buddy/shared/extensions/extensionSettings.ts +++ b/apps/buddy/shared/extensions/extensionSettings.ts @@ -1,6 +1,7 @@ import type { EventSnapshot } from '../events/eventTypes' import { z } from 'zod' import { builtinSettingsModuleIds, publicSettingsGroups } from '../settings/settingsCatalog' +import { extensionConditionReferenceSchema } from './extensionConditions' export const extensionSettingIdSchema = z.string().max(180).regex(/^[a-z][a-z0-9.-]+$/) export const extensionSettingKeySchema = z.string().max(80).regex(/^[a-z][a-zA-Z0-9]*$/) @@ -22,7 +23,7 @@ export const extensionConfigurationSnapshotSchema = z.object({ values: extension export type ExtensionConfigurationSnapshot = z.infer export const extensionSettingsModules = builtinSettingsModuleIds export const extensionSettingsGroups = publicSettingsGroups -const itemBase = { id: extensionSettingIdSchema, key: extensionSettingKeySchema, group: extensionSettingIdSchema, title: z.string().min(1).max(100), description: z.string().max(500).default(''), order: z.number().int().min(-1000).max(1000).default(0) } +const itemBase = { enabledWhen: extensionConditionReferenceSchema.optional(), id: extensionSettingIdSchema, key: extensionSettingKeySchema, group: extensionSettingIdSchema, title: z.string().min(1).max(100), description: z.string().max(500).default(''), order: z.number().int().min(-1000).max(1000).default(0) } export const extensionSettingItemSchema = z.discriminatedUnion('type', [ z.object({ ...itemBase, type: z.literal('boolean'), default: z.boolean() }).strict(), z.object({ ...itemBase, type: z.literal('string'), default: z.string().max(8192) }).strict(), diff --git a/apps/buddy/shared/runtime/rpcPeer.ts b/apps/buddy/shared/runtime/rpcPeer.ts index 30d7aed4..31947900 100644 --- a/apps/buddy/shared/runtime/rpcPeer.ts +++ b/apps/buddy/shared/runtime/rpcPeer.ts @@ -4,7 +4,7 @@ export interface RuntimeRpcPeerContract { notify: (method: string, params: unknown) => void onNotification: (listener: (method: string, params: unknown) => void) => () => void onRequest: (method: string, handler: RuntimeRequestHandler) => () => void - request: (method: string, params: unknown, timeoutMs?: number, signal?: AbortSignal, requestId?: string) => Promise + request: (method: string, params: unknown, timeoutMs?: number | null, signal?: AbortSignal, requestId?: string) => Promise close: (reason: Error) => void } diff --git a/apps/buddy/shared/usage/usageApi.ts b/apps/buddy/shared/usage/usageApi.ts index 5c961d69..0ac574e7 100644 --- a/apps/buddy/shared/usage/usageApi.ts +++ b/apps/buddy/shared/usage/usageApi.ts @@ -18,7 +18,8 @@ export const usageRecordSchema = z.object({ providerId: idSchema, purpose: z.string().min(1), reasoningTokens: z.number().int().nonnegative().nullable(), - runId: idSchema, + runId: idSchema.nullable(), + invocationId: idSchema.nullable().optional(), totalCost: z.number().nonnegative(), totalTokens: z.number().int().nonnegative(), }).strict() diff --git a/apps/buddy/shared/workbench/workbenchContributionCatalog.ts b/apps/buddy/shared/workbench/workbenchContributionCatalog.ts index 0fee1eae..306a93e1 100644 --- a/apps/buddy/shared/workbench/workbenchContributionCatalog.ts +++ b/apps/buddy/shared/workbench/workbenchContributionCatalog.ts @@ -45,9 +45,12 @@ export const workbenchDecorations = { export const workbenchRuntimeCapabilities = { 'events': { title: { 'zh-CN': '作用域事件订阅', 'en-US': 'Scoped events' }, scope: 'application', description: 'Lexora 0.9.1 context.events.on(pattern | readonly patterns[], listener, {once?,signal?}) subscribes to owned domain events as {type,data}. Exact names, suffix :* and :**, root * and **; overlapping patterns deliver once per group. No publishing or added permissions. Existing callbacks remain supported. See events.md.' }, - 'agent.tools': { title: { 'zh-CN': 'Agent 指令与工具', 'en-US': 'Agent instructions and tools' }, scope: 'application', description: 'API 3 contributes.agent declares instructions, tools and optional enabledWhen boolean setting. Requires permissions.agent. Register tools with context.agent.registerTool during activate; instructions reference {{full-tool-id}}. Callbacks receive invocation-scoped task/models APIs and AbortSignal, not raw Pi or credentials. See agent-settings.md.' }, - 'agent.models': { title: { 'zh-CN': '受控模型调用', 'en-US': 'Scoped model requests' }, scope: 'application', description: 'permissions.models allows invocation.models.generateText({system?,prompt,model?,maxTokens?}). Missing/null model inherits the originating turn model. Text only, no tools; usage belongs to the originating run. Up to 4 model calls and 120 seconds per invocation. Credentials remain in the host. See agent-settings.md.' }, - 'agent.task': { title: { 'zh-CN': '当前任务标题', 'en-US': 'Invoking task title' }, scope: 'application', description: 'permissions.tasks:read permits invocation.task.get() title metadata; title additionally permits rename({title,expectedRevision}). Only the invoking task; no arbitrary IDs, history or SQL. Manual titles are protected, stale writes return applied:false, renaming never changes activity time. See agent-settings.md.' }, + 'agent.tools': { title: { 'zh-CN': 'Agent 指令与工具', 'en-US': 'Agent instructions and tools' }, scope: 'application', description: 'API 3 contributes.agent declares instructions, tools and optional enabledWhen boolean setting key or registered condition reference. Requires permissions.agent. Register tools with context.agent.registerTool during activate; instructions reference {{full-tool-id}}. Callbacks receive invocation-scoped task/models APIs and AbortSignal, not raw Pi or credentials. See agent-settings.md.' }, + 'agent.actions': { title: { 'zh-CN': '任务事件与菜单动作', 'en-US': 'Task event and menu actions' }, scope: 'application', description: 'API 3 contributes.agent.actions declares id/title/triggers, registered with context.agent.registerAction. Exact triggers: task:input:committed, task:turn:completed, user. Typed invocation.cause preserves each event payload; user adds a task menu action. Independent cancellable invocation, no main-chat tool result. Requires permissions.agent; task/models permissions remain separate. See events.md and agent-settings.md.' }, + 'agent.models': { title: { 'zh-CN': '受控模型调用', 'en-US': 'Scoped model requests' }, scope: 'application', description: 'permissions.models allows invocation.models.generateText({system?,prompt,model?,maxTokens?}). Missing/null model inherits the tool turn model or captured action task model. Text only, no tools. Tool usage belongs to its run; action usage belongs to its independent invocation. Up to 4 model calls and 120 seconds per invocation. Credentials remain in the host. See agent-settings.md.' }, + 'agent.task': { title: { 'zh-CN': '当前任务标题', 'en-US': 'Invoking task title' }, scope: 'application', description: 'permissions.tasks:read permits invocation.task.get() title metadata; title additionally permits rename({title}). Host captures the initial revision and rejects stale writes. Manual/legacy titles require an explicit user action to replace. Only the invoking task; no arbitrary IDs or SQL; no activity-time changes. See agent-settings.md.' }, + 'agent.taskMessages': { title: { 'zh-CN': '当前任务文本', 'en-US': 'Invoking task text' }, scope: 'application', description: 'permissions.taskMessages allows invocation.task.messages(): up to 16 visible user/assistant texts, each at most 1500 characters, preserving the first user input. No tool output, hidden branches or attachment bodies. Bound to the invocation task and branch. See agent-settings.md.' }, + 'conditions': { title: { 'zh-CN': '实时条件函数', 'en-US': 'Live condition functions' }, scope: 'application', description: 'API 3 contributes.conditions declares id/inputs; register with context.conditions.register. Settings items, agent and actions accept enabledWhen:{condition,params?}. Versioned readonly snapshots distinguish configuration ownership from settings/task invocation scope. Inputs: configuration, form, runtime.models, runtime.task, workbench. Model/task permissions still apply. Host invalidates stale results and rechecks before execution; custom state uses conditions.invalidate({condition?,scopeKey?}). See conditions.md and api.d.ts.' }, 'settings': { title: { 'zh-CN': '声明式设置', 'en-US': 'Declarative settings' }, scope: 'application', description: `API 3 contributes.settings declares modules/groups/items. Groups target owned modules or ${builtinSettingsModuleIds.join(', ')}. Items target owned groups or ${Object.keys(publicSettingsGroups).join(', ')}. Native boolean/string/number/select/model controls save host-owned configuration, read with context.configuration.get(). See agent-settings.md.` }, 'commands': { title: { 'zh-CN': '注册输入命令', 'en-US': 'Registered commands' }, scope: 'application', description: 'API 3 commands may declare slash:{name,description?}. Host exposes /plugin-name:name only (plugin-name is the stable suffix after the dot in the plugin ID); top-level names are system-owned. Local names match [a-z][a-z0-9-]* (max 64). Titles and descriptions may be localized. Duplicate local names or invalid/missing handler registrations fail validation or activation. Plugins may share command names. Choose a suggestion to bind its stable command ID; ambiguous plain text requires a choice. Slash executes exact names with string arguments and origin pane; no model request. See commands.md.' }, 'workbench.panes': { title: { 'zh-CN': '分屏布局订阅', 'en-US': 'Pane snapshots' }, scope: 'application', description: 'API 3 host workbench.panes and onPanesChange expose opaque id, active, visible, and rect relative to the workbench mount. Subscribe to layout and visibility changes; no task identity, history, or DOM access. See interactions.md.' }, diff --git a/apps/buddy/src/i18n/locales/en-US/chat.ts b/apps/buddy/src/i18n/locales/en-US/chat.ts index d9543717..ce0cd140 100644 --- a/apps/buddy/src/i18n/locales/en-US/chat.ts +++ b/apps/buddy/src/i18n/locales/en-US/chat.ts @@ -392,6 +392,7 @@ export default { 'desktop.chat.processToolImageReferenceLatest': 'Latest image in this conversation', 'desktop.chat.processToolImageRunning': 'Generating', 'desktop.chat.processToolInput': 'Input', + 'desktop.chat.processToolSkipped': 'Skipped', 'desktop.chat.processToolInterrupted': 'Interrupted', 'desktop.chat.processToolNoOutput': 'Command produced no output', 'desktop.chat.processToolPet': 'Pet action', diff --git a/apps/buddy/src/i18n/locales/zh-CN/chat.ts b/apps/buddy/src/i18n/locales/zh-CN/chat.ts index 76126840..a4de5b61 100644 --- a/apps/buddy/src/i18n/locales/zh-CN/chat.ts +++ b/apps/buddy/src/i18n/locales/zh-CN/chat.ts @@ -390,6 +390,7 @@ export default { 'desktop.chat.processToolImageReferenceLatest': '参考当前对话中的最新图片', 'desktop.chat.processToolImageRunning': '生成中', 'desktop.chat.processToolInput': '输入', + 'desktop.chat.processToolSkipped': '已跳过', 'desktop.chat.processToolInterrupted': '已中断', 'desktop.chat.processToolNoOutput': '命令未产生输出', 'desktop.chat.processToolPet': '桌宠动作', diff --git a/apps/buddy/src/modules/extensions/state/__tests__/useExtensionState.spec.ts b/apps/buddy/src/modules/extensions/state/__tests__/useExtensionState.spec.ts index ccd80a3c..9634b3f7 100644 --- a/apps/buddy/src/modules/extensions/state/__tests__/useExtensionState.spec.ts +++ b/apps/buddy/src/modules/extensions/state/__tests__/useExtensionState.spec.ts @@ -23,7 +23,7 @@ const extension: ExtensionStatus = { it('publishes installed contributions even while installation jobs are pending or fail', async () => { const jobs = deferred() const scope = effectScope() - const api: Partial = { list: async () => [extension], installations: () => jobs.promise, onChanged: () => () => {} } + const api: Partial = { list: async () => [extension], installations: () => jobs.promise, taskActions: async () => [], onChanged: () => () => {} } const state = scope.run(() => useExtensionState(api as ExtensionApi))! try { const pending = state.refresh() @@ -43,7 +43,7 @@ it('does not let stale or disposed refreshes overwrite current contributions', a const delayed = deferred() let requests = 0 const scope = effectScope() - const api: Partial = { list: () => ++requests === 1 ? delayed.promise : Promise.resolve(requests === 2 ? [extension] : []), installations: async () => [], onChanged: () => () => {} } + const api: Partial = { list: () => ++requests === 1 ? delayed.promise : Promise.resolve(requests === 2 ? [extension] : []), installations: async () => [], taskActions: async () => [], onChanged: () => () => {} } const state = scope.run(() => useExtensionState(api as ExtensionApi))! const previous = state.refresh() await state.refresh() diff --git a/apps/buddy/src/modules/extensions/state/useExtensionState.ts b/apps/buddy/src/modules/extensions/state/useExtensionState.ts index 3075393f..0928fa98 100644 --- a/apps/buddy/src/modules/extensions/state/useExtensionState.ts +++ b/apps/buddy/src/modules/extensions/state/useExtensionState.ts @@ -1,8 +1,10 @@ +import type { ExtensionTaskAction } from '@buddy-shared/extensions/extensionActionApi' import type { ExtensionApi, ExtensionStatus } from '@buddy-shared/extensions/extensionApi' import type { ExtensionInstallation } from '@buddy-shared/extensions/extensionInstallation' import { computed, onScopeDispose, shallowRef } from 'vue' export function useExtensionState(api: ExtensionApi) { + const taskActions = shallowRef([]) const installed = shallowRef([]) const installedError = shallowRef('') const installationsError = shallowRef('') @@ -14,6 +16,13 @@ export function useExtensionState(api: ExtensionApi) { const current = ++revision const currentResult = () => !disposed && current === revision await Promise.all([ + api.taskActions().then((items) => { + if (currentResult()) + taskActions.value = items + }).catch(() => { + if (currentResult()) + taskActions.value = [] + }), api.list().then((snapshot) => { if (currentResult()) { installed.value = snapshot @@ -39,7 +48,7 @@ export function useExtensionState(api: ExtensionApi) { disposed = true revision++ }) - return { api, installed, installations, error, refresh } + return { api, taskActions, installed, installations, error, refresh } } export function extensionErrorCode(reason: unknown): string { diff --git a/apps/buddy/src/modules/extensions/widgets/DesktopExtensionInstallReview.vue b/apps/buddy/src/modules/extensions/widgets/DesktopExtensionInstallReview.vue index 0b0b6470..1fbd794b 100644 --- a/apps/buddy/src/modules/extensions/widgets/DesktopExtensionInstallReview.vue +++ b/apps/buddy/src/modules/extensions/widgets/DesktopExtensionInstallReview.vue @@ -14,9 +14,10 @@ const english = computed(() => props.language === 'en-US') const permissions = computed(() => { const { permissions } = props.review.manifest return [ - ...(permissions.agent ? [{ key: 'agent', icon: Alert20Regular, title: english.value ? 'Add agent instructions and tools' : '追加 Agent 指令与工具', description: english.value ? 'Influence how the model handles tasks and let it invoke plugin tools.' : '影响模型处理任务的方式,允许模型调用插件工具。' }] : []), + ...(permissions.agent ? [{ key: 'agent', icon: Alert20Regular, title: english.value ? 'Run plugin tools and actions' : '运行插件工具与动作', description: english.value ? 'Contribute model tools and run declared actions when task events occur or you request them.' : '提供模型工具,并在声明的任务事件发生或你主动请求时执行动作。' }] : []), + ...(permissions.taskMessages ? [{ key: 'taskMessages', icon: Document20Regular, title: english.value ? 'Read task messages' : '读取任务消息', description: english.value ? 'Read a bounded text excerpt from the invoking task. Attachments and tool output are excluded.' : '读取触发任务的有限消息文本,不包含附件内容和工具输出。' }] : []), ...(permissions.models ? [{ key: 'models', icon: Alert20Regular, title: english.value ? 'Call configured models' : '调用已配置的模型', description: english.value ? 'Send tool-provided text to a configured model. Additional requests may incur usage costs. Credentials stay in Lexora.' : '将工具提供的文本发送给已配置的模型,额外请求可能产生费用;凭据不会交给插件。' }] : []), - ...(permissions.tasks !== 'none' ? [{ key: `tasks:${permissions.tasks}`, icon: Document20Regular, title: english.value ? 'Access the invoking task' : '访问触发工具的任务', description: permissions.tasks === 'title' ? english.value ? 'Read task title metadata and update non-manual titles without changing task activity time.' : '读取标题元数据并更新非手动标题,不改变任务活动时间。' : english.value ? 'Read title metadata of the task that invokes the tool.' : '读取调用工具的任务标题元数据。' }] : []), + ...(permissions.tasks !== 'none' ? [{ key: `tasks:${permissions.tasks}`, icon: Document20Regular, title: english.value ? 'Access the invoking task' : '访问触发调用的任务', description: permissions.tasks === 'title' ? english.value ? 'Update generated titles; replace protected titles only when you explicitly run an action.' : '更新自动标题;只有你主动执行动作时才能替换受保护的标题。' : english.value ? 'Read title metadata of the task that invokes the tool.' : '读取调用工具的任务标题元数据。' }] : []), ...(permissions.windowEffects ? [{ key: 'windowEffects', icon: Alert20Regular, title: english.value ? 'Display window effects' : '显示窗口特效', description: english.value ? 'Draw over Lexora and receive typing activity. Typed text and keys are never shared.' : '在 Lexora 窗口绘制效果,接收对话输入活动;不会获取输入文字或按键。' }] : []), ...(permissions.controls.length ? [{ key: 'controls', icon: Alert20Regular, title: english.value ? 'Provide optional controls' : '提供可选控件', description: english.value ? 'After you enable a control, the plugin can submit your choices through it.' : '由你启用控件后,插件可以通过它提交你的选择。' }] : []), ...(permissions.notifications ? [{ key: 'notifications', icon: Alert20Regular, title: labels.value.notifications, description: english.value ? 'Show reminders in your system notification center.' : '通过系统通知向你发送提醒。' }] : []), diff --git a/apps/buddy/src/modules/extensions/widgets/DesktopExtensionMenu.vue b/apps/buddy/src/modules/extensions/widgets/DesktopExtensionMenu.vue index d00b7d40..cfd8b098 100644 --- a/apps/buddy/src/modules/extensions/widgets/DesktopExtensionMenu.vue +++ b/apps/buddy/src/modules/extensions/widgets/DesktopExtensionMenu.vue @@ -18,20 +18,32 @@ let disposed = false onScopeDispose(() => { disposed = true }) -const items = computed(() => { +const menus = computed(() => { const values = { ...workbench.value.values, ...props.values } return state.installed.value.filter(plugin => plugin.enabled && plugin.compatible).flatMap(plugin => plugin.manifest.contributes.menus.flatMap((menu) => { const command = plugin.manifest.contributes.commands.find(command => command.id === menu.command) return menu.target === props.target && command && matchesWorkbenchContext(menu.when, values) && matchesWorkbenchContext(command.when, values) ? [{ key: menu.id, label: command.title, plugin, menu }] : [] })).sort((left, right) => left.menu.order - right.menu.order || left.key.localeCompare(right.key)) }) +const actions = computed(() => props.target === 'task.actions' && props.taskId ? state.taskActions.value : []) +const items = computed(() => [...menus.value, ...actions.value.map(action => ({ key: action.actionId, label: action.title }))]) async function execute(key: string): Promise { - const item = items.value.find(item => item.key === key) - if (!item || props.disabled || busy.value) + const item = menus.value.find(item => item.key === key) + const action = actions.value.find(action => action.actionId === key) + const conversationId = props.taskId + if ((!item && !action) || props.disabled || busy.value) return const instanceId = scope?.instanceId() busy.value = true try { + if (action && conversationId) { + const result = await state.api.invokeTaskAction({ conversationId, extensionId: action.extensionId, actionId: action.actionId }) + if (!disposed && props.taskId === conversationId && result.status === 'skipped') + message.info(language.value === 'en-US' ? 'No changes applied' : '本次未作更改') + return + } + if (!item) + return const selection = props.capture?.() const result = await state.api.executeMenu(item.plugin.manifest.id, item.menu.id, { target: props.target, instanceId, resource: selection?.resource ?? null, ...(item.plugin.manifest.permissions.selectedContent && selection?.content !== undefined ? { content: selection.content } : {}) }) if (!disposed && !props.disabled && scope?.instanceId() === instanceId) diff --git a/apps/buddy/src/modules/settings/state/__tests__/usePluginSettingConditions.spec.ts b/apps/buddy/src/modules/settings/state/__tests__/usePluginSettingConditions.spec.ts new file mode 100644 index 00000000..cedb9139 --- /dev/null +++ b/apps/buddy/src/modules/settings/state/__tests__/usePluginSettingConditions.spec.ts @@ -0,0 +1,100 @@ +import type { ExtensionConditionsChanged, ExtensionConditionState } from '@buddy-shared/extensions/extensionConditions' +import type { PluginSettingField } from '../../model/settingsRegistry' +import { deferred } from '@buddy-tests/deferred' +import { expect, it, vi } from 'vitest' +import { effectScope, nextTick, shallowRef } from 'vue' +import { usePluginSettingConditions } from '../usePluginSettingConditions' + +it('keeps the last condition while drafts are evaluated, then applies false or unavailable results', async () => { + vi.useFakeTimers() + const scope = effectScope() + const field: PluginSettingField = { extensionId: 'tests.settings', revision: 'v1', item: { id: 'tests.settings.name', key: 'name', title: 'Name', description: '', type: 'string', group: 'settings.general.general', default: '', order: 0, enabledWhen: { condition: 'tests.settings.available', params: {} } } } + const reads: ReturnType>>[] = [] + const store = scope.run(() => usePluginSettingConditions({ + settingConditions: async () => { + const result = deferred>() + reads.push(result) + return result.promise + }, + onConditionsChanged: () => () => {}, + }, shallowRef({ 'tests.settings': { name: '' } })))! + try { + const remove = store.observe(field) + expect(store.states.value[store.key(field)]).toBeUndefined() + await vi.advanceTimersByTimeAsync(50) + reads[0]!.resolve({ [field.item.id]: { status: 'ready', value: true } }) + await vi.advanceTimersByTimeAsync(0) + for (const value of ['e', 'ed', 'editing']) { + store.draft(field, value) + expect(store.states.value[store.key(field)]).toEqual({ status: 'ready', value: true }) + await vi.advanceTimersByTimeAsync(50) + expect(store.states.value[store.key(field)]).toEqual({ status: 'ready', value: true }) + } + reads[1]!.resolve({ [field.item.id]: { status: 'ready', value: false } }) + await vi.advanceTimersByTimeAsync(0) + expect(store.states.value[store.key(field)]).toEqual({ status: 'ready', value: true }) + reads[3]!.resolve({ [field.item.id]: { status: 'ready', value: false, reason: 'Disabled' } }) + await vi.advanceTimersByTimeAsync(0) + expect(store.states.value[store.key(field)]).toEqual({ status: 'ready', value: false, reason: 'Disabled' }) + store.refresh(field.extensionId) + await vi.advanceTimersByTimeAsync(50) + reads[4]!.reject(new Error('unavailable')) + await vi.advanceTimersByTimeAsync(0) + expect(store.states.value[store.key(field)]).toEqual({ status: 'unavailable', value: false }) + remove() + store.observe({ ...field, revision: 'v2' }) + expect(store.states.value[store.key(field)]).toBeUndefined() + } + finally { + scope.stop() + vi.useRealTimers() + } +}) + +it('refreshes only mounted fields, overlays drafts and rejects responses from an older invalidation or disposed page', async () => { + vi.useFakeTimers() + const scope = effectScope() + const configurations = shallowRef({ 'tests.settings': { enabled: true, name: 'saved' } }) + const field: PluginSettingField = { extensionId: 'tests.settings', revision: 'v1', item: { id: 'tests.settings.name', key: 'name', title: 'Name', description: '', type: 'string', group: 'settings.general.general', default: '', order: 0, enabledWhen: { condition: 'tests.settings.available', params: {} } } } + const reads: { fields: readonly string[], form: unknown, result: ReturnType>> }[] = [] + let changed!: (event: ExtensionConditionsChanged) => void + let stopped = false + const store = scope.run(() => usePluginSettingConditions({ settingConditions: async (_id, fields, form) => { + const result = deferred>() + reads.push({ fields, form, result }) + return result.promise + }, onConditionsChanged: (listener) => { + changed = listener + return () => { + stopped = true + } + } }, configurations))! + try { + await vi.advanceTimersByTimeAsync(100) + expect(reads).toHaveLength(0) + const remove = store.observe(field) + await vi.advanceTimersByTimeAsync(50) + changed({ extensionId: 'tests.settings', revision: 1 }) + await vi.advanceTimersByTimeAsync(50) + reads[1]!.result.resolve({ [field.item.id]: { status: 'ready', value: false } }) + await nextTick() + await vi.advanceTimersByTimeAsync(0) + reads[0]!.result.resolve({ [field.item.id]: { status: 'ready', value: true } }) + await vi.advanceTimersByTimeAsync(0) + expect(store.states.value[store.key(field)]).toEqual({ status: 'ready', value: false }) + store.draft(field, 'editing') + await vi.advanceTimersByTimeAsync(50) + expect(reads[2]).toMatchObject({ fields: [field.item.id], form: { name: 'editing' } }) + expect(configurations.value['tests.settings'].name).toBe('saved') + remove() + reads[2]!.result.resolve({ [field.item.id]: { status: 'ready', value: true } }) + await vi.advanceTimersByTimeAsync(100) + expect(store.states.value).toEqual({}) + expect(reads).toHaveLength(3) + } + finally { + scope.stop() + vi.useRealTimers() + } + expect(stopped).toBe(true) +}) diff --git a/apps/buddy/src/modules/settings/state/__tests__/usePluginSettings.spec.ts b/apps/buddy/src/modules/settings/state/__tests__/usePluginSettings.spec.ts index 4b9e2e69..e1bc97ff 100644 --- a/apps/buddy/src/modules/settings/state/__tests__/usePluginSettings.spec.ts +++ b/apps/buddy/src/modules/settings/state/__tests__/usePluginSettings.spec.ts @@ -46,6 +46,8 @@ function fixture() { const reads: ReturnType>[] = [] const writes: ReturnType>[] = [] const store = scope.run(() => usePluginSettings(installed, { + settingConditions: async () => ({}), + onConditionsChanged: () => () => {}, configurationSnapshot: () => { const result = deferred() reads.push(result) diff --git a/apps/buddy/src/modules/settings/state/usePluginSettingConditions.ts b/apps/buddy/src/modules/settings/state/usePluginSettingConditions.ts new file mode 100644 index 00000000..42bc21c2 --- /dev/null +++ b/apps/buddy/src/modules/settings/state/usePluginSettingConditions.ts @@ -0,0 +1,85 @@ +import type { ExtensionApi } from '@buddy-shared/extensions/extensionApi' +import type { ExtensionConditionState } from '@buddy-shared/extensions/extensionConditions' +import type { ExtensionConfiguration, ExtensionSettingValue } from '@buddy-shared/extensions/extensionSettings' +import type { Ref } from 'vue' +import type { PluginSettingField } from '../model/settingsRegistry' +import { onScopeDispose, shallowRef, watch } from 'vue' + +export function usePluginSettingConditions(api: Pick, configurations: Readonly>>) { + const states = shallowRef>({}) + const fields = new Map() + const drafts = new Map() + const requests = new Map() + const scheduled = new Set() + let timer: ReturnType | undefined + let disposed = false + const key = (field: PluginSettingField) => `${field.extensionId}:${field.item.id}` + function refresh(id: string) { + if (disposed) + return + const mounted = [...fields.values()].filter(field => field.extensionId === id && field.item.enabledWhen) + requests.set(id, (requests.get(id) ?? 0) + 1) + if (!mounted.length) + return + scheduled.add(id) + timer ??= setTimeout(() => { + timer = undefined + const ids = [...scheduled] + scheduled.clear() + for (const id of ids) void evaluate(id) + }, 50) + } + async function evaluate(id: string) { + const mounted = [...fields.values()].filter(field => field.extensionId === id && field.item.enabledWhen) + if (!mounted.length || disposed) + return + const request = requests.get(id) + const result = await api.settingConditions(id, [...new Set(mounted.map(field => field.item.id))], drafts.get(id)).catch(() => ({} as Record)) + if (disposed || request !== requests.get(id)) + return + states.value = { ...states.value, ...Object.fromEntries(mounted.map(field => [key(field), result[field.item.id] ?? { status: 'unavailable', value: false }])) } + } + function draft(field: PluginSettingField, value: ExtensionSettingValue | undefined) { + const values = { ...drafts.get(field.extensionId) } + if (value === undefined || JSON.stringify(value) === JSON.stringify(configurations.value[field.extensionId]?.[field.item.key])) + delete values[field.item.key] + else values[field.item.key] = value + if (JSON.stringify(values) === JSON.stringify(drafts.get(field.extensionId) ?? {})) + return + drafts.set(field.extensionId, values) + refresh(field.extensionId) + } + function observe(field: PluginSettingField) { + const token = Symbol('setting-condition') + fields.set(token, field) + refresh(field.extensionId) + return () => { + fields.delete(token) + if (![...fields.values()].some(current => key(current) === key(field))) { + const next = { ...states.value } + delete next[key(field)] + states.value = next + draft(field, undefined) + } + refresh(field.extensionId) + if (![...fields.values()].some(current => current.extensionId === field.extensionId)) { + drafts.delete(field.extensionId) + scheduled.delete(field.extensionId) + } + } + } + watch(() => JSON.stringify(configurations.value), () => { + for (const id of new Set([...fields.values()].map(field => field.extensionId))) refresh(id) + }) + const stop = api.onConditionsChanged(event => refresh(event.extensionId)) + onScopeDispose(() => { + disposed = true + stop() + clearTimeout(timer) + fields.clear() + drafts.clear() + requests.clear() + scheduled.clear() + }) + return { states, key, observe, draft, refresh } +} diff --git a/apps/buddy/src/modules/settings/state/usePluginSettings.ts b/apps/buddy/src/modules/settings/state/usePluginSettings.ts index 5e748dbe..598ef795 100644 --- a/apps/buddy/src/modules/settings/state/usePluginSettings.ts +++ b/apps/buddy/src/modules/settings/state/usePluginSettings.ts @@ -3,6 +3,7 @@ import type { ExtensionConfiguration, ExtensionConfigurationSnapshot, ExtensionS import type { Ref } from 'vue' import type { PluginSettingField } from '../model/settingsRegistry' import { computed, onScopeDispose, shallowRef, watch } from 'vue' +import { usePluginSettingConditions } from './usePluginSettingConditions' import { activeSettingsExtensions } from './useSettingsRegistry' interface ConfigurationSession { @@ -12,9 +13,10 @@ interface ConfigurationSession { loading: boolean } -export function usePluginSettings(installed: Readonly>, api: Pick) { +export function usePluginSettings(installed: Readonly>, api: Pick) { const snapshots = shallowRef>({}) const configurations = computed>(() => Object.fromEntries(Object.entries(snapshots.value).map(([id, snapshot]) => [id, snapshot.values]))) + const conditions = usePluginSettingConditions(api, configurations) const invalidKeys = computed>(() => Object.fromEntries(Object.entries(snapshots.value).map(([id, snapshot]) => [id, snapshot.invalidKeys]))) const errors = shallowRef>(new Set()) const pending = shallowRef>(new Set()) @@ -113,10 +115,11 @@ export function usePluginSettings(installed: Readonly>, a return 'failed' } finally { + conditions.draft(field, undefined) session.saving = false syncPending() } } - return { configurations, invalidKeys, errors, pending, loading, save, reload } + return { conditions, configurations, invalidKeys, errors, pending, loading, save, reload } } export type PluginSettings = ReturnType diff --git a/apps/buddy/src/modules/settings/widgets/plugins/DesktopPluginSetting.vue b/apps/buddy/src/modules/settings/widgets/plugins/DesktopPluginSetting.vue index 6f1dea57..f0f9ff56 100644 --- a/apps/buddy/src/modules/settings/widgets/plugins/DesktopPluginSetting.vue +++ b/apps/buddy/src/modules/settings/widgets/plugins/DesktopPluginSetting.vue @@ -2,15 +2,21 @@ import type { ExtensionSettingValue } from '@buddy-shared/extensions/extensionSettings' import type { PluginSettingField } from '../../model/settingsRegistry' import { useMessage } from 'naive-ui' +import { computed, watch } from 'vue' import { useBuddyI18n } from '@/i18n/buddyI18n' import { useSettingsContext } from '../../settingsContext' import DesktopPluginSettingField from './DesktopPluginSettingField.vue' const props = defineProps<{ field: PluginSettingField }>() const { pluginSettings, providerSettings, applicationSettings } = useSettingsContext() -const { configurations, invalidKeys, pending } = pluginSettings +const { conditions, configurations, invalidKeys, pending } = pluginSettings const { t } = useBuddyI18n(applicationSettings.language) const message = useMessage() +watch(() => props.field, (field, _old, cleanup) => cleanup(conditions.observe(field)), { immediate: true }) +const condition = computed(() => props.field.item.enabledWhen ? conditions.states.value[conditions.key(props.field)] : { status: 'ready' as const, value: true }) +const invalid = computed(() => invalidKeys.value[props.field.extensionId]?.includes(props.field.item.key) ?? false) +const saving = computed(() => pending.value.has(props.field.extensionId) || !configurations.value[props.field.extensionId]) +const reason = computed(() => condition.value?.reason ?? (condition.value?.status === 'unavailable' ? (applicationSettings.language.value === 'zh-CN' ? '暂时无法判断此项是否可用,请重试。' : 'Availability could not be determined. Please retry.') : undefined)) async function save(value: ExtensionSettingValue) { if (await pluginSettings.save(props.field, value) === 'failed') message.error(t('desktop.settings.saveFailed')) @@ -21,11 +27,17 @@ async function save(value: ExtensionSettingValue) { diff --git a/apps/buddy/src/modules/settings/widgets/plugins/DesktopPluginSettingField.vue b/apps/buddy/src/modules/settings/widgets/plugins/DesktopPluginSettingField.vue index 6a7130bc..fed12f97 100644 --- a/apps/buddy/src/modules/settings/widgets/plugins/DesktopPluginSettingField.vue +++ b/apps/buddy/src/modules/settings/widgets/plugins/DesktopPluginSettingField.vue @@ -10,16 +10,20 @@ const props = defineProps<{ item: ExtensionSettingItem value: ExtensionSettingValue disabled: boolean + saving?: boolean + repairDisabled?: boolean + conditionReason?: string + conditionUnavailable?: boolean invalid: boolean models: readonly LocalRuntimeModelOption[] providers: readonly LocalProvider[] language: BuddyLocale }>() -const emit = defineEmits<{ change: [value: ExtensionSettingValue] }>() +const emit = defineEmits<{ change: [value: ExtensionSettingValue], draft: [value: ExtensionSettingValue], retry: [] }>() const labelId = useId() const draft = shallowRef(props.value) -watch([() => props.value, () => props.disabled, () => props.item.id], ([value, disabled]) => { - if (!disabled) +watch([() => props.value, () => props.saving, () => props.item.id], ([value, saving]) => { + if (!saving) draft.value = value }) function commit(value: ExtensionSettingValue) { @@ -38,17 +42,23 @@ function commit(value: ExtensionSettingValue) { {{ item.description }} {{ language === 'zh-CN' ? '原值已保留,但不符合当前版本的要求。请修改此项或恢复默认值。' : 'The saved value is preserved but is incompatible with this version. Change this field or restore its default.' }} - + {{ language === 'zh-CN' ? '恢复默认值' : 'Restore default' }} + + {{ conditionReason }} + + {{ language === 'zh-CN' ? '重试' : 'Retry' }} + +
- +
diff --git a/apps/buddy/src/modules/settings/widgets/plugins/__tests__/DesktopPluginSettingField.spec.ts b/apps/buddy/src/modules/settings/widgets/plugins/__tests__/DesktopPluginSettingField.spec.ts new file mode 100644 index 00000000..38478635 --- /dev/null +++ b/apps/buddy/src/modules/settings/widgets/plugins/__tests__/DesktopPluginSettingField.spec.ts @@ -0,0 +1,44 @@ +// @vitest-environment jsdom +import { expect, it } from 'vitest' +import { createApp, h, nextTick, shallowRef } from 'vue' +import DesktopPluginSettingField from '../DesktopPluginSettingField.vue' + +it('preserves a text draft through condition recalculation and resets it only at the save boundary', async () => { + const disabled = shallowRef(false) + const saving = shallowRef(false) + const saved = shallowRef('saved') + const root = document.createElement('div') + document.body.append(root) + const app = createApp({ render: () => h(DesktopPluginSettingField, { + item: { id: 'tests.editor.name', key: 'name', group: 'settings.general.general', type: 'string', title: 'Name', description: '', default: '', order: 0 }, + value: saved.value, + disabled: disabled.value, + saving: saving.value, + invalid: false, + models: [], + providers: [], + language: 'zh-CN', + }) }) + app.mount(root) + try { + const input = root.querySelector('input')! + input.value = 'unfinished draft' + input.dispatchEvent(new Event('input', { bubbles: true })) + await nextTick() + disabled.value = true + await nextTick() + disabled.value = false + await nextTick() + expect(input.value).toBe('unfinished draft') + saving.value = true + await nextTick() + saved.value = 'committed' + saving.value = false + await nextTick() + expect(input.value).toBe('committed') + } + finally { + app.unmount() + root.remove() + } +}) diff --git a/apps/buddy/src/modules/tasks/model/transcript/__tests__/chatAgentActivities.spec.ts b/apps/buddy/src/modules/tasks/model/transcript/__tests__/chatAgentActivities.spec.ts index a23f78ce..6f6a1aa2 100644 --- a/apps/buddy/src/modules/tasks/model/transcript/__tests__/chatAgentActivities.spec.ts +++ b/apps/buddy/src/modules/tasks/model/transcript/__tests__/chatAgentActivities.spec.ts @@ -4,6 +4,7 @@ import { presentChatActivityLayout } from '../chatActivityLayout' import { reasoningPreview, summarizeChatActivity, summarizeChatActivityCounts } from '../chatActivitySummary' import { createChatAgentActivityProjector } from '../chatAgentActivities' import { canExpandChatTool, describeChatTool } from '../chatToolDisplay' +import { projectExtensionActionTool } from '../chatTranscriptActivities' describe('activity grouping', () => { it('keeps the beginning in historical previews and follows the latest visible content while thinking', () => { @@ -152,11 +153,13 @@ describe('tool display registration', () => { expect(describeChatTool(node, 'en-US').label).toBe(english) }) - it('keeps a structured operation label and icon together when an extension supplies the card', () => { + it('keeps external calls generic even when their output uses a built-in card', () => { const presentation = { card: 'diff', operation: 'created', path: 'output.txt', diff: null, firstChangedLine: null, description: null, output: null, truncated: false } as const const node: ChatAgentToolNode = { ...tool('extension'), toolName: 'extension_file', presentation } - expect(describeChatTool(node, 'zh-CN')).toMatchObject({ label: '创建文件', icon: 'create', target: 'output.txt' }) - expect(describeChatTool({ ...node, presentation: { ...presentation, operation: 'edited' } }, 'zh-CN')).toMatchObject({ label: '编辑文件', icon: 'edit' }) + expect(describeChatTool(node, 'zh-CN')).toMatchObject({ label: '工具调用', icon: 'tool', target: 'extension_file' }) + expect(describeChatTool({ ...node, presentation: { ...presentation, operation: 'edited' } }, 'zh-CN')).toMatchObject({ label: '工具调用', icon: 'tool', target: 'extension_file' }) + const reads = [tool('one'), tool('two')].map(node => ({ ...node, toolName: 'external_read' })) + expect(presentChatActivityLayout(reads).compact.size).toBe(0) }) it('omits argument names from output presentation while keeping a meaningful description', () => { @@ -173,13 +176,25 @@ describe('tool display registration', () => { it('uses a registered runtime label when no localized display registration exists', () => { const node: ChatAgentToolNode = { ...tool('external'), toolName: 'external_tool', toolLabel: 'Query local data', presentation: { card: 'generic', argumentNames: [], description: null, output: null, truncated: false } } - expect(describeChatTool(node, 'zh-CN').label).toBe('Query local data') + expect(describeChatTool(node, 'zh-CN')).toMatchObject({ label: '工具调用', icon: 'tool', target: 'Query local data', context: '' }) + expect(describeChatTool(node, 'en-US')).toMatchObject({ label: 'Tool call', target: 'Query local data' }) + }) + + it('uses the same heading for connector calls and independent plugin actions', () => { + const node: ChatAgentToolNode = { ...tool('mcp'), toolName: 'mcp_server_lookup', presentation: { card: 'connector', connector: 'Local data', tool: 'lookup', argumentNames: [], description: null, output: 'found', truncated: false } } + expect(describeChatTool(node, 'zh-CN')).toMatchObject({ label: '工具调用', icon: 'tool', target: 'lookup', context: '' }) + const action = projectExtensionActionTool({ kind: 'extension-action', id: 'action', conversationId: 'conversation', branchId: 'branch', sourceMessageId: 'source', extensionId: 'tests.title', extensionName: '标题自动生成', actionId: 'tests.title.generate', title: '重新生成标题', trigger: 'user', status: 'completed', message: '已更新', createdAt: '2026-09-29T00:00:00.000Z', completedAt: '2026-09-29T00:00:01.000Z' }) + expect(describeChatTool(action, 'zh-CN')).toMatchObject({ label: '工具调用', icon: 'tool', target: '重新生成标题', context: '', status: '已完成' }) + const group = createChatAgentActivityProjector().project([action, thought('checking'), node, tool('read')])[0]! + expect(group).toMatchObject({ toolCount: 3, counts: [{ category: 'other', count: 2 }, { category: 'read', count: 1 }] }) + if (group.kind === 'activity-group') + expect(summarizeChatActivity(group, 'zh-CN').label).toBe('读取 1 个文件 · 工具调用 2 次') }) it('does not expose denied output and preserves an unfamiliar tool name', () => { const node: ChatAgentToolNode = { ...tool('external', 'denied'), toolName: 'external_tool', presentation: { card: 'generic', argumentNames: [], description: null, output: 'not authorized', truncated: false } } expect(canExpandChatTool(node)).toBe(false) - expect(describeChatTool(node, 'zh-CN')).toMatchObject({ label: 'external_tool', status: '未获批准' }) + expect(describeChatTool(node, 'zh-CN')).toMatchObject({ label: '工具调用', target: 'external_tool', status: '未获批准' }) expect(canExpandChatTool({ ...node, presentation: { card: 'diff', operation: 'edited', path: 'private.txt', diff: '+1 private', firstChangedLine: 1, output: null, truncated: false, description: null } })).toBe(false) }) }) 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 2b130b85..24c72647 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 @@ -26,6 +26,9 @@ const run: LocalRun = { 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) } } +function action(seconds: number, overrides: Partial> = {}): Extract { + return { kind: 'extension-action', id: `action-${seconds}`, conversationId: run.conversationId, branchId: run.branchId, sourceMessageId: run.triggeringMessageId, extensionId: 'tests.title', extensionName: 'Title', actionId: 'tests.title.generate', title: 'Generate title', trigger: 'task:input:committed', status: 'completed', message: 'Updated', createdAt: time(seconds), completedAt: time(seconds + 1), ...overrides } +} const eventInputs: Array<[number, string, LocalRunEvent['payload']]> = [ [1, 'message.completed', { role: 'assistant', messageId: 'before', phase: 'commentary', content: { text: 'before steering' } }], [30, 'message.completed', { role: 'assistant', messageId: 'between', phase: 'commentary', content: { text: 'after steering' } }], @@ -44,6 +47,52 @@ function order(projection: ReturnType) { } describe('interleaved conversation segments', () => { + it('keeps the avatar before leading actions while the first reply arrives and when it is cancelled', () => { + const items = [message('initial', 'user', 0), action(1), action(2)] + 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, 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)) + expect(ended.rows.filter(row => row.kind === 'agent-turn' && row.showIdentity !== false).map(row => row.key)).toEqual(['agent-turn:run']) + expect(ended.rows.at(-1)).toMatchObject({ kind: 'agent-turn', ownsResultActions: true }) + }) + + it('retains a single avatar above a leading action for a completed text-only reply', () => { + const completed = { ...run, status: 'completed' as const, completedAt: time(5) } + const reply: LocalRunEvent = { runId: run.id, sequence: 1, createdAt: time(5), type: 'message.completed', payload: { role: 'assistant', messageId: 'reply', phase: 'final_answer', content: { text: 'Done' } } } + const result = projectChatTranscript(input(completed, [reply], [message('initial', 'user', 0), action(1), message('reply', 'assistant', 5)])) + expect(result.rows.map(row => row.key)).toEqual(['message:initial', 'agent-turn:run', 'message:reply']) + expect(result.rows.at(-1)).toMatchObject({ showIdentity: false, resultRunId: run.id }) + }) + + it('keeps later actions after the answer and does not associate another branch or input with the reply', () => { + const completed = { ...run, status: 'completed' as const, completedAt: time(5) } + const reply: LocalRunEvent = { runId: run.id, sequence: 1, createdAt: time(5), type: 'message.completed', payload: { role: 'assistant', messageId: 'reply', phase: 'final_answer', content: { text: 'Done' } } } + for (const invocation of [action(6), action(1, { branchId: 'another-branch' }), action(1, { sourceMessageId: 'another-input' }), action(1, { status: 'skipped' })]) { + const result = projectChatTranscript(input(completed, [reply], [message('initial', 'user', 0), invocation, message('reply', 'assistant', 5)])) + expect(result.rows.filter(row => row.kind === 'agent-turn')).toHaveLength(0) + expect(result.rows.filter(row => row.kind === 'message').find(row => row.message.id === 'reply')?.showIdentity).not.toBe(false) + if (invocation.createdAt > reply.createdAt) + expect(result.rows.at(-1)).toMatchObject({ kind: 'activity-flow' }) + } + }) + + it('groups adjacent independent actions while preserving input boundaries and completed outcomes', () => { + const completed = { ...run, status: 'cancelled' as const, completedAt: time(5) } + const result = projectChatTranscript(input(completed, [], [message('initial', 'user', 0), action(6), action(7), message('next', 'user', 8), action(9, { sourceMessageId: 'next' })])) + expect(result.rows.map(row => row.kind)).toEqual(['message', 'agent-turn', 'activity-flow', 'message', 'activity-flow']) + expect(result.rows[2]).toMatchObject({ nodes: [{ invocation: { id: 'action-6' } }, { invocation: { id: 'action-7' } }] }) + expect(result.rows[4]).toMatchObject({ nodes: [{ invocation: { sourceMessageId: 'next' } }] }) + }) + it('keeps later activity below an intermediate answer and withholds run result controls while running', () => { const projection = projectChatTranscript(input()) expect(order(projection)).toEqual(['initial', 'before', 'steer-a', 'steer-b', 'between', 'hello', 'continuation']) diff --git a/apps/buddy/src/modules/tasks/model/transcript/chatActivityLayout.ts b/apps/buddy/src/modules/tasks/model/transcript/chatActivityLayout.ts index 6607b5a1..e6d9c544 100644 --- a/apps/buddy/src/modules/tasks/model/transcript/chatActivityLayout.ts +++ b/apps/buddy/src/modules/tasks/model/transcript/chatActivityLayout.ts @@ -1,6 +1,6 @@ import type { ChatAgentActivityGroup } from './chatAgentActivities' import type { ChatAgentToolNode } from './chatAgentTurn' -import { getChatToolRegistration } from './chatToolRegistry' +import { getChatToolRegistration, isRegisteredChatTool } from './chatToolRegistry' interface ChatToolDetailsEntry { id: string @@ -43,7 +43,7 @@ export function presentChatActivityLayout(nodes: ChatAgentActivityGroup['nodes'] } for (const node of nodes) { - if (node.kind === 'tool' && node.presentation.card === 'read' && node.presentation.path + if (node.kind === 'tool' && isRegisteredChatTool(node.toolName) && node.presentation.card === 'read' && node.presentation.path && node.status === 'completed' && !node.isError) { const icon = getChatToolRegistration(node).icon if (readIcon !== icon) diff --git a/apps/buddy/src/modules/tasks/model/transcript/chatAgentTurn.ts b/apps/buddy/src/modules/tasks/model/transcript/chatAgentTurn.ts index 1cc91f29..73060798 100644 --- a/apps/buddy/src/modules/tasks/model/transcript/chatAgentTurn.ts +++ b/apps/buddy/src/modules/tasks/model/transcript/chatAgentTurn.ts @@ -1,4 +1,5 @@ import type { ContextPanelOperation } from '@buddy-shared/context-panel/contextPanel' +import type { ExtensionActionTimelineItem } from '@buddy-shared/extensions/extensionActionApi' import type { LocalRun, LocalRunEvent } from '@buddy-shared/runs/runApi' import type { BuddyToolPresentation } from '@buddy-shared/runs/runEventPresentation' @@ -42,10 +43,12 @@ export interface ChatAgentToolNode { description: string | null id: string isError: boolean + invocation?: Readonly kind: 'tool' presentation: BuddyToolPresentation - status: 'awaiting_approval' | 'completed' | 'denied' | 'failed' | 'interrupted' | 'preparing' | 'running' - toolCallId: string + progressPlacement?: 'inline' + status: 'awaiting_approval' | 'cancelled' | 'completed' | 'denied' | 'failed' | 'interrupted' | 'preparing' | 'running' | 'skipped' + toolCallId?: string toolName: string toolLabel?: string } @@ -112,7 +115,7 @@ export function createChatAgentTurnReducer( const compactions = createChatRunCompactionReducer(run.id) const usage = createChatRunTokenUsageReducer(run.id) const reasoning = new Map() - const tools = new Map() + const tools = new Map() const approvalTools = new Map() const text = new Map() const panels = new Map() diff --git a/apps/buddy/src/modules/tasks/model/transcript/chatIncrementalTranscript.ts b/apps/buddy/src/modules/tasks/model/transcript/chatIncrementalTranscript.ts index 145fd6e5..268c3332 100644 --- a/apps/buddy/src/modules/tasks/model/transcript/chatIncrementalTranscript.ts +++ b/apps/buddy/src/modules/tasks/model/transcript/chatIncrementalTranscript.ts @@ -213,12 +213,16 @@ export function createChatTranscriptProjectionCache( }) const runProjections = input.runProjections ?? null const interleavedRunIds = new Set(input.runs.filter(run => input.timelineItems.some(item => ( - item.kind === 'message' - && item.role === 'user' - && item.id !== run.triggeringMessageId - && item.branchId === run.branchId - && item.createdAt >= run.startedAt - && (run.completedAt === null || item.createdAt <= run.completedAt) + item.kind === 'extension-action' + ? item.branchId === run.branchId + && item.sourceMessageId === run.triggeringMessageId + && (item.status !== 'skipped' || item.trigger === 'user') + : item.kind === 'message' + && item.role === 'user' + && item.id !== run.triggeringMessageId + && item.branchId === run.branchId + && item.createdAt >= run.startedAt + && (run.completedAt === null || item.createdAt <= run.completedAt) ))).map(run => run.id)) const seenTurns = new Set() for (const row of projection.rows) { diff --git a/apps/buddy/src/modules/tasks/model/transcript/chatMessageTime.ts b/apps/buddy/src/modules/tasks/model/transcript/chatMessageTime.ts index cf5f793c..2ddd3356 100644 --- a/apps/buddy/src/modules/tasks/model/transcript/chatMessageTime.ts +++ b/apps/buddy/src/modules/tasks/model/transcript/chatMessageTime.ts @@ -65,9 +65,11 @@ export function projectChatTranscriptDisplayRows( ) const dayAnchorAt = row.kind === 'agent-turn' ? row.turn.startedAt - : row.kind === 'message' && !assistantMessageBelongsToAgentTurn - ? row.message.createdAt - : null + : row.kind === 'activity-flow' + ? row.createdAt + : row.kind === 'message' && !assistantMessageBelongsToAgentTurn + ? row.message.createdAt + : null if (dayAnchorAt) { const dayKey = dayjs(dayAnchorAt).format('YYYY-MM-DD') if (dayKey !== previousDayKey) { diff --git a/apps/buddy/src/modules/tasks/model/transcript/chatPersistedTranscriptRows.ts b/apps/buddy/src/modules/tasks/model/transcript/chatPersistedTranscriptRows.ts index c7a806f9..4ea1b1ad 100644 --- a/apps/buddy/src/modules/tasks/model/transcript/chatPersistedTranscriptRows.ts +++ b/apps/buddy/src/modules/tasks/model/transcript/chatPersistedTranscriptRows.ts @@ -4,9 +4,8 @@ import type { LocalConversationTimelineItem, LocalMessage } from '@buddy-shared/ import type { LocalRunOutput } from '@buddy-shared/runs/runApi' import type { ChatAgentTurn } from './chatAgentTurn' +import type { PersistedChatTranscriptRow, PresentedChatTranscriptRow } from './chatTranscriptActivities' import type { - ChatTranscriptAgentTurnRow, - ChatTranscriptCompactionRow, ChatTranscriptMessageRow, ChatTranscriptTurnOutputs, } from './chatTranscriptTypes' @@ -20,16 +19,8 @@ export function projectPersistedChatTranscriptRows( outputs: ReadonlyArray = [], changeSets: ReadonlyArray = [], includeUnanchoredTurns = false, -): Array< - ChatTranscriptAgentTurnRow - | ChatTranscriptCompactionRow - | ChatTranscriptMessageRow -> { - const rows: Array< - ChatTranscriptAgentTurnRow - | ChatTranscriptCompactionRow - | ChatTranscriptMessageRow - > = [] +): PresentedChatTranscriptRow[] { + const rows: PersistedChatTranscriptRow[] = [] const turnsByTrigger = new Map() const turnsByRunId = new Map(turns.map(turn => [turn.runId, turn])) const outputsByRunId = projectTurnOutputs(outputs) @@ -53,8 +44,15 @@ export function projectPersistedChatTranscriptRows( })) const processMessageIds = new Set(turns.flatMap(turn => turn.processMessageIds)) const timelineMessageIds = new Set(items.filter(item => item.kind === 'message').map(item => item.id)) + const visibleActions = items.filter((item): item is Extract => item.kind === 'extension-action' && (item.status !== 'skipped' || item.trigger === 'user')) + const messageCreatedAt = new Map(items.filter(item => item.kind === 'message').map(item => [item.id, item.createdAt])) for (const turn of [...turns].sort((left, right) => left.startedAt.localeCompare(right.startedAt) || left.runId.localeCompare(right.runId))) { - if (!shouldShowAgentTurn(turn)) + const responseStartedAt = turn.finalMessageId + ? turn.messageStartedAt?.[turn.finalMessageId] ?? messageCreatedAt.get(turn.finalMessageId) + : null + const hasLeadingAction = responseStartedAt && visibleActions.some(action => action.sourceMessageId === turn.triggeringMessageId + && action.branchId === turn.branchId && action.createdAt <= responseStartedAt) + if (!shouldShowAgentTurn(turn) && !hasLeadingAction) continue if (includeUnanchoredTurns && !timelineMessageIds.has(turn.triggeringMessageId)) { rows.push({ @@ -71,6 +69,11 @@ export function projectPersistedChatTranscriptRows( } for (const item of items) { + if (item.kind === 'extension-action') { + if (item.status !== 'skipped' || item.trigger === 'user') + rows.push({ action: item, key: `extension-action:${item.id}`, kind: 'extension-action' }) + continue + } if (item.kind === 'compaction') { if (projectConversationCompactionState(item.status, item.errorCode) === 'not_needed') continue diff --git a/apps/buddy/src/modules/tasks/model/transcript/chatToolDisplay.ts b/apps/buddy/src/modules/tasks/model/transcript/chatToolDisplay.ts index 8dbd01ab..5b17e592 100644 --- a/apps/buddy/src/modules/tasks/model/transcript/chatToolDisplay.ts +++ b/apps/buddy/src/modules/tasks/model/transcript/chatToolDisplay.ts @@ -36,15 +36,17 @@ export function describeChatTool(node: ChatAgentToolNode, language: BuddyLocale) const t: BuddyTranslate = (key, params) => translateBuddy(language, key, params) const registration = getChatToolRegistration(node) const p = node.presentation + if (!isRegisteredChatTool(node.toolName)) { + const name = node.toolLabel && node.toolLabel !== node.toolName + ? node.toolLabel + : p.card === 'connector' ? p.tool : node.toolName + return { label: t('tool.use'), icon: 'tool' as const, target: name, fullTarget: name, context: '', status: toolStatus(node, language, t) } + } let label = t(registration.label) if (p.card === 'automation') label = t(automationLabels[p.operation]) else if (p.card === 'system') label = translateSystemAction(language, p.action) - else if (p.card === 'connector') - label = node.toolLabel && node.toolLabel !== node.toolName ? node.toolLabel : p.tool - else if (p.card === 'generic' && !isRegisteredChatTool(node.toolName)) - label = node.toolLabel ?? node.toolName const rawTarget = toolTarget(node, t) const target = rawTarget === label ? '' : rawTarget @@ -89,6 +91,10 @@ function toolTarget(node: ChatAgentToolNode, t: BuddyTranslate): string { } function toolStatus(node: ChatAgentToolNode, language: BuddyLocale, t: BuddyTranslate): string { + if (node.status === 'cancelled') + return t('run.status.cancelled') + if (node.status === 'skipped') + return t('desktop.chat.processToolSkipped') if (node.errorCode === 'PATH_NOT_FOUND') { if (node.toolName === 'ls' || node.toolName === 'find') return t('desktop.chat.processToolDirectoryNotFound') diff --git a/apps/buddy/src/modules/tasks/model/transcript/chatToolRegistry.ts b/apps/buddy/src/modules/tasks/model/transcript/chatToolRegistry.ts index 6094b9e8..63df0e41 100644 --- a/apps/buddy/src/modules/tasks/model/transcript/chatToolRegistry.ts +++ b/apps/buddy/src/modules/tasks/model/transcript/chatToolRegistry.ts @@ -2,7 +2,7 @@ import type { BuddyToolPresentation } from '@buddy-shared/runs/runEventPresentat import type { ChatAgentToolNode } from './chatAgentTurn' import type { BuddyI18nKey } from '@/i18n/buddyI18n' -export type ChatToolIcon = 'activity' | 'artifact' | 'authorization' | 'automation' | 'browser' | 'browser-act' | 'browser-open' | 'browser-snapshot' | 'connector' | 'create' | 'directory' | 'edit' | 'file' | 'image' | 'image-edit' | 'pet' | 'search' | 'skill' | 'terminal' | 'tool' +export type ChatToolIcon = 'activity' | 'artifact' | 'authorization' | 'automation' | 'browser' | 'browser-act' | 'browser-open' | 'browser-snapshot' | 'connector' | 'create' | 'directory' | 'edit' | 'file' | 'image' | 'image-edit' | 'pet' | 'search' | 'skill' | 'system' | 'terminal' | 'tool' export type ChatToolCategory = 'read' | 'search' | 'command' | 'create' | 'edit' | 'web' | 'other' interface ChatToolRegistration { @@ -22,7 +22,7 @@ const cards = { 'image': { category: 'other', icon: 'image', label: 'desktop.chat.processToolImage' }, 'pet': { category: 'other', icon: 'pet', label: 'desktop.chat.processToolPet' }, 'automation': { category: 'other', icon: 'automation', label: 'desktop.chat.processToolAutomation' }, - 'system': { category: 'other', icon: 'tool', label: 'desktop.chat.processToolSystemAction' }, + 'system': { category: 'other', icon: 'system', label: 'desktop.chat.processToolSystemAction' }, 'directory-authorization': { category: 'other', icon: 'authorization', label: 'desktop.chat.processToolDirectoryAuthorization' }, 'generic': { category: 'other', icon: 'tool', label: 'tool.use' }, } as const satisfies Record @@ -72,6 +72,8 @@ const builtins: Readonly> = { } export function getChatToolRegistration(node: ChatAgentToolNode): ChatToolRegistration { + if (!isRegisteredChatTool(node.toolName)) + return cards.generic const p = node.presentation if (p.card === 'read' && /(?:^|[/\\])[^/\\]+[/\\]SKILL\.md$/.test(p.path)) return skillRead @@ -81,7 +83,7 @@ export function getChatToolRegistration(node: ChatAgentToolNode): ChatToolRegist return webOperations[p.operation] if (p.card === 'browser') return browserOperations[p.operation] - return Object.hasOwn(builtins, node.toolName) ? builtins[node.toolName]! : cards[p.card] + return builtins[node.toolName]! } export function isRegisteredChatTool(toolName: string): boolean { diff --git a/apps/buddy/src/modules/tasks/model/transcript/chatTranscriptActivities.ts b/apps/buddy/src/modules/tasks/model/transcript/chatTranscriptActivities.ts new file mode 100644 index 00000000..3e3b7a7d --- /dev/null +++ b/apps/buddy/src/modules/tasks/model/transcript/chatTranscriptActivities.ts @@ -0,0 +1,60 @@ +import type { ExtensionActionTimelineItem } from '@buddy-shared/extensions/extensionActionApi' +import type { ChatAgentToolNode, ChatAgentTurn } from './chatAgentTurn' +import type { ChatTranscriptActivityFlowRow, ChatTranscriptAgentTurnRow, ChatTranscriptCompactionRow, ChatTranscriptExtensionActionRow, ChatTranscriptMessageRow } from './chatTranscriptTypes' + +export type PersistedChatTranscriptRow = ChatTranscriptAgentTurnRow | ChatTranscriptCompactionRow | ChatTranscriptExtensionActionRow | ChatTranscriptMessageRow +export type PresentedChatTranscriptRow = ChatTranscriptAgentTurnRow | ChatTranscriptCompactionRow | ChatTranscriptActivityFlowRow | ChatTranscriptMessageRow + +export function projectExtensionActionTool(invocation: Readonly): ChatAgentToolNode { + return { + id: `extension-action:${invocation.id}`, + kind: 'tool', + invocation, + toolName: invocation.actionId, + toolLabel: invocation.title, + description: null, + status: invocation.status, + isError: invocation.status === 'failed' || invocation.status === 'interrupted', + progressPlacement: 'inline', + presentation: { card: 'generic', argumentNames: [], description: null, output: invocation.message, truncated: false }, + } +} + +export function mergeChatTranscriptActivities(rows: readonly PersistedChatTranscriptRow[]): PresentedChatTranscriptRow[] { + const presented: PresentedChatTranscriptRow[] = [] + for (const row of rows) { + const previous = presented.at(-1) + if (row.kind === 'extension-action') { + const node = projectExtensionActionTool(row.action) + if (previous?.kind === 'agent-turn' && belongsToTurn(row.action, previous.turn)) { + presented[presented.length - 1] = { ...previous, turn: { ...previous.turn, nodes: [...previous.turn.nodes, node] } } + } + else if (previous?.kind === 'activity-flow' && previous.nodes.every(member => member.invocation?.branchId === row.action.branchId + && member.invocation.sourceMessageId === row.action.sourceMessageId)) { + presented[presented.length - 1] = { ...previous, nodes: [...previous.nodes, node] } + } + else { + presented.push({ kind: 'activity-flow', key: row.key, createdAt: row.action.createdAt, nodes: [node] }) + } + continue + } + if (row.kind === 'agent-turn') { + if (previous?.kind === 'agent-turn' && previous.turn.runId === row.turn.runId) { + presented[presented.length - 1] = { ...row, key: previous.key, turn: { ...row.turn, nodes: [...previous.turn.nodes, ...row.turn.nodes] } } + continue + } + if (previous?.kind === 'activity-flow' && previous.nodes.every(node => node.invocation && belongsToTurn(node.invocation, row.turn))) { + presented[presented.length - 1] = { ...row, turn: { ...row.turn, nodes: [...previous.nodes, ...row.turn.nodes] } } + continue + } + } + presented.push(row) + } + return presented +} + +function belongsToTurn(action: Readonly, turn: ChatAgentTurn): boolean { + return action.sourceMessageId === turn.triggeringMessageId + && action.branchId === turn.branchId + && (!turn.completedAt || action.createdAt <= turn.completedAt) +} diff --git a/apps/buddy/src/modules/tasks/model/transcript/chatTranscriptSegments.ts b/apps/buddy/src/modules/tasks/model/transcript/chatTranscriptSegments.ts index e10aa031..f4699f72 100644 --- a/apps/buddy/src/modules/tasks/model/transcript/chatTranscriptSegments.ts +++ b/apps/buddy/src/modules/tasks/model/transcript/chatTranscriptSegments.ts @@ -1,15 +1,15 @@ import type { ChatAgentTurnNode } from './chatAgentTurn' -import type { ChatTranscriptAgentTurnRow, ChatTranscriptCompactionRow, ChatTranscriptMessageRow, ChatTranscriptRow } from './chatTranscriptTypes' - -type PersistedRow = ChatTranscriptAgentTurnRow | ChatTranscriptCompactionRow | ChatTranscriptMessageRow +import type { PersistedChatTranscriptRow, PresentedChatTranscriptRow } from './chatTranscriptActivities' +import type { ChatTranscriptAgentTurnRow, ChatTranscriptRow } from './chatTranscriptTypes' +import { mergeChatTranscriptActivities } from './chatTranscriptActivities' export function interleaveChatTranscriptSegments( - rows: readonly PersistedRow[], + rows: readonly PersistedChatTranscriptRow[], messageStartedAt: ReadonlyMap, -): PersistedRow[] { - const timestamp = (row: ChatTranscriptMessageRow | ChatTranscriptCompactionRow) => row.kind === 'message' +): PresentedChatTranscriptRow[] { + const timestamp = (row: Exclude) => row.kind === 'message' ? messageStartedAt.get(row.message.id) ?? row.message.createdAt - : row.compaction.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])) const segments = new Map() @@ -62,10 +62,10 @@ export function interleaveChatTranscriptSegments( segments.set(position, siblings) }) } - return alignChatAssistantIdentity([...(segments.get(-1) ?? []), ...timeline.flatMap((row, index) => [row, ...(segments.get(index) ?? [])])]) + return alignChatAssistantIdentity(mergeChatTranscriptActivities([...(segments.get(-1) ?? []), ...timeline.flatMap((row, index) => [row, ...(segments.get(index) ?? [])])])) } -function alignChatAssistantIdentity(rows: PersistedRow[]): PersistedRow[] { +function alignChatAssistantIdentity(rows: PresentedChatTranscriptRow[]): PresentedChatTranscriptRow[] { const visibleRunIds = new Set() return rows.map((row) => { const runId = row.kind === 'agent-turn' diff --git a/apps/buddy/src/modules/tasks/model/transcript/chatTranscriptTypes.ts b/apps/buddy/src/modules/tasks/model/transcript/chatTranscriptTypes.ts index 964d25c5..88fb6bb4 100644 --- a/apps/buddy/src/modules/tasks/model/transcript/chatTranscriptTypes.ts +++ b/apps/buddy/src/modules/tasks/model/transcript/chatTranscriptTypes.ts @@ -4,7 +4,7 @@ import type { LocalConversationTimelineItem, LocalMessage } from '@buddy-shared/ import type { LocalRun, LocalRunEvent, LocalRunOutput } from '@buddy-shared/runs/runApi' import type { LocalRunTokenUsage } from '@buddy-shared/usage/runTokenUsage' -import type { ChatAgentTurn } from './chatAgentTurn' +import type { ChatAgentToolNode, ChatAgentTurn } from './chatAgentTurn' import type { ChatRecoveryNotice } from './chatRunRecovery' import type { ChatRunTranscriptProjection } from './chatRunTranscriptProjector' @@ -32,6 +32,19 @@ export interface ChatTranscriptCompactionRow { kind: 'compaction' } +export interface ChatTranscriptExtensionActionRow { + action: Extract + key: string + kind: 'extension-action' +} + +export interface ChatTranscriptActivityFlowRow { + createdAt: string + key: string + kind: 'activity-flow' + nodes: ReadonlyArray +} + export interface ChatTranscriptAgentTurnRow { showIdentity?: false showOutcome?: false @@ -57,6 +70,7 @@ export type ChatTranscriptRow = | ChatTranscriptActivityRow | ChatTranscriptAgentTurnRow | ChatTranscriptCompactionRow + | ChatTranscriptActivityFlowRow | ChatTranscriptMessageRow | ChatTranscriptRecoveryNoticeRow 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 a16dbe1b..c34f14b7 100644 --- a/apps/buddy/src/modules/tasks/state/__tests__/useTaskCapability.spec.ts +++ b/apps/buddy/src/modules/tasks/state/__tests__/useTaskCapability.spec.ts @@ -26,8 +26,10 @@ describe('useTaskCapability', () => { 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([]) const calls = vi.mocked(api.localChat.conversations.listTimeline).mock.calls.length @@ -1190,6 +1192,7 @@ function createDesktopApi() { markSeen: vi.fn(async () => ({ items: [], unseenCount: 0 })), }, conversations: { + onTimelineChanged: vi.fn(() => () => {}), onChanged: () => () => {}, activateBranch: vi.fn(), delete: vi.fn(), diff --git a/apps/buddy/src/modules/tasks/state/runs/__tests__/useChatRunSync.spec.ts b/apps/buddy/src/modules/tasks/state/runs/__tests__/useChatRunSync.spec.ts index aff29841..9fcf6015 100644 --- a/apps/buddy/src/modules/tasks/state/runs/__tests__/useChatRunSync.spec.ts +++ b/apps/buddy/src/modules/tasks/state/runs/__tests__/useChatRunSync.spec.ts @@ -5,9 +5,53 @@ import { deferred } from '@buddy-tests/deferred' import { describe, expect, it, vi } from 'vitest' import { effectScope, ref } from 'vue' +import { projectPersistedChatTranscriptRows } from '../../../model/transcript/chatPersistedTranscriptRows' import { useChatRunSync } from '../useChatRunSync' describe('useChatRunSync', () => { + it('refreshes a running action on an older page and removes only automatic skipped actions from the transcript', async () => { + const action: Extract = { + kind: 'extension-action', + id: 'automatic', + conversationId: 'task', + branchId: 'branch', + sourceMessageId: null, + extensionId: 'tests.action', + extensionName: 'Action', + actionId: 'tests.action.run', + title: 'Run', + trigger: 'task:input:committed', + status: 'running', + message: null, + createdAt: '2026-09-29T00:00:00.000Z', + completedAt: null, + } + const manual = { ...action, id: 'manual', trigger: 'user' as const, status: 'skipped' as const } + const latest = timelineMessage('latest', 'task', 'branch', 2) + let completed = false + const api = createApi({ listTimeline: async input => input.cursor + ? timelinePage([{ ...action, status: completed ? 'skipped' : 'running' }, manual], null) + : timelinePage([latest], 'older') }) + const sync = useChatRunSync({ activeBranchId: ref('branch'), activeConversationId: ref('task'), api, onError: (error) => { + throw error + } }) + try { + await sync.refreshActiveConversation() + await sync.loadOlderMessages() + expect(sync.timelineItems.value.find(item => item.id === action.id)).toMatchObject({ status: 'running' }) + expect(projectPersistedChatTranscriptRows(sync.timelineItems.value, [])).toHaveLength(2) + completed = true + await sync.refreshActiveConversation() + expect(sync.timelineItems.value.find(item => item.id === action.id)).toMatchObject({ status: 'skipped' }) + const rows = projectPersistedChatTranscriptRows(sync.timelineItems.value, []) + expect(JSON.stringify(rows)).not.toContain('"id":"automatic"') + expect(JSON.stringify(rows)).toContain('"id":"manual"') + expect(sync.messages.value).toEqual([latest]) + expect(sync.hasOlderMessages.value).toBe(false) + } + finally { sync.dispose() } + }) + it('restores the loaded range with fresh pages and keeps a switched task loading until the range is ready', async () => { const activeConversationId = ref('conversation-a') const activeBranchId = ref('branch-conversation-a') diff --git a/apps/buddy/src/modules/tasks/state/runs/useChatRunSync.ts b/apps/buddy/src/modules/tasks/state/runs/useChatRunSync.ts index 1373e6b1..d3655d92 100644 --- a/apps/buddy/src/modules/tasks/state/runs/useChatRunSync.ts +++ b/apps/buddy/src/modules/tasks/state/runs/useChatRunSync.ts @@ -128,9 +128,10 @@ export function useChatRunSync(options: ChatRunSyncOptions): ChatRunSync { if (!isCurrent(request)) return let page = latest - const newestItem = projection.state.timelineItems.value.at(-1) - const rememberedStart = newestItem - ? timelineItemKey(newestItem) + const refreshBoundary = projection.state.timelineItems.value.find(item => item.kind === 'extension-action' && item.status === 'running') + ?? projection.state.timelineItems.value.at(-1) + const rememberedStart = refreshBoundary + ? timelineItemKey(refreshBoundary) : loadedRanges.get(`${request.conversationId}:${request.branchId}`) while (page.nextCursor) { if (!rememberedStart || page.items.some(item => timelineItemKey(item) === rememberedStart)) diff --git a/apps/buddy/src/modules/tasks/state/useTaskLifecycle.ts b/apps/buddy/src/modules/tasks/state/useTaskLifecycle.ts index 337ea4f5..561d99cf 100644 --- a/apps/buddy/src/modules/tasks/state/useTaskLifecycle.ts +++ b/apps/buddy/src/modules/tasks/state/useTaskLifecycle.ts @@ -117,6 +117,7 @@ export function useTaskLifecycle(options: TaskLifecycleOptions) { void options.runSync.refreshActiveConversation().catch(options.onError) }, 100) } + const stopTimeline = options.api.conversations.onTimelineChanged(refreshCommittedContent) const stopArtifacts = options.api.artifacts.onChanged(refreshCommittedContent) const stopChanges = options.api.changes.onChanged(refreshCommittedContent) const stopRunEventListener = options.api.chat.onRunEvent(event => options.runSync.handleRunEvent(event)) @@ -130,6 +131,7 @@ export function useTaskLifecycle(options: TaskLifecycleOptions) { dispose() { isDisposed = true stopRunEventListener() + stopTimeline() stopArtifacts() stopChanges() if (contentRefresh !== null) diff --git a/apps/buddy/src/modules/tasks/widgets/canvas/ConversationNodeDetail.vue b/apps/buddy/src/modules/tasks/widgets/canvas/ConversationNodeDetail.vue index 55ed5336..6676db2c 100644 --- a/apps/buddy/src/modules/tasks/widgets/canvas/ConversationNodeDetail.vue +++ b/apps/buddy/src/modules/tasks/widgets/canvas/ConversationNodeDetail.vue @@ -6,6 +6,7 @@ import { Dismiss20Regular, Edit20Regular, Keyboard20Regular, Wand20Regular } fro import { useBuddyI18n } from '@/i18n/buddyI18n' import DesktopIcon from '@/shared/ui/icon/DesktopIcon.vue' import BuddyChatAgentTurn from '../transcript/BuddyChatAgentTurn.vue' +import BuddyChatAgentTurnFlow from '../transcript/BuddyChatAgentTurnFlow.vue' import BuddyChatCompactionRow from '../transcript/BuddyChatCompactionRow.vue' import BuddyChatMessageBody from '../transcript/BuddyChatMessageBody.vue' import BuddyChatRunActivity from '../transcript/BuddyChatRunActivity.vue' @@ -63,6 +64,7 @@ const activityNavigation = useChatActivityNavigation() +

{{ t('desktop.chat.recoveryAttachmentsMissing', { count: row.notice.missingAttachmentCount }) }} diff --git a/apps/buddy/src/modules/tasks/widgets/transcript/BuddyChatActivityGroup.vue b/apps/buddy/src/modules/tasks/widgets/transcript/BuddyChatActivityGroup.vue index 146be855..dfe1bec8 100644 --- a/apps/buddy/src/modules/tasks/widgets/transcript/BuddyChatActivityGroup.vue +++ b/apps/buddy/src/modules/tasks/widgets/transcript/BuddyChatActivityGroup.vue @@ -27,7 +27,7 @@ const open = shallowRef(false) const highlightedIssue = shallowRef(null) const content = useTemplateRef('content') const header = useTemplateRef('header') -const hasHistory = computed(() => props.group.nodes.some(node => node.kind === 'tool' ? !isChatToolActive(node) : node.status !== 'running')) +const hasVisibleActivity = computed(() => props.group.nodes.some(node => node.kind === 'tool' ? node.progressPlacement === 'inline' || !isChatToolActive(node) : node.status !== 'running')) const singleTool = computed(() => props.group.toolCount === 1 && props.group.nodes.every(node => node.kind === 'tool' || node.status === 'running')) const singleReasoning = computed(() => props.group.nodes.length === 1 && props.group.nodes[0]?.kind === 'reasoning') const layout = computed(() => presentChatActivityLayout(props.group.nodes)) @@ -59,8 +59,8 @@ async function revealActivity(nodeId: string) { await nextTick() if (!open.value || highlightedIssue.value !== node.id) return - const row = [...(content.value?.querySelectorAll('[data-tool-call-id]') ?? [])] - .find(element => element.dataset.toolCallId === node.toolCallId) + const row = [...(content.value?.querySelectorAll('[data-activity-node-id]') ?? [])] + .find(element => element.dataset.activityNodeId === node.id) const target = row?.querySelector('button:not(:disabled)') ?? row target?.focus({ preventScroll: true }) target?.scrollIntoView({ block: 'center', behavior: 'instant' }) @@ -77,7 +77,7 @@ defineExpose({ revealActivity })