diff --git a/.gitattributes b/.gitattributes index d5855464390..f9017a0df67 100644 --- a/.gitattributes +++ b/.gitattributes @@ -18,9 +18,10 @@ # Repository-only plugin examples are tested from a Git checkout, but are not # ASF source-release inputs. /scripts/plugins export-ignore -# These integration tests use the repository-only Feishu plugin example. +# These integration tests use repository-only plugin examples. /packages/runtime-host/test/assistant/memory-network/test/feishu-source.test.ts export-ignore /packages/runtime-host/test/assistant/proactive-matters/test/source-assistant-loop.test.ts export-ignore +/packages/ui/src/__tests__/codex-composer-integration.test.tsx export-ignore # DeepSeek Harness remains available to repository development and benchmarks, # but its external runtime dependency closure is not an ASF source-release input. diff --git a/packages/ui/src/__tests__/codex-composer-integration.test.tsx b/packages/ui/src/__tests__/codex-composer-integration.test.tsx new file mode 100644 index 00000000000..7ee1e63d2c7 --- /dev/null +++ b/packages/ui/src/__tests__/codex-composer-integration.test.tsx @@ -0,0 +1,116 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { readFileSync } from 'node:fs'; +import { runInNewContext } from 'node:vm'; +import * as React from 'react'; +import { act, createRef, useState } from 'react'; +import { MakaClientPluginSdkModule, MakaClientSlotCore, MakaClientSlotProvider } from '../client-plugin-slots.js'; +import type { ExecutorSelection } from '@maka/core/executor-catalog'; +import { Composer, type ComposerHandle } from '../composer.js'; +import { LocaleProvider } from '../locale-context.js'; +import { installTranscriptDom } from './transcript-test-dom.js'; +import { actions, catalog, choices, session } from './composer-model-picker-fixture.js'; + +test('the real Codex contribution shares one Composer picker with native and ACP models', async () => { + let factory!: (require: (id: string) => unknown) => { apply(context: unknown): void }; + runInNewContext(readFileSync(new URL('../../../../scripts/plugins/codex-app-server-executor/client.js', import.meta.url), 'utf8'), { + AbortController, + window: { __MakaModuleLoader__: { load: (entry: { factory: typeof factory }) => { factory = entry.factory; } } }, + }); + const core = new MakaClientSlotCore(); + let loads = 0; + const client = factory(id => { + if (id === 'react') return React; + if (id === '@maka/ui/client-plugin') return MakaClientPluginSdkModule; + throw new Error(`Unknown client module: ${id}`); + }); + client.apply({ slots: core, remote: { call: async () => { + loads++; + if (loads === 1) throw new Error('Temporary discovery failure'); + return [{ model: 'codex-model', displayName: 'Codex model', defaultReasoningEffort: 'high', supportedReasoningEfforts: [{ reasoningEffort: 'low' }, { reasoningEffort: 'high' }] }]; + } }, style() {} }); + const dom = installTranscriptDom(); + dom.window.getSelection = () => null; + dom.document.getSelection = () => null; + const writes: unknown[] = []; + let finish!: () => void; + const ref = createRef(); + function Frame({ external = false }: { external?: boolean }) { + const [target, setTarget] = useState(); + const [selection, setSelection] = useState(external + ? { executorId: 'agent', configuration: { model: 'opaque-high', mode: 'plan' } } : undefined); + return } + newChatModel={{ llmConnectionId: 'first', llmConnectionSlug: 'first', model: 'gpt-6.1-sol' }} + onPickNewChatModel={value => { writes.push(value); }} + executorTarget={target} onExecutorTargetChange={next => new Promise(resolve => { + writes.push(next); finish = () => { setTarget(next); setSelection(undefined); resolve(); }; + })} + executorPicker={{ catalog, fixed: external, selection, + onSelect: next => { writes.push(next); setSelection(next); setTarget(undefined); }, onSetup() {}, onRetry() {}, onNewTask() {} }} + onSend={() => undefined} onStop={() => undefined} + />; + } + try { + await dom.render(); + assert.equal(loads, 0, 'mounting a native new task must not start Codex'); + const { click, trigger, option } = actions(dom); + await act(async () => { + const event = new dom.window.Event('keydown', { bubbles: true }); + Object.defineProperty(event, 'key', { value: 'ArrowDown' }); + trigger()!.dispatchEvent(event); + }); + assert.equal(loads, 1); + await click([...dom.document.querySelectorAll('button')].find(el => el.textContent === 'Retry')); + assert.equal(loads, 2, 'failed plugin discovery can be retried'); + assert.equal(dom.document.querySelectorAll('.maka-model-switcher-trigger').length, 1); + assert.ok(dom.document.querySelector('[role="group"][aria-label="Agent"]')); + assert.ok(dom.document.querySelector('[role="group"][aria-label="first"]')); + assert.ok(option('Codex model')?.querySelector('[data-provider-brand="openai"]'), 'contributed models use the same provider mark as native models'); + await act(async () => { ref.current?.setText('Continue'); }); + await click(option('Codex model')); + assert.deepEqual(writes.at(-1), { executorId: 'codex.app-server', model: 'codex-model', thinkingLevel: 'high' }); + assert.equal(dom.document.querySelector('button[aria-label="Send"]')?.getAttribute('aria-disabled'), 'true'); + await act(async () => { finish(); }); + assert.match(trigger()?.textContent ?? '', /Codex.*Codex model/); + await click(trigger('.maka-thinking-level-selector')); + await click(option('Low')); + assert.deepEqual(writes.at(-1), { executorId: 'codex.app-server', model: 'codex-model', thinkingLevel: 'low' }); + await act(async () => { finish(); }); + await click(trigger()); + await click(option('Other')); + assert.deepEqual(writes.at(-1), { executorId: 'agent', configuration: { model: 'other' } }); + assert.equal(trigger()?.textContent, 'Agent · Other'); + await click(trigger()); + await click(dom.document.querySelector('[role="group"][aria-label="second"] [role="option"]')); + assert.deepEqual(writes.at(-1), { llmConnectionId: 'second', llmConnectionSlug: 'second', model: 'gpt-6.1-sol' }); + await dom.render(); + assert.equal(trigger()?.textContent, 'Family'); + assert.equal(trigger('.maka-executor-mode-selector')?.textContent, 'Plan'); + await click(trigger()); + assert.ok(option('Other')); + assert.equal(option('Codex model'), undefined, 'an existing ACP session stays on its executor'); + assert.equal(loads, 2, 'browsing a fixed ACP session must not load Codex'); + } finally { await dom.cleanup(); } +}); diff --git a/packages/ui/src/__tests__/composer-model-picker-fixture.ts b/packages/ui/src/__tests__/composer-model-picker-fixture.ts new file mode 100644 index 00000000000..0d786d53d2e --- /dev/null +++ b/packages/ui/src/__tests__/composer-model-picker-fixture.ts @@ -0,0 +1,49 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import assert from 'node:assert/strict'; +import { act } from 'react'; +import type { SessionSummary } from '@maka/core/session'; +import type { ChatModelChoice } from '@maka/core/chat-model-choice'; +import type { ComposerExecutorPicker } from '../composer-model-picker.js'; +import type { installTranscriptDom } from './transcript-test-dom.js'; + +export const choices: ChatModelChoice[] = ['first', 'second'].map(connectionId => ({ + connectionId, connectionSlug: connectionId, connectionName: connectionId, providerType: 'openai', + providerLabel: 'OpenAI', model: 'gpt-6.1-sol', label: 'GPT-6.1 Sol', isDefault: false, + contextWindow: 1_048_576, thinkingLevels: ['low', 'high'], +})); +export const session = (id: string) => ({ id, llmConnectionId: 'first', llmConnectionSlug: 'first', model: 'gpt-6.1-sol' }) as SessionSummary; +export const catalog: ComposerExecutorPicker['catalog'] = [{ + id: 'agent', displayName: 'Agent', readiness: 'ready', supportsModelChange: true, supportsAttachments: false, + currentModel: 'opaque-high', currentMode: 'plan', supportsModeChange: true, + models: [{ id: 'opaque-high', name: 'Family High' }, { id: 'opaque-low', name: 'Family Low' }, { id: 'other', name: 'Other' }], + modelGroups: [{ id: 'family', name: 'Family', variants: [{ modelId: 'opaque-high', level: 'high' }, { modelId: 'opaque-low', level: 'low' }] }], + modes: [{ id: 'plan', name: 'Plan' }, { id: 'code', name: 'Code' }], +}]; +export function actions(dom: ReturnType) { + return { + click: async (element: Element | null | undefined) => { + assert.ok(element); + await act(async () => { element.dispatchEvent(new dom.window.Event('click', { bubbles: true })); }); + }, + option: (name: string) => [...dom.document.querySelectorAll('[role="option"]')].find(el => el.textContent === name), + trigger: (selector = '.maka-model-switcher-trigger') => dom.document.querySelector(selector + ' button'), + }; +} diff --git a/packages/ui/src/__tests__/composer-model-picker-recovery.test.tsx b/packages/ui/src/__tests__/composer-model-picker-recovery.test.tsx index 9ac09a79cf0..917a76b9eb7 100644 --- a/packages/ui/src/__tests__/composer-model-picker-recovery.test.tsx +++ b/packages/ui/src/__tests__/composer-model-picker-recovery.test.tsx @@ -19,44 +19,15 @@ import assert from 'node:assert/strict'; import test from 'node:test'; -import { readFileSync } from 'node:fs'; -import { runInNewContext } from 'node:vm'; -import * as React from 'react'; import { act, createRef, useState } from 'react'; -import { MakaClientPluginSdkModule, MakaClientSlotCore, MakaClientSlotProvider } from '../client-plugin-slots.js'; -import type { SessionSummary } from '@maka/core/session'; import type { ExecutorSelection } from '@maka/core/executor-catalog'; -import type { ChatModelChoice } from '@maka/core/chat-model-choice'; import { Composer, type ComposerHandle } from '../composer.js'; -import { ComposerModelPicker, type ComposerExecutorPicker } from '../composer-model-picker.js'; +import { ComposerModelPicker } from '../composer-model-picker.js'; import { highestExecutorModelVariant } from '../executor-model-presentation.js'; import { LocaleProvider } from '../locale-context.js'; import { installTranscriptDom } from './transcript-test-dom.js'; import { deriveComposerModelSwitchAvailability } from '../composer-helpers.js'; - -const choices: ChatModelChoice[] = ['first', 'second'].map(connectionId => ({ - connectionId, connectionSlug: connectionId, connectionName: connectionId, providerType: 'openai', - providerLabel: 'OpenAI', model: 'gpt-6.1-sol', label: 'GPT-6.1 Sol', isDefault: false, - contextWindow: 1_048_576, thinkingLevels: ['low', 'high'], -})); -const session = (id: string) => ({ id, llmConnectionId: 'first', llmConnectionSlug: 'first', model: 'gpt-6.1-sol' }) as SessionSummary; -const catalog: ComposerExecutorPicker['catalog'] = [{ - id: 'agent', displayName: 'Agent', readiness: 'ready', supportsModelChange: true, supportsAttachments: false, - currentModel: 'opaque-high', currentMode: 'plan', supportsModeChange: true, - models: [{ id: 'opaque-high', name: 'Family High' }, { id: 'opaque-low', name: 'Family Low' }, { id: 'other', name: 'Other' }], - modelGroups: [{ id: 'family', name: 'Family', variants: [{ modelId: 'opaque-high', level: 'high' }, { modelId: 'opaque-low', level: 'low' }] }], - modes: [{ id: 'plan', name: 'Plan' }, { id: 'code', name: 'Code' }], -}]; -function actions(dom: ReturnType) { - return { - click: async (element: Element | null | undefined) => { - assert.ok(element); - await act(async () => { element.dispatchEvent(new dom.window.Event('click', { bubbles: true })); }); - }, - option: (name: string) => [...dom.document.querySelectorAll('[role="option"]')].find(el => el.textContent === name), - trigger: (selector = '.maka-model-switcher-trigger') => dom.document.querySelector(selector + ' button'), - }; -} +import { actions, catalog, choices, session } from './composer-model-picker-fixture.js'; test('new tasks can recover another Agent without changing their selection', async () => { const dom = installTranscriptDom(); @@ -83,91 +54,6 @@ test('new tasks can recover another Agent without changing their selection', asy } finally { await dom.cleanup(); } }); -test('the real Codex contribution shares one Composer picker with native and ACP models', async () => { - let factory!: (require: (id: string) => unknown) => { apply(context: unknown): void }; - runInNewContext(readFileSync(new URL('../../../../scripts/plugins/codex-app-server-executor/client.js', import.meta.url), 'utf8'), { - AbortController, - window: { __MakaModuleLoader__: { load: (entry: { factory: typeof factory }) => { factory = entry.factory; } } }, - }); - const core = new MakaClientSlotCore(); - let loads = 0; - const client = factory(id => { - if (id === 'react') return React; - if (id === '@maka/ui/client-plugin') return MakaClientPluginSdkModule; - throw new Error(`Unknown client module: ${id}`); - }); - client.apply({ slots: core, remote: { call: async () => { - loads++; - if (loads === 1) throw new Error('Temporary discovery failure'); - return [{ model: 'codex-model', displayName: 'Codex model', defaultReasoningEffort: 'high', supportedReasoningEfforts: [{ reasoningEffort: 'low' }, { reasoningEffort: 'high' }] }]; - } }, style() {} }); - const dom = installTranscriptDom(); - dom.window.getSelection = () => null; - dom.document.getSelection = () => null; - const writes: unknown[] = []; - let finish!: () => void; - const ref = createRef(); - function Frame({ external = false }: { external?: boolean }) { - const [target, setTarget] = useState(); - const [selection, setSelection] = useState(external - ? { executorId: 'agent', configuration: { model: 'opaque-high', mode: 'plan' } } : undefined); - return } - newChatModel={{ llmConnectionId: 'first', llmConnectionSlug: 'first', model: 'gpt-6.1-sol' }} - onPickNewChatModel={value => { writes.push(value); }} - executorTarget={target} onExecutorTargetChange={next => new Promise(resolve => { - writes.push(next); finish = () => { setTarget(next); setSelection(undefined); resolve(); }; - })} - executorPicker={{ catalog, fixed: external, selection, - onSelect: next => { writes.push(next); setSelection(next); setTarget(undefined); }, onSetup() {}, onRetry() {}, onNewTask() {} }} - onSend={() => undefined} onStop={() => undefined} - />; - } - try { - await dom.render(); - assert.equal(loads, 0, 'mounting a native new task must not start Codex'); - const { click, trigger, option } = actions(dom); - await act(async () => { - const event = new dom.window.Event('keydown', { bubbles: true }); - Object.defineProperty(event, 'key', { value: 'ArrowDown' }); - trigger()!.dispatchEvent(event); - }); - assert.equal(loads, 1); - await click([...dom.document.querySelectorAll('button')].find(el => el.textContent === 'Retry')); - assert.equal(loads, 2, 'failed plugin discovery can be retried'); - assert.equal(dom.document.querySelectorAll('.maka-model-switcher-trigger').length, 1); - assert.ok(dom.document.querySelector('[role="group"][aria-label="Agent"]')); - assert.ok(dom.document.querySelector('[role="group"][aria-label="first"]')); - assert.ok(option('Codex model')?.querySelector('[data-provider-brand="openai"]'), 'contributed models use the same provider mark as native models'); - await act(async () => { ref.current?.setText('Continue'); }); - await click(option('Codex model')); - assert.deepEqual(writes.at(-1), { executorId: 'codex.app-server', model: 'codex-model', thinkingLevel: 'high' }); - assert.equal(dom.document.querySelector('button[aria-label="Send"]')?.getAttribute('aria-disabled'), 'true'); - await act(async () => { finish(); }); - assert.match(trigger()?.textContent ?? '', /Codex.*Codex model/); - await click(trigger('.maka-thinking-level-selector')); - await click(option('Low')); - assert.deepEqual(writes.at(-1), { executorId: 'codex.app-server', model: 'codex-model', thinkingLevel: 'low' }); - await act(async () => { finish(); }); - await click(trigger()); - await click(option('Other')); - assert.deepEqual(writes.at(-1), { executorId: 'agent', configuration: { model: 'other' } }); - assert.equal(trigger()?.textContent, 'Agent · Other'); - await click(trigger()); - await click(dom.document.querySelector('[role="group"][aria-label="second"] [role="option"]')); - assert.deepEqual(writes.at(-1), { llmConnectionId: 'second', llmConnectionSlug: 'second', model: 'gpt-6.1-sol' }); - await dom.render(); - assert.equal(trigger()?.textContent, 'Family'); - assert.equal(trigger('.maka-executor-mode-selector')?.textContent, 'Plan'); - await click(trigger()); - assert.ok(option('Other')); - assert.equal(option('Codex model'), undefined, 'an existing ACP session stays on its executor'); - assert.equal(loads, 2, 'browsing a fixed ACP session must not load Codex'); - } finally { await dom.cleanup(); } -}); - test('recovery opens the grouped Selector and commits the exact connection identity', async () => { const dom = installTranscriptDom(); dom.window.getSelection = () => null; diff --git a/scripts/asf-source-release.test.mjs b/scripts/asf-source-release.test.mjs index 53071317986..e4536036862 100644 --- a/scripts/asf-source-release.test.mjs +++ b/scripts/asf-source-release.test.mjs @@ -522,6 +522,15 @@ describe('ASF source release verification', () => { 'packages/runtime-host/test/assistant/proactive-matters/test/source-assistant-loop.test.ts'; mkdirSync(dirname(join(repositoryRoot, sourceLoopTest)), { recursive: true }); writeFileSync(join(repositoryRoot, sourceLoopTest), 'export {};\n'); + const pickerTests = 'packages/ui/src/__tests__'; + mkdirSync(join(repositoryRoot, pickerTests), { recursive: true }); + for (const name of [ + 'codex-composer-integration.test.tsx', + 'composer-model-picker-recovery.test.tsx', + 'composer-model-picker-fixture.ts', + ]) { + writeFileSync(join(repositoryRoot, pickerTests, name), 'export {};\n'); + } mkdirSync(join(repositoryRoot, 'packages/eval/harbor/deepseek-harness-toolchain'), { recursive: true, }); @@ -562,6 +571,8 @@ describe('ASF source release verification', () => { '.claude/launch.json', '.maka-shots/review.png', 'maka-proposal-zh-review.txt', + 'scripts/plugins/codex-app-server-executor/index.mjs', + 'packages/ui/src/__tests__', 'packages/runtime-host/test/assistant/memory-network/test', 'packages/runtime-host/test/assistant/proactive-matters/test', 'packages/eval/harbor/deepseek-harness-toolchain/package-lock.json', @@ -644,6 +655,9 @@ describe('ASF source release verification', () => { assert.doesNotMatch(entries, /memory-network\/test\/feishu-source\.test\.ts/); assert.doesNotMatch(entries, /proactive-matters\/test\/source-assistant-loop\.test\.ts/); assert.match(entries, /memory-network\/test\/sources\.test\.ts/); + assert.doesNotMatch(entries, /__tests__\/codex-composer-integration\.test\.tsx/); + assert.match(entries, /__tests__\/composer-model-picker-recovery\.test\.tsx/); + assert.match(entries, /__tests__\/composer-model-picker-fixture\.ts/); assert.doesNotMatch(entries, /deepseek-harness-toolchain\/package(?:-lock)?\.json/); assert.match(entries, /deepseek-harness-toolchain\/patch-subprocess-local\.mjs/); assert.match(entries, /deepseek-harness-profile\/cordis\.yml/);