Files
openclaude/tests/sdk/package-consumer-types.test.ts
BogdanandGitHub 1827d84709 feat(agents): add per-agent step limits (#1815)
* feat(agents): add per-agent step limits

Add maxSteps agent configuration for markdown, JSON, plugin, and SDK agent definitions. Enforce the limit in subagent query execution by blocking over-limit tool calls, preserving a no-tool summary turn, and recording an agent_step_limit terminal reason. Add focused coverage for default behavior, invalid values, multi-turn accumulation, plugin parsing, failure-loop interaction, and summary-tool blocking.

* test(agents): isolate agent loader fixtures

* test(agents): stabilize agent loader config fixtures

* fix(agents): harden step-limit summaries

* fix(sdk): harden agent injection follow-up

* fix(sdk): report invalid agent step limits
2026-06-30 11:23:21 +08:00

328 lines
11 KiB
TypeScript

/**
* Package consumer validation test.
*
* Ensures the packed SDK types compile correctly for a real TypeScript consumer.
* Reproduces: npm pack → install into temp project → tsc with skipLibCheck:false.
*
* This catches issues like:
* - Constructor parameter properties in .d.ts (not allowed)
* - Missing local imports for re-exported types
* - Self-referential type wrappers
*/
import { afterAll, describe, expect, test } from 'bun:test'
import { execFileSync, execSync } from 'child_process'
import { existsSync, mkdirSync, rmSync, writeFileSync, cpSync, readFileSync } from 'fs'
import { join } from 'path'
import { randomUUID } from 'crypto'
const ROOT = join(import.meta.dir, '..', '..')
const SDK_DTS = join(ROOT, 'src', 'entrypoints', 'sdk.d.ts')
const CORE_TYPES_TS = join(ROOT, 'src', 'entrypoints', 'sdk', 'coreTypes.generated.ts')
const TSC_BIN = join(ROOT, 'node_modules', 'typescript', 'bin', 'tsc')
/** All temp dirs created during tests — cleaned up in afterAll */
const tempDirs: string[] = []
/**
* Set up a minimal TypeScript consumer project that imports from the SDK.
* Simulates what npm pack + install would produce.
*/
function setupConsumerProject(name: string): string {
const tmpDir = join(ROOT, '.tmp', `sdk-consumer-${name}-${randomUUID().slice(0, 8)}`)
tempDirs.push(tmpDir)
mkdirSync(tmpDir, { recursive: true })
// Create consumer tsconfig — skipLibCheck:false is critical
writeFileSync(
join(tmpDir, 'tsconfig.json'),
JSON.stringify(
{
compilerOptions: {
target: 'ES2022',
module: 'ESNext',
moduleResolution: 'bundler',
strict: true,
skipLibCheck: false,
noEmit: true,
types: [],
},
include: ['consumer.ts'],
},
null,
2,
),
)
// Simulate node_modules/@gitlawb/openclaude structure
const pkgDir = join(tmpDir, 'node_modules', '@gitlawb', 'openclaude')
mkdirSync(pkgDir, { recursive: true })
mkdirSync(join(pkgDir, 'src', 'entrypoints', 'sdk'), { recursive: true })
mkdirSync(join(pkgDir, 'dist'), { recursive: true })
// Package.json with "exports" mapping (matches real package)
writeFileSync(
join(pkgDir, 'package.json'),
JSON.stringify(
{
name: '@gitlawb/openclaude',
version: '0.0.0-test',
type: 'module',
exports: {
'./package.json': './package.json',
'./dist/cli.mjs': './dist/cli.mjs',
'./sdk': {
types: './src/entrypoints/sdk.d.ts',
import: './dist/sdk.mjs',
},
},
},
null,
2,
),
)
// Copy type files
cpSync(SDK_DTS, join(pkgDir, 'src', 'entrypoints', 'sdk.d.ts'))
cpSync(CORE_TYPES_TS, join(pkgDir, 'src', 'entrypoints', 'sdk', 'coreTypes.generated.ts'))
// Dummy dist file so module resolution doesn't fail
writeFileSync(join(pkgDir, 'dist', 'sdk.mjs'), 'export {}')
return tmpDir
}
/** Compile consumer.ts in the given tmpDir. Returns stdout (empty = success). */
function tsc(tmpDir: string): string {
return execFileSync(process.execPath, [TSC_BIN, '-p', 'tsconfig.json', '--pretty', 'false'], {
cwd: tmpDir,
encoding: 'utf-8',
timeout: 60000,
stdio: ['pipe', 'pipe', 'pipe'],
}).trim()
}
function ensureBuildArtifacts(): void {
const distCli = join(ROOT, 'dist', 'cli.mjs')
const distSdk = join(ROOT, 'dist', 'sdk.mjs')
if (existsSync(distCli) && existsSync(distSdk)) return
execSync('bun run build', {
cwd: ROOT,
stdio: ['ignore', 'pipe', 'pipe'],
timeout: 180000,
})
}
afterAll(() => {
for (const dir of tempDirs) {
try {
if (existsSync(dir)) rmSync(dir, { recursive: true, force: true })
} catch {
// Windows EBUSY — ignore, will be cleaned on next run or reboot
}
}
})
// tsc compilation can be slow under CPU contention — 30s timeout per test
describe('package consumer types', () => {
test('SDK types compile for external consumer with skipLibCheck:false', () => {
const tmpDir = setupConsumerProject('basic')
writeFileSync(
join(tmpDir, 'consumer.ts'),
[
`import type {`,
` SDKMessage,`,
` SDKUserMessage,`,
` SDKResultMessage,`,
` SDKRateLimitError,`,
` QueryOptions,`,
` SDKSession,`,
` SDKSessionOptions,`,
`} from '@gitlawb/openclaude/sdk'`,
``,
`// Use the types so they're not unused-imports-eliminated`,
`type _Msg = SDKMessage`,
`type _User = SDKUserMessage`,
`type _Result = SDKResultMessage`,
``,
`// Verify SDKRateLimitError properties are accessible`,
`declare const err: SDKRateLimitError`,
`const _resets: number | undefined = err.resetsAt`,
`const _rateType: string | undefined = err.rateLimitType`,
``,
`// Verify session types`,
`declare const session: SDKSession`,
`const _messages: SDKMessage[] = session.getMessages()`,
``,
`// SDK-provided agents can rely on the runtime name fallback for description.`,
`const _queryOptions: QueryOptions = {`,
` cwd: '/tmp/project',`,
` agents: {`,
` helper: {`,
` prompt: 'Help with package consumer type coverage',`,
` maxSteps: 2,`,
` },`,
` },`,
`}`,
`const _sessionOptions: SDKSessionOptions = {`,
` cwd: '/tmp/project',`,
` agents: {`,
` helper: {`,
` prompt: 'Help with persistent SDK session type coverage',`,
` maxSteps: 2,`,
` },`,
` },`,
`}`,
].join('\n'),
)
expect(tsc(tmpDir)).toBe('')
}, 30_000)
test('SDKMessage/SDKUserMessage/SDKResultMessage are re-exported correctly', () => {
const tmpDir = setupConsumerProject('reexports')
writeFileSync(
join(tmpDir, 'consumer.ts'),
[
`import type { SDKMessage, SDKUserMessage, SDKResultMessage } from '@gitlawb/openclaude/sdk'`,
``,
`// Discriminated union check — if types are broken, this won't compile`,
`function handle(msg: SDKMessage) {`,
` if (msg.type === 'user') {`,
` const u: SDKUserMessage = msg`,
` console.log(u.message.content)`,
` }`,
` if (msg.type === 'result') {`,
` const r: SDKResultMessage = msg`,
` console.log(r.type)`,
` }`,
`}`,
].join('\n'),
)
expect(tsc(tmpDir)).toBe('')
}, 30_000)
test('result usage standard counters are required numbers', () => {
const tmpDir = setupConsumerProject('usage-counters')
writeFileSync(
join(tmpDir, 'consumer.ts'),
[
`import type { SDKResultMessage } from '@gitlawb/openclaude/sdk'`,
``,
`// Result messages are populated from QueryEngine.totalUsage`,
`// (initialized from EMPTY_USAGE), so the standard counters are`,
`// always-present numbers — strict consumers may sum them without`,
`// undefined guards.`,
`declare const result: SDKResultMessage`,
`const _total: number =`,
` result.usage.input_tokens +`,
` result.usage.output_tokens +`,
` result.usage.cache_creation_input_tokens +`,
` result.usage.cache_read_input_tokens`,
``,
`// Richer nested metadata is modeled explicitly`,
`const _cache5m: number | undefined =`,
` result.usage.cache_creation?.ephemeral_5m_input_tokens`,
`const _webSearches: number | undefined =`,
` result.usage.server_tool_use?.web_search_requests`,
`const _tier: string | undefined = result.usage.service_tier`,
``,
`// Unanticipated fields still flow through the index signature`,
`const _extra: unknown = result.usage.inference_geo`,
].join('\n'),
)
expect(tsc(tmpDir)).toBe('')
}, 30_000)
test('SDKRateLimitError has resetsAt and rateLimitType as class properties', () => {
const tmpDir = setupConsumerProject('ratelimit')
writeFileSync(
join(tmpDir, 'consumer.ts'),
[
`import { SDKRateLimitError } from '@gitlawb/openclaude/sdk'`,
``,
`// Constructor should accept (message?, resetsAt?, rateLimitType?)`,
`const err = new SDKRateLimitError('rate limited', 12345, 'requests')`,
``,
`// Properties should be accessible on the instance`,
`const resets: number | undefined = err.resetsAt`,
`const rateType: string | undefined = err.rateLimitType`,
``,
`console.log(resets, rateType)`,
].join('\n'),
)
expect(tsc(tmpDir)).toBe('')
}, 30_000)
test('control initialize response accepts generated model and account values', () => {
const tmpDir = setupConsumerProject('control-init')
writeFileSync(
join(tmpDir, 'consumer.ts'),
[
`import type { SDKControlInitializeResponse, ModelInfo } from '@gitlawb/openclaude/sdk'`,
``,
`const models: ModelInfo[] = [{`,
` value: 'claude-opus-4-6',`,
` displayName: 'Claude Opus 4.6',`,
` description: 'Most capable model',`,
` supportsEffort: true,`,
` supportedEffortLevels: ['low', 'medium', 'high', 'max'],`,
`}]`,
``,
`const response: SDKControlInitializeResponse = {`,
` commands: [],`,
` agents: [],`,
` output_style: 'default',`,
` available_output_styles: ['default'],`,
` models,`,
` account: { apiProvider: 'openai' },`,
` pid: 1234,`,
`}`,
``,
`console.log(response)`,
].join('\n'),
)
expect(tsc(tmpDir)).toBe('')
}, 30_000)
})
describe('package exports resolution', () => {
test('package.json export is defined in exports map', () => {
// Read the package.json and verify exports structure
const pkgJson = JSON.parse(readFileSync(join(ROOT, 'package.json'), 'utf-8'))
expect(pkgJson.exports).toBeDefined()
expect(pkgJson.exports['./package.json']).toBe('./package.json')
expect(pkgJson.exports['./dist/cli.mjs']).toBe('./dist/cli.mjs')
expect(pkgJson.exports['./sdk']).toBeDefined()
expect(pkgJson.exports['./sdk'].import).toBe('./dist/sdk.mjs')
expect(pkgJson.exports['./sdk'].types).toBe('./src/entrypoints/sdk.d.ts')
})
test('root export is not defined (intentionally blocked)', () => {
// Verify that "." is not in exports map
const pkgJson = JSON.parse(readFileSync(join(ROOT, 'package.json'), 'utf-8'))
expect(pkgJson.exports['.']).toBeUndefined()
// No main field means root import is intentionally broken
expect(pkgJson.main).toBeUndefined()
})
test('exported files exist after build', () => {
ensureBuildArtifacts()
// Verify the files referenced in exports exist
expect(existsSync(join(ROOT, 'package.json'))).toBe(true)
expect(existsSync(join(ROOT, 'dist', 'cli.mjs'))).toBe(true)
expect(existsSync(join(ROOT, 'dist', 'sdk.mjs'))).toBe(true)
expect(existsSync(join(ROOT, 'src', 'entrypoints', 'sdk.d.ts'))).toBe(true)
})
})