From f301974b166215210193ee206d445cb4894b95eb Mon Sep 17 00:00:00 2001 From: AbigailDeng <108705114+AbigailDeng@users.noreply.github.com> Date: Tue, 29 Sep 2026 17:42:01 +0800 Subject: [PATCH] Revert premature channel skill service suggestions --- .../2026-09-28-channel-service-access.md | 3 +- .../2026-09-28-channel-skill-services.md | 84 ++--- .../src/locales/channelMessages.en-US.ts | 29 -- .../src/locales/channelMessages.zh-CN.ts | 26 -- .../channels/ChannelConfigurationPage.tsx | 16 - .../channels/ChannelDefaultSkill.test.tsx | 33 +- .../channels/ChannelEditPage.test.tsx | 8 - .../channels/ChannelServicePicker.tsx | 3 - .../channels/ChannelSkillServices.test.tsx | 344 ------------------ .../channels/ChannelSkillServices.tsx | 229 ------------ .../channels/connectionStyles.ts | 13 - .../channels/queries.ts | 2 - .../api/channelSkillServicesApi.test.ts | 90 ----- .../src/shared/api/channelSkillServicesApi.ts | 92 ----- 14 files changed, 52 insertions(+), 920 deletions(-) delete mode 100644 apps/aevatar-console-web/src/pages/workflow-activity-vnext/channels/ChannelSkillServices.test.tsx delete mode 100644 apps/aevatar-console-web/src/pages/workflow-activity-vnext/channels/ChannelSkillServices.tsx delete mode 100644 apps/aevatar-console-web/src/shared/api/channelSkillServicesApi.test.ts delete mode 100644 apps/aevatar-console-web/src/shared/api/channelSkillServicesApi.ts diff --git a/apps/aevatar-console-web/docs/superpowers/specs/2026-09-28-channel-service-access.md b/apps/aevatar-console-web/docs/superpowers/specs/2026-09-28-channel-service-access.md index 7f5399273..9afc5febe 100644 --- a/apps/aevatar-console-web/docs/superpowers/specs/2026-09-28-channel-service-access.md +++ b/apps/aevatar-console-web/docs/superpowers/specs/2026-09-28-channel-service-access.md @@ -40,7 +40,8 @@ hints get an availability notice with unresolved IDs behind a details disclosure There are no instructions to customize login consent. Users search and select services, then explicitly click Bind bot or Save changes. -The optional Skill's backend suggestions use this same inventory boundary. +Skill service suggestions are withdrawn; selecting a Skill does not trigger +service discovery or display a recommendation panel. Required built-in services retain their existing selection and validation rules. A successful inventory refresh removes missing, inactive or account-denied selections from the draft, selected count and next submission. Active services diff --git a/apps/aevatar-console-web/docs/superpowers/specs/2026-09-28-channel-skill-services.md b/apps/aevatar-console-web/docs/superpowers/specs/2026-09-28-channel-skill-services.md index c80566240..84896ec0b 100644 --- a/apps/aevatar-console-web/docs/superpowers/specs/2026-09-28-channel-skill-services.md +++ b/apps/aevatar-console-web/docs/superpowers/specs/2026-09-28-channel-skill-services.md @@ -1,62 +1,28 @@ -# Channel Skill service suggestions +# Channel Skill service suggestions — withdrawn Issue: https://github.com/aevatarAI/aevatar/issues/3678 -## Backend-owned discovery - -The console calls `GET /api/skills/service-recommendations?skillName=...` on -Mainnet. The backend owns Skill resolution, service discovery and evidence. -See the [backend contract](https://github.com/aevatarAI/aevatar/blob/3da66b8813c03a7b1d87616889dd3ff343dcde1a/docs/contracts/skill-service-recommendations.md) -for the response, source contracts, layering and limitations. - -The backend is delivered in [PR #3683](https://github.com/aevatarAI/aevatar/pull/3683) -against `feature/integrate`. This console change is delivered separately in -[PR #3679](https://github.com/aevatarAI/aevatar/pull/3679) against -`feat/2026-08-04_workflow-activity-vnext`. The backend endpoint must be merged -and deployed for recommendations to become available. - -The browser does not fetch `SKILL.md`, load the catalog for inference, or match -service names against Skill content. Its API adapter validates the selected -Skill identity, evidence enums and exact UserService instance IDs. A missing or -unsupported backend result produces a retryable discovery state, with the -manual picker still usable; there is no local inference fallback. - -## User behavior - -Binding and editing show suggestions after a Skill is selected. Reasons name the -backend's evidence category without exposing private instructions. Account -instances show personal/organization source, activity and availability. -Active services available to the NyxID account determine which exact IDs are -selectable, including services omitted at login. Account-level organization -restrictions still apply. Login service grants do not constrain a channel -Agent Key selection. Both Bind and Edit use this same inventory boundary; see -[Channel service selection](2026-09-28-channel-service-access.md). - -Selecting an instance adds its exact UserService ID to the form; normal save -commits it. Discovery never selects, authorizes or removes services. Changing or -clearing the Skill preserves manual choices. Queries are keyed by scope and -Skill name and consume abort signals, so late results do not replace the current -Skill's recommendations. - -Missing connections and inactive services offer NyxID connection management in a -new tab. Channel forms no longer show an OAuth service-access review action or -consent instructions. Refresh reloads backend discovery and account inventory. -Pending and failed discovery stay within the suggestion region. - -The current backend sources do not declare mandatory dependencies. Suggestions -remain advisory; text mentions explicitly say they may be needed for some tasks. -An empty result does not prove no services are required. Existing platform- -required services keep their own behavior. Inventory is not credential-validity -evidence. - -## Design and verification - -Retain the compact Channels form, design tokens, wrapped names, keyboard actions -and English/Chinese locales. Excalidraw assets remain unchanged: -`30e74d7b410ae72c4c91432355436679033679c54c10b1702908435b001577de`. -Use real authenticated backend responses; mocks exist only in tests. - -Route integration tests cover rendering backend suggestions, exact instance -selection and save, access gaps, switching/clearing, stale responses and recovery. -API tests cover request encoding and malformed/mismatched results. -Full frontend typecheck, suite and production build are delegated to GitHub CI. +The service recommendation feature introduced by [PR #3679](https://github.com/aevatarAI/aevatar/pull/3679) +is withdrawn from Bind and Edit at the user’s request on 2026-09-29. +The backend [PR #3683](https://github.com/aevatarAI/aevatar/pull/3683), targeting +`feature/integrate`, has not been merged or deployed with the frontend. + +The console does not request `/api/skills/service-recommendations` or show the +Suggested for panel, Refresh suggestions action, discovery failure message, +or Manage connections in NyxID link. The recommendation component, adapter, +query key, styles and locale messages are removed. + +Manual service selection remains governed by +[Channel service selection](2026-09-28-channel-service-access.md): active, +account-available NyxID services can be selected even if omitted at login. +Skill selection, required built-in services, explicit Bind/Save and observed +completion retain their existing behavior. + +Any future reintroduction requires explicit approval and verification that the +backend capability is deployed. Backend PR approval or deployment alone must +not silently re-enable this frontend feature. + +Existing Bind and Edit route tests verify that a selected Skill produces no +recommendation panel or request. Related channel and inventory tests protect +manual selection and save behavior. Full frontend suite, typecheck and build +remain delegated to GitHub CI. diff --git a/apps/aevatar-console-web/src/locales/channelMessages.en-US.ts b/apps/aevatar-console-web/src/locales/channelMessages.en-US.ts index 06f041910..d2fa1cb0b 100644 --- a/apps/aevatar-console-web/src/locales/channelMessages.en-US.ts +++ b/apps/aevatar-console-web/src/locales/channelMessages.en-US.ts @@ -1,33 +1,4 @@ export default { - 'channels.suggestions.title': 'Suggested for {skill}', - 'channels.suggestions.refresh': 'Refresh suggestions', - 'channels.suggestions.loading': 'Finding related services...', - 'channels.suggestions.error': - 'Could not identify related services. Refresh to retry, or select services manually below.', - 'channels.suggestions.help': - 'Suggestions may be incomplete and do not confirm required dependencies. Select the services your task needs; each addition is saved with the channel.', - 'channels.suggestions.linked': 'Linked to this skill in Ornn.', - 'channels.suggestions.catalog': 'The service catalog recommends this skill.', - 'channels.suggestions.mention': - 'Mentioned in the skill description or instructions; may be needed for some tasks.', - 'channels.suggestions.unknownSource': 'Unknown source', - 'channels.suggestions.checking': 'Checking access...', - 'channels.suggestions.accessError': - 'Could not check access. Refresh to retry.', - 'channels.suggestions.inactive': 'Inactive — manage this service in NyxID.', - 'channels.suggestions.unavailable': - 'Access unavailable — check with the service owner.', - 'channels.suggestions.missingInstance': - 'No active connection found. Refresh or check this service in NyxID.', - 'channels.suggestions.notSelected': 'Not selected', - 'channels.suggestions.selected': 'Selected', - 'channels.suggestions.selectNamed': 'Select {service}', - 'channels.suggestions.select': 'Select', - 'channels.suggestions.notConnected': - 'No connection found in your account. Add this service in NyxID.', - 'channels.suggestions.empty': - 'No related services identified. You can still select services manually below.', - 'channels.suggestions.manage': 'Manage connections in NyxID ↗', 'channels.column.owner': 'Owner', 'channels.owner.organization': 'Organization', 'channels.owner.id': 'Owner ID', diff --git a/apps/aevatar-console-web/src/locales/channelMessages.zh-CN.ts b/apps/aevatar-console-web/src/locales/channelMessages.zh-CN.ts index 040c54242..d46bc5a13 100644 --- a/apps/aevatar-console-web/src/locales/channelMessages.zh-CN.ts +++ b/apps/aevatar-console-web/src/locales/channelMessages.zh-CN.ts @@ -1,30 +1,4 @@ export default { - 'channels.suggestions.title': '为 {skill} 推荐', - 'channels.suggestions.refresh': '刷新推荐', - 'channels.suggestions.loading': '正在识别相关服务…', - 'channels.suggestions.error': - '暂时无法识别相关服务。请刷新重试,或在下方手动选择。', - 'channels.suggestions.help': - '推荐结果可能不完整,也不代表必需依赖。请按任务需要选择服务,保存 Channel 后生效。', - 'channels.suggestions.linked': '该 Skill 在 Ornn 中关联了此服务。', - 'channels.suggestions.catalog': '服务目录将该 Skill 列为推荐技能。', - 'channels.suggestions.mention': - 'Skill 的描述或说明提到了此服务,部分任务可能需要。', - 'channels.suggestions.unknownSource': '未知来源', - 'channels.suggestions.checking': '正在检查访问权限…', - 'channels.suggestions.accessError': '暂时无法检查访问权限,请刷新重试。', - 'channels.suggestions.inactive': '服务未启用,请前往 NyxID 管理。', - 'channels.suggestions.unavailable': '暂无访问权限,请联系服务所有者。', - 'channels.suggestions.missingInstance': - '未找到 active 状态的连接,请刷新或在 NyxID 检查该服务。', - 'channels.suggestions.notSelected': '尚未选择', - 'channels.suggestions.selected': '已选择', - 'channels.suggestions.selectNamed': '选择 {service}', - 'channels.suggestions.select': '选择', - 'channels.suggestions.notConnected': - '账号中未找到此服务的连接,请前往 NyxID 添加。', - 'channels.suggestions.empty': '未识别到相关服务。你仍可在下方手动选择。', - 'channels.suggestions.manage': '在 NyxID 管理连接 ↗', 'channels.column.owner': '归属', 'channels.owner.organization': '组织', 'channels.owner.id': '归属 ID', diff --git a/apps/aevatar-console-web/src/pages/workflow-activity-vnext/channels/ChannelConfigurationPage.tsx b/apps/aevatar-console-web/src/pages/workflow-activity-vnext/channels/ChannelConfigurationPage.tsx index add475648..526c33c5b 100644 --- a/apps/aevatar-console-web/src/pages/workflow-activity-vnext/channels/ChannelConfigurationPage.tsx +++ b/apps/aevatar-console-web/src/pages/workflow-activity-vnext/channels/ChannelConfigurationPage.tsx @@ -30,7 +30,6 @@ import WorkflowActivityVNextShell from '../WorkflowActivityVNextShell'; import ChannelServiceAccessNotice from './ChannelServiceAccessNotice'; import ChannelServicePicker from './ChannelServicePicker'; import ChannelSkillField from './ChannelSkillField'; -import ChannelSkillServices from './ChannelSkillServices'; import { channelConnectionCss } from './connectionStyles'; import { ChannelBadge, @@ -602,21 +601,6 @@ function ConfigurationForm({ retry={() => void services.refetch()} editing={editing} replacesDefaults={baseline?.authorizationMode === 'nyxid_default'} - suggestions={ - - setServiceIds((ids) => [...new Set([...ids, id])]) - } - refreshAccess={() => void services.refetch()} - /> - } /> {failure ? (

