Skip to content

Commit bfd0525

Browse files
fix(sdk): address PR review for preset registry (SD-3128)
Three review findings from PR 3541: 1. Restore structured ToolCatalog.tools type. The refactor narrowed the public catalog row to `unknown[]`, breaking TS consumers that read tools[i].toolName etc. Move ToolCatalogEntry + ToolCatalogOperation into presets.ts as public types and tighten the catalog signature. 2. Fail fast on malformed provider bundles. Node and Python preset loaders previously coerced a missing or non-array `tools` field to `[]`, hiding broken codegen output behind a silently empty tool surface. Restore the pre-presets TOOLS_ASSET_INVALID throw at the preset boundary. 3. Cross-lang parity for empty-string presets. Python choose_tools treated `{'preset': ''}` as legacy via `or DEFAULT_PRESET`; Node and MCP both raise PRESET_NOT_FOUND. Use an explicit None check so Python matches. Tests added covering structural catalog access, empty-string preset fail-fast, and cross-lang parity for the empty-string case. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 29a5d2f commit bfd0525

8 files changed

Lines changed: 122 additions & 32 deletions

File tree

packages/sdk/langs/node/src/__tests__/presets.test.ts

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,39 @@ describe('preset registry', () => {
5050
expect(details.availablePresets).toContain('legacy');
5151
}
5252
});
53+
54+
test('getPreset("") throws PRESET_NOT_FOUND (empty string is not the default)', () => {
55+
try {
56+
getPreset('');
57+
throw new Error('Expected getPreset("") to throw.');
58+
} catch (error) {
59+
expect(error).toBeInstanceOf(SuperDocCliError);
60+
expect((error as SuperDocCliError).code).toBe('PRESET_NOT_FOUND');
61+
}
62+
});
63+
64+
test('chooseTools({preset: ""}) throws PRESET_NOT_FOUND (cross-lang parity)', async () => {
65+
await expect(chooseTools({ provider: 'openai', preset: '' })).rejects.toMatchObject({
66+
code: 'PRESET_NOT_FOUND',
67+
});
68+
});
69+
});
70+
71+
describe('public ToolCatalog type — structural access', () => {
72+
test('getToolCatalog().tools entries expose typed properties', async () => {
73+
const catalog = await getToolCatalog();
74+
expect(catalog.tools.length).toBeGreaterThan(0);
75+
const first = catalog.tools[0]!;
76+
// These property accesses validate that ToolCatalog.tools is structurally
77+
// typed (ToolCatalogEntry[]) — not unknown[]. Compile failure here means
78+
// the public catalog row type regressed.
79+
expect(typeof first.toolName).toBe('string');
80+
expect(typeof first.description).toBe('string');
81+
expect(typeof first.mutates).toBe('boolean');
82+
expect(Array.isArray(first.operations)).toBe(true);
83+
expect(typeof first.operations[0]?.operationId).toBe('string');
84+
expect(typeof first.operations[0]?.intentAction).toBe('string');
85+
});
5386
});
5487

