mirror of
https://github.com/nexu-io/open-design.git
synced 2026-10-27 09:51:12 +01:00
fix(connectors): split drawer badge vs inventory counts; fix daemon test typecheck (#767 review)
Two follow-ups for @mrcfps's review on PR #767. 1) **P1: Drawer empty-state regression.** The previous commit reused the curated `toolCount` for both the header badge AND the inventory section's loading gate / empty-state branch / section count. The inventory section renders `connector.tools` directly, so a hydrated connector with raw provider tools but an empty allowlist (e.g. a write-only Composio surface) would render "no tools available" and hide the actual inventory list — exactly the contradiction my own PR description warned against. Fix splits the two surfaces: - `badgeToolCount` (curated, via the new exported helper `getConnectorBadgeToolCount`) feeds the card and drawer header badge — the summary count, where the #748 stability matters. - `inventoryToolCount = connector.tools.length` (inline) drives the drawer's loading gate, section count, and empty-state branch — the surfaces describing the actual rendered list. The card has no inventory section so it stays on the badge helper unchanged. 2) **CI: daemon test typecheck failed.** The connectors-service test's `provisionedTools[0].safety` index access tripped daemon `tsconfig.tests.json`'s `noUncheckedIndexedAccess` strict setting, even though my local `tsc -p tsconfig.json --noEmit` was clean — that config is a separate compilation. Bind through a defined-checked local before reading `.safety`, per @mrcfps's exact suggestion. Tests: - 4 new web tests in `EntryView.test.ts` pin the `getConnectorBadgeToolCount` contract, including the explicit regression: a connector with `allowedToolNames=[]` and `tools.length=800` returns badge=0 but the inventory length stays at 800 — the drawer's empty-state branch must use the inventory count, never the badge count. - Existing daemon test fixed without losing assertion coverage. Verified locally: - daemon vitest: 921/921 - web vitest: 336/336 (was 332, +4) - daemon `tsc -p tsconfig.json` and `tsc -p tsconfig.tests.json` (the CI killer): both clean - web `tsc -b --noEmit` clean - i18n-check passes Process learning baked into this PR: from now on I'll always run the `tsconfig.tests.json` separately before pushing, since the workspace typecheck script chains both and the second one is what CI fails on.
This commit is contained in:
@@ -542,8 +542,12 @@ describe('ConnectorDetail.allowedToolNames (issue #748)', () => {
|
||||
// refreshEligible classification stays a property of the definition,
|
||||
// not of the badge surface — confirm the helper still agrees so a
|
||||
// future refactor can't quietly let write tools into the allowlist
|
||||
// via a different code path.
|
||||
expect(isRefreshEligibleConnectorToolSafety(provisionedTools[0].safety)).toBe(false);
|
||||
// via a different code path. Bind through a defined-checked local so
|
||||
// the daemon's `noUncheckedIndexedAccess` strict tsconfig doesn't
|
||||
// type the index access as `T | undefined`.
|
||||
const firstProvisionedTool = provisionedTools[0];
|
||||
expect(firstProvisionedTool).toBeDefined();
|
||||
expect(isRefreshEligibleConnectorToolSafety(firstProvisionedTool!.safety)).toBe(false);
|
||||
});
|
||||
|
||||
it('treats an empty allowedToolNames as a real "0 tools" badge value (not a missing field)', async () => {
|
||||
|
||||
@@ -149,6 +149,32 @@ function scoreConnectorText(value: string | undefined, query: string, baseScore:
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Tool count rendered in the connector card badge and the drawer
|
||||
* header badge — the "N tools" summary the user sees at a glance.
|
||||
*
|
||||
* Tracks the curated `allowedToolNames` size so the badge stays close
|
||||
* to "tools the agent can actually invoke" instead of the raw provider
|
||||
* inventory: pre-Composio-hydration GitHub ships ~2 hardcoded tools,
|
||||
* post-hydration `connector.tools` swells to ≈868 raw provider tools,
|
||||
* but `allowedToolNames` only grows with read-only auto-approval
|
||||
* additions (issue #748).
|
||||
*
|
||||
* Falls back to `connector.tools.length` when `allowedToolNames` is
|
||||
* missing — tolerant of older daemon builds that haven't shipped the
|
||||
* field yet.
|
||||
*
|
||||
* **Do not** reuse this for drawer empty-state / loading-gate
|
||||
* decisions: those surfaces render `connector.tools` directly, so
|
||||
* they need `connector.tools.length` instead. Mixing the two is the
|
||||
* regression #767 review caught — a connector with an empty
|
||||
* allowlist but a non-empty inventory would render "no tools
|
||||
* available" while the actual inventory was suppressed.
|
||||
*/
|
||||
export function getConnectorBadgeToolCount(connector: ConnectorDetail): number {
|
||||
return connector.allowedToolNames?.length ?? connector.tools.length;
|
||||
}
|
||||
|
||||
export function getConnectorSearchScore(connector: ConnectorDetail, query: string): number | null {
|
||||
const normalizedQuery = query.trim().toLowerCase();
|
||||
if (!normalizedQuery) return 0;
|
||||
@@ -933,14 +959,9 @@ function ConnectorCard({
|
||||
const isConnected = connector.status === 'connected';
|
||||
const canConnect = !disabled && !isPending && connector.status === 'available';
|
||||
const canDisconnect = !disabled && !isPending && isConnected;
|
||||
// Use the curated allowedToolNames count rather than connector.tools.length
|
||||
// so the badge stays close to "tools the agent can actually invoke" instead
|
||||
// of jumping from ~2 hardcoded fallback tools to several hundred raw
|
||||
// provider tools the moment a Composio API key is configured (issue #748).
|
||||
// Fall back to tools.length for safety: older daemon builds (or any
|
||||
// connector that hasn't migrated yet) won't ship allowedToolNames over
|
||||
// the wire.
|
||||
const toolCount = connector.allowedToolNames?.length ?? connector.tools.length;
|
||||
// Card has no inventory section, just the summary badge — see
|
||||
// `getConnectorBadgeToolCount` for the curated-vs-inventory split.
|
||||
const toolCount = getConnectorBadgeToolCount(connector);
|
||||
const showToolsBadge = toolsLoaded;
|
||||
const toolsBadgeLabel = formatToolsBadge(toolCount, t);
|
||||
|
||||
@@ -1093,12 +1114,19 @@ function ConnectorDetailDrawer({
|
||||
const canConnect = !disabled && !isPending && connector.status === 'available';
|
||||
const canDisconnect = !disabled && !isPending && isConnected;
|
||||
const accountLabel = getDisplayableConnectorAccountLabel(connector);
|
||||
// Mirror the connector card: badge count tracks the curated allowlist
|
||||
// (issue #748). The drawer below still iterates over connector.tools to
|
||||
// show the full inventory — the count and the list are intentionally
|
||||
// different surfaces.
|
||||
const toolCount = connector.allowedToolNames?.length ?? connector.tools.length;
|
||||
const isLoadingTools = !toolsLoaded || (toolsLoading && toolCount === 0);
|
||||
// Two distinct counts in this drawer (issue #748, refined per #767
|
||||
// review):
|
||||
// - badgeToolCount: curated allowlist size, mirrors the connector
|
||||
// card summary. Stable across Composio hydration.
|
||||
// - inventoryToolCount: full provider inventory size. Drives the
|
||||
// loading gate, the section count, and the empty-state branch —
|
||||
// the section below renders the entire `connector.tools` array,
|
||||
// so a connector with an empty allowlist but a non-empty
|
||||
// inventory (e.g. write-only provider tools) must still render
|
||||
// the list, not collapse to "no tools available".
|
||||
const badgeToolCount = getConnectorBadgeToolCount(connector);
|
||||
const inventoryToolCount = connector.tools.length;
|
||||
const isLoadingTools = !toolsLoaded || (toolsLoading && inventoryToolCount === 0);
|
||||
const showToolsBadge = toolsLoaded;
|
||||
const closeBtnRef = useRef<HTMLButtonElement | null>(null);
|
||||
|
||||
@@ -1153,9 +1181,9 @@ function ConnectorDetailDrawer({
|
||||
{statusLabel(connector.status, t)}
|
||||
</span>
|
||||
{showToolsBadge ? (
|
||||
<span className="connector-tools-badge is-ready" title={formatToolsBadge(toolCount, t)}>
|
||||
<span className="connector-tools-badge is-ready" title={formatToolsBadge(badgeToolCount, t)}>
|
||||
<Icon name="settings" size={10} />
|
||||
<span>{formatToolsBadge(toolCount, t)}</span>
|
||||
<span>{formatToolsBadge(badgeToolCount, t)}</span>
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
@@ -1212,11 +1240,11 @@ function ConnectorDetailDrawer({
|
||||
|
||||
<section className="connector-drawer-section">
|
||||
<h3 className="connector-drawer-section-title">
|
||||
{t('connectors.toolsSection')} <span className="connector-drawer-count">{toolCount}</span>
|
||||
{t('connectors.toolsSection')} <span className="connector-drawer-count">{inventoryToolCount}</span>
|
||||
</h3>
|
||||
{isLoadingTools ? (
|
||||
<p className="connector-drawer-empty"><Icon name="spinner" size={12} /> {t('connectors.toolsLoading')}</p>
|
||||
) : toolCount === 0 ? (
|
||||
) : inventoryToolCount === 0 ? (
|
||||
<p className="connector-drawer-empty">{t('connectors.noToolsAvailable')}</p>
|
||||
) : (
|
||||
<ul className="connector-drawer-tools">
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import type { ConnectorDetail, ConnectorToolDetail } from '@open-design/contracts';
|
||||
|
||||
import {
|
||||
getConnectorBadgeToolCount,
|
||||
isTrustedConnectorCallbackOrigin,
|
||||
sortConnectorsForDisplay,
|
||||
sortConnectorsForSearch,
|
||||
@@ -97,3 +100,74 @@ describe('connector display sorting', () => {
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
// Issue #748 + #767 review: the connector card / drawer header badge
|
||||
// must track the curated allowlist size (so the number stays close to
|
||||
// "tools the agent can actually invoke" instead of jumping from ≈2 to
|
||||
// ≈868 when Composio hydrates the full provider inventory). Crucially,
|
||||
// this curated count must NOT be reused for drawer empty-state /
|
||||
// loading-gate decisions, because the drawer renders the full
|
||||
// inventory — a connector with an empty allowlist but a non-empty
|
||||
// inventory must still show the list.
|
||||
describe('getConnectorBadgeToolCount (issue #748)', () => {
|
||||
function makeRawTool(overrides: Partial<ConnectorToolDetail> = {}): ConnectorToolDetail {
|
||||
return {
|
||||
name: 'docs.bulk_op',
|
||||
title: 'Bulk op',
|
||||
safety: { sideEffect: 'write', approval: 'confirm', reason: 'Write op.' },
|
||||
refreshEligible: false,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
function makeConnector(overrides: Partial<ConnectorDetail> = {}): ConnectorDetail {
|
||||
return {
|
||||
id: 'docs',
|
||||
name: 'Docs',
|
||||
provider: 'composio',
|
||||
category: 'docs',
|
||||
status: 'available',
|
||||
tools: [],
|
||||
allowedToolNames: [],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
it('uses the curated allowedToolNames length for the badge', () => {
|
||||
const connector = makeConnector({
|
||||
tools: [makeRawTool({ name: 'docs.search' }), makeRawTool({ name: 'docs.fetch' })],
|
||||
allowedToolNames: ['docs.search', 'docs.fetch'],
|
||||
});
|
||||
expect(getConnectorBadgeToolCount(connector)).toBe(2);
|
||||
});
|
||||
|
||||
it('falls back to tools.length when allowedToolNames is missing (older daemon, defensive)', () => {
|
||||
const connector = makeConnector({
|
||||
tools: [makeRawTool({ name: 'docs.search' })],
|
||||
});
|
||||
// Strip the field to simulate a wire payload from a daemon build
|
||||
// that hasn't shipped allowedToolNames yet — the badge must still
|
||||
// render some count instead of NaN/crash.
|
||||
delete (connector as Partial<ConnectorDetail>).allowedToolNames;
|
||||
expect(getConnectorBadgeToolCount(connector)).toBe(1);
|
||||
});
|
||||
|
||||
it('returns 0 when the allowlist is empty even if the inventory is huge — drawer empty-state is the inventory caller\'s job', () => {
|
||||
// The exact #767-review regression: a hydrated connector with 800
|
||||
// raw provider tools (e.g. write-only Composio surface) but no
|
||||
// execution-safe tools auto-allowed yet. The badge correctly
|
||||
// reports 0 — but the drawer still has 800 tools to enumerate, so
|
||||
// it must NOT pipe this number into its empty-state branch.
|
||||
const inventory = Array.from({ length: 800 }, (_, index) =>
|
||||
makeRawTool({ name: `docs.bulk_op_${index}` }),
|
||||
);
|
||||
const connector = makeConnector({ tools: inventory, allowedToolNames: [] });
|
||||
expect(getConnectorBadgeToolCount(connector)).toBe(0);
|
||||
// Sanity: the drawer's inventory-side number stays at the full
|
||||
// count. These two numbers being different is the contract.
|
||||
expect(connector.tools.length).toBe(800);
|
||||
});
|
||||
|
||||
it('treats an empty inventory + empty allowlist as a real "0 tools" badge (not loading)', () => {
|
||||
expect(getConnectorBadgeToolCount(makeConnector())).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user