Skip to content

Commit fc95924

Browse files
committed
refactor: add buildCategories(uiMode) function and CATEGORY_NAMES const array
Introduce buildCategories(uiMode?) that returns mode-resolved tool variants directly (openai tools in openai mode, default tools otherwise), eliminating the need for runtime adapter dispatch. The same category names are used in both modes — only the tool implementations differ. - Add CATEGORY_NAMES const array as the single source of truth for category names - Add ToolCategoryMap type derived from CATEGORY_NAMES - Add buildCategories() function with mode-specific tool resolution - Change ToolCategory type to derive from CATEGORY_NAMES instead of keyof typeof toolCategories - Mark static toolCategories as @deprecated (will be removed when tools-loader migrates) - Add compile-time satisfies checks via ToolCategoryMap type annotation - Add unit tests for buildCategories: mode variants, circular-init safety, ordering
1 parent 832e8e3 commit fc95924

File tree

4 files changed

+180
-7
lines changed

4 files changed

+180
-7
lines changed

src/tools/categories.ts

Lines changed: 76 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88
* The final tool ordering presented to MCP clients is determined by tools-loader.ts,
99
* which also auto-injects get-actor-run and get-actor-output right after call-actor.
1010
*/
11-
import type { ToolCategory } from '../types.js';
11+
import type { ToolEntry, UiMode } from '../types.js';
1212
import { callActor } from './actor.js';
1313
import { getDataset, getDatasetItems, getDatasetSchema } from './common/dataset.js';
1414
import { getUserDatasetsList } from './common/dataset_collection.js';
@@ -21,11 +21,26 @@ import { getUserKeyValueStoresList } from './common/key_value_store_collection.j
2121
import { abortActorRun, getActorRun, getActorRunLog } from './common/run.js';
2222
import { getUserRunsList } from './common/run_collection.js';
2323
import { searchApifyDocsTool } from './common/search-apify-docs.js';
24+
import { defaultCallActor } from './default/call-actor.js';
25+
import { defaultFetchActorDetails } from './default/fetch-actor-details.js';
26+
import { defaultGetActorRun } from './default/get-actor-run.js';
27+
import { defaultSearchActors } from './default/search-actors.js';
2428
import { fetchActorDetailsTool } from './fetch-actor-details.js';
29+
import { openaiCallActor } from './openai/call-actor.js';
30+
import { openaiFetchActorDetails } from './openai/fetch-actor-details.js';
2531
import { fetchActorDetailsInternalTool } from './openai/fetch-actor-details-internal.js';
32+
import { openaiGetActorRun } from './openai/get-actor-run.js';
33+
import { openaiSearchActors } from './openai/search-actors.js';
2634
import { searchActorsInternalTool } from './openai/search-actors-internal.js';
2735
import { searchActors } from './store_collection.js';
2836