5588
describe('chooseTools — default preset equivalence', () => {

packages/sdk/langs/node/src/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -262,6 +262,8 @@ export type {
262262
CacheStrategy,
263263
SystemPromptForProviderResult,
264264
ToolCatalog,
265+
ToolCatalogEntry,
266+
ToolCatalogOperation,
265267
ToolChooserInput,
266268
ToolProvider,
267269
} from './tools.js';

packages/sdk/langs/node/src/presets.ts

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,33 @@ export type ToolProvider = 'openai' | 'anthropic' | 'vercel' | 'generic';
4444
*/
4545
export type CacheStrategy = 'explicit' | 'automatic' | 'unsupported' | 'disabled';
4646

47+
/**
48+
* One operation row in a {@link ToolCatalogEntry}. Each catalog entry can
49+
* dispatch to one or more operations (e.g. multi-action intent tools), so
50+
* the catalog records the operation id and the action discriminator that
51+
* routes to it.
52+
*/
53+
export type ToolCatalogOperation = {
54+
operationId: string;
55+
intentAction: string;
56+
required?: string[];
57+
requiredOneOf?: string[][];
58+
};
59+
60+
/**
61+
* One entry in the {@link ToolCatalog}. Matches the shape of the catalog
62+
* emitted by the legacy preset's codegen — kept stable as the public
63+
* catalog row shape so TypeScript consumers can introspect `tools[i]`
64+
* without losing property typing.
65+
*/
66+
export type ToolCatalogEntry = {
67+
toolName: string;
68+
description: string;
69+
inputSchema: Record<string, unknown>;
70+
mutates: boolean;
71+
operations: ToolCatalogOperation[];
72+
};
73+
4774
/**
4875
* Full tool catalog shape. The legacy preset returns the existing codegen
4976
* catalog with `contractVersion`, `generatedAt`, `toolCount`, `tools`.
@@ -52,7 +79,7 @@ export type ToolCatalog = {
5279
contractVersion: string;
5380
generatedAt: string | null;
5481
toolCount: number;
55-
tools: unknown[];
82+
tools: ToolCatalogEntry[];
5683
};
5784

5885
export interface GetToolsOptions {

packages/sdk/langs/node/src/presets/legacy.ts

Lines changed: 25 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,15 @@ import type { BoundDocApi } from '../generated/client.js';
2121
import type { InvokeOptions } from '../runtime/process.js';
2222
import { SuperDocCliError } from '../runtime/errors.js';
2323
import { dispatchIntentTool } from '../generated/intent-dispatch.generated.js';
24-
import type { PresetDescriptor, GetToolsOptions, GetToolsResult, ToolCatalog, ToolProvider } from '../presets.js';
24+
import type {
25+
GetToolsOptions,
26+
GetToolsResult,
27+
PresetDescriptor,
28+
ToolCatalog,
29+
ToolCatalogEntry,
30+
ToolCatalogOperation,
31+
ToolProvider,
32+
} from '../presets.js';
2533

2634
// Resolve tools directory relative to package root (works from both src/ and dist/)
2735
const toolsDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', '..', 'tools');
@@ -33,23 +41,6 @@ const providerFileByName: Record<ToolProvider, string> = {
3341
generic: 'tools.generic.json',
3442
};
3543

36-
type OperationEntry = {
37-
operationId: string;
38-
intentAction: string;
39-
required?: string[];
40-
requiredOneOf?: string[][];
41-
};
42-
43-
type ToolCatalogEntry = {
44-
toolName: string;
45-
description: string;
46-
inputSchema: Record<string, unknown>;
47-
mutates: boolean;
48-
operations: OperationEntry[];
49-
};
50-
51-
type LegacyToolCatalog = ToolCatalog & { tools: ToolCatalogEntry[] };
52-
5344
const STRIP_EMPTY_OPTIONAL_ARGS = new Set(['parentId', 'parentCommentId', 'id', 'status']);
5445

5546
function isRecord(value: unknown): value is Record<string, unknown> {
@@ -103,11 +94,11 @@ async function readProviderTools(provider: ToolProvider): Promise<{
10394
}
10495

10596
// Cached catalog instance — loaded once per process.
106-
let _catalogCache: LegacyToolCatalog | null = null;
97+
let _catalogCache: ToolCatalog | null = null;
10798

108-
async function getCachedCatalog(): Promise<LegacyToolCatalog> {
99+
async function getCachedCatalog(): Promise<ToolCatalog> {
109100
if (_catalogCache == null) {
110-
_catalogCache = await readJson<LegacyToolCatalog>('catalog.json');
101+
_catalogCache = await readJson<ToolCatalog>('catalog.json');
111102
}
112103
return _catalogCache;
113104
}
@@ -210,7 +201,7 @@ function validateToolArgs(toolName: string, args: Record<string, unknown>, tool:
210201
// 3. Reject missing per-operation required keys. For multi-action tools,
211202
// resolve the operation by action; for single-op tools, use the sole entry.
212203
const action = args.action;
213-
let op: OperationEntry | undefined;
204+
let op: ToolCatalogOperation | undefined;
214205
if (typeof action === 'string' && tool.operations.length > 1) {
215206
op = tool.operations.find((o) => o.intentAction === action);
216207
} else if (tool.operations.length === 1) {
@@ -230,7 +221,7 @@ function validateOperationRequired(
230221
toolName: string,
231222
action: unknown,
232223
args: Record<string, unknown>,
233-
op: OperationEntry,
224+
op: ToolCatalogOperation,
234225
): void {
235226
const actionLabel = typeof action === 'string' ? ` action "${action}"` : '';
236227

@@ -262,8 +253,16 @@ function validateOperationRequired(
262253

263254
async function legacyGetTools(provider: ToolProvider, options?: GetToolsOptions): Promise<GetToolsResult> {
264255
const { tools } = await readProviderTools(provider);
265-
const rawTools = Array.isArray(tools) ? tools : [];
266-
return applyCacheMarkers(rawTools, provider, options?.cache === true);
256+
// Fail fast on malformed provider artifacts so agents don't silently boot
257+
// with zero tools. Matches the pre-presets behavior of the public
258+
// `listTools` path (TOOLS_ASSET_INVALID).
259+
if (!Array.isArray(tools)) {
260+
throw new SuperDocCliError('Tool provider bundle is missing tools array.', {
261+
code: 'TOOLS_ASSET_INVALID',
262+
details: { provider },
263+
});
264+
}
265+
return applyCacheMarkers(tools, provider, options?.cache === true);
267266
}
268267

269268
async function legacyGetCatalog(): Promise<ToolCatalog> {
@@ -316,8 +315,7 @@ async function legacyDispatch(
316315
}
317316

318317
const catalog = await getCachedCatalog();
319-
const entries = catalog.tools as ToolCatalogEntry[];
320-
const tool = entries.find((t) => t.toolName === toolName);
318+
const tool = catalog.tools.find((t) => t.toolName === toolName);
321319
if (tool == null) {
322320
throw new SuperDocCliError(`Unknown tool: ${toolName}`, {
323321
code: 'TOOL_DISPATCH_NOT_FOUND',

packages/sdk/langs/node/src/tools.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,11 +16,13 @@ import {
1616
listPresets,
1717
type CacheStrategy,
1818
type ToolCatalog,
19+
type ToolCatalogEntry,
20+
type ToolCatalogOperation,
1921
type ToolProvider,
2022
} from './presets.js';
2123

2224
export { DEFAULT_PRESET, getPreset, listPresets };
23-
export type { CacheStrategy, ToolCatalog, ToolProvider };
25+
export type { CacheStrategy, ToolCatalog, ToolCatalogEntry, ToolCatalogOperation, ToolProvider };
2426

2527
// ---------------------------------------------------------------------------
2628
// chooseTools — provider-shaped tool list with optional cache markers

packages/sdk/langs/python/superdoc/presets/legacy.py

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -147,8 +147,16 @@ def _legacy_get_tools(provider: ToolProvider, *, cache: bool = False) -> Dict[st
147147
raise SuperDocError('provider is required.', code='INVALID_ARGUMENT', details={'provider': provider})
148148
provider_file = _read_json_asset(_PROVIDER_FILE[provider])
149149
tools = provider_file.get('tools')
150-
raw_tools = tools if isinstance(tools, list) else []
151-
return _apply_cache_markers(cast(List[Any], raw_tools), provider, cache)
150+
# Fail fast on malformed provider artifacts so agents don't silently boot
151+
# with zero tools. Matches the Node legacy preset's behavior and the
152+
# pre-presets contract of the public list_tools path.
153+
if not isinstance(tools, list):
154+
raise SuperDocError(
155+
'Tool provider bundle is missing tools array.',
156+
code='TOOLS_ASSET_INVALID',
157+
details={'provider': provider},
158+
)
159+
return _apply_cache_markers(cast(List[Any], tools), provider, cache)
152160

153161

154162
def _legacy_get_catalog() -> Dict[str, Any]:

packages/sdk/langs/python/superdoc/tools_api.py

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -77,7 +77,12 @@ def choose_tools(input: ToolChooserInput) -> Dict[str, Any]:
7777
details={'provider': provider},
7878
)
7979

80-
preset_id = input.get('preset') or DEFAULT_PRESET
80+
# Default only when `preset` is absent. An explicit empty string is passed
81+
# through to get_preset() so it raises PRESET_NOT_FOUND, matching Node/MCP
82+
# fail-fast behavior. Using `or DEFAULT_PRESET` would silently treat
83+
# `preset: ''` as legacy and hide misconfiguration.
84+
preset_arg = input.get('preset')
85+
preset_id = preset_arg if preset_arg is not None else DEFAULT_PRESET
8186
cache_requested = bool(input.get('cache'))
8287

8388
preset = get_preset(preset_id)

packages/sdk/langs/python/tests/test_presets.py

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,21 @@ def test_get_preset_nonexistent_raises_preset_not_found():
5757
assert 'legacy' in excinfo.value.details['availablePresets']
5858

5959

60+
def test_get_preset_empty_string_raises_preset_not_found():
61+
"""Empty string is NOT the default — it must fail fast like Node."""
62+
with pytest.raises(SuperDocError) as excinfo:
63+
get_preset('')
64+
assert excinfo.value.code == 'PRESET_NOT_FOUND'
65+
66+
67+
def test_choose_tools_empty_preset_raises_preset_not_found():
68+
"""Cross-lang parity with Node: chooseTools({preset: ''}) must throw, not
69+
silently use legacy."""
70+
with pytest.raises(SuperDocError) as excinfo:
71+
choose_tools({'provider': 'openai', 'preset': ''})
72+
assert excinfo.value.code == 'PRESET_NOT_FOUND'
73+
74+
6075
# ---------------------------------------------------------------------------
6176
# choose_tools — default preset equivalence
6277
# ---------------------------------------------------------------------------

0 commit comments

Comments
 (0)