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:
SidQin-cyber
2026-05-07 15:29:15 +08:00
parent f270902234
commit 447a270671
3 changed files with 126 additions and 20 deletions
+6 -2
View File
@@ -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 () => {
+46 -18
View File
@@ -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);
});
});