37+
/**
38+
* Static tool categories using adapter tools that dispatch at runtime based on uiMode.
39+
*
40+
* @deprecated Use {@link buildCategories} instead, which returns mode-resolved tool variants
41+
* directly without runtime dispatching. This static map will be removed once the tools-loader
42+
* is refactored to use buildCategories().
43+
*/
2944
export const toolCategories = {
3045
experimental: [
3146
addTool,
@@ -63,9 +78,67 @@ export const toolCategories = {
6378
dev: [
6479
getHtmlSkeleton,
6580
],
66-
};
81+
} satisfies Record<string, ToolEntry[]>;
82+
83+
/**
84+
* Canonical list of all tool category names, derived from the toolCategories map
85+
* so there is a single source of truth for category definitions.
86+
*/
87+
export const CATEGORY_NAMES = Object.keys(toolCategories) as (keyof typeof toolCategories)[];
88+
89+
/** Map from category name to an array of tool entries. */
90+
export type ToolCategoryMap = Record<(typeof CATEGORY_NAMES)[number], ToolEntry[]>;
91+
92+
/**
93+
* Build tool categories for a given UI mode.
94+
*
95+
* Returns the same category names as {@link toolCategories}, but with mode-resolved
96+
* tool variants: openai mode gets openai-specific implementations (async execution,
97+
* widget metadata), default mode gets standard implementations.
98+
*
99+
* This eliminates the need for runtime adapter dispatch — each tool is the correct
100+
* variant for its mode from the start.
101+
*/
102+
export function buildCategories(uiMode?: UiMode): ToolCategoryMap {
103+
const isOpenai = uiMode === 'openai';
104+
return {
105+
experimental: [
106+
addTool,
107+
],
108+
actors: isOpenai
109+
? [openaiSearchActors, openaiFetchActorDetails, openaiCallActor]
110+
: [defaultSearchActors, defaultFetchActorDetails, defaultCallActor],
111+
ui: isOpenai
112+
? [searchActorsInternalTool, fetchActorDetailsInternalTool]
113+
: [],
114+
docs: [
115+
searchApifyDocsTool,
116+
fetchApifyDocsTool,
117+
],
118+
runs: [
119+
isOpenai ? openaiGetActorRun : defaultGetActorRun,
120+
getUserRunsList,
121+
getActorRunLog,
122+
abortActorRun,
123+
],
124+
storage: [
125+
getDataset,
126+
getDatasetItems,
127+
getDatasetSchema,
128+
getActorOutput,
129+
getKeyValueStore,
130+
getKeyValueStoreKeys,
131+
getKeyValueStoreRecord,
132+
getUserDatasetsList,
133+
getUserKeyValueStoresList,
134+
],
135+
dev: [
136+
getHtmlSkeleton,
137+
],
138+
};
139+
}
67140

68-
export const toolCategoriesEnabledByDefault: ToolCategory[] = [
141+
export const toolCategoriesEnabledByDefault: (typeof CATEGORY_NAMES)[number][] = [
69142
'actors',
70143
'docs',
71144
];

src/tools/index.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import { HelperTools } from '../const.js';
22
import type { ToolCategory } from '../types.js';
33
import { getExpectedToolsByCategories } from '../utils/tool-categories-helpers.js';
44
import { callActorGetDataset, getActorsAsTools } from './actor.js';
5-
import { toolCategories, toolCategoriesEnabledByDefault } from './categories.js';
5+
import { buildCategories, CATEGORY_NAMES, toolCategories, toolCategoriesEnabledByDefault } from './categories.js';
66

77
// Use string constants instead of importing tool objects to avoid circular dependency
88
export const unauthEnabledTools: string[] = [
@@ -13,7 +13,7 @@ export const unauthEnabledTools: string[] = [
1313

1414
// Re-export from categories.ts
1515
// This is actually needed to avoid circular dependency issues
16-
export { toolCategories, toolCategoriesEnabledByDefault };
16+
export { buildCategories, CATEGORY_NAMES, toolCategories, toolCategoriesEnabledByDefault };
1717

1818
// Computed here (not in helper file) to avoid module initialization issues
1919
export const defaultTools = getExpectedToolsByCategories(toolCategoriesEnabledByDefault);

src/types.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ import type z from 'zod';
1717
import type { ApifyClient } from './apify-client.js';
1818
import type { ACTOR_PRICING_MODEL, TELEMETRY_ENV, TOOL_STATUS } from './const.js';
1919
import type { ActorsMcpServer } from './mcp/server.js';
20-
import type { toolCategories } from './tools/index.js';
20+
import type { CATEGORY_NAMES } from './tools/categories.js';
2121
import type { StructuredPricingInfo } from './utils/pricing-info.js';
2222
import type { ProgressTracker } from './utils/progress.js';
2323

@@ -230,7 +230,7 @@ export type PricingInfo = ActorRunPricingInfo & {
230230
tieredPricing?: TieredPricing;
231231
} | PricePerEventActorPricingInfo;
232232

233-
export type ToolCategory = keyof typeof toolCategories;
233+
export type ToolCategory = (typeof CATEGORY_NAMES)[number];
234234
/**
235235
* Selector for tools input - can be a category key or a specific tool name.
236236
*/
Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
import { describe, expect, it } from 'vitest';
2+
3+
import { HelperTools } from '../../src/const.js';
4+
import { buildCategories, CATEGORY_NAMES, toolCategories } from '../../src/tools/index.js';
5+
import type { ToolCategory, ToolEntry } from '../../src/types.js';
6+
7+
describe('CATEGORY_NAMES', () => {
8+
it('should be derived from toolCategories keys', () => {
9+
// CATEGORY_NAMES is derived from toolCategories via Object.keys(),
10+
// so they are guaranteed to match. This test verifies the derivation is correct.
11+
const staticKeys = Object.keys(toolCategories);
12+
expect(CATEGORY_NAMES).toEqual(staticKeys);
13+
});
14+
});
15+
16+
describe('buildCategories', () => {
17+
it('should return all category keys matching CATEGORY_NAMES', () => {
18+
const defaultResult = buildCategories();
19+
const openaiResult = buildCategories('openai');
20+
21+
for (const name of CATEGORY_NAMES) {
22+
expect(defaultResult).toHaveProperty(name);
23+
expect(openaiResult).toHaveProperty(name);
24+
}
25+
});
26+
27+
it('should return no undefined entries in any category (circular-init safety)', () => {
28+
const defaultResult = buildCategories();
29+
const openaiResult = buildCategories('openai');
30+
31+
for (const name of CATEGORY_NAMES) {
32+
for (const tool of defaultResult[name]) {
33+
expect(tool).toBeDefined();
34+
expect(tool.name).toBeDefined();
35+
}
36+
for (const tool of openaiResult[name]) {
37+
expect(tool).toBeDefined();
38+
expect(tool.name).toBeDefined();
39+
}
40+
}
41+
});
42+
43+
it('should return empty ui category in default mode', () => {
44+
const result = buildCategories();
45+
expect(result.ui).toEqual([]);
46+
});
47+
48+
it('should return non-empty ui category in openai mode', () => {
49+
const result = buildCategories('openai');
50+
expect(result.ui.length).toBeGreaterThan(0);
51+
});
52+
53+
it('should return different tool variants for actors category based on mode', () => {
54+
const defaultResult = buildCategories();
55+
const openaiResult = buildCategories('openai');
56+
57+
// Both modes should have the same tool names in actors category
58+
const defaultNames = defaultResult.actors.map((t: ToolEntry) => t.name);
59+
const openaiNames = openaiResult.actors.map((t: ToolEntry) => t.name);
60+
expect(defaultNames).toEqual(openaiNames);
61+
62+
// But the actual tool objects should be different (different call implementations)
63+
expect(defaultResult.actors[0]).not.toBe(openaiResult.actors[0]);
64+
});
65+
66+
it('should return different get-actor-run variants based on mode', () => {
67+
const defaultResult = buildCategories();
68+
const openaiResult = buildCategories('openai');
69+
70+
const defaultGetRun = defaultResult.runs.find((t: ToolEntry) => t.name === HelperTools.ACTOR_RUNS_GET);
71+
const openaiGetRun = openaiResult.runs.find((t: ToolEntry) => t.name === HelperTools.ACTOR_RUNS_GET);
72+
73+
expect(defaultGetRun).toBeDefined();
74+
expect(openaiGetRun).toBeDefined();
75+
// Different objects (different implementations)
76+
expect(defaultGetRun).not.toBe(openaiGetRun);
77+
});
78+
79+
it('should share identical tools for mode-independent categories', () => {
80+
const defaultResult = buildCategories();
81+
const openaiResult = buildCategories('openai');
82+
83+
const modeIndependentCategories: ToolCategory[] = ['experimental', 'docs', 'storage', 'dev'];
84+
for (const cat of modeIndependentCategories) {
85+
expect(defaultResult[cat]).toEqual(openaiResult[cat]);
86+
}
87+
});
88+
89+
it('should preserve tool ordering within categories', () => {
90+
const result = buildCategories();
91+
const actorNames = result.actors.map((t: ToolEntry) => t.name);
92+
93+
// Verify workflow order: search → details → call
94+
expect(actorNames).toEqual([
95+
HelperTools.STORE_SEARCH,
96+
HelperTools.ACTOR_GET_DETAILS,
97+
HelperTools.ACTOR_CALL,
98+
]);
99+
});
100+
});

0 commit comments

Comments
 (0)