Files
a46b31c3ec feat: SDK Core — Permission System, Async Context, and Engine Extensions (#951)
* feat(sdk): add SDK foundation — type declarations, errors, and utilities

Adds standalone SDK building blocks with no SDK source dependencies:
- sdk.d.ts: ambient type declarations for SDK bundle
- coreSchemas.ts + coreTypes.generated.ts: Zod schemas and generated types
- errors.ts: SDK-specific error classes
- validation.ts: input validation utilities
- messageFilters.ts: extracted message filter logic
- handlePromptSubmit.ts: imports from messageFilters
- 16 generated-types tests

* fix(sdk): narrow assertFunction type from broad Function to callable signature

Code review finding: assertFunction used `asserts value is Function` which
accepts any function-like value without narrowing. Changed to
`(...args: any[]) => any` for better type safety.

* fix(sdk): update sdk.d.ts header — manually maintained, not generated

Reviewer noted the header said "Generated from index.ts" but no generator
produces this file. Updated to "Manually maintained — keep in sync with
index.ts". Drift detection added in validate-externals.ts (PR 3).

* fix(sdk): align sdk.d.ts types with canonical coreTypes.generated.ts

Tighten SDK public type contract to resolve reviewer blockers:

- PermissionResult: unknown[] → precise 6-shape discriminated union
  (addRules/replaceRules/removeRules/setMode/addDirectories/removeDirectories)
- SDKSessionInfo: snake_case → camelCase (sessionId, lastModified, etc.)
- ForkSessionResult: session_id → sessionId
- SDKPermissionRequestMessage: uuid + session_id now required
- SDKPermissionTimeoutMessage: added uuid + session_id
- SessionMessage: parent_uuid → parentUuid
- SDKMessage/SDKUserMessage/SDKResultMessage: replaced loose inline
  definitions with re-exports from coreTypes.generated.ts

* feat(sdk): wire existing code modules + SDK shared utilities

Modifies core modules for SDK integration:
- QueryEngine, tools, state, commands: SDK type hooks
- SDK shared utilities (shared.ts, permissions.ts)
- 21 SDK tests (shared-utils, permissions)

Stack: main ← pr1-foundation ← pr2-sdk-core

* feat(sdk): add snake_case ↔ camelCase key mapping utilities

casing.ts provides recursive key transformation for the SDK boundary
layer. Internal runtime uses snake_case; public API exposes camelCase.
Will be used by shared.ts, sessions.ts, query.ts at export boundaries.

* test(sdk): add tests for snake_case ↔ camelCase mapping utilities

Covers snakeToCamel, camelToSnake, mapKeysToCamel, mapKeysToSnake
including nested objects, arrays, null/undefined, and round-trips.

* fix(sdk): prevent permission timeout race condition with once-only resolve wrapper

Add createOnceOnlyResolve utility to prevent double-resolution of promises
when timeout and host response happen simultaneously. This ensures
deterministic behavior in the permission handling flow.

* fix(sdk): improve race condition test robustness

* fix(sdk): handle consecutive underscores in snakeToCamel conversion

Changes:
- Use _+([a-z]) regex to match multiple consecutive underscores before letters
- Add lookahead (?=. ) to preserve underscore-letter pairs at string end
- Handle dunder names (__proto__, __typename) by stripping wrapper and capitalizing
- Add tests for consecutive underscores and trailing underscore preservation

* fix(sdk): include original error message in permission callback denial

When a canUseTool callback throws an error, the catch block now
includes the original error message in the denial message, making
debugging easier for SDK consumers.

* feat(sdk): add optional timeout to env mutex for deadlock prevention

Add timeout parameter to acquireEnvMutex() to prevent infinite waits
in deadlock scenarios. The timeout is optional and defaults to no timeout
(wait forever) for backward compatibility.

Returns a MutexAcquireResult object with acquired status and optional
timeout reason for failed acquisitions.

* fix(sdk): remove timed-out callback from mutex queue to prevent deadlock

* test(sdk): add missing error path and timeout scenario tests

Add tests for timeout scenarios when host doesn't respond to permission
requests, fallback behavior when no onPermissionRequest callback, and
MCP connection edge cases for undefined/empty config.

* fix(sdk): address code review issues - race conditions, validation, error handling

- Add createPermissionTarget() factory that applies onceOnlyResolve at
  registration time, fixing race condition where timeout and host response
  could both try to resolve the same promise
- Add try-catch to releaseEnvMutex() to prevent permanent lock if callback throws
- Extract DEFAULT_PERMISSION_TIMEOUT_MS constant (30 seconds)
- Add MCP config validation rejecting null, non-objects, and arrays
- Preserve error stack traces in MCP connection failures
- Add runtime validation to mapMessageToSDK for null/non-object/invalid type
- Update tests to use createPermissionTarget and add validation tests

* test(sdk): add sequential timeout-then-host-response race condition tests

Adds two tests addressing reviewer request for proof that host response
after SDK timeout is safely handled with no double-resolve or leaked listener:

1. Integration test: stale host resolve called after timeout deny —
   verifies no error, no mutation, map cleanup
2. Unit test: raw resolve called exactly once when timeout wins —
   directly proves createOnceOnlyResolve prevents second execution

* fix: restore openclaude.json comment in REPL.tsx

Reviewer caught that the comment was incorrectly changed to
~/.claude.json during merge — project has already migrated to
~/.openclaude.json.

* fix(sdk): register pending permission before emitting onPermissionRequest

The previous code emitted onPermissionRequest before calling
registerPendingPermission, so a host responding synchronously from
the callback would find an empty map and its response was lost.
Swap the order so registration happens first.

Adds a regression test for the synchronous host response path.

* fix(sdk): make state setters context-aware for SDK isolation

When running inside runWithSdkContext(), setter functions (regenerateSessionId,
switchSession, setCwdState, setOriginalCwd) now write to the AsyncLocalStorage
context instead of global STATE. This prevents cross-session state leakage in
multi-session SDK scenarios.

Reads were already context-aware; this completes the isolation by making writes
consistent. Outside of SDK context, behavior is unchanged — all writes go to
global STATE as before.

* test(sdk): add context-aware state isolation tests

Tests verify that setters within runWithSdkContext() write to the SDK
context (not global STATE) and that parallel async contexts do not leak
state between sessions. Covers setCwdState, setOriginalCwd,
regenerateSessionId, switchSession, and an end-to-end parallel session
scenario.

* fix(sdk): selective tool schema cache invalidation for multi-engine isolation

Replace global clearToolSchemaCache() in QueryEngine.updateTools() with
selective invalidation that only removes cache entries for tools no longer
in the tool set. This preserves cached schemas for tools that remain,
avoiding unnecessary recomputation for concurrent QueryEngine instances
in multi-session SDK scenarios.

New function invalidateRemovedToolSchemas() handles both simple tool name
keys and schema-variant keys (format: "toolName:{...schemaJSON...}").

* docs(sdk): address PR2 non-blocking documentation and logging issues

- Document request_id vs tool_use_id relationship in shared.ts
  (request_id for response correlation, tool_use_id for tracking)
- Add injectable SDKLogger interface to permissions.ts, replacing
  direct console.warn calls with logger.warn (hosts can control noise)
- Document Node.js-only AsyncLocalStorage requirement in state.ts
  (requires Node.js 12.17.0+ or 14.0.0+)
- Clarify env-mutex is host utility (SDK doesn't mutate process.env)

* fix(sdk): handle throwing onPermissionRequest and fix permission request shape

- Wrap onPermissionRequest in try-catch to clean up pending resolver on throw
- Add uuid and session_id to permission_request message to match SDK schema
- Add regression tests for throwing callback and message shape validation

* fix(sdk): use explicit no-session placeholder for standalone permission prompts

- Add NO_SESSION_PLACEHOLDER constant ('no-session') for permission requests
- Update SDKPermissionRequestMessage doc to explain session_id semantics
- Replace empty string fallback with explicit placeholder
- Add test verifying placeholder behavior when sessionId omitted

* docs(sdk): add example code to permission denial warning

Include canUseTool example in warning message to improve developer
experience and make SDK usage more discoverable for new users.

* fix(sdk): scope parentSessionId to SDK context for parallel isolation

regenerateSessionId({ setCurrentAsParent: true }) was writing to the
process-global STATE.parentSessionId even inside runWithSdkContext(),
allowing one SDK context to overwrite another's parent-session metadata.

Add parentSessionId to the SdkContext type and update both
regenerateSessionId and getParentSessionId to read/write from the
active context when one exists, using an explicit if-else pattern
rather than ?? to avoid undefined fallback leaking across contexts.

The non-SDK CLI path (no active context) continues to use STATE
directly, preserving existing behavior.

---------

Co-authored-by: Ali Alakbarli <ali.alakbarli@users.noreply.github.com>
2026-05-02 08:32:50 +08:00

93 lines
2.9 KiB
TypeScript

import { describe, test, expect } from 'bun:test'
import {
snakeToCamel,
camelToSnake,
mapKeysToCamel,
mapKeysToSnake,
} from '../../src/entrypoints/sdk/casing.js'
describe('snakeToCamel', () => {
test('converts snake_case to camelCase', () => {
expect(snakeToCamel('session_id')).toBe('sessionId')
expect(snakeToCamel('last_modified')).toBe('lastModified')
expect(snakeToCamel('parent_tool_use_id')).toBe('parentToolUseId')
})
test('leaves already-camelCase unchanged', () => {
expect(snakeToCamel('sessionId')).toBe('sessionId')
expect(snakeToCamel('cwd')).toBe('cwd')
})
test('handles empty string', () => {
expect(snakeToCamel('')).toBe('')
})
test('handles consecutive underscores correctly', () => {
// __proto__ should become Proto (both underscores removed before letter)
expect(snakeToCamel('__proto__')).toBe('Proto')
expect(snakeToCamel('__typename')).toBe('Typename')
expect(snakeToCamel('a__b_c')).toBe('aB_c')
})
test('preserves trailing underscores', () => {
expect(snakeToCamel('test_')).toBe('test_')
expect(snakeToCamel('test__')).toBe('test__')
})
})
describe('camelToSnake', () => {
test('converts camelCase to snake_case', () => {
expect(camelToSnake('sessionId')).toBe('session_id')
expect(camelToSnake('lastModified')).toBe('last_modified')
})
test('leaves already-snake_case unchanged', () => {
expect(camelToSnake('session_id')).toBe('session_id')
})
})
describe('mapKeysToCamel', () => {
test('converts top-level keys', () => {
const input = { session_id: 'abc', last_modified: 123 }
const result = mapKeysToCamel(input)
expect(result).toEqual({ sessionId: 'abc', lastModified: 123 })
})
test('converts nested object keys', () => {
const input = { outer_key: { inner_key: 'value' } }
const result = mapKeysToCamel(input)
expect(result).toEqual({ outerKey: { innerKey: 'value' } })
})
test('converts arrays of objects', () => {
const input = [{ item_name: 'a' }, { item_name: 'b' }]
const result = mapKeysToCamel(input)
expect(result).toEqual([{ itemName: 'a' }, { itemName: 'b' }])
})
test('returns null/undefined as-is', () => {
expect(mapKeysToCamel(null)).toBeNull()
expect(mapKeysToCamel(undefined)).toBeUndefined()
})
test('returns primitives as-is', () => {
expect(mapKeysToCamel('hello')).toBe('hello')
expect(mapKeysToCamel(42)).toBe(42)
})
})
describe('mapKeysToSnake', () => {
test('converts top-level keys', () => {
const input = { sessionId: 'abc', lastModified: 123 }
const result = mapKeysToSnake(input)
expect(result).toEqual({ session_id: 'abc', last_modified: 123 })
})
test('round-trips with mapKeysToCamel', () => {
const original = { session_id: 'abc', last_modified: 123 }
const camel = mapKeysToCamel(original)
const back = mapKeysToSnake(camel)
expect(back).toEqual(original)
})
})