|
|
@@ -0,0 +1,1163 @@
|
|
|
+import { describe, expect, it } from 'vitest'
|
|
|
+import { jsonSchemaToPy, renderToolsSdkPy } from '@deepseek-ai/dsh-tools/src/py-types.ts'
|
|
|
+import { parameterSchemaSpecToJsonSchema } from '@deepseek-ai/dsh-tools'
|
|
|
+import type { ToolSdkSchema } from '@deepseek-ai/dsh-tools/src/ts-types.ts'
|
|
|
+
|
|
|
+describe('jsonSchemaToPy', () => {
|
|
|
+ it('maps the defineTool DSL subset', () => {
|
|
|
+ const cases: [unknown, string][] = [
|
|
|
+ [{ type: 'string' }, 'str'],
|
|
|
+ [{ type: 'number' }, 'float'],
|
|
|
+ [{ type: 'boolean' }, 'bool'],
|
|
|
+ [{ type: 'string', enum: ['a', 'b'] }, 'Literal["a", "b"]'],
|
|
|
+ [{ type: 'array', items: { type: 'number' } }, 'list[float]'],
|
|
|
+ [{ type: 'array', items: { type: 'string', enum: ['x', 'y'] } }, 'list[Literal["x", "y"]]'],
|
|
|
+ [{ type: 'array' }, 'list[Any]'],
|
|
|
+ [{ type: 'object' }, 'dict[str, Any]'],
|
|
|
+ [{ type: 'object', properties: {} }, 'dict[str, Any]'],
|
|
|
+ [{ type: 'object', properties: { x: { type: 'string' } } }, 'dict[str, Any]'],
|
|
|
+ ]
|
|
|
+ for (const [schema, expected] of cases) {
|
|
|
+ expect(jsonSchemaToPy(schema), JSON.stringify(schema)).toBe(expected)
|
|
|
+ }
|
|
|
+ })
|
|
|
+
|
|
|
+ it('is total: unsupported or hostile constructs degrade to Any, never throw', () => {
|
|
|
+ const cases: unknown[] = [
|
|
|
+ undefined,
|
|
|
+ null,
|
|
|
+ 42,
|
|
|
+ 'string-schema',
|
|
|
+ {},
|
|
|
+ { oneOf: 7 },
|
|
|
+ { $ref: '#/defs/x' },
|
|
|
+ { type: 'object', properties: 7 },
|
|
|
+ { type: 'string', enum: [1, 2] },
|
|
|
+ { type: 'string', enum: [] },
|
|
|
+ ]
|
|
|
+ for (const schema of cases) {
|
|
|
+ expect(() => jsonSchemaToPy(schema), JSON.stringify(schema)).not.toThrow()
|
|
|
+ }
|
|
|
+ expect(jsonSchemaToPy({ type: 'integer' })).toBe('int')
|
|
|
+ expect(jsonSchemaToPy({ type: 'string', const: 'fixed' })).toBe('Literal["fixed"]')
|
|
|
+ expect(jsonSchemaToPy({ type: 'boolean', const: true })).toBe('Literal[True]')
|
|
|
+ expect(jsonSchemaToPy({ type: 'number', const: 1.5 })).toBe('Literal[1.5]')
|
|
|
+ expect(jsonSchemaToPy({ type: 'boolean', enum: [false] })).toBe('Literal[False]')
|
|
|
+ expect(jsonSchemaToPy({ type: 'null' })).toBe('None')
|
|
|
+ expect(jsonSchemaToPy({ oneOf: [{ type: 'string' }, { type: 'null' }] })).toBe('str | None')
|
|
|
+ expect(jsonSchemaToPy({ oneOf: [] })).toBe('Any')
|
|
|
+ expect(jsonSchemaToPy({ type: 'object', properties: 7 })).toBe('Any')
|
|
|
+ expect(jsonSchemaToPy({ type: 'string', enum: [1, 2] })).toBe('Any')
|
|
|
+ expect(jsonSchemaToPy({ type: 'string', enum: [] })).toBe('Any')
|
|
|
+ })
|
|
|
+
|
|
|
+ it('leans on JSON.stringify to keep a Literal parseable', () => {
|
|
|
+ // Nothing here escapes anything itself; `JSON.stringify` carries both
|
|
|
+ // classes of hazard. The two kinds of code point CPython refuses anywhere
|
|
|
+ // in source: NUL, and the D800–DFFF unpaired-surrogate block under ES2019
|
|
|
+ // well-formed stringification.
|
|
|
+ expect(jsonSchemaToPy({ type: 'string', const: 'a\u0000b' })).toBe(String.raw`Literal["a\u0000b"]`)
|
|
|
+ expect(jsonSchemaToPy({ type: 'string', enum: ['a\ud800b'] })).toBe(String.raw`Literal["a\ud800b"]`)
|
|
|
+ // And the ones that break this line in particular: a bare quote closing
|
|
|
+ // the literal early, a trailing ODD backslash eating the closing quote (an
|
|
|
+ // even run does not), a bare newline ending it before its terminator.
|
|
|
+ // Every escape it emits is also a Python escape for the same character, so
|
|
|
+ // the value round-trips.
|
|
|
+ expect(jsonSchemaToPy({ type: 'string', const: 'say "hi"\n' })).toBe(String.raw`Literal["say \"hi\"\n"]`)
|
|
|
+ expect(jsonSchemaToPy({ type: 'string', const: 'ends\\' })).toBe(String.raw`Literal["ends\\"]`)
|
|
|
+ })
|
|
|
+
|
|
|
+ it('passes NEL and the line/paragraph separators through raw, which CPython does not treat as line terminators', () => {
|
|
|
+ // `JSON.stringify` escapes LF and CR but not NEL (U+0085), LS (U+2028), or
|
|
|
+ // PS (U+2029), which is safe here and not by accident: those three are
|
|
|
+ // `str.splitlines()` boundaries, not tokenizer line terminators, so they
|
|
|
+ // end neither a string literal nor a `#` comment — measured on CPython
|
|
|
+ // 3.9.6 and 3.12.13. Pinning the raw form keeps a later "escape them for
|
|
|
+ // symmetry with LF" change from landing as a silent both-flavors
|
|
|
+ // divergence from `ts-types`. Escapes below — the two forms denote the
|
|
|
+ // same bytes, and none of the three has a visible width.
|
|
|
+ expect(jsonSchemaToPy({ type: 'string', const: 'a\u2028b' })).toBe('Literal["a\u2028b"]')
|
|
|
+ expect(jsonSchemaToPy({ type: 'string', enum: ['a\u2029b'] })).toBe('Literal["a\u2029b"]')
|
|
|
+ // NEL is inside `UNPRINTABLE`'s class, so the description path escapes it.
|
|
|
+ // This is one of the two routes that carry it raw; the other is the
|
|
|
+ // subscript tool-name comment's own `JSON.stringify` call.
|
|
|
+ expect(jsonSchemaToPy({ type: 'string', const: 'a\u0085b' })).toBe('Literal["a\u0085b"]')
|
|
|
+ })
|
|
|
+
|
|
|
+ it('emits exact digits for a beyond-safe-range integer literal', () => {
|
|
|
+ // Python integers are arbitrary-precision, so the emitted digits ARE the
|
|
|
+ // value the model programs against. `String(2 ** 60)` prints the rounded
|
|
|
+ // ...847000, which is a DIFFERENT integer from the double's exact
|
|
|
+ // ...846976: `Number::toString` is shortest round-trip, so it emits the 16
|
|
|
+ // digits that re-read to the same double and pads with zeros, and those
|
|
|
+ // padded digits name an integer no double holds. Passing one back would
|
|
|
+ // have to cross the argument boundary as a JSON number, so the SDK would
|
|
|
+ // document a value no program can pass. This assertion is what separates
|
|
|
+ // the two spellings; the 1e21 case below separates them again on the other
|
|
|
+ // failure mode, where `String` gives no integer literal at all.
|
|
|
+ expect(jsonSchemaToPy({ type: 'integer', const: 2 ** 60 })).toBe('Literal[1152921504606846976]')
|
|
|
+ expect(jsonSchemaToPy({ type: 'integer', enum: [2 ** 60, -(2 ** 60)] }))
|
|
|
+ .toBe('Literal[1152921504606846976, -1152921504606846976]')
|
|
|
+ // `String(1e21)` prints `1e+21`, not a Python integer literal at all. The
|
|
|
+ // rule keys off the VALUE, not the declared type, so a `number` const that
|
|
|
+ // happens to be an integral double is spelled the same exact way (both
|
|
|
+ // spellings denote the same double, and only the digits also denote the
|
|
|
+ // same Python integer).
|
|
|
+ expect(jsonSchemaToPy({ type: 'integer', const: 1e21 })).toBe('Literal[1000000000000000000000]')
|
|
|
+ expect(jsonSchemaToPy({ type: 'number', const: 1e21 })).toBe('Literal[1000000000000000000000]')
|
|
|
+ // Within the safe range, and for non-integral numbers, the plain spelling
|
|
|
+ // is already exact and stays unchanged.
|
|
|
+ expect(jsonSchemaToPy({ type: 'integer', const: 2 ** 53 - 1 })).toBe('Literal[9007199254740991]')
|
|
|
+ expect(jsonSchemaToPy({ type: 'number', const: 1e-7 })).toBe('Literal[1e-7]')
|
|
|
+ })
|
|
|
+})
|
|
|
+
|
|
|
+describe('renderToolsSdkPy', () => {
|
|
|
+ const bash: ToolSdkSchema = {
|
|
|
+ name: 'bash',
|
|
|
+ description: 'Run a shell command.',
|
|
|
+ parameters: parameterSchemaSpecToJsonSchema({ command: { type: 'string', required: true } }) as unknown as Record<string, unknown>,
|
|
|
+ output: { type: 'string' },
|
|
|
+ }
|
|
|
+ const exotic: ToolSdkSchema = {
|
|
|
+ name: 'my-mcp.tool',
|
|
|
+ description: 'Exotic name.',
|
|
|
+ parameters: parameterSchemaSpecToJsonSchema({}) as unknown as Record<string, unknown>,
|
|
|
+ output: { type: 'string' },
|
|
|
+ }
|
|
|
+ const reserved: ToolSdkSchema = {
|
|
|
+ name: 'class',
|
|
|
+ description: 'Uses a reserved Python word.',
|
|
|
+ parameters: parameterSchemaSpecToJsonSchema({}) as unknown as Record<string, unknown>,
|
|
|
+ output: { type: 'string' },
|
|
|
+ }
|
|
|
+ /** One tool carrying `description` at both emission sites: the method docstring and the field comment. */
|
|
|
+ const described = (description: string): ToolSdkSchema => ({
|
|
|
+ name: 'weird',
|
|
|
+ description,
|
|
|
+ parameters: parameterSchemaSpecToJsonSchema({
|
|
|
+ field: { type: 'string', required: true, description },
|
|
|
+ }) as unknown as Record<string, unknown>,
|
|
|
+ output: { type: 'string' },
|
|
|
+ })
|
|
|
+
|
|
|
+ it('declares identifier tools as async methods and lists exotic/reserved names as subscript comments', () => {
|
|
|
+ const text = renderToolsSdkPy([exotic, bash, reserved])
|
|
|
+ expect(text).toContain('class Tools(Protocol):')
|
|
|
+ // The argument object is a named TypedDict, not an opaque dict.
|
|
|
+ expect(text).toContain('class BashArgs(TypedDict):')
|
|
|
+ expect(text).toContain('async def bash(self, args: BashArgs) -> str:')
|
|
|
+ // Empty-property tools keep the opaque dict (nothing to name).
|
|
|
+ expect(text).toContain('# tools["my-mcp.tool"](args: dict[str, Any]) -> str')
|
|
|
+ expect(text).toContain('# tools["class"](args: dict[str, Any]) -> str')
|
|
|
+ // Fixed instruction lines the model relies on.
|
|
|
+ expect(text).toContain('top-level `await`')
|
|
|
+ // The binding boundary: `tools`/`ToolCallError` are bound, the TypedDicts
|
|
|
+ // are not. Both halves are pinned — dropping either one turns a correct
|
|
|
+ // contract into a wrong one (a model that reads only "STATIC STUB" would
|
|
|
+ // stop catching `ToolCallError`).
|
|
|
+ expect(text).toContain('exactly two of the names declared below are bound: `tools` and `ToolCallError`')
|
|
|
+ expect(text).toContain('never `FooArgs(field=1)`, which raises `NameError`')
|
|
|
+ expect(text).toContain('ToolCallError')
|
|
|
+ expect(text).toContain('class ToolCallError(Exception):')
|
|
|
+ expect(text).toContain('MAY overlap under `asyncio.gather`')
|
|
|
+ expect(text).toContain('lossless JSON')
|
|
|
+ expect(text).toContain('```python')
|
|
|
+ expect(text).toContain('tools: Tools')
|
|
|
+ })
|
|
|
+
|
|
|
+ it('renders required as plain fields and optional as NotRequired, with per-field description comments', () => {
|
|
|
+ const tool: ToolSdkSchema = {
|
|
|
+ name: 'search',
|
|
|
+ description: 'Search for text.',
|
|
|
+ parameters: parameterSchemaSpecToJsonSchema({
|
|
|
+ query: { type: 'string', required: true, description: 'What to search for.' },
|
|
|
+ limit: { type: 'number', description: 'Max results.' },
|
|
|
+ }) as unknown as Record<string, unknown>,
|
|
|
+ output: { type: 'string' },
|
|
|
+ }
|
|
|
+ const text = renderToolsSdkPy([tool])
|
|
|
+ expect(text).toContain('class SearchArgs(TypedDict):')
|
|
|
+ expect(text).toContain(' # What to search for.')
|
|
|
+ expect(text).toContain(' query: str')
|
|
|
+ expect(text).toContain(' # Max results.')
|
|
|
+ expect(text).toContain(' limit: NotRequired[float]')
|
|
|
+ expect(text).toContain('async def search(self, args: SearchArgs) -> str:')
|
|
|
+ // NotRequired is imported because an optional field used it; Any is NOT,
|
|
|
+ // since every type here is concrete — the import line lists only what ran.
|
|
|
+ expect(text).toContain('from typing import NotRequired, Protocol, TypedDict')
|
|
|
+ })
|
|
|
+
|
|
|
+ it('prefixes Tool when a name CamelCases to a non-letter head, and degrades a malformed schema to Any', () => {
|
|
|
+ const tool: ToolSdkSchema = {
|
|
|
+ name: '1st-tool', // subscript path; CamelCases to "1stTool" → prefixed "Tool1stTool"
|
|
|
+ description: 'Hostile-shape probe.',
|
|
|
+ // Malformed node: the unified schema validator rejects it whole, so the
|
|
|
+ // args position degrades to Any (registration would refuse this schema;
|
|
|
+ // the renderer just must not throw on it).
|
|
|
+ parameters: { type: 'object', properties: { field: { type: 'string', description: 42 } } },
|
|
|
+ output: { type: 'object', additionalProperties: false, properties: { ok: { type: 'boolean' } }, required: ['ok'] },
|
|
|
+ }
|
|
|
+ const text = renderToolsSdkPy([tool])
|
|
|
+ expect(text).toContain('# tools["1st-tool"](args: Any) -> Tool1stToolOutput')
|
|
|
+ expect(text).toContain('class Tool1stToolOutput(TypedDict):')
|
|
|
+ expect(text).toContain(' ok: bool')
|
|
|
+ })
|
|
|
+
|
|
|
+ it('treats every field as optional when the object carries no required array', () => {
|
|
|
+ const tool: ToolSdkSchema = {
|
|
|
+ name: 'all_optional',
|
|
|
+ description: 'No required array.',
|
|
|
+ parameters: { type: 'object', properties: { flag: { type: 'boolean' } } },
|
|
|
+ output: { type: 'string' },
|
|
|
+ }
|
|
|
+ const text = renderToolsSdkPy([tool])
|
|
|
+ expect(text).toContain(' flag: NotRequired[bool]')
|
|
|
+ })
|
|
|
+
|
|
|
+ it('renders an enum inside an object property as a Literal field', () => {
|
|
|
+ const tool: ToolSdkSchema = {
|
|
|
+ name: 'mode_tool',
|
|
|
+ description: 'Pick a mode.',
|
|
|
+ parameters: parameterSchemaSpecToJsonSchema({
|
|
|
+ mode: { type: 'string', required: true, enum: ['fast', 'slow'] },
|
|
|
+ }) as unknown as Record<string, unknown>,
|
|
|
+ output: { type: 'string' },
|
|
|
+ }
|
|
|
+ const text = renderToolsSdkPy([tool])
|
|
|
+ expect(text).toContain('class ModeToolArgs(TypedDict):')
|
|
|
+ expect(text).toContain(' mode: Literal["fast", "slow"]')
|
|
|
+ expect(text).toContain('from typing import Literal, Protocol, TypedDict')
|
|
|
+ })
|
|
|
+
|
|
|
+ it('renders one level of nested object as its own named TypedDict declared before the parent', () => {
|
|
|
+ const tool: ToolSdkSchema = {
|
|
|
+ name: 'workflow',
|
|
|
+ description: 'Run a workflow.',
|
|
|
+ parameters: parameterSchemaSpecToJsonSchema({
|
|
|
+ meta: {
|
|
|
+ type: 'object',
|
|
|
+ required: true,
|
|
|
+ additionalProperties: false,
|
|
|
+ description: 'Identity block.',
|
|
|
+ properties: {
|
|
|
+ name: { type: 'string', required: true, description: 'Short name.' },
|
|
|
+ phases: {
|
|
|
+ type: 'array',
|
|
|
+ items: {
|
|
|
+ type: 'object',
|
|
|
+ additionalProperties: false,
|
|
|
+ properties: { title: { type: 'string', required: true, description: 'Phase title.' } },
|
|
|
+ },
|
|
|
+ },
|
|
|
+ },
|
|
|
+ },
|
|
|
+ }) as unknown as Record<string, unknown>,
|
|
|
+ output: { type: 'string' },
|
|
|
+ }
|
|
|
+ const text = renderToolsSdkPy([tool])
|
|
|
+ // Nested class for the `meta` object, and a further nested class for the
|
|
|
+ // array item object, each named after its field path.
|
|
|
+ expect(text).toContain('class WorkflowArgsMeta(TypedDict):')
|
|
|
+ expect(text).toContain('class WorkflowArgsMetaPhases(TypedDict):')
|
|
|
+ expect(text).toContain(' meta: WorkflowArgsMeta')
|
|
|
+ expect(text).toContain(' phases: NotRequired[list[WorkflowArgsMetaPhases]]')
|
|
|
+ // Dependency-before-dependent: the item class precedes its container,
|
|
|
+ // which precedes the top-level args class, which precedes the protocol.
|
|
|
+ expect(text.indexOf('class WorkflowArgsMetaPhases')).toBeLessThan(text.indexOf('class WorkflowArgsMeta(TypedDict):'))
|
|
|
+ expect(text.indexOf('class WorkflowArgsMeta(TypedDict):')).toBeLessThan(text.indexOf('class WorkflowArgs(TypedDict):'))
|
|
|
+ expect(text.indexOf('class WorkflowArgs(TypedDict):')).toBeLessThan(text.indexOf('class Tools(Protocol):'))
|
|
|
+ })
|
|
|
+
|
|
|
+ it('renders a oneOf of object branches as a union of named TypedDicts declared before the parent', () => {
|
|
|
+ const tool: ToolSdkSchema = {
|
|
|
+ name: 'act',
|
|
|
+ description: 'Union output.',
|
|
|
+ parameters: { type: 'object', additionalProperties: false, properties: {} },
|
|
|
+ output: {
|
|
|
+ oneOf: [
|
|
|
+ { type: 'object', additionalProperties: false, properties: { ok: { type: 'boolean' } }, required: ['ok'] },
|
|
|
+ { type: 'object', additionalProperties: false, properties: { err: { type: 'string' } }, required: ['err'] },
|
|
|
+ ],
|
|
|
+ },
|
|
|
+ }
|
|
|
+ const text = renderToolsSdkPy([tool])
|
|
|
+ // Each object branch becomes its own named class (`${base}Output1/2`),
|
|
|
+ // declared before the protocol references the union.
|
|
|
+ expect(text).toContain('class ActOutput1(TypedDict):')
|
|
|
+ expect(text).toContain('class ActOutput2(TypedDict):')
|
|
|
+ expect(text).toContain('-> ActOutput1 | ActOutput2')
|
|
|
+ expect(text.indexOf('class ActOutput1(TypedDict):')).toBeLessThan(text.indexOf('class Tools(Protocol):'))
|
|
|
+ expect(text.indexOf('class ActOutput2(TypedDict):')).toBeLessThan(text.indexOf('class Tools(Protocol):'))
|
|
|
+ })
|
|
|
+
|
|
|
+ it('degrades a context-free oneOf of object branches to a union of dict[str, Any]', () => {
|
|
|
+ // jsonSchemaToPy has no naming context, so each object branch degrades
|
|
|
+ // rather than declaring a class.
|
|
|
+ const type = jsonSchemaToPy({
|
|
|
+ oneOf: [
|
|
|
+ { type: 'object', additionalProperties: false, properties: { ok: { type: 'boolean' } }, required: ['ok'] },
|
|
|
+ { type: 'string' },
|
|
|
+ ],
|
|
|
+ })
|
|
|
+ expect(type).toBe('dict[str, Any] | str')
|
|
|
+ // Both branches objects, and the same shape reached through an array: the
|
|
|
+ // marker is the CALL's className, so a propagated frame name (`1`, the
|
|
|
+ // index-derived branch name) does not revive class declaration on a walk
|
|
|
+ // that has nowhere to declare into.
|
|
|
+ const object = { type: 'object', additionalProperties: false, properties: { ok: { type: 'boolean' } }, required: ['ok'] }
|
|
|
+ expect(jsonSchemaToPy({ oneOf: [object, object] })).toBe('dict[str, Any] | dict[str, Any]')
|
|
|
+ expect(jsonSchemaToPy({ type: 'array', items: { oneOf: [object, { type: 'string' }] } })).toBe('list[dict[str, Any] | str]')
|
|
|
+ })
|
|
|
+
|
|
|
+ it('suffixes a counter when two tools CamelCase to the same class base', () => {
|
|
|
+ const a: ToolSdkSchema = {
|
|
|
+ name: 'my-tool',
|
|
|
+ description: 'Dash form.',
|
|
|
+ parameters: parameterSchemaSpecToJsonSchema({ x: { type: 'string', required: true } }) as unknown as Record<string, unknown>,
|
|
|
+ output: { type: 'string' },
|
|
|
+ }
|
|
|
+ const b: ToolSdkSchema = {
|
|
|
+ name: 'my.tool',
|
|
|
+ description: 'Dot form.',
|
|
|
+ parameters: parameterSchemaSpecToJsonSchema({ y: { type: 'string', required: true } }) as unknown as Record<string, unknown>,
|
|
|
+ output: { type: 'string' },
|
|
|
+ }
|
|
|
+ const text = renderToolsSdkPy([a, b])
|
|
|
+ // Both sanitize to `MyToolArgs`; the second collides and gets a suffix.
|
|
|
+ expect(text).toContain('class MyToolArgs(TypedDict):')
|
|
|
+ expect(text).toContain('class MyToolArgs2(TypedDict):')
|
|
|
+ })
|
|
|
+
|
|
|
+ it('caps class-name length so a deep single-field chain stays linear', () => {
|
|
|
+ // Child class names derive from their parent's, so without a cap the sum of
|
|
|
+ // names would be Theta(depth^2). MAX_CLASS_NAME_BASE (120) bounds each name.
|
|
|
+ const depth = 4000
|
|
|
+ let schema: Record<string, unknown> = { type: 'string' }
|
|
|
+ for (let i = 0; i < depth; i++) {
|
|
|
+ schema = { type: 'object', additionalProperties: false, properties: { inner: schema }, required: ['inner'] }
|
|
|
+ }
|
|
|
+ const tool: ToolSdkSchema = { name: 'deep', description: 'Deep chain.', parameters: schema, output: { type: 'string' } }
|
|
|
+ const text = renderToolsSdkPy([tool])
|
|
|
+ const longestClassName = [...text.matchAll(/^class (\w+)\(TypedDict\):/gm)].reduce((max, m) => Math.max(max, m[1]?.length ?? 0), 0)
|
|
|
+ expect(longestClassName).toBeLessThanOrEqual(140)
|
|
|
+ expect(text.length).toBeLessThan(depth * 400)
|
|
|
+ })
|
|
|
+
|
|
|
+ it('skips an already-taken counter suffix when a sibling object occupies it', () => {
|
|
|
+ // `phase` and `Phase` both CamelCase to base `FooArgsPhase`; `phase2`
|
|
|
+ // independently takes `FooArgsPhase2`, so `Phase`'s collision scan must
|
|
|
+ // advance to `FooArgsPhase3` (exercises the collision-skip loop).
|
|
|
+ const obj = (field: string) => ({ type: 'object' as const, additionalProperties: false, properties: { [field]: { type: 'string' } } })
|
|
|
+ const tool: ToolSdkSchema = {
|
|
|
+ name: 'foo',
|
|
|
+ description: 'Sibling objects with colliding class bases.',
|
|
|
+ parameters: {
|
|
|
+ type: 'object',
|
|
|
+ additionalProperties: false,
|
|
|
+ properties: { phase: obj('a'), phase2: obj('b'), Phase: obj('c') },
|
|
|
+ required: ['phase', 'phase2', 'Phase'],
|
|
|
+ },
|
|
|
+ output: { type: 'string' },
|
|
|
+ }
|
|
|
+ const text = renderToolsSdkPy([tool])
|
|
|
+ expect(text).toContain('class FooArgsPhase(TypedDict):')
|
|
|
+ expect(text).toContain('class FooArgsPhase2(TypedDict):')
|
|
|
+ expect(text).toContain('class FooArgsPhase3(TypedDict):')
|
|
|
+ })
|
|
|
+
|
|
|
+ it('references the named TypedDict from a reserved/subscript tool too', () => {
|
|
|
+ const tool: ToolSdkSchema = {
|
|
|
+ name: 'class',
|
|
|
+ description: 'Reserved word tool.',
|
|
|
+ parameters: parameterSchemaSpecToJsonSchema({ value: { type: 'string', required: true } }) as unknown as Record<string, unknown>,
|
|
|
+ output: { type: 'string' },
|
|
|
+ }
|
|
|
+ const text = renderToolsSdkPy([tool])
|
|
|
+ expect(text).toContain('class ClassArgs(TypedDict):')
|
|
|
+ expect(text).toContain('# tools["class"](args: ClassArgs) -> str')
|
|
|
+ })
|
|
|
+
|
|
|
+ it('degrades an object to dict[str, Any] when a field name is not a legal Python attribute', () => {
|
|
|
+ const tool: ToolSdkSchema = {
|
|
|
+ name: 'weird_fields',
|
|
|
+ description: 'Has an illegal field name.',
|
|
|
+ parameters: { type: 'object', properties: { 'a-b': { type: 'string' } } },
|
|
|
+ output: { type: 'string' },
|
|
|
+ }
|
|
|
+ const text = renderToolsSdkPy([tool])
|
|
|
+ expect(text).toContain('async def weird_fields(self, args: dict[str, Any]) -> str:')
|
|
|
+ expect(text).not.toContain('WeirdFieldsArgs')
|
|
|
+ })
|
|
|
+
|
|
|
+ it('keeps soft-keyword field names as TypedDict fields (each is special in exactly one syntactic position)', () => {
|
|
|
+ const tool: ToolSdkSchema = {
|
|
|
+ name: 'search',
|
|
|
+ description: 'Soft keywords as fields.',
|
|
|
+ parameters: {
|
|
|
+ type: 'object',
|
|
|
+ additionalProperties: false,
|
|
|
+ properties: {
|
|
|
+ match: { type: 'string' },
|
|
|
+ case: { type: 'boolean' },
|
|
|
+ type: { type: 'string' },
|
|
|
+ },
|
|
|
+ required: ['match'],
|
|
|
+ },
|
|
|
+ output: { type: 'string' },
|
|
|
+ }
|
|
|
+ const text = renderToolsSdkPy([tool])
|
|
|
+ // The object keeps its shape rather than degrading to dict[str, Any].
|
|
|
+ expect(text).toContain('class SearchArgs(TypedDict):')
|
|
|
+ expect(text).toContain('match: str')
|
|
|
+ expect(text).toContain('case: NotRequired[bool]')
|
|
|
+ expect(text).toContain('type: NotRequired[str]')
|
|
|
+ expect(text).not.toContain('dict[str, Any]')
|
|
|
+ })
|
|
|
+
|
|
|
+ it('keeps a non-ASCII field name as a TypedDict field and derives its class name from it', () => {
|
|
|
+ // `路径` satisfies `xid_start xid_continue*`, so CPython accepts it as an
|
|
|
+ // attribute and as the `TypedDict` key. Rejecting it would degrade the
|
|
|
+ // whole object, dropping every SIBLING field's name, requiredness and type
|
|
|
+ // too — and under `mode: 'code'` the native schemas are omitted, so
|
|
|
+ // nothing else carries them. The nested class name is from the field, so
|
|
|
+ // `camelCase` has to pass the same characters through instead of splitting
|
|
|
+ // on them.
|
|
|
+ const tool: ToolSdkSchema = {
|
|
|
+ name: '搜索',
|
|
|
+ description: 'Unicode identifiers.',
|
|
|
+ parameters: {
|
|
|
+ type: 'object',
|
|
|
+ additionalProperties: false,
|
|
|
+ properties: {
|
|
|
+ 路径: { type: 'string' },
|
|
|
+ opts: { type: 'object', additionalProperties: false, properties: { 深度: { type: 'number' } } },
|
|
|
+ },
|
|
|
+ required: ['路径'],
|
|
|
+ },
|
|
|
+ output: { type: 'string' },
|
|
|
+ }
|
|
|
+ const text = renderToolsSdkPy([tool])
|
|
|
+ expect(text).toContain('async def 搜索(self, args: 搜索Args) -> str:')
|
|
|
+ expect(text).toContain('class 搜索Args(TypedDict):')
|
|
|
+ expect(text).toContain(' 路径: str')
|
|
|
+ expect(text).toContain('class 搜索ArgsOpts(TypedDict):')
|
|
|
+ expect(text).toContain(' 深度: NotRequired[float]')
|
|
|
+ expect(text).not.toContain('dict[str, Any]')
|
|
|
+ })
|
|
|
+
|
|
|
+ it('degrades a field name that NFKC-normalizes to something else, which would be declared under another spelling', () => {
|
|
|
+ // U+FB01 LATIN SMALL LIGATURE FI passes the identifier grammar, but CPython
|
|
|
+ // normalizes identifiers at compile time while the harness compares the
|
|
|
+ // JSON key as written: `field: str` would declare and be reachable as
|
|
|
+ // `field`, a key the tool never accepts. Two keys that normalize together
|
|
|
+ // would additionally collapse into one declaration. The subscript path
|
|
|
+ // carries the exact bytes instead.
|
|
|
+ const text = renderToolsSdkPy([
|
|
|
+ {
|
|
|
+ name: 'ligature',
|
|
|
+ description: 'Normalizing field name.',
|
|
|
+ parameters: { type: 'object', additionalProperties: false, properties: { field: { type: 'string' } } },
|
|
|
+ output: { type: 'string' },
|
|
|
+ },
|
|
|
+ ])
|
|
|
+ expect(text).toContain('async def ligature(self, args: dict[str, Any]) -> str:')
|
|
|
+ expect(text).not.toContain('field:')
|
|
|
+ expect(text).not.toContain('field:')
|
|
|
+ })
|
|
|
+
|
|
|
+ it('keeps U+200C in a name tail while rejecting it at a name head, per the two XID properties', () => {
|
|
|
+ // ZWNJ carries `XID_Continue` and not `XID_Start`, so the predicate splits
|
|
|
+ // on position: bare in a tail, subscripted at a head. Both verdicts are
|
|
|
+ // stable across the supported engines — the property arrives in Unicode
|
|
|
+ // 15.1 and the floor (Node 22.19.0, Unicode 16.0) is past it.
|
|
|
+ //
|
|
|
+ // The interpreter side is where this one skews, and it is the same skew the
|
|
|
+ // docstring's four other characters record, reached in a tail position
|
|
|
+ // instead of at a head: CPython reads XID_Continue out of the
|
|
|
+ // `DerivedCoreProperties.txt` of the UCD it was built against (13.0.0 on
|
|
|
+ // 3.9.6 and 15.0.0 on 3.12.13 both lack the row, and
|
|
|
+ // `'a\u200Cb'.isidentifier()` is False on both, measured). What then needs
|
|
|
+ // 15.1 tables or newer is the bare field, once in each class, and the
|
|
|
+ // `Tool\u200CbArgs` class name. The subscript comment quoting the tool name
|
|
|
+ // is not one of them: it is not parsed as an identifier.
|
|
|
+ const of = (name: string): ToolSdkSchema => ({
|
|
|
+ name,
|
|
|
+ description: `Tool ${name}.`,
|
|
|
+ parameters: { type: 'object', additionalProperties: false, properties: { 'a\u200Cb': { type: 'string' } } },
|
|
|
+ output: { type: 'string' },
|
|
|
+ })
|
|
|
+ const text = renderToolsSdkPy([of('ping'), of('\u200Cb')])
|
|
|
+ expect(text).toContain('async def ping(self, args: PingArgs) -> str:')
|
|
|
+ expect(text).toContain(' a\u200Cb: NotRequired[str]')
|
|
|
+ // A head that is XID_Continue but not XID_Start takes the subscript path,
|
|
|
+ // and `camelCase` prefixes `Tool` to make the class name start legally.
|
|
|
+ expect(text).toContain('# tools["\u200Cb"](args: Tool\u200CbArgs) -> str')
|
|
|
+ expect(text).toContain('class Tool\u200CbArgs(TypedDict):')
|
|
|
+ expect(text).not.toContain('async def \u200Cb')
|
|
|
+ })
|
|
|
+
|
|
|
+ it('subscripts a tool name that NFKC-normalizes to something else, while declaring a plain Unicode one', () => {
|
|
|
+ // Same split at the tool-name site: `路径` becomes an `async def`, the
|
|
|
+ // ligature name cannot, because `async def find` would define `find`. The
|
|
|
+ // subscript comment quotes the name, so its exact bytes survive, and its
|
|
|
+ // TypedDict is still named and referenced — the name is only unusable as a
|
|
|
+ // method, not as a class-name source. The `FInd` spelling comes from `fi`'s
|
|
|
+ // multi-character full case mapping (`'fi'.toUpperCase()` is `'FI'`), not
|
|
|
+ // from `camelCase`'s NFKC step, which is the identity on `FInd`: the
|
|
|
+ // ligature is XID_Start, so the split set keeps it and only the
|
|
|
+ // capitalization of the head transforms it.
|
|
|
+ const of = (name: string): ToolSdkSchema => ({
|
|
|
+ name,
|
|
|
+ description: `Tool ${name}.`,
|
|
|
+ parameters: { type: 'object', additionalProperties: false, properties: { q: { type: 'string' } }, required: ['q'] },
|
|
|
+ output: { type: 'string' },
|
|
|
+ })
|
|
|
+ const text = renderToolsSdkPy([of('路径'), of('find')])
|
|
|
+ expect(text).toContain('async def 路径(self, args: 路径Args) -> str:')
|
|
|
+ expect(text).toContain('# tools["find"](args: FIndArgs) -> str')
|
|
|
+ expect(text).toContain('class FIndArgs(TypedDict):')
|
|
|
+ expect(text).not.toContain('async def find')
|
|
|
+ expect(text).not.toContain('async def find')
|
|
|
+ })
|
|
|
+
|
|
|
+ it('derives a class name through the case-mapping table, independently of the bare-name predicate', () => {
|
|
|
+ // The head capitalization reads a table `isBareIdentifier` never consults,
|
|
|
+ // so the class-name path can carry a character the predicate cleared. ƛ
|
|
|
+ // (U+019B) is XID_Start and NFKC-stable, so the method is emitted bare;
|
|
|
+ // the head maps to (U+A7DC), a code point the engine's tables assign and
|
|
|
+ // an older interpreter's do not. This pins which table produced the name,
|
|
|
+ // so a change to the mapping step shows up here rather than only in a
|
|
|
+ // downstream Python parse.
|
|
|
+ //
|
|
|
+ // Unlike the other Unicode cases in this file, the table row is recent:
|
|
|
+ // U+A7DC and the U+019B uppercase mapping to it both arrive in Unicode
|
|
|
+ // 16.0 (`DerivedAge.txt`; CPython 3.12.13's 15.0.0 has neither). The
|
|
|
+ // engines floor sits exactly there with no margin — Node 22.19.0 reports
|
|
|
+ // Unicode 16.0 (ICU 77.1) and maps U+019B to U+A7DC, measured — so an
|
|
|
+ // engine below the floor fails here as a renderer regression whose real
|
|
|
+ // cause is the table version.
|
|
|
+ const text = renderToolsSdkPy([
|
|
|
+ {
|
|
|
+ name: 'ƛ',
|
|
|
+ description: 'Lambda with stroke.',
|
|
|
+ parameters: { type: 'object', additionalProperties: false, properties: { q: { type: 'string' } }, required: ['q'] },
|
|
|
+ output: { type: 'string' },
|
|
|
+ },
|
|
|
+ ])
|
|
|
+ expect(text).toContain('async def ƛ(self, args: Args) -> str:')
|
|
|
+ expect(text).toContain('class Args(TypedDict):')
|
|
|
+ expect(text).not.toContain('class ƛArgs')
|
|
|
+ })
|
|
|
+
|
|
|
+ it('drops a surrogate half rather than cutting a pair when capping an astral class-name base', () => {
|
|
|
+ // Class-name bases are capped by `slice`, which counts UTF-16 code units,
|
|
|
+ // so a boundary landing inside an astral pair would leave a lone high
|
|
|
+ // surrogate — not an identifier character, and not encodable text. Padding
|
|
|
+ // with one ASCII character shifts the boundary onto the pair.
|
|
|
+ // U+10330 GOTHIC LETTER AHSA: XID_Start and NFKC-stable, unlike `𝕏`, which
|
|
|
+ // NFKC-folds to ASCII `X` and so never reaches the boundary at all.
|
|
|
+ const AHSA = String.fromCodePoint(0x10330)
|
|
|
+ const className = (pad: string): string => {
|
|
|
+ const text = renderToolsSdkPy([
|
|
|
+ {
|
|
|
+ name: `${pad}${AHSA.repeat(200)}`,
|
|
|
+ description: 'Astral name.',
|
|
|
+ parameters: { type: 'object', additionalProperties: false, properties: { a: { type: 'string' } } },
|
|
|
+ output: { type: 'string' },
|
|
|
+ },
|
|
|
+ ])
|
|
|
+ // The base is `${camelCase(name)}Args` capped to 120 code units, so the
|
|
|
+ // `Args` suffix itself is cut off here; match the declaration instead.
|
|
|
+ return /^class (.+)\(TypedDict\):$/mu.exec(text)![1]!
|
|
|
+ }
|
|
|
+ // Each character is 2 code units, so an unpadded name fills the cap with 60
|
|
|
+ // whole characters; one ASCII character of padding puts the boundary inside
|
|
|
+ // the 60th pair, and that half is dropped rather than emitted.
|
|
|
+ expect(className('')).toBe(AHSA.repeat(60))
|
|
|
+ const padded = className('x')
|
|
|
+ expect(padded).toBe(`X${AHSA.repeat(59)}`)
|
|
|
+ expect(padded).toHaveLength(119)
|
|
|
+ })
|
|
|
+
|
|
|
+ it('normalizes the seam the Tool prefix creates, which the prefixed part alone does not cover', () => {
|
|
|
+ // U+0301 COMBINING ACUTE ACCENT is XID_Continue but not XID_Start, so a name
|
|
|
+ // headed by it takes the `Tool` prefix — and `Tool` ends in `l`, which
|
|
|
+ // composes with it. Normalizing only the part being prefixed would emit
|
|
|
+ // `Tool` + U+0301, which CPython compiles as `Too` + U+013A: the class
|
|
|
+ // the SDK declares would not be the class the interpreter defines. Every
|
|
|
+ // code point below is an escape — the two forms render identically.
|
|
|
+ const text = renderToolsSdkPy([
|
|
|
+ {
|
|
|
+ name: '\u0301abc',
|
|
|
+ description: 'Combining-mark head.',
|
|
|
+ parameters: { type: 'object', additionalProperties: false, properties: { q: { type: 'string' } }, required: ['q'] },
|
|
|
+ output: { type: 'string' },
|
|
|
+ },
|
|
|
+ ])
|
|
|
+ expect(text).toContain('class Too\u013AabcArgs(TypedDict):')
|
|
|
+ expect(text).toContain('# tools["\u0301abc"](args: Too\u013AabcArgs) -> str')
|
|
|
+ expect(text).not.toContain('Tool\u0301')
|
|
|
+ })
|
|
|
+
|
|
|
+ it('normalizes a class-name join where two separately stable segments compose', () => {
|
|
|
+ // Hangul jamo compose ACROSS the join `childClassName` makes: the parent
|
|
|
+ // base ends in U+1100 (L jamo) and the child segment starts with U+1161 (V
|
|
|
+ // jamo), each NFKC-stable alone, together U+AC00. Unnormalized, the declared
|
|
|
+ // name differs from the compiled symbol, and two byte-distinct names can
|
|
|
+ // fold onto one — `usedClassNames` dedupes by raw bytes, so the collision
|
|
|
+ // counter never sees it and the later declaration shadows the earlier one
|
|
|
+ // under CPython. Escapes again, for the same reason as above.
|
|
|
+ const text = renderToolsSdkPy([
|
|
|
+ {
|
|
|
+ name: 'x',
|
|
|
+ description: 'Jamo field names.',
|
|
|
+ parameters: {
|
|
|
+ type: 'object',
|
|
|
+ additionalProperties: false,
|
|
|
+ required: ['\uAC00\u1100'],
|
|
|
+ properties: {
|
|
|
+ '\uAC00\u1100': {
|
|
|
+ type: 'object',
|
|
|
+ additionalProperties: false,
|
|
|
+ required: ['\u1161x'],
|
|
|
+ properties: {
|
|
|
+ '\u1161x': { type: 'object', additionalProperties: false, properties: { q: { type: 'string' } } },
|
|
|
+ },
|
|
|
+ },
|
|
|
+ },
|
|
|
+ },
|
|
|
+ output: { type: 'string' },
|
|
|
+ },
|
|
|
+ ])
|
|
|
+ // The join is `XArgs` + U+AC00 U+1100 followed by U+1161 `x`, whose
|
|
|
+ // trailing L+V pair composes into a second U+AC00.
|
|
|
+ expect(text).toContain('class XArgs\uAC00\uAC00x(TypedDict):')
|
|
|
+ expect(text).toContain(' \u1161x: XArgs\uAC00\uAC00x')
|
|
|
+ expect(text).not.toContain('\u1100\u1161')
|
|
|
+ // The level above it is a join that composes nothing (LV + L), so it stays
|
|
|
+ // byte-identical — normalizing is not silently rewriting every name.
|
|
|
+ expect(text).toContain('class XArgs\uAC00\u1100(TypedDict):')
|
|
|
+ })
|
|
|
+
|
|
|
+ it('routes a fold collision through the counter that raw-byte dedup would miss', () => {
|
|
|
+ // The other half of the `childClassName` normalization: two joins that are
|
|
|
+ // byte-distinct before NFKC and identical after. Field `\uAC00` allocates
|
|
|
+ // `XArgs\uAC00`; the sibling `\u1100` allocates `XArgs\u1100`, and ITS child
|
|
|
+ // `\u1161` joins to `XArgs\u1100\u1161` — the same `XArgs\uAC00` once composed.
|
|
|
+ // Normalizing at the join is what lets `usedClassNames`, which dedupes by raw
|
|
|
+ // bytes, see the collision at all; unnormalized, both would be declared and
|
|
|
+ // CPython would compile the second as a shadow of the first.
|
|
|
+ const text = renderToolsSdkPy([
|
|
|
+ {
|
|
|
+ name: 'x',
|
|
|
+ description: 'Colliding jamo joins.',
|
|
|
+ parameters: {
|
|
|
+ type: 'object',
|
|
|
+ additionalProperties: false,
|
|
|
+ required: ['\uAC00', '\u1100'],
|
|
|
+ properties: {
|
|
|
+ '\uAC00': { type: 'object', additionalProperties: false, required: ['q'], properties: { q: { type: 'string' } } },
|
|
|
+ '\u1100': {
|
|
|
+ type: 'object',
|
|
|
+ additionalProperties: false,
|
|
|
+ required: ['\u1161'],
|
|
|
+ properties: {
|
|
|
+ '\u1161': { type: 'object', additionalProperties: false, required: ['q'], properties: { q: { type: 'string' } } },
|
|
|
+ },
|
|
|
+ },
|
|
|
+ },
|
|
|
+ },
|
|
|
+ output: { type: 'string' },
|
|
|
+ },
|
|
|
+ ])
|
|
|
+ expect(text).toContain('class XArgs\uAC00(TypedDict):')
|
|
|
+ expect(text).toContain('class XArgs\uAC002(TypedDict):')
|
|
|
+ expect(text).toContain(' \u1161: XArgs\uAC002')
|
|
|
+ })
|
|
|
+
|
|
|
+ it('names both branches of a oneOf of objects on the argument side', () => {
|
|
|
+ // The output side is pinned elsewhere; arguments reach the same
|
|
|
+ // `childClassName(frame.className, index + 1)` path, and the annotation is
|
|
|
+ // the union of the two derived names rather than a degraded dict.
|
|
|
+ const text = renderToolsSdkPy([
|
|
|
+ {
|
|
|
+ name: 'x',
|
|
|
+ description: 'Union arguments.',
|
|
|
+ parameters: {
|
|
|
+ oneOf: [
|
|
|
+ { type: 'object', additionalProperties: false, required: ['a'], properties: { a: { type: 'string' } } },
|
|
|
+ { type: 'object', additionalProperties: false, required: ['b'], properties: { b: { type: 'number' } } },
|
|
|
+ ],
|
|
|
+ },
|
|
|
+ output: { type: 'string' },
|
|
|
+ },
|
|
|
+ ])
|
|
|
+ expect(text).toContain('class XArgs1(TypedDict):')
|
|
|
+ expect(text).toContain('class XArgs2(TypedDict):')
|
|
|
+ expect(text).toContain('async def x(self, args: XArgs1 | XArgs2) -> str:')
|
|
|
+ })
|
|
|
+
|
|
|
+ it('declares a closed empty object with omitted properties as an empty TypedDict, not dict[str, Any]', () => {
|
|
|
+ // `{ type: 'object', additionalProperties: false }` with no `properties`
|
|
|
+ // is a closed empty object — no key accepted — exactly as the validator
|
|
|
+ // and the TS renderer read it. It must not degrade to a permissive dict.
|
|
|
+ const tool: ToolSdkSchema = {
|
|
|
+ name: 'closed',
|
|
|
+ description: 'Closed empty object with omitted properties.',
|
|
|
+ parameters: {
|
|
|
+ type: 'object',
|
|
|
+ additionalProperties: false,
|
|
|
+ properties: { inner: { type: 'object', additionalProperties: false } },
|
|
|
+ required: ['inner'],
|
|
|
+ },
|
|
|
+ output: { type: 'string' },
|
|
|
+ }
|
|
|
+ const text = renderToolsSdkPy([tool])
|
|
|
+ expect(text).toMatch(/class ClosedArgsInner\(TypedDict\):\n pass/)
|
|
|
+ expect(text).toContain('inner: ClosedArgsInner')
|
|
|
+ expect(text).not.toContain('dict[str, Any]')
|
|
|
+ })
|
|
|
+
|
|
|
+ it('degrades an open object with omitted properties to dict[str, Any]', () => {
|
|
|
+ // An OPEN empty object (default additionalProperties) is any dict.
|
|
|
+ const type = jsonSchemaToPy({ type: 'object', properties: {} })
|
|
|
+ expect(type).toBe('dict[str, Any]')
|
|
|
+ expect(jsonSchemaToPy({ type: 'object' })).toBe('dict[str, Any]')
|
|
|
+ })
|
|
|
+
|
|
|
+ it('renders docstrings for descriptions and orders emissions lexicographically', () => {
|
|
|
+ const text = renderToolsSdkPy([bash, exotic])
|
|
|
+ expect(text).toContain('"""Run a shell command."""')
|
|
|
+ // Descriptions on subscript names ride as a comment beside their entry.
|
|
|
+ expect(text).toContain('# tools["my-mcp.tool"]')
|
|
|
+ expect(text).toContain('# Exotic name.')
|
|
|
+ // Lexicographic: `bash` before `my-mcp.tool`.
|
|
|
+ expect(text.indexOf('async def bash')).toBeLessThan(text.indexOf('# tools["my-mcp.tool"]'))
|
|
|
+ })
|
|
|
+
|
|
|
+ it('places a docstring as the first statement of its own method body', () => {
|
|
|
+ // Python attaches a docstring to a function only when it is that
|
|
|
+ // function's first statement. Above the `async def` the first one would
|
|
|
+ // document the `Tools` class and every later one would be a dead
|
|
|
+ // expression, so each method must open its body with its own docstring.
|
|
|
+ const second: ToolSdkSchema = {
|
|
|
+ name: 'zzz',
|
|
|
+ description: 'Second by name.',
|
|
|
+ parameters: parameterSchemaSpecToJsonSchema({}) as unknown as Record<string, unknown>,
|
|
|
+ output: { type: 'string' },
|
|
|
+ }
|
|
|
+ const lines = renderToolsSdkPy([bash, second]).split('\n')
|
|
|
+ for (const [name, doc] of [['bash', 'Run a shell command.'], ['zzz', 'Second by name.']]) {
|
|
|
+ const signature = lines.findIndex(line => line.startsWith(`${' '.repeat(4)}async def ${name}(`))
|
|
|
+ expect(signature).toBeGreaterThan(-1)
|
|
|
+ // Ends in `:`, not the `: ...` stub — a docstring IS the whole body.
|
|
|
+ expect(lines[signature]?.endsWith(':')).toBe(true)
|
|
|
+ expect(lines[signature + 1]).toBe(`${' '.repeat(8)}"""${doc}"""`)
|
|
|
+ }
|
|
|
+ // No docstring is left floating at class-body indentation.
|
|
|
+ expect(lines.filter(line => line.startsWith(`${' '.repeat(4)}"""`))).toEqual([])
|
|
|
+ })
|
|
|
+
|
|
|
+ it('orders subscript entries against methods by name, not by member kind', () => {
|
|
|
+ // `a-tool` sorts before `z`, so the subscript comment must precede the
|
|
|
+ // method: one ordered stream, not methods-then-comments.
|
|
|
+ const noArgs = parameterSchemaSpecToJsonSchema({}) as unknown as Record<string, unknown>
|
|
|
+ const text = renderToolsSdkPy([
|
|
|
+ { name: 'z', description: 'Last by name.', parameters: noArgs, output: { type: 'string' } },
|
|
|
+ { name: 'a-tool', description: 'First by name.', parameters: noArgs, output: { type: 'string' } },
|
|
|
+ ])
|
|
|
+ expect(text.indexOf('# tools["a-tool"]')).toBeLessThan(text.indexOf('async def z'))
|
|
|
+ // The interleaved comment does not disturb the class body: `z` still parses
|
|
|
+ // as the statement that keeps `pass` out.
|
|
|
+ expect(text).not.toContain(`${' '.repeat(4)}pass`)
|
|
|
+ })
|
|
|
+
|
|
|
+ it('is deterministic: byte-identical output regardless of input order or duplication', () => {
|
|
|
+ expect(renderToolsSdkPy([bash, exotic])).toBe(renderToolsSdkPy([exotic, bash]))
|
|
|
+ expect(renderToolsSdkPy([bash, bash])).toBe(renderToolsSdkPy([bash, bash]))
|
|
|
+ })
|
|
|
+
|
|
|
+ it('renders a pass body and a minimal import for an empty tool set', () => {
|
|
|
+ const text = renderToolsSdkPy([])
|
|
|
+ expect(text).toContain('class Tools(Protocol):')
|
|
|
+ expect(text).toContain(' pass')
|
|
|
+ // Nothing but the protocol is used, so the import line is just Protocol.
|
|
|
+ expect(text).toContain('from typing import Protocol')
|
|
|
+ })
|
|
|
+
|
|
|
+ it('omits the docstring/comment when a schema has no description', () => {
|
|
|
+ const undescribedIdentifier: ToolSdkSchema = {
|
|
|
+ name: 'plain',
|
|
|
+ description: '',
|
|
|
+ parameters: parameterSchemaSpecToJsonSchema({}) as unknown as Record<string, unknown>,
|
|
|
+ output: { type: 'string' },
|
|
|
+ }
|
|
|
+ const undescribedExotic: ToolSdkSchema = {
|
|
|
+ name: 'weird-name',
|
|
|
+ description: '',
|
|
|
+ parameters: parameterSchemaSpecToJsonSchema({}) as unknown as Record<string, unknown>,
|
|
|
+ output: { type: 'string' },
|
|
|
+ }
|
|
|
+ const text = renderToolsSdkPy([undescribedIdentifier, undescribedExotic])
|
|
|
+ // Identifier method appears without a docstring in its body — hence the
|
|
|
+ // `: ...` stub, which a documented method replaces with the docstring.
|
|
|
+ expect(text).toContain('async def plain(self, args: dict[str, Any]) -> str: ...')
|
|
|
+ expect(text).not.toContain('"""')
|
|
|
+ // Subscript entry appears without the "# ..." description follow-up.
|
|
|
+ expect(text).toContain('# tools["weird-name"]')
|
|
|
+ expect(text.split('\n').every(line => !line.startsWith(' # '))).toBe(true)
|
|
|
+ // A whitespace-only description collapses to nothing and is treated as
|
|
|
+ // absent: no empty `""""""` docstring, no bare `# ` line.
|
|
|
+ const blank = renderToolsSdkPy([
|
|
|
+ { ...undescribedIdentifier, description: ' \t\n ' },
|
|
|
+ { ...undescribedExotic, description: ' ' },
|
|
|
+ ])
|
|
|
+ expect(blank).toBe(text)
|
|
|
+ })
|
|
|
+
|
|
|
+ it('marks an open object TypedDict and declares a closed empty object', () => {
|
|
|
+ const t: ToolSdkSchema = {
|
|
|
+ name: 'openness',
|
|
|
+ description: '',
|
|
|
+ parameters: {
|
|
|
+ type: 'object',
|
|
|
+ additionalProperties: false,
|
|
|
+ properties: {
|
|
|
+ open: { type: 'object', additionalProperties: true, properties: { x: { type: 'string' } }, required: ['x'] },
|
|
|
+ closedEmpty: { type: 'object', additionalProperties: false, properties: {} },
|
|
|
+ },
|
|
|
+ required: ['open', 'closedEmpty'],
|
|
|
+ },
|
|
|
+ output: { type: 'string' },
|
|
|
+ }
|
|
|
+ const text = renderToolsSdkPy([t])
|
|
|
+ // The open nested object carries the in-band openness note...
|
|
|
+ expect(text).toContain('class OpennessArgsOpen(TypedDict):')
|
|
|
+ expect(text).toMatch(/class OpennessArgsOpen\(TypedDict\):\n x: str\n # Additional keys beyond those declared are allowed\./)
|
|
|
+ // ...the closed root does not...
|
|
|
+ expect(text).toMatch(/class OpennessArgs\(TypedDict\):\n open: OpennessArgsOpen\n closedEmpty: OpennessArgsClosedEmpty\n\n/)
|
|
|
+ // ...and a closed EMPTY object declares an empty TypedDict rather than
|
|
|
+ // degrading to dict[str, Any] (which would falsely accept any keys).
|
|
|
+ expect(text).toMatch(/class OpennessArgsClosedEmpty\(TypedDict\):\n pass/)
|
|
|
+ expect(text).toContain('closedEmpty: OpennessArgsClosedEmpty')
|
|
|
+ })
|
|
|
+
|
|
|
+ it('renders a deeply nested array schema without exhausting the call stack, capped at CPython\'s bracket limit', () => {
|
|
|
+ // The registry supports depth-unbounded schemas; the renderer must not
|
|
|
+ // reintroduce a recursion limit during prompt assembly. It must also not
|
|
|
+ // emit more open brackets than CPython's tokenizer accepts (200), so the
|
|
|
+ // chain degrades to `Any` at MAX_LIST_NESTING instead of rendering an SDK
|
|
|
+ // block that is not valid Python.
|
|
|
+ let deep: Record<string, unknown> = { type: 'string' }
|
|
|
+ for (let i = 0; i < 20000; i++) deep = { type: 'array', items: deep }
|
|
|
+ const type = jsonSchemaToPy(deep)
|
|
|
+ expect(type.startsWith('list[list[')).toBe(true)
|
|
|
+ expect(type.endsWith(']]')).toBe(true)
|
|
|
+ // 180 `list[` levels around `Any`, not 20000 around `str`.
|
|
|
+ expect(type).toBe(`${'list['.repeat(180)}Any${']'.repeat(180)}`)
|
|
|
+ expect(type.split('[').length - 1).toBeLessThan(200)
|
|
|
+ })
|
|
|
+
|
|
|
+ it('keeps a chain just under the nesting cap exact, and restarts nesting per TypedDict field', () => {
|
|
|
+ // 179 levels still render the real item type: the cap degrades only what
|
|
|
+ // would not parse.
|
|
|
+ let under: Record<string, unknown> = { type: 'string' }
|
|
|
+ for (let i = 0; i < 179; i++) under = { type: 'array', items: under }
|
|
|
+ expect(jsonSchemaToPy(under)).toBe(`${'list['.repeat(179)}str${']'.repeat(179)}`)
|
|
|
+ // A field annotation is a fresh logical line, so a 179-deep chain reached
|
|
|
+ // THROUGH an object field is unaffected by the depth spent on the object.
|
|
|
+ const tool: ToolSdkSchema = {
|
|
|
+ name: 'deep_field',
|
|
|
+ description: 'Deep array under a field.',
|
|
|
+ parameters: { type: 'object', additionalProperties: false, properties: { rows: under }, required: ['rows'] },
|
|
|
+ output: { type: 'string' },
|
|
|
+ }
|
|
|
+ expect(renderToolsSdkPy([tool])).toContain(` rows: ${'list['.repeat(179)}str${']'.repeat(179)}`)
|
|
|
+ })
|
|
|
+
|
|
|
+ it('caps the argument annotation, the site whose enclosing paren stays open', () => {
|
|
|
+ // The worst of the three emission sites: the parameter list's `(` is still
|
|
|
+ // open around this annotation, so 180 `list[` plus the innermost bracket
|
|
|
+ // plus that paren is 182 of CPython's 200. Only a raw `register()` whose
|
|
|
+ // `parameters` is an array reached from the root through `oneOf` arms
|
|
|
+ // alone gets there — the root array itself, or one under any depth of
|
|
|
+ // unions, since an arm inherits the enclosing depth unchanged. An object
|
|
|
+ // ancestor takes it out of this case: its fields restart at the 181 site.
|
|
|
+ // `defineTool` compiles an object root, whose annotation is a bare
|
|
|
+ // TypedDict name or a one-bracket `dict[str, Any]`, never a chain.
|
|
|
+ const rooted = (depth: number): ToolSdkSchema => {
|
|
|
+ let schema: Record<string, unknown> = { type: 'string', const: 'x' }
|
|
|
+ for (let i = 0; i < depth; i++) schema = { type: 'array', items: schema }
|
|
|
+ return { name: 'rooted', description: 'Array-rooted parameters.', parameters: schema, output: { type: 'string' } }
|
|
|
+ }
|
|
|
+ // Exactly at the cap with a scalar underneath is the worst case itself: the
|
|
|
+ // chain's root frame starts at `listDepth: 0` here, so all 180 `list[`
|
|
|
+ // still emit and the innermost `Literal[` is reached rather than degraded.
|
|
|
+ const worst = renderToolsSdkPy([rooted(180)])
|
|
|
+ expect(worst).toContain(`async def rooted(self, args: ${'list['.repeat(180)}Literal["x"]${']'.repeat(180)}) -> str:`)
|
|
|
+ const annotation = worst.split('async def rooted(self, args: ')[1]!.split(') -> str:')[0]!
|
|
|
+ // 181 brackets on the annotation plus the still-open parameter-list paren,
|
|
|
+ // the 182 the cap is chosen against.
|
|
|
+ expect(annotation.split('[').length - 1).toBe(181)
|
|
|
+ // One array deeper is where the degradation lands, and it lands on the item
|
|
|
+ // rather than on another `list[`, so the count cannot grow past that.
|
|
|
+ expect(renderToolsSdkPy([rooted(181)]))
|
|
|
+ .toContain(`async def rooted(self, args: ${'list['.repeat(180)}Any${']'.repeat(180)}) -> str:`)
|
|
|
+ // A union spine reaches the same 182, at any number of arms deep: each arm
|
|
|
+ // inherits the enclosing depth because `A | B` opens nothing, so the chain
|
|
|
+ // under the innermost one still starts at 0. Three unions here, to pin that
|
|
|
+ // it is the whole `oneOf`-only path and not just a single root union.
|
|
|
+ let spine: Record<string, unknown> = rooted(180).parameters
|
|
|
+ for (let i = 0; i < 3; i++) spine = { oneOf: [spine, { type: 'string' }] }
|
|
|
+ const text = renderToolsSdkPy([{ ...rooted(180), parameters: spine }])
|
|
|
+ const chain = `${'list['.repeat(180)}Literal["x"]${']'.repeat(180)}`
|
|
|
+ expect(text).toContain(`args: ${chain} | str | str | str) -> str:`)
|
|
|
+ expect(text.split('async def rooted(self, args: ')[1]!.split(') -> str:')[0]!.split('[').length - 1).toBe(181)
|
|
|
+ // An object ancestor is the boundary of that path: the field it declares is
|
|
|
+ // a class-body line, so the same chain lands on the 181 site instead.
|
|
|
+ const boxed = renderToolsSdkPy([
|
|
|
+ {
|
|
|
+ ...rooted(180),
|
|
|
+ parameters: { type: 'object', properties: { rows: rooted(180).parameters }, required: ['rows'] },
|
|
|
+ },
|
|
|
+ ])
|
|
|
+ expect(boxed).toContain(` rows: ${'list['.repeat(179)}Any${']'.repeat(179)}`)
|
|
|
+ })
|
|
|
+
|
|
|
+ it('renders a deeply nested oneOf chain in linear time (no per-level re-materialization)', () => {
|
|
|
+ // Each level is a two-branch oneOf whose first branch recurses; joining the
|
|
|
+ // accumulated union string at every level would be Theta(depth^2). At this
|
|
|
+ // depth the quadratic path (~100,000^2 char copies) blows past vitest's 5s
|
|
|
+ // default, so this fails loud on a regression; the `+`/ConsString path is
|
|
|
+ // milliseconds. (Guard the depth explicitly so the assertions stay exact.)
|
|
|
+ // The resulting chain is intentionally uncapped, unlike list nesting: it is
|
|
|
+ // grammatically valid Python at any length, and only CPython's `compile()`
|
|
|
+ // recursion would reject it — see the `oneOf` arm in py-types.ts.
|
|
|
+ const depth = 100000
|
|
|
+ let deep: Record<string, unknown> = { type: 'string' }
|
|
|
+ for (let i = 0; i < depth; i++) deep = { oneOf: [deep, { type: 'null' }] }
|
|
|
+ const type = jsonSchemaToPy(deep)
|
|
|
+ expect(type.startsWith('str | None')).toBe(true)
|
|
|
+ expect(type.endsWith(' | None')).toBe(true)
|
|
|
+ expect(type.length).toBe('str'.length + ' | None'.length * depth)
|
|
|
+ })
|
|
|
+
|
|
|
+ it('names a deep oneOf-of-object chain in linear time (bounded propagated class names)', () => {
|
|
|
+ // Every level is a oneOf whose SECOND branch is a named object (a closed
|
|
|
+ // empty TypedDict) and whose first branch recurses — so every level has an
|
|
|
+ // object node, each propagating a class name one segment longer. Without a
|
|
|
+ // propagation cap, allocateClassName slices an ever-longer rope at every
|
|
|
+ // level → Theta(depth^2) (~9.5s at this depth, past the 5s default);
|
|
|
+ // childClassName caps the base so it stays linear (~ms). Assertions are
|
|
|
+ // shape-based but the depth is the tripwire: a regression times out.
|
|
|
+ const depth = 60000
|
|
|
+ let deep: Record<string, unknown> = { type: 'object', additionalProperties: false, properties: {} }
|
|
|
+ for (let i = 0; i < depth; i++) {
|
|
|
+ deep = { oneOf: [deep, { type: 'object', additionalProperties: false, properties: {} }] }
|
|
|
+ }
|
|
|
+ const tool: ToolSdkSchema = { name: 'deep', description: 'Deep oneOf-object chain.', parameters: { type: 'object', additionalProperties: false, properties: { root: deep }, required: ['root'] }, output: { type: 'string' } }
|
|
|
+ const text = renderToolsSdkPy([tool])
|
|
|
+ // No emitted class name exceeds the cap (plus a short collision suffix).
|
|
|
+ const longest = [...text.matchAll(/^class (\w+)\(TypedDict\):/gm)].reduce((max, m) => Math.max(max, m[1]?.length ?? 0), 0)
|
|
|
+ expect(longest).toBeLessThanOrEqual(140)
|
|
|
+ expect(text).toContain('class Tools(Protocol):')
|
|
|
+ })
|
|
|
+
|
|
|
+ it('caps the class name for a tool whose name exceeds the base length limit', () => {
|
|
|
+ // The root class base is `${CamelCase(name)}Args`; a very long tool name
|
|
|
+ // makes it exceed MAX_CLASS_NAME_BASE, so allocateClassName caps it.
|
|
|
+ const longName = `x_${'a'.repeat(200)}`
|
|
|
+ const tool: ToolSdkSchema = {
|
|
|
+ name: longName,
|
|
|
+ description: 'Long name.',
|
|
|
+ parameters: { type: 'object', additionalProperties: false, properties: { f: { type: 'string' } }, required: ['f'] },
|
|
|
+ output: { type: 'string' },
|
|
|
+ }
|
|
|
+ const text = renderToolsSdkPy([tool])
|
|
|
+ const longest = [...text.matchAll(/^class (\w+)\(TypedDict\):/gm)].reduce((max, m) => Math.max(max, m[1]?.length ?? 0), 0)
|
|
|
+ expect(longest).toBeLessThanOrEqual(140)
|
|
|
+ expect(text).toContain('class Tools(Protocol):')
|
|
|
+ })
|
|
|
+
|
|
|
+ it('emits pass for a subscript-only tool set (comments are not statements)', () => {
|
|
|
+ const t: ToolSdkSchema = {
|
|
|
+ name: 'my-exotic.tool',
|
|
|
+ description: '',
|
|
|
+ parameters: parameterSchemaSpecToJsonSchema({}) as unknown as Record<string, unknown>,
|
|
|
+ output: { type: 'string' },
|
|
|
+ }
|
|
|
+ const text = renderToolsSdkPy([t])
|
|
|
+ // The class body must contain a statement before the subscript comments.
|
|
|
+ expect(text).toMatch(/class Tools\(Protocol\):\n pass\n # tools\["my-exotic\.tool"\]/)
|
|
|
+ })
|
|
|
+
|
|
|
+ it('degrades an object whose field would be name-mangled (__token) to dict[str, Any]', () => {
|
|
|
+ // Class-syntax TypedDict mangles a leading-double-underscore non-dunder
|
|
|
+ // annotation to _ClassName__token — a different JSON key than the schema.
|
|
|
+ const t: ToolSdkSchema = {
|
|
|
+ name: 'mangler',
|
|
|
+ description: '',
|
|
|
+ parameters: {
|
|
|
+ type: 'object',
|
|
|
+ additionalProperties: false,
|
|
|
+ properties: { __token: { type: 'string' } },
|
|
|
+ required: ['__token'],
|
|
|
+ },
|
|
|
+ output: { type: 'string' },
|
|
|
+ }
|
|
|
+ const text = renderToolsSdkPy([t])
|
|
|
+ expect(text).toContain('async def mangler(self, args: dict[str, Any]) -> str: ...')
|
|
|
+ expect(text).not.toContain('__token:')
|
|
|
+ // Dunder-form fields (__meta__) are NOT mangled and stay expressible.
|
|
|
+ const dunder: ToolSdkSchema = {
|
|
|
+ name: 'dunder',
|
|
|
+ description: '',
|
|
|
+ parameters: {
|
|
|
+ type: 'object',
|
|
|
+ additionalProperties: false,
|
|
|
+ properties: { __meta__: { type: 'string' } },
|
|
|
+ required: ['__meta__'],
|
|
|
+ },
|
|
|
+ output: { type: 'string' },
|
|
|
+ }
|
|
|
+ expect(renderToolsSdkPy([dunder])).toContain('__meta__: str')
|
|
|
+ })
|
|
|
+
|
|
|
+ it('degrades an object with a __debug__ field, which CPython refuses to assign', () => {
|
|
|
+ // `__debug__` is a legal identifier and dunder-form, so it clears both the
|
|
|
+ // identifier rule and the name-mangling rule, but CPython rejects the
|
|
|
+ // annotation at COMPILE time (`SyntaxError: cannot assign to __debug__`) —
|
|
|
+ // and this block is Code Mode's only SDK, so it must always parse.
|
|
|
+ const t: ToolSdkSchema = {
|
|
|
+ name: 'debugger',
|
|
|
+ description: '',
|
|
|
+ parameters: {
|
|
|
+ type: 'object',
|
|
|
+ additionalProperties: false,
|
|
|
+ properties: { __debug__: { type: 'string' } },
|
|
|
+ required: ['__debug__'],
|
|
|
+ },
|
|
|
+ output: { type: 'string' },
|
|
|
+ }
|
|
|
+ const text = renderToolsSdkPy([t])
|
|
|
+ expect(text).toContain('async def debugger(self, args: dict[str, Any]) -> str: ...')
|
|
|
+ expect(text).not.toContain('__debug__')
|
|
|
+ })
|
|
|
+
|
|
|
+ it('routes every underscore-leading tool name to subscript access', () => {
|
|
|
+ // `_foo` and `__meta__` are both legal Python attributes, unlike an exotic
|
|
|
+ // name or a hard keyword, yet the whole underscore family goes to
|
|
|
+ // `tools[name]` under one rule. Only some forms actually break — `__token`
|
|
|
+ // name-mangles at the CALL SITE inside the model's own class, and a dunder
|
|
|
+ // that exists on `object` (`__class__`) resolves before the proxy's
|
|
|
+ // __getattr__ runs — so the family rule is what routes `_foo` and
|
|
|
+ // `__meta__`, not a defect in those two names.
|
|
|
+ const make = (name: string): ToolSdkSchema => ({
|
|
|
+ name,
|
|
|
+ description: 'Leading underscore.',
|
|
|
+ parameters: parameterSchemaSpecToJsonSchema({}) as unknown as Record<string, unknown>,
|
|
|
+ output: { type: 'string' },
|
|
|
+ })
|
|
|
+ const text = renderToolsSdkPy([make('_foo'), make('__meta__'), make('__token')])
|
|
|
+ for (const name of ['_foo', '__meta__', '__token']) {
|
|
|
+ expect(text).toContain(`# tools[${JSON.stringify(name)}](args: dict[str, Any]) -> str`)
|
|
|
+ expect(text).not.toContain(`async def ${name}(`)
|
|
|
+ }
|
|
|
+ // No method emitted at all, so the class body needs the explicit `pass`.
|
|
|
+ expect(text).toContain(' pass\n')
|
|
|
+ })
|
|
|
+
|
|
|
+ it('quotes a tool name through the same JSON.stringify the Literal path depends on', () => {
|
|
|
+ // A lone surrogate is reachable in a name — `"\ud800"` survives
|
|
|
+ // `JSON.parse` of MCP wire JSON — and this path has no UNPRINTABLE /
|
|
|
+ // LONE_SURROGATE fallback behind it, only ES2019 well-formed
|
|
|
+ // stringification. Raw, it would make the whole SDK block uncompilable,
|
|
|
+ // exactly as on the `Literal[...]` path.
|
|
|
+ const text = renderToolsSdkPy([
|
|
|
+ {
|
|
|
+ name: 'a\ud800b',
|
|
|
+ description: 'Lone surrogate in the name.',
|
|
|
+ parameters: parameterSchemaSpecToJsonSchema({}) as unknown as Record<string, unknown>,
|
|
|
+ output: { type: 'string' },
|
|
|
+ },
|
|
|
+ ])
|
|
|
+ expect(text).toContain(String.raw`# tools["a\ud800b"](args: dict[str, Any]) -> str`)
|
|
|
+ expect(text).not.toContain('\ud800')
|
|
|
+ })
|
|
|
+
|
|
|
+ it('escapes quotes and backslashes in descriptions so the docstring stays valid Python', () => {
|
|
|
+ // A description ending in `"` or an odd backslash would otherwise merge
|
|
|
+ // with (or escape) the closing triple quote — and this block is Code
|
|
|
+ // Mode's only SDK, so it must always parse.
|
|
|
+ const trailingQuote = renderToolsSdkPy([described('ends in a quote"')])
|
|
|
+ expect(trailingQuote).toContain(String.raw`"""ends in a quote\""""`)
|
|
|
+ const trailingBackslash = renderToolsSdkPy([described('ends in a backslash\\')])
|
|
|
+ expect(trailingBackslash).toContain(String.raw`"""ends in a backslash\\"""`)
|
|
|
+ const tripleQuote = renderToolsSdkPy([described('contains """ triple quote')])
|
|
|
+ expect(tripleQuote).toContain(String.raw`"""contains \"\"\" triple quote"""`)
|
|
|
+ })
|
|
|
+
|
|
|
+ it('escapes unprintable control characters, which CPython refuses inside source at all', () => {
|
|
|
+ // `compile()` raises `SyntaxError: source code string cannot contain null
|
|
|
+ // bytes` for a NUL ANYWHERE in the source text, including inside a string
|
|
|
+ // literal or a comment, so a NUL that survives normalization into a
|
|
|
+ // docstring or a `#` field comment stops this block — Code Mode's only SDK —
|
|
|
+ // from parsing at all. The whitespace collapse does not remove it (a NUL is
|
|
|
+ // not whitespace). Rendering it as a visible escape keeps the source
|
|
|
+ // parseable and still shows the model what the schema said.
|
|
|
+ const nul = renderToolsSdkPy([described('before\u0000after')])
|
|
|
+ // Both emission sites: the method docstring and the `#` field comment. The
|
|
|
+ // docstring's backslash is doubled by the same escaping that keeps a literal
|
|
|
+ // backslash from escaping the closing triple quote, so Python parses it back
|
|
|
+ // to the visible `\x00` the comment shows directly. Neither carries the byte.
|
|
|
+ expect(nul).not.toContain('\u0000')
|
|
|
+ expect(nul).toContain(String.raw`"""before\\x00after"""`)
|
|
|
+ expect(nul).toContain(String.raw`# before\x00after`)
|
|
|
+ // The other C0 controls and DEL escape on the same path. Tab, newline and
|
|
|
+ // carriage return never reach it: the whitespace collapse folds them to a
|
|
|
+ // space first.
|
|
|
+ const others = renderToolsSdkPy([described('bell\u0007esc\u001bdel\u007f')])
|
|
|
+ expect(others).toContain(String.raw`bell\x07esc\x1bdel\x7f`)
|
|
|
+ expect(renderToolsSdkPy([described('tab\tnewline\ncr\r')])).toContain('"""tab newline cr"""')
|
|
|
+ // No C1 control is ECMAScript whitespace (TAB/VT/FF/SP/NBSP/ZWNBSP/Zs plus
|
|
|
+ // LF/CR/LS/PS), so the collapse folds none of U+0080 to U+009F and the
|
|
|
+ // escape is what keeps them out of the docstring, where they would be
|
|
|
+ // invisible. NBSP, which IS whitespace, folds instead. Windows-1252 bytes
|
|
|
+ // 0x80 to 0x9F decoded as Latin-1 land exactly here.
|
|
|
+ const nel = renderToolsSdkPy([described('a\u0085b')])
|
|
|
+ expect(nel).not.toContain('\u0085')
|
|
|
+ expect(nel).toContain(String.raw`# a\x85b`)
|
|
|
+ const c1 = renderToolsSdkPy([described('csi\u009bst\u009cend\u009f')])
|
|
|
+ expect(c1).toContain(String.raw`csi\x9bst\x9cend\x9f`)
|
|
|
+ expect(renderToolsSdkPy([described('nb\u00a0sp')])).toContain('"""nb sp"""')
|
|
|
+ // `Cf` formatting characters pass through by category, not by
|
|
|
+ // addressability — U+00AD would fit `\xNN`, the rest would need a second
|
|
|
+ // form. They terminate neither a Python string literal nor a `#` comment,
|
|
|
+ // so the block stays parseable with the code point intact.
|
|
|
+ expect(renderToolsSdkPy([described('zero\u200bwidth')])).toContain('"""zero\u200bwidth"""')
|
|
|
+ // Whitespace around a surviving control character is not an absent
|
|
|
+ // description. The escape's output is non-whitespace ASCII and the escaped
|
|
|
+ // sets are disjoint from what `trim()` strips, so the two operations touch
|
|
|
+ // different characters and their order is unobservable.
|
|
|
+ expect(renderToolsSdkPy([described(' \u0085 ')])).toContain(String.raw`# \x85`)
|
|
|
+ })
|
|
|
+
|
|
|
+ it('escapes unpaired surrogates, which make the source impossible to encode', () => {
|
|
|
+ // This is the NUL case, not the invisible-character case: Python source
|
|
|
+ // must be UTF-8-encodable, and `compile()` raises `UnicodeEncodeError:
|
|
|
+ // surrogates not allowed` for a lone surrogate in a string literal and in a
|
|
|
+ // `#` comment alike, so one would stop this block — Code Mode's only SDK —
|
|
|
+ // from parsing. A wire description reaches it: `JSON.parse` on a `"\ud800"`
|
|
|
+ // escape yields exactly this code point.
|
|
|
+ const high = renderToolsSdkPy([described('a\ud800b')])
|
|
|
+ expect(high).not.toContain('\ud800')
|
|
|
+ expect(high).toContain(String.raw`# a\ud800b`)
|
|
|
+ expect(high).toContain(String.raw`"""a\\ud800b"""`)
|
|
|
+ // A lone LOW surrogate is just as unencodable, and `\xNN` reaches neither.
|
|
|
+ expect(renderToolsSdkPy([described('a\udfffb')])).toContain(String.raw`# a\udfffb`)
|
|
|
+ // A well-formed pair is ONE astral code point, not two surrogates — the
|
|
|
+ // regex's `u` flag is what draws that line, so an emoji survives intact.
|
|
|
+ expect(renderToolsSdkPy([described('emoji \u{1f600} ok')])).toContain('"""emoji \u{1f600} ok"""')
|
|
|
+ })
|
|
|
+})
|