diff --git a/apps/aevatar-console-web/src/pages/workflow-activity-vnext/channels/ChannelDefaultSkill.test.tsx b/apps/aevatar-console-web/src/pages/workflow-activity-vnext/channels/ChannelDefaultSkill.test.tsx index 637377c05..5b7322edc 100644 --- a/apps/aevatar-console-web/src/pages/workflow-activity-vnext/channels/ChannelDefaultSkill.test.tsx +++ b/apps/aevatar-console-web/src/pages/workflow-activity-vnext/channels/ChannelDefaultSkill.test.tsx @@ -91,14 +91,7 @@ beforeEach(() => { return response(linkedSkill()); if (String(input).endsWith(`/skills/${secondSkillId}`)) return response(linkedSkill(secondSkillId)); - if (String(input).startsWith('/api/skills/service-recommendations?')) - return response({ - skillName: new URL( - String(input), - 'https://console.test', - ).searchParams.get('skillName'), - suggestions: [], - }); + if (String(input).endsWith('/user-services')) return response({ services: [ @@ -151,6 +144,18 @@ it('resolves the exact ID outside the search results, then explicitly binds its expect(selectedSkill()).toHaveTextContent('booking-capacity-renamed'), ); await waitFor(() => expect(bind).toBeEnabled()); + expect(screen.queryByText(/^Suggested for /)).not.toBeInTheDocument(); + expect( + screen.queryByRole('button', { name: 'Refresh suggestions' }), + ).not.toBeInTheDocument(); + expect( + screen.queryByRole('link', { name: 'Manage connections in NyxID ↗' }), + ).not.toBeInTheDocument(); + expect( + fetchMock.mock.calls.some(([input]) => + String(input).includes('/api/skills/service-recommendations'), + ), + ).toBe(false); expect(writes()).toHaveLength(0); fireEvent.click(bind); await screen.findByRole('alert'); @@ -222,6 +227,18 @@ it('ignores the URL default when editing a saved registration', async () => { renderWithQueryClient(); await screen.findByRole('combobox'); expect(selectedSkill()).toHaveTextContent('saved-support'); + expect(screen.queryByText(/^Suggested for /)).not.toBeInTheDocument(); + expect( + screen.queryByRole('button', { name: 'Refresh suggestions' }), + ).not.toBeInTheDocument(); + expect( + screen.queryByRole('link', { name: 'Manage connections in NyxID ↗' }), + ).not.toBeInTheDocument(); + expect( + fetchMock.mock.calls.some(([input]) => + String(input).includes('/api/skills/service-recommendations'), + ), + ).toBe(false); expect(screen.getByRole('button', { name: 'Save changes' })).toBeDisabled(); expect(detailReads()).toHaveLength(0); expect(writes()).toHaveLength(0); diff --git a/apps/aevatar-console-web/src/pages/workflow-activity-vnext/channels/ChannelEditPage.test.tsx b/apps/aevatar-console-web/src/pages/workflow-activity-vnext/channels/ChannelEditPage.test.tsx index 9bd06b62c..21a69fdeb 100644 --- a/apps/aevatar-console-web/src/pages/workflow-activity-vnext/channels/ChannelEditPage.test.tsx +++ b/apps/aevatar-console-web/src/pages/workflow-activity-vnext/channels/ChannelEditPage.test.tsx @@ -99,14 +99,6 @@ const selectedServiceIds = [ 'user-service-ornn', ]; function catalogue(input: RequestInfo | URL, services = serviceCatalogue) { - if (String(input).startsWith('/api/skills/service-recommendations?')) - return response({ - skillName: new URL( - String(input), - 'https://console.test', - ).searchParams.get('skillName'), - suggestions: [], - }); if (String(input).includes('/skill-search')) return response({ data: { diff --git a/apps/aevatar-console-web/src/pages/workflow-activity-vnext/channels/ChannelServicePicker.tsx b/apps/aevatar-console-web/src/pages/workflow-activity-vnext/channels/ChannelServicePicker.tsx index 50788dbff..d2921b3cc 100644 --- a/apps/aevatar-console-web/src/pages/workflow-activity-vnext/channels/ChannelServicePicker.tsx +++ b/apps/aevatar-console-web/src/pages/workflow-activity-vnext/channels/ChannelServicePicker.tsx @@ -20,7 +20,6 @@ export default function ChannelServicePicker({ retry, editing = false, replacesDefaults = false, - suggestions, }: { readonly accessNotice?: React.ReactNode; readonly suggestedIds?: readonly string[]; @@ -36,7 +35,6 @@ export default function ChannelServicePicker({ readonly retry: () => void; readonly editing?: boolean; readonly replacesDefaults?: boolean; - readonly suggestions?: React.ReactNode; }) { const [search, setSearch] = React.useState(''); const term = search.trim().toLocaleLowerCase(); @@ -72,7 +70,6 @@ export default function ChannelServicePicker({ )}

{accessNotice} - {suggestions} {loading ? ( ({ authFetch: jest.fn() })); -jest.mock('@/shared/studio/api', () => ({ - studioApi: { - getAuthSession: jest.fn().mockResolvedValue({ authenticated: false }), - }, -})); - -const fetchMock = jest.mocked(authFetch); -const response = (value: unknown, status = 200) => - ({ ok: status === 200, status, json: async () => value }) as Response; -const personal = (id: string, slug: string, label = slug) => ({ - id, - slug, - label, - is_active: true, - credential_source: { type: 'personal' }, -}); -const inventory = [ - personal('us-ornn', 'ornn-api', 'Ornn'), - personal('us-llm', 'chrono-llm-public', 'LLM'), - personal('us-manual', 'manual-only', 'Manual service'), - personal('us-personal', 'api-github', 'Personal GitHub'), - { - ...personal('us-org', 'api-github', 'Team GitHub'), - credential_source: { type: 'org', org_name: 'Acme', allowed: true }, - }, - personal('us-linear', 'linear', 'Linear'), - { ...personal('us-inactive', 'inactive-service'), is_active: false }, - { - ...personal('us-owner', 'owner-service'), - credential_source: { type: 'org', org_name: 'Other team', allowed: false }, - }, -]; -const allowedIds = ['us-ornn', 'us-llm', 'us-manual', 'us-personal', 'us-org']; -const registration = { - id: 'reg-alpha', - nyx_channel_bot_id: 'bot-alpha', - platform: 'lark', - label: 'Support bot', - owned: true, - binding_status: 'bound', - availability_status: 'available', - nyx_status: 'active', - skill_name: 'support', - authorization_mode: 'explicit_service_allowlist', - service_ids: ['us-manual', 'us-ornn', 'us-llm'], - state_version: 12, -}; -const instance = (id: string, slug: string, label: string) => ({ - id, - slug, - label, - active: true, - allowed: true, - source: 'personal', - organizationName: '', -}); -function suggestionsResponse(skillName = 'support', recovered = false) { - return { - skillName, - suggestions: - skillName === 'no-services' - ? [] - : [ - { - slug: 'api-github', - label: 'GitHub', - evidence: 'linked', - instances: [ - instance('us-personal', 'api-github', 'Personal GitHub'), - { - ...instance('us-org', 'api-github', 'Team GitHub'), - source: 'organization', - organizationName: 'Acme', - }, - ], - }, - { - slug: 'linear', - label: 'Linear', - evidence: 'mention', - instances: [instance('us-linear', 'linear', 'Linear')], - }, - { - slug: 'inactive-service', - label: 'Inactive service', - evidence: 'mention', - instances: [ - { - ...instance( - 'us-inactive', - 'inactive-service', - 'Inactive service', - ), - active: false, - }, - ], - }, - { - slug: 'owner-service', - label: 'Owner service', - evidence: 'mention', - instances: [ - { - ...instance('us-owner', 'owner-service', 'Owner service'), - allowed: false, - source: 'organization', - organizationName: 'Other team', - }, - ], - }, - { - slug: 'api-drive', - label: 'Drive', - evidence: 'catalog', - instances: [], - }, - { - slug: 'slack', - label: 'Slack', - evidence: 'mention', - instances: recovered - ? [instance('us-slack', 'slack', 'Connected Slack')] - : [], - }, - ], - }; -} - -async function serve(input: RequestInfo | URL, init?: RequestInit) { - const url = String(input); - if (init?.method === 'POST') - return response({ error: 'insecure_webhook_base_url' }, 400); - if (url.endsWith('/user-services')) return response({ services: inventory }); - if (url.startsWith('/api/skills/service-recommendations?')) - return response( - suggestionsResponse( - new URL(url, 'https://console.test').searchParams.get('skillName') ?? - '', - ), - ); - if (url.includes('/skill-search')) - return response({ - data: { - items: ['support', 'slow-skill', 'no-services'].map((name) => ({ - guid: `guid-${name}`, - name, - })), - meta: { hasMore: false }, - }, - }); - if (url === '/api/channels/registrations?scope=all') - return response([registration]); - if (url === '/api/channels/registrations/reg-alpha') - return response(registration); - throw new Error(`Unexpected request: ${url}`); -} - -beforeEach(() => { - fetchMock.mockReset().mockImplementation(serve); - persistAuthSession( - createNyxIDServiceSession({ allowed_service_ids: allowedIds }), - ); -}); - -function renderForm() { - return renderWithQueryClient( - , - ); -} -function suggestions(skill = 'support') { - return screen.getByRole('region', { name: `Suggested for ${skill}` }); -} -async function chooseSkill(name: string) { - fireEvent.mouseDown(await screen.findByRole('combobox')); - fireEvent.click( - await screen.findByText(name, { - selector: '.channels__skill-option strong', - }), - ); -} -const writes = () => - fetchMock.mock.calls.filter(([, init]) => init?.method === 'POST'); - -it('displays backend recommendations and access gaps, then saves only the explicitly selected UserService instance', async () => { - renderForm(); - await screen.findByRole('button', { name: 'Select Team GitHub' }); - const related = within(suggestions()); - expect( - related.getByText('Linked to this skill in Ornn.'), - ).toBeInTheDocument(); - expect( - related.getByText('The service catalog recommends this skill.'), - ).toBeInTheDocument(); - expect(related.getByText('Slack')).toBeInTheDocument(); - expect(related.getAllByText(/may be needed for some tasks/)).not.toHaveLength( - 0, - ); - expect(related.getByRole('button', { name: 'Select Linear' })).toBeEnabled(); - expect(related.getByText(/Inactive —/)).toBeInTheDocument(); - expect(related.getByText(/Access unavailable —/)).toBeInTheDocument(); - expect(related.getAllByText(/No connection found/)).toHaveLength(2); - expect( - related.queryByRole('button', { name: 'Select Owner service' }), - ).not.toBeInTheDocument(); - expect( - related.getByRole('link', { name: /Manage connections/ }), - ).toHaveAttribute('href', 'https://nyx.chrono-ai.fun/services'); - expect( - related.queryByRole('link', { name: /Review service access/ }), - ).not.toBeInTheDocument(); - expect( - fetchMock.mock.calls.some(([url]) => - /\/skills\/.*\/json|\/catalog\?/.test(String(url)), - ), - ).toBe(false); - expect(screen.getByRole('button', { name: 'Save changes' })).toBeDisabled(); - expect(writes()).toHaveLength(0); - fireEvent.click(related.getByRole('button', { name: 'Select Linear' })); - fireEvent.click(related.getByRole('button', { name: 'Select Team GitHub' })); - expect(related.getAllByText('Selected')).toHaveLength(2); - expect( - screen.getByRole('checkbox', { name: /Personal GitHub/ }), - ).not.toBeChecked(); - expect(screen.getByRole('checkbox', { name: /Team GitHub/ })).toBeChecked(); - fireEvent.click(screen.getByRole('button', { name: 'Save changes' })); - await screen.findByRole('alert'); - expect(writes()).toHaveLength(1); - expect(JSON.parse(String(writes()[0][1]?.body))).toEqual({ - skill_name: 'support', - authorization_mode: 'explicit_service_allowlist', - service_ids: ['us-linear', 'us-llm', 'us-manual', 'us-org', 'us-ornn'], - }); -}); - -it('updates suggestions on skill changes and ignores late results while retaining manual choices after clearing', async () => { - let complete!: (value: Response) => void; - const pending = new Promise((resolve) => { - complete = resolve; - }); - let pendingSignal: AbortSignal | null | undefined; - fetchMock.mockImplementation((input, init) => { - if ( - String(input).endsWith('/service-recommendations?skillName=slow-skill') - ) { - pendingSignal = init?.signal; - return pending; - } - return serve(input, init); - }); - renderForm(); - fireEvent.click( - await screen.findByRole('button', { name: 'Select Personal GitHub' }), - ); - await chooseSkill('slow-skill'); - await waitFor(() => expect(pendingSignal).toBeTruthy()); - expect( - within(suggestions('slow-skill')).getByText('Finding related services...'), - ).toBeInTheDocument(); - expect( - within(suggestions('slow-skill')).queryByText('GitHub'), - ).not.toBeInTheDocument(); - await chooseSkill('no-services'); - await screen.findByText(/No related services identified/); - expect(pendingSignal?.aborted).toBe(true); - await act(async () => complete(response(suggestionsResponse('slow-skill')))); - expect( - within(suggestions('no-services')).queryByText('Slack'), - ).not.toBeInTheDocument(); - fireEvent.mouseDown(screen.getByRole('img', { name: 'close-circle' })); - expect( - screen.queryByRole('region', { name: /Suggested for/ }), - ).not.toBeInTheDocument(); - expect( - screen.getByRole('checkbox', { name: /Personal GitHub/ }), - ).toBeChecked(); - expect( - screen.getByRole('checkbox', { name: /Manual service/ }), - ).toBeChecked(); - expect(writes()).toHaveLength(0); -}); - -it('keeps manual selection usable on discovery failure and refreshes connections and authorization on retry', async () => { - let recovered = false; - fetchMock.mockImplementation((input, init) => { - if (!recovered && String(input).includes('/service-recommendations?')) - return Promise.resolve( - response({ message: 'PRIVATE_SERVER_DETAIL' }, 503), - ); - if (recovered && String(input).includes('/service-recommendations?')) - return Promise.resolve(response(suggestionsResponse('support', true))); - if (recovered && String(input).endsWith('/user-services')) - return Promise.resolve( - response({ - services: [ - ...inventory, - personal('us-slack', 'slack', 'Connected Slack'), - ], - }), - ); - return serve(input, init); - }); - renderForm(); - await screen.findByText(/Could not identify related services/); - expect(document.body).not.toHaveTextContent('PRIVATE_SERVER_DETAIL'); - fireEvent.click(screen.getByRole('checkbox', { name: /Personal GitHub/ })); - expect(screen.getByRole('button', { name: 'Save changes' })).toBeEnabled(); - recovered = true; - persistAuthSession( - createNyxIDServiceSession({ - allowed_service_ids: [...allowedIds, 'us-slack', 'us-linear'], - }), - ); - fireEvent.click(screen.getByRole('button', { name: 'Refresh suggestions' })); - await screen.findByRole('button', { name: 'Select Connected Slack' }); - expect(screen.getByRole('button', { name: 'Select Linear' })).toBeEnabled(); - expect( - screen.queryByText(/Could not identify related services/), - ).not.toBeInTheDocument(); - expect( - screen.getByRole('checkbox', { name: /Personal GitHub/ }), - ).toBeChecked(); - expect( - screen.getByRole('checkbox', { name: /Connected Slack/ }), - ).not.toBeChecked(); - expect(writes()).toHaveLength(0); -}); diff --git a/apps/aevatar-console-web/src/pages/workflow-activity-vnext/channels/ChannelSkillServices.tsx b/apps/aevatar-console-web/src/pages/workflow-activity-vnext/channels/ChannelSkillServices.tsx deleted file mode 100644 index 32b68322d..000000000 --- a/apps/aevatar-console-web/src/pages/workflow-activity-vnext/channels/ChannelSkillServices.tsx +++ /dev/null @@ -1,229 +0,0 @@ -import { useQuery } from '@tanstack/react-query'; -import { Button } from 'antd'; -import * as React from 'react'; -import type { ChannelServiceChoice } from '@/shared/api/channelServicesApi'; -import { getChannelSkillServices } from '@/shared/api/channelSkillServicesApi'; -import { t } from '@/shared/i18n/messages'; -import { AevatarLoadingDots } from '@/shared/ui/AevatarLoading'; -import { channelKeys } from './queries'; - -export default function ChannelSkillServices({ - scopeId, - skillName, - services, - selectedIds, - checkingAccess, - accessFailed, - disabled, - onSelect, - refreshAccess, -}: { - readonly scopeId: string; - readonly skillName: string; - readonly services: readonly ChannelServiceChoice[]; - readonly selectedIds: readonly string[]; - readonly checkingAccess: boolean; - readonly accessFailed: boolean; - readonly disabled: boolean; - readonly onSelect: (id: string) => void; - readonly refreshAccess: () => void; -}) { - const titleId = React.useId(); - const query = useQuery({ - queryKey: channelKeys.skillServices(scopeId, skillName), - queryFn: ({ signal }) => getChannelSkillServices(skillName, signal), - enabled: Boolean(scopeId && skillName), - retry: false, - refetchOnWindowFocus: false, - refetchOnReconnect: false, - }); - if (!skillName) return null; - const availableIds = new Set( - services - .filter((service) => service.active && service.allowed) - .map((service) => service.id), - ); - const refreshing = query.isFetching || checkingAccess; - return ( -
-
-

- {t('channels.suggestions.title', 'Suggested for {skill}', { - skill: skillName, - })} -

- -
- {query.isPending || query.isFetching ? ( -

- - {t('channels.suggestions.loading', 'Finding related services...')} -

- ) : query.isError ? ( -

- {t( - 'channels.suggestions.error', - 'Could not identify related services. Refresh to retry, or select services manually below.', - )} -

- ) : ( - <> -

- {t( - 'channels.suggestions.help', - 'Suggestions may be incomplete and do not confirm required dependencies. Select the services your task needs; each addition is saved with the channel.', - )} -

- {query.data.length ? ( -
    - {query.data.map((recommendation) => ( -
  • - {recommendation.label} - - {recommendation.slug} - -

    - {recommendation.evidence === 'linked' - ? t( - 'channels.suggestions.linked', - 'Linked to this skill in Ornn.', - ) - : recommendation.evidence === 'catalog' - ? t( - 'channels.suggestions.catalog', - 'The service catalog recommends this skill.', - ) - : t( - 'channels.suggestions.mention', - 'Mentioned in the skill description or instructions; may be needed for some tasks.', - )} -

    - {recommendation.instances.length ? ( - recommendation.instances.map((instance) => { - const selected = selectedIds.includes(instance.id); - const selectable = availableIds.has(instance.id); - return ( -
    -
    - - {instance.label} - - - {instance.source === 'personal' - ? t('channels.connect.personal', 'Personal') - : instance.source === 'organization' - ? instance.organizationName || - t( - 'channels.connect.organization', - 'Organization', - ) - : t( - 'channels.suggestions.unknownSource', - 'Unknown source', - )} - - - {checkingAccess - ? t( - 'channels.suggestions.checking', - 'Checking access...', - ) - : accessFailed - ? t( - 'channels.suggestions.accessError', - 'Could not check access. Refresh to retry.', - ) - : !instance.active - ? t( - 'channels.suggestions.inactive', - 'Inactive — manage this service in NyxID.', - ) - : !instance.allowed - ? t( - 'channels.suggestions.unavailable', - 'Access unavailable — check with the service owner.', - ) - : !selectable - ? t( - 'channels.suggestions.missingInstance', - 'No active connection found. Refresh or check this service in NyxID.', - ) - : !selected - ? t( - 'channels.suggestions.notSelected', - 'Not selected', - ) - : null} - -
    - {selected ? ( - - {t('channels.suggestions.selected', 'Selected')} - - ) : selectable && - instance.active && - instance.allowed ? ( - - ) : null} -
    - ); - }) - ) : ( -

    - {t( - 'channels.suggestions.notConnected', - 'No connection found in your account. Add this service in NyxID.', - )} -

    - )} -
  • - ))} -
- ) : ( -

- {t( - 'channels.suggestions.empty', - 'No related services identified. You can still select services manually below.', - )} -

- )} - - )} - -
- ); -} diff --git a/apps/aevatar-console-web/src/pages/workflow-activity-vnext/channels/connectionStyles.ts b/apps/aevatar-console-web/src/pages/workflow-activity-vnext/channels/connectionStyles.ts index dcc849730..5364e8ee9 100644 --- a/apps/aevatar-console-web/src/pages/workflow-activity-vnext/channels/connectionStyles.ts +++ b/apps/aevatar-console-web/src/pages/workflow-activity-vnext/channels/connectionStyles.ts @@ -23,19 +23,6 @@ export const channelConnectionCss = ` .channels__connection-form button.ant-input-password-icon:focus-visible { outline: 2px solid var(--wa-blue); outline-offset: 2px; } .channels__services-heading { align-items: center; display: flex; gap: 16px; justify-content: space-between; } .channels__services-heading > span { color: var(--wa-blue); font-size: 11px; } -.channels__skill-services { background: var(--wa-subtle); border: 1px solid var(--channels-border); border-radius: var(--wa-radius); margin-top: 12px; padding: 12px; } -.channels__skill-services .channels__services-heading { align-items: flex-start; flex-wrap: wrap; gap: 8px; } -.channels__skill-services h3 { color: var(--wa-ink); flex: 1 1 160px; font-size: 12px; line-height: 20px; margin: 0; overflow-wrap: anywhere; } -.channels__suggestion-list { list-style: none; margin: 12px 0 0; max-height: 360px; overflow-y: auto; padding: 0; scrollbar-gutter: stable; } -.channels__suggestion { border-top: 1px solid var(--channels-border); color: var(--wa-ink); font-size: 12px; padding: 12px 0; overflow-wrap: anywhere; } -.channels__suggestion:first-child { border-top: 0; padding-top: 0; } -.channels__suggestion:last-child { padding-bottom: 0; } -.channels__suggestion-instance { align-items: center; display: flex; flex-wrap: wrap; gap: 8px; margin-top: 8px; } -.channels__suggestion-instance > div { flex: 1 1 180px; min-width: 0; } -.channels__suggestion-selected { color: var(--wa-blue); font-size: 11px; } -.channels__suggestion-links { display: flex; flex-wrap: wrap; gap: 8px 16px; margin-top: 12px; } -.channels__suggestion-links a { color: var(--wa-blue); font-size: 11px; line-height: 18px; } -.channels__suggestion-links a:focus-visible { outline: 2px solid var(--wa-blue); outline-offset: 2px; } .channels__access-notice { background: var(--wa-blue-bg); border: 1px solid var(--channels-border); border-left: 3px solid var(--wa-blue); border-radius: var(--wa-radius); color: var(--wa-ink); font-size: 12px; line-height: 20px; margin-top: 16px; padding: 16px; } .channels__access-notice--needed { background: var(--wa-amber-bg); border-left-color: var(--wa-amber); } .channels__access-notice-heading { align-items: center; display: flex; gap: 8px; } diff --git a/apps/aevatar-console-web/src/pages/workflow-activity-vnext/channels/queries.ts b/apps/aevatar-console-web/src/pages/workflow-activity-vnext/channels/queries.ts index 3b2737304..5d751b311 100644 --- a/apps/aevatar-console-web/src/pages/workflow-activity-vnext/channels/queries.ts +++ b/apps/aevatar-console-web/src/pages/workflow-activity-vnext/channels/queries.ts @@ -11,8 +11,6 @@ export const channelKeys = { ['channels', scopeId, 'skills', search] as const, skill: (scopeId: string, id: string) => ['channels', scopeId, 'skill', id] as const, - skillServices: (scopeId: string, name: string) => - ['channels', scopeId, 'skill-services', name] as const, list: (scopeId: string) => ['channels', scopeId, 'registrations'] as const, }; diff --git a/apps/aevatar-console-web/src/shared/api/channelSkillServicesApi.test.ts b/apps/aevatar-console-web/src/shared/api/channelSkillServicesApi.test.ts deleted file mode 100644 index 1a26aa62a..000000000 --- a/apps/aevatar-console-web/src/shared/api/channelSkillServicesApi.test.ts +++ /dev/null @@ -1,90 +0,0 @@ -import { authFetch } from '@/shared/auth/fetch'; -import { getChannelSkillServices } from './channelSkillServicesApi'; - -jest.mock('@/shared/auth/fetch', () => ({ authFetch: jest.fn() })); -const fetchMock = jest.mocked(authFetch); -const response = (value: unknown, status = 200) => - ({ ok: status === 200, status, json: async () => value }) as Response; - -afterEach(() => fetchMock.mockReset()); - -it('reads the backend contract with an encoded skill name and preserves exact instance identity', async () => { - fetchMock.mockResolvedValue( - response({ - skillName: 'team+docs', - suggestions: [ - { - slug: 'api-github', - label: 'GitHub', - evidence: 'catalog', - instances: [ - { - id: 'us-team', - slug: 'api-github', - label: 'Team GitHub', - active: true, - allowed: false, - source: 'organization', - organizationName: 'Acme', - }, - ], - }, - ], - }), - ); - const signal = new AbortController().signal; - expect(await getChannelSkillServices('team+docs', signal)).toEqual([ - { - slug: 'api-github', - label: 'GitHub', - evidence: 'catalog', - instances: [ - { - id: 'us-team', - slug: 'api-github', - label: 'Team GitHub', - active: true, - allowed: false, - source: 'organization', - organizationName: 'Acme', - }, - ], - }, - ]); - expect(fetchMock).toHaveBeenCalledWith( - '/api/skills/service-recommendations?skillName=team%2Bdocs', - { - signal, - cache: 'no-store', - }, - ); -}); - -it('rejects mismatched and unsupported results and never surfaces upstream error contents', async () => { - fetchMock.mockResolvedValueOnce( - response({ skillName: 'another-skill', suggestions: [] }), - ); - await expect(getChannelSkillServices('support')).rejects.toThrow( - 'Mismatched skill recommendations.', - ); - fetchMock.mockResolvedValueOnce( - response({ - skillName: 'support', - suggestions: [ - { - slug: 'github', - label: 'GitHub', - evidence: 'new-unknown-kind', - instances: [], - }, - ], - }), - ); - await expect(getChannelSkillServices('support')).rejects.toThrow( - 'Invalid service suggestion.', - ); - fetchMock.mockResolvedValueOnce(response({ error: 'TEST_ONLY_SECRET' }, 502)); - await expect(getChannelSkillServices('support')).rejects.toThrow( - 'Channel request failed (502).', - ); -}); diff --git a/apps/aevatar-console-web/src/shared/api/channelSkillServicesApi.ts b/apps/aevatar-console-web/src/shared/api/channelSkillServicesApi.ts deleted file mode 100644 index afe7c9ef3..000000000 --- a/apps/aevatar-console-web/src/shared/api/channelSkillServicesApi.ts +++ /dev/null @@ -1,92 +0,0 @@ -import { authFetch } from '@/shared/auth/fetch'; -import type { ChannelServiceChoice } from './channelServicesApi'; -import { ChannelApiError } from './channelsApi'; -import { - expectArray, - expectBoolean, - expectRecord, - readString, -} from './http/decoders'; - -export interface SkillServiceRecommendation { - readonly slug: string; - readonly label: string; - readonly evidence: 'linked' | 'catalog' | 'mention'; - readonly instances: readonly ChannelServiceChoice[]; -} - -export async function getChannelSkillServices( - skillName: string, - signal?: AbortSignal, -): Promise { - const params = new URLSearchParams({ skillName }); - const response = await authFetch( - `/api/skills/service-recommendations?${params}`, - { - signal, - cache: 'no-store', - }, - ); - if (!response.ok) throw new ChannelApiError(response.status); - const body = expectRecord( - await response.json(), - 'Skill service recommendations', - ); - if (body.skillName !== skillName) - throw new Error('Mismatched skill recommendations.'); - const suggestions = expectArray( - body.suggestions, - 'Service suggestions', - (value) => { - const item = expectRecord(value, 'Service suggestion'); - const slug = readString(item, 'slug', 'Service slug'); - const label = readString(item, 'label', 'Service label'); - const evidence = item.evidence; - if ( - !slug.trim() || - !label.trim() || - (evidence !== 'linked' && - evidence !== 'catalog' && - evidence !== 'mention') - ) - throw new Error('Invalid service suggestion.'); - const instances = expectArray( - item.instances, - 'Service instances', - (entry): ChannelServiceChoice => { - const instance = expectRecord(entry, 'Service instance'); - const id = readString(instance, 'id', 'UserService ID'); - const source = instance.source; - if ( - !id.trim() || - instance.slug !== slug || - (source !== 'personal' && - source !== 'organization' && - source !== 'unknown') - ) - throw new Error('Invalid service instance.'); - return { - id, - slug, - source, - label: readString(instance, 'label', 'Instance label'), - active: expectBoolean(instance.active, 'Service activity'), - allowed: expectBoolean(instance.allowed, 'Account access'), - organizationName: - readString(instance, 'organizationName', 'Organization name') || - null, - }; - }, - ); - if ( - new Set(instances.map((instance) => instance.id)).size !== - instances.length - ) - throw new Error('Ambiguous service instances.'); - return { slug, label, evidence, instances }; - }, - ); - if (new Set(suggestions.map((item) => item.slug)).size !== suggestions.length) - throw new Error('Ambiguous service suggestions.'); - return suggestions; -}