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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
305 changes: 304 additions & 1 deletion .playwright/scripts/__tests__/extensionAgent.e2e.mjs

Large diffs are not rendered by default.

35 changes: 29 additions & 6 deletions .playwright/scripts/__tests__/settings.e2e.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -52,14 +52,18 @@ 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')
globalThis.settingsFixtureFailures = { load: true, save: false }
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
Expand Down Expand Up @@ -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 = []
Expand All @@ -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')
Expand Down Expand Up @@ -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,
Expand All @@ -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: '插件分组' },
Expand All @@ -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')
}
21 changes: 21 additions & 0 deletions apps/buddy/electron/main/app/DesktopIntegrations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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(() => {
Expand Down Expand Up @@ -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) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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() }
Expand Down
17 changes: 16 additions & 1 deletion apps/buddy/electron/main/extensions/registerExtensionIpc.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand All @@ -25,10 +27,13 @@ export function registerExtensionIpc(options: {
get: (url: string, init: { signal: AbortSignal }) => Promise<Response>
developmentDirectory?: string
notificationsEnabled?: () => boolean
taskActions: () => Promise<ExtensionTaskAction[]>
invokeTaskAction: (input: ExtensionTaskActionInput) => Promise<ExtensionTaskActionResult>
agentChanged?: () => void
record?: ApplicationDiagnosticReporter
conditionRuntime?: (input: { models: boolean, task: boolean, taskId: string | null, runId: string | null }, signal: AbortSignal) => Promise<ExtensionConditionRuntime>
agentRequest?: (input: { invocationId: string, method: string, params: JsonValue }, signal: AbortSignal) => Promise<JsonValue>
}): { dispose: () => Promise<void>, reviewPackage: (path: string) => Promise<void>, inspect: (id: string) => Promise<ExtensionInspection>, agent: { list: () => Promise<ExtensionAgentDescriptor[]>, invoke: (input: ExtensionAgentInvocation, signal: AbortSignal) => Promise<JsonValue> } } {
}): { conditions: ExtensionService['conditions'], dispose: () => Promise<void>, reviewPackage: (path: string) => Promise<void>, inspect: (id: string) => Promise<ExtensionInspection>, agent: { list: () => Promise<ExtensionAgentDescriptor[]>, invoke: (input: ExtensionAgentInvocation, signal: AbortSignal) => Promise<JsonValue> } } {
const store = new ExtensionPackageStore(join(options.home, 'extensions'), options.version)
const protocol = new ExtensionProtocol(store)
const stopProtocol = protocol.install(session.defaultSession, 'view')
Expand All @@ -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) => {
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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 () => {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,13 +1,19 @@
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'
import { ExtensionHostEvents as HostSource } from '../ExtensionHostEvents'
import { ExtensionViewState } from '../ExtensionViewState'

it('keeps the self-contained public SDK and internal event contracts equivalent', () => {
expectTypeOf<Omit<Sdk.ConditionContext, 'signal'>>().toEqualTypeOf<EventSnapshot<ExtensionConditionContext>>()
expectTypeOf<Sdk.ExtensionEvents>().toEqualTypeOf<ExtensionHostEvents>()
expectTypeOf<Sdk.TaskActionEvents>().toEqualTypeOf<TaskActionEvents>()
expectTypeOf<Sdk.AgentActionContext['cause']>().toEqualTypeOf<ExtensionActionCause>()
expectTypeOf<Sdk.ViewEvents>().toEqualTypeOf<ExtensionViewEvents>()
expectTypeOf<Sdk.EventPattern<Sdk.ViewEvents>>().toEqualTypeOf<EventPattern<ExtensionViewEvents>>()
expectTypeOf<Sdk.EventMessage<Sdk.ViewEvents, 'view:**' | 'control:changed'>>().toEqualTypeOf<EventMessage<ExtensionViewEvents, 'view:**' | 'control:changed'>>()
Expand Down
Loading