From b58ccd62b81b8e6caac4f80f7c13a902a4cb94af Mon Sep 17 00:00:00 2001 From: Brent Rager Date: Thu, 2 Jul 2026 19:57:49 -0400 Subject: [PATCH] SEP Phase 1: TypeScript extension SDK + tool-path fixtures MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add @smooai/smooth-extension-sdk — the TS SDK for building SEP extensions. defineExtension/defineTool (zod v4 z.toJSONSchema + raw JSON-Schema/TypeBox pass-through), a symmetric JSON-RPC 2.0 Peer, ndjson stdio transport plus an in-memory linkedPair, createTestHost (in-process scripted host), runConformance (replays the shared fixtures against a real extension subprocess), and the hello demo extension (hello.greet: zod schema, streamed tool/update progress, $/cancel cancellation). Wired into pnpm-workspace + the typescript CI lane. Extends spec/extension/conformance/fixtures.json for the tool path: is_error and details tool results, a message-only tool/update, and invalid fixtures. Plan: /Users/brentrager/.claude/plans/greedy-yawning-petal.md Phase 0: spec #142, engine host core#21. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01SqrZj2fVMqiHbSJSte3XbA --- .changeset/sep-phase1-sdk.md | 18 ++ .github/workflows/typescript.yml | 16 +- pnpm-lock.yaml | 30 +++ pnpm-workspace.yaml | 5 + spec/extension/conformance/fixtures.json | 28 +++ typescript/extension-sdk/README.md | 94 +++++++++ typescript/extension-sdk/examples/hello.ts | 33 +++ typescript/extension-sdk/package.json | 58 ++++++ typescript/extension-sdk/src/conformance.ts | 132 ++++++++++++ typescript/extension-sdk/src/extension.ts | 174 ++++++++++++++++ typescript/extension-sdk/src/index.ts | 34 ++++ typescript/extension-sdk/src/jsonrpc.ts | 192 ++++++++++++++++++ typescript/extension-sdk/src/protocol.ts | 117 +++++++++++ typescript/extension-sdk/src/schema.ts | 29 +++ typescript/extension-sdk/src/test-host.ts | 92 +++++++++ typescript/extension-sdk/src/transport.ts | 88 ++++++++ .../extension-sdk/test/conformance.test.ts | 24 +++ typescript/extension-sdk/test/jsonrpc.test.ts | 47 +++++ typescript/extension-sdk/test/schema.test.ts | 24 +++ .../extension-sdk/test/tool-path.test.ts | 104 ++++++++++ typescript/extension-sdk/tsconfig.json | 23 +++ typescript/extension-sdk/tsconfig.test.json | 9 + typescript/extension-sdk/vitest.config.ts | 10 + 23 files changed, 1373 insertions(+), 8 deletions(-) create mode 100644 .changeset/sep-phase1-sdk.md create mode 100644 typescript/extension-sdk/README.md create mode 100644 typescript/extension-sdk/examples/hello.ts create mode 100644 typescript/extension-sdk/package.json create mode 100644 typescript/extension-sdk/src/conformance.ts create mode 100644 typescript/extension-sdk/src/extension.ts create mode 100644 typescript/extension-sdk/src/index.ts create mode 100644 typescript/extension-sdk/src/jsonrpc.ts create mode 100644 typescript/extension-sdk/src/protocol.ts create mode 100644 typescript/extension-sdk/src/schema.ts create mode 100644 typescript/extension-sdk/src/test-host.ts create mode 100644 typescript/extension-sdk/src/transport.ts create mode 100644 typescript/extension-sdk/test/conformance.test.ts create mode 100644 typescript/extension-sdk/test/jsonrpc.test.ts create mode 100644 typescript/extension-sdk/test/schema.test.ts create mode 100644 typescript/extension-sdk/test/tool-path.test.ts create mode 100644 typescript/extension-sdk/tsconfig.json create mode 100644 typescript/extension-sdk/tsconfig.test.json create mode 100644 typescript/extension-sdk/vitest.config.ts diff --git a/.changeset/sep-phase1-sdk.md b/.changeset/sep-phase1-sdk.md new file mode 100644 index 0000000..bb4bd55 --- /dev/null +++ b/.changeset/sep-phase1-sdk.md @@ -0,0 +1,18 @@ +--- +"@smooai/smooth-extension-sdk": minor +"@smooai/smooth-operator": patch +--- + +Add the SEP TypeScript extension SDK — Phase 1 (the tool path). + +New published package `@smooai/smooth-extension-sdk`: build Smooth Extension Protocol +extensions in TypeScript. `defineExtension`/`defineTool` (zod v4 via `z.toJSONSchema`, with +raw JSON-Schema / TypeBox pass-through), a symmetric JSON-RPC 2.0 `Peer`, an ndjson stdio +transport (plus an in-memory `linkedPair`), `createTestHost` for driving an extension +in-process, and `runConformance` to replay the shared fixtures against a real extension +subprocess. Ships the `hello` demo extension (`hello.greet` — zod schema, streamed +`tool/update` progress, `$/cancel` cancellation). Wired into the TypeScript CI lane. + +Extends `spec/extension/conformance/fixtures.json` for the tool path: `is_error` and +`details` tool results, a message-only `tool/update`, and invalid fixtures (missing +`content`, out-of-range `progress`). diff --git a/.github/workflows/typescript.yml b/.github/workflows/typescript.yml index 59f3455..c5971f6 100644 --- a/.github/workflows/typescript.yml +++ b/.github/workflows/typescript.yml @@ -1,9 +1,9 @@ # Typecheck + test the TypeScript trees: the published SDK (typescript/ — client, -# React bindings, web-component widget) AND the native TS WebSocket server -# (typescript/server/). Both are pnpm-workspace members; one root install links them. -# The default `vitest run` (the `test` script) excludes the live-gateway E2E, so this -# job needs no secrets. spec/ is a trigger path because the conformance test validates -# against spec/conformance/fixtures.json. +# React bindings, web-component widget), the native TS WebSocket server +# (typescript/server/), and the published SEP extension SDK (typescript/extension-sdk/). +# All are pnpm-workspace members; one root install links them. The default `vitest run` +# (the `test` script) excludes the live-gateway E2E, so this job needs no secrets. spec/ +# is a trigger path because the conformance tests validate against spec/**/fixtures.json. name: TypeScript on: @@ -45,10 +45,10 @@ jobs: - name: Install (frozen lockfile) run: pnpm install --frozen-lockfile - # Scope to the two TypeScript packages the lane owns; `console` (the private + # Scope to the TypeScript packages the lane owns; `console` (the private # admin app) is a workspace member too but out of scope for this lane. - name: Typecheck - run: pnpm --filter "@smooai/smooth-operator" --filter "@smooai/smooth-operator-server" typecheck + run: pnpm --filter "@smooai/smooth-operator" --filter "@smooai/smooth-operator-server" --filter "@smooai/smooth-extension-sdk" typecheck - name: Test - run: pnpm --filter "@smooai/smooth-operator" --filter "@smooai/smooth-operator-server" test + run: pnpm --filter "@smooai/smooth-operator" --filter "@smooai/smooth-operator-server" --filter "@smooai/smooth-extension-sdk" test diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 823cc0e..22037a6 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -110,6 +110,31 @@ importers: specifier: ^2.1.8 version: 2.1.9(@types/node@25.9.2)(jsdom@25.0.1) + typescript/extension-sdk: + dependencies: + ajv: + specifier: ^8.17.1 + version: 8.20.0 + ajv-formats: + specifier: ^3.0.1 + version: 3.0.1(ajv@8.20.0) + zod: + specifier: ^4.0.0 + version: 4.4.3 + devDependencies: + '@types/node': + specifier: ^25.9.2 + version: 25.9.2 + tsx: + specifier: ^4.19.2 + version: 4.22.4 + typescript: + specifier: ^5.7.3 + version: 5.7.3 + vitest: + specifier: ^2.1.8 + version: 2.1.9(@types/node@25.9.2)(jsdom@25.0.1) + typescript/server: dependencies: '@smooai/smooth-operator-core': @@ -3380,6 +3405,9 @@ packages: resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} engines: {node: '>=10'} + zod@4.4.3: + resolution: {integrity: sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==} + snapshots: '@alloc/quick-lru@5.2.0': {} @@ -6752,3 +6780,5 @@ snapshots: yaml@2.9.0: {} yocto-queue@0.1.0: {} + + zod@4.4.3: {} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 5bb3c7d..58582c3 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -12,7 +12,12 @@ # speaks the protocol and runs `@smooai/smooth-operator-core` per turn (the TS # sibling of `rust/smooth-operator-server` and `dotnet/server`). Private (not # published); the client in `typescript/` is the published SDK. +# +# `typescript/extension-sdk` is the published SEP (Smooth Extension Protocol) SDK +# — `defineExtension`/`defineTool`, a stdio JSON-RPC transport, an in-process test +# host, and a conformance runner for building extensions in TypeScript. packages: - typescript - typescript/server + - typescript/extension-sdk - console diff --git a/spec/extension/conformance/fixtures.json b/spec/extension/conformance/fixtures.json index 1d8c3fa..85cd413 100644 --- a/spec/extension/conformance/fixtures.json +++ b/spec/extension/conformance/fixtures.json @@ -115,12 +115,30 @@ "instance": { "content": "hello", "is_error": false } }, + "tool_execute_result_error": { + "$schema_ref": "methods/tool-execute.schema.json#/$defs/Result", + "description": "A failed tool execution — the host surfaces it as an error tool-result.", + "instance": { "content": "unknown tool: nope", "is_error": true } + }, + + "tool_execute_result_with_details": { + "$schema_ref": "methods/tool-execute.schema.json#/$defs/Result", + "description": "A tool result carrying structured `details` for UI rendering alongside the LLM-facing content.", + "instance": { "content": "3 files changed", "is_error": false, "details": { "files": ["a.ts", "b.ts", "c.ts"] } } + }, + "tool_update_params": { "$schema_ref": "methods/tool-update.schema.json#/$defs/Params", "description": "Progress notification for an in-flight tool/execute.", "instance": { "call_id": "call-1", "message": "working...", "progress": 0.5 } }, + "tool_update_params_message_only": { + "$schema_ref": "methods/tool-update.schema.json#/$defs/Params", + "description": "A progress notification with only a message (no fractional progress) — the minimal tool/update.", + "instance": { "call_id": "call-1", "message": "started" } + }, + "cancel_params": { "$schema_ref": "methods/cancel.schema.json#/$defs/Params", "description": "Cancellation of an in-flight request by numeric id.", @@ -339,6 +357,16 @@ "context": { "token": "epoch-7", "tier": "command" } } }, + { + "name": "tool_execute_result_missing_content", + "$schema_ref": "methods/tool-execute.schema.json#/$defs/Result", + "instance": { "is_error": true } + }, + { + "name": "tool_update_progress_out_of_range", + "$schema_ref": "methods/tool-update.schema.json#/$defs/Params", + "instance": { "call_id": "call-1", "progress": 1.5 } + }, { "name": "frame_wrong_jsonrpc_version", "$schema_ref": "methods/envelope.schema.json#/$defs/Request", diff --git a/typescript/extension-sdk/README.md b/typescript/extension-sdk/README.md new file mode 100644 index 0000000..2a8f481 --- /dev/null +++ b/typescript/extension-sdk/README.md @@ -0,0 +1,94 @@ +# @smooai/smooth-extension-sdk + +Build **SEP** (Smooth Extension Protocol) extensions in TypeScript. + +An extension is a long-lived subprocess that speaks JSON-RPC 2.0 over ndjson on +its stdin/stdout to a SEP host (`smooth-operator-core` and its polyglot servers). +This SDK is the DX centerpiece: describe your extension declaratively, `serve()` +it, test it in-process, and gate it against the shared conformance fixtures. + +## Quick start + +```ts +import { z } from 'zod'; +import { defineExtension, defineTool } from '@smooai/smooth-extension-sdk'; + +export const hello = defineExtension((smooth) => { + smooth.name = 'hello'; + smooth.version = '0.1.0'; + + smooth.registerTool( + defineTool({ + name: 'greet', + description: 'Greet someone by name.', + parameters: z.object({ name: z.string() }), + async execute(args, ctx) { + ctx.onUpdate({ message: `greeting ${args.name}`, progress: 0.5 }); + return { content: `Hello, ${args.name}!` }; + }, + }), + ); +}); + +hello.serve(); // wire to stdin/stdout and run +``` + +The host exposes the tool to the LLM as `hello.greet`. + +## Schemas + +`parameters` accepts three shapes — the wire truth is always JSON Schema: + +- a **zod v4** schema → converted with `z.toJSONSchema()` +- a **TypeBox** schema → TypeBox schemas already ARE JSON Schema, passed through +- a **raw JSON Schema** object → passed through unchanged + +## Tool context + +`execute(args, ctx)` receives a `ctx` with: + +- `ctx.onUpdate({ message?, progress?, details? })` — stream `tool/update` progress +- `ctx.signal` — an `AbortSignal` that fires when the host sends `$/cancel` +- `ctx.callId` / `ctx.context` — the call id and dispatch context (epoch token + tier) + +Return a `{ content, is_error?, details? }` result, or just a string shorthand for +`{ content }`. + +## Testing + +```ts +import { createTestHost } from '@smooai/smooth-extension-sdk'; +import { hello } from './hello.js'; + +const host = createTestHost(hello); // in-process, no subprocess +await host.initialize(); +const res = await host.callTool('greet', { name: 'Ada' }); +// res === { content: 'Hello, Ada!' } +host.close(); +``` + +`runConformance` replays the shared SEP fixtures against a **real** extension +subprocess, validating every reply against its schema: + +```ts +import { runConformance } from '@smooai/smooth-extension-sdk'; + +const report = await runConformance({ command: 'node', args: ['./hello.js'] }); +// report.passed === true +``` + +## API + +- `defineExtension((smooth) => void)` — set `smooth.name`/`version`, `registerTool`, `on(event)`, `log`. +- `defineTool({ name, description, parameters, deferred?, execute })` +- `createTestHost(extension)` → `{ initialize, callTool, ping, sendEvent, shutdown, close }` +- `runConformance({ command, args?, env?, cwd?, specDir? })` → `ConformanceReport` +- `Peer`, `stdioTransport`, `linkedPair`, `toJsonSchema` — the building blocks +- `PROTOCOL_VERSION`, `method`, `errorCode` and the wire types + +## Scope (Phase 1) + +The tool path: registration, execute, streamed progress, cancellation, plus +observe `on(event)` subscriptions and lifecycle (`initialize`/`ping`/`shutdown`). +Hooks, commands, ui/kv/session/exec land in later phases — the wire and API were +shaped to grow into them without breaking the tool path. diff --git a/typescript/extension-sdk/examples/hello.ts b/typescript/extension-sdk/examples/hello.ts new file mode 100644 index 0000000..4d5a239 --- /dev/null +++ b/typescript/extension-sdk/examples/hello.ts @@ -0,0 +1,33 @@ +/** + * `hello` — the Phase 1 demo extension. One tool, `hello.greet`, exercising the + * whole tool path: a zod-typed schema, streamed progress, and cancellation. + * + * Run it as a real SEP subprocess: `tsx examples/hello.ts` + * The host handshakes, then dispatches `hello.greet` like any native tool. + */ +import { z } from 'zod'; +import { defineExtension, defineTool } from '../src/index.js'; + +export const hello = defineExtension((smooth) => { + smooth.name = 'hello'; + smooth.version = '0.1.0'; + + smooth.registerTool( + defineTool({ + name: 'greet', + description: 'Greet someone by name.', + parameters: z.object({ name: z.string().describe('Who to greet.') }), + async execute(args, ctx) { + ctx.onUpdate({ message: `greeting ${args.name}`, progress: 0.5 }); + return { content: `Hello, ${args.name}!` }; + }, + }), + ); + + smooth.on('turn_start', () => smooth.log('info', 'a turn started')); +}); + +// When run directly (not imported by a test), serve over stdio. +if (import.meta.url === `file://${process.argv[1]}`) { + hello.serve(); +} diff --git a/typescript/extension-sdk/package.json b/typescript/extension-sdk/package.json new file mode 100644 index 0000000..339fe23 --- /dev/null +++ b/typescript/extension-sdk/package.json @@ -0,0 +1,58 @@ +{ + "name": "@smooai/smooth-extension-sdk", + "version": "0.1.0", + "description": "TypeScript SDK for building Smooth Extension Protocol (SEP) extensions: `defineExtension`, `defineTool`, a stdio JSON-RPC transport, an in-process test host, and a conformance runner. Extensions are subprocesses speaking JSON-RPC 2.0 ndjson to any SEP host (smooth-operator-core and its polyglot servers).", + "license": "MIT", + "type": "module", + "engines": { + "node": ">=22" + }, + "main": "./dist/index.js", + "module": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js" + } + }, + "publishConfig": { + "access": "public" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/SmooAI/smooth-operator.git", + "directory": "typescript/extension-sdk" + }, + "homepage": "https://github.com/SmooAI/smooth-operator/tree/main/typescript/extension-sdk", + "keywords": [ + "smooth-operator", + "sep", + "extension", + "json-rpc", + "agent", + "tools", + "sdk" + ], + "files": [ + "dist", + "src" + ], + "scripts": { + "build": "tsc -p tsconfig.json", + "typecheck": "tsc -p tsconfig.json --noEmit && tsc -p tsconfig.test.json", + "test": "vitest run", + "test:watch": "vitest" + }, + "dependencies": { + "ajv": "^8.17.1", + "ajv-formats": "^3.0.1", + "zod": "^4.0.0" + }, + "devDependencies": { + "@types/node": "^25.9.2", + "tsx": "^4.19.2", + "typescript": "^5.7.3", + "vitest": "^2.1.8" + } +} diff --git a/typescript/extension-sdk/src/conformance.ts b/typescript/extension-sdk/src/conformance.ts new file mode 100644 index 0000000..2415416 --- /dev/null +++ b/typescript/extension-sdk/src/conformance.ts @@ -0,0 +1,132 @@ +/** + * `runConformance` — replay the shared SEP conformance fixtures against a REAL + * extension subprocess. Where the schema-only conformance test (in the spec + * repo) proves the fixtures match the schemas, this proves a live extension, + * spawned and handshaken over stdio, answers each method with a schema-valid + * reply. It is the SDK's dogfood gate and the template every polyglot SDK's + * conformance runner follows. + */ +import { spawn } from 'node:child_process'; +import { readFile, readdir } from 'node:fs/promises'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import _Ajv2020, { Ajv2020 as AjvClass, type ErrorObject } from 'ajv/dist/2020.js'; +import _addFormats from 'ajv-formats'; +import { Peer } from './jsonrpc.js'; +import { PROTOCOL_VERSION, method } from './protocol.js'; +import { stdioTransport } from './transport.js'; + +// ajv/ajv-formats ship CJS with a double-default under NodeNext; normalize both +// to the actual callable (same trick as the spec repo's validate.ts). +type Ajv = AjvClass; +const Ajv2020 = ((_Ajv2020 as unknown as { default?: unknown }).default ?? _Ajv2020) as typeof AjvClass; +const addFormats = ((_addFormats as unknown as { default?: unknown }).default ?? _addFormats) as (ajv: Ajv) => Ajv; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +/** Repo-relative default: /spec/extension (works from src/ and dist/). */ +export const DEFAULT_SPEC_DIR = join(__dirname, '..', '..', '..', 'spec', 'extension'); + +export interface ConformanceStep { + name: string; + ok: boolean; + detail?: string; +} +export interface ConformanceReport { + passed: boolean; + steps: ConformanceStep[]; +} + +export interface RunConformanceOptions { + command: string; + args?: string[]; + env?: Record; + cwd?: string; + /** Where spec/extension lives; defaults to the in-repo copy. */ + specDir?: string; +} + +interface Fixture { + $schema_ref: string; + instance: unknown; +} + +/** Load every methods/*.schema.json under `specDir` into one ajv instance. */ +async function loadValidator(specDir: string): Promise<(ref: string, value: unknown) => ErrorObject[]> { + const ajv = new Ajv2020({ allErrors: true, strict: false }); + addFormats(ajv); + const fileToId = new Map(); + const methodsDir = join(specDir, 'methods'); + for (const e of await readdir(methodsDir, { withFileTypes: true })) { + if (!e.isFile() || !e.name.endsWith('.schema.json')) continue; + const schema = JSON.parse(await readFile(join(methodsDir, e.name), 'utf8')) as { $id?: string }; + const id = schema.$id ?? `urn:sep:${e.name}`; + if (!ajv.getSchema(id)) ajv.addSchema(schema, id); + fileToId.set(e.name, id); + } + return (ref, value) => { + const [path, pointer] = ref.split('#'); + const file = path!.split('/').pop()!; + const id = fileToId.get(file); + if (!id) throw new Error(`no schema for ref ${ref}`); + const validate = ajv.getSchema(pointer ? `${id}#${pointer}` : id); + if (!validate) throw new Error(`ajv could not resolve ${ref}`); + return validate(value) ? [] : (validate.errors ?? []); + }; +} + +function fmt(errors: ErrorObject[]): string { + return errors.map((e) => `${e.instancePath || ''} ${e.message ?? ''}`.trim()).join('; '); +} + +/** + * Spawn `command`, handshake, and replay the request/reply fixtures against the + * live process, validating every reply against its `Result` schema. Resolves a + * report; also returns non-zero via `passed: false` rather than throwing so a + * caller can assert on the detail. + */ +export async function runConformance(opts: RunConformanceOptions): Promise { + const specDir = opts.specDir ?? DEFAULT_SPEC_DIR; + const validate = await loadValidator(specDir); + const fixtures = JSON.parse(await readFile(join(specDir, 'conformance', 'fixtures.json'), 'utf8')) as Record; + + const child = spawn(opts.command, opts.args ?? [], { + stdio: ['pipe', 'pipe', 'inherit'], + env: { ...process.env, ...opts.env }, + cwd: opts.cwd, + }); + const transport = stdioTransport(child.stdout!, child.stdin!); + const peer = new Peer({ send: (frame) => transport.send(frame) }); + peer.setNotificationHandler(method.TOOL_UPDATE, () => {}); + peer.setNotificationHandler(method.LOG, () => {}); + transport.start((frame) => peer.receive(frame)); + + const steps: ConformanceStep[] = []; + const check = async (name: string, requestMethod: string, params: unknown, resultRef: string) => { + try { + const result = await peer.request(requestMethod, params); + const errors = validate(resultRef, result); + steps.push({ name, ok: errors.length === 0, detail: errors.length ? fmt(errors) : undefined }); + } catch (err) { + steps.push({ name, ok: false, detail: err instanceof Error ? err.message : String(err) }); + } + }; + + try { + await check('initialize', method.INITIALIZE, initParams(fixtures), 'methods/initialize.schema.json#/$defs/Result'); + await check('ping', method.PING, {}, 'methods/ping.schema.json#/$defs/Result'); + await check('tool/execute', method.TOOL_EXECUTE, fixtures.tool_execute_params!.instance, 'methods/tool-execute.schema.json#/$defs/Result'); + await check('shutdown', method.SHUTDOWN, {}, 'methods/shutdown.schema.json#/$defs/Result'); + } finally { + peer.close(); + transport.close(); + child.kill(); + } + + return { passed: steps.every((s) => s.ok), steps }; +} + +/** Handshake params from the fixture, pinned to the version this SDK speaks. */ +function initParams(fixtures: Record): unknown { + const base = fixtures.initialize_params!.instance as Record; + return { ...base, protocol_version: PROTOCOL_VERSION }; +} diff --git a/typescript/extension-sdk/src/extension.ts b/typescript/extension-sdk/src/extension.ts new file mode 100644 index 0000000..ed75053 --- /dev/null +++ b/typescript/extension-sdk/src/extension.ts @@ -0,0 +1,174 @@ +/** + * `defineExtension` / `defineTool` — the DX centerpiece. + * + * An extension is a long-lived subprocess speaking SEP over its stdio. You + * describe it declaratively; `serve()` wires it to `process.stdin/stdout`, and + * `createTestHost` (test-host.ts) drives the same object in-process. + * + * Phase 1 surface: `registerTool` (schema + execute + streaming progress + + * cancellation) and `on(event)` observe subscriptions. Hooks, commands, ui/kv/ + * session/exec land in later phases; the wire and this API were shaped to grow + * into them without breaking the tool path. + */ +import { Peer } from './jsonrpc.js'; +import { PROTOCOL_VERSION, method } from './protocol.js'; +import type { Context, EventParams, InitializeParams, InitializeResult, ToolExecuteParams, ToolExecuteResult, ToolUpdateParams } from './protocol.js'; +import { toJsonSchema, type ParameterSchema } from './schema.js'; +import { stdioTransport, type Transport } from './transport.js'; + +/** Progress + cancellation handed to a tool while it runs. */ +export interface ToolContext { + /** Correlates `onUpdate` calls with this execution. */ + callId: string; + /** The dispatch context (epoch token + tier). */ + context: Context; + /** Fires when the host sends `$/cancel` for this call. */ + signal: AbortSignal; + /** Stream a progress notification back to the host. */ + onUpdate(update: Omit): void; +} + +/** What a tool's `execute` may return: a full result or just its `content`. */ +export type ToolReturn = ToolExecuteResult | string; + +export interface ToolDef> { + name: string; + description: string; + parameters: ParameterSchema; + deferred?: boolean; + execute(args: TArgs, ctx: ToolContext): Promise | ToolReturn; +} + +/** Identity for `defineTool` — keeps the generic arg inferred at the call site. */ +export function defineTool>(def: ToolDef): ToolDef { + return def; +} + +/** Handler for an observe `event`. Fire-and-forget; return value ignored. */ +export type EventHandler = (payload: Record | undefined, ctx: Context) => void | Promise; + +/** The builder passed to `defineExtension`'s setup. Mirrors pi's `ExtensionAPI`. */ +export interface SmoothApi { + name: string; + version: string; + registerTool(tool: ToolDef): void; + on(event: string, handler: EventHandler): void; + log(level: 'debug' | 'info' | 'warn' | 'error', message: string, fields?: Record): void; +} + +export type ExtensionSetup = (smooth: SmoothApi) => void; + +export interface ConnectHandle { + peer: Peer; + close(): void; +} + +export class Extension { + private readonly tools = new Map>(); + private readonly events = new Map(); + private name = 'extension'; + private version = '0.0.0'; + /** Set once connected so `log()` before connect is a safe no-op. */ + private live?: Peer; + + constructor(setup: ExtensionSetup) { + const api: SmoothApi = { + get name() { + return self.name; + }, + set name(v: string) { + self.name = v; + }, + get version() { + return self.version; + }, + set version(v: string) { + self.version = v; + }, + registerTool: (tool) => { + this.tools.set(tool.name, tool); + }, + on: (event, handler) => { + const list = this.events.get(event) ?? []; + list.push(handler); + this.events.set(event, list); + }, + log: (level, message, fields) => { + this.live?.notify(method.LOG, { level, message, ...(fields ? { fields } : {}) }); + }, + }; + // `self` alias so the getter/setter pair above closes over the instance. + const self = this; + setup(api); + } + + /** Wire this extension to a transport. Returns a handle to close it. */ + connect(transport: Transport, onShutdown: () => void = () => {}): ConnectHandle { + const peer = new Peer({ send: (frame) => transport.send(frame) }); + this.live = peer; + + peer.setRequestHandler(method.INITIALIZE, (params) => this.initialize(params as InitializeParams)); + peer.setRequestHandler(method.PING, () => ({})); + peer.setRequestHandler(method.SHUTDOWN, () => { + queueMicrotask(onShutdown); + return {}; + }); + peer.setRequestHandler(method.TOOL_EXECUTE, (params, signal) => this.executeTool(params as ToolExecuteParams, peer, signal)); + peer.setNotificationHandler(method.EVENT, (params) => this.dispatchEvent(params as EventParams)); + + transport.start((frame) => peer.receive(frame)); + return { + peer, + close() { + peer.close(); + transport.close(); + }, + }; + } + + /** Connect over this process's stdin/stdout and keep the process alive. */ + serve(): ConnectHandle { + return this.connect(stdioTransport(), () => { + // Give the shutdown reply a tick to flush, then exit. + setTimeout(() => process.exit(0), 10); + }); + } + + private initialize(_params: InitializeParams): InitializeResult { + const tools = [...this.tools.values()].map((t) => ({ + name: t.name, + description: t.description, + parameters: toJsonSchema(t.parameters), + ...(t.deferred ? { deferred: true } : {}), + })); + return { + protocol_version: PROTOCOL_VERSION, + extension: { name: this.name, version: this.version }, + registrations: { tools, subscriptions: [...this.events.keys()] }, + }; + } + + private async executeTool(params: ToolExecuteParams, peer: Peer, signal: AbortSignal): Promise { + const tool = this.tools.get(params.tool); + if (!tool) return { content: `unknown tool: ${params.tool}`, is_error: true }; + const ctx: ToolContext = { + callId: params.call_id, + context: params.context, + signal, + onUpdate: (update) => peer.notify(method.TOOL_UPDATE, { call_id: params.call_id, ...update }), + }; + const out = await tool.execute(params.arguments, ctx); + return typeof out === 'string' ? { content: out } : out; + } + + private dispatchEvent(params: EventParams): void { + for (const handler of this.events.get(params.event) ?? []) { + void handler(params.payload, params.context); + } + } +} + +/** Define an extension. Set `smooth.name`/`smooth.version` and register tools. */ +export function defineExtension(setup: ExtensionSetup): Extension { + return new Extension(setup); +} diff --git a/typescript/extension-sdk/src/index.ts b/typescript/extension-sdk/src/index.ts new file mode 100644 index 0000000..f5c36cb --- /dev/null +++ b/typescript/extension-sdk/src/index.ts @@ -0,0 +1,34 @@ +/** + * @smooai/smooth-extension-sdk — build SEP (Smooth Extension Protocol) + * extensions in TypeScript. + * + * An extension is a subprocess speaking JSON-RPC 2.0 ndjson over stdio to any + * SEP host (smooth-operator-core and its polyglot servers). Describe it with + * `defineExtension`/`defineTool`, `serve()` it, test it in-process with + * `createTestHost`, and gate it against the shared fixtures with + * `runConformance`. + */ +export { defineExtension, defineTool, Extension } from './extension.js'; +export type { ExtensionSetup, SmoothApi, ToolDef, ToolContext, ToolReturn, EventHandler, ConnectHandle } from './extension.js'; +export { createTestHost } from './test-host.js'; +export type { TestHost, CallToolOptions } from './test-host.js'; +export { runConformance, DEFAULT_SPEC_DIR } from './conformance.js'; +export type { ConformanceReport, ConformanceStep, RunConformanceOptions } from './conformance.js'; +export { toJsonSchema } from './schema.js'; +export type { ParameterSchema } from './schema.js'; +export { Peer, RpcError } from './jsonrpc.js'; +export type { JsonRpcFrame } from './jsonrpc.js'; +export { stdioTransport, linkedPair } from './transport.js'; +export type { Transport } from './transport.js'; +export { PROTOCOL_VERSION, method, errorCode } from './protocol.js'; +export type { + Context, + InitializeParams, + InitializeResult, + Registrations, + ToolRegistration, + ToolExecuteParams, + ToolExecuteResult, + ToolUpdateParams, + EventParams, +} from './protocol.js'; diff --git a/typescript/extension-sdk/src/jsonrpc.ts b/typescript/extension-sdk/src/jsonrpc.ts new file mode 100644 index 0000000..73c6b98 --- /dev/null +++ b/typescript/extension-sdk/src/jsonrpc.ts @@ -0,0 +1,192 @@ +/** + * A symmetric JSON-RPC 2.0 peer over a message-passing transport. + * + * Both ends of SEP are peers: each issues requests, replies to the other's + * requests, and sends fire-and-forget notifications. This one `Peer` class is + * the shared core — the extension runtime and the in-process test host are both + * just a `Peer` with different handlers. It is transport-agnostic: it emits + * frame objects via `send` and is fed inbound frames via `receive`; a codec + * turns those into ndjson lines (see `transport.ts`). + * + * Cancellation is wired both ways: + * - Outbound: pass an `AbortSignal`; on abort the Peer sends `$/cancel { id }` + * and rejects the pending request. + * - Inbound: a request handler receives an `AbortSignal` that fires when the + * remote sends `$/cancel` for that request's id. + */ +import { errorCode, method } from './protocol.js'; + +export interface JsonRpcRequest { + jsonrpc: '2.0'; + id: number | string; + method: string; + params?: unknown; +} +export interface JsonRpcNotification { + jsonrpc: '2.0'; + method: string; + params?: unknown; +} +export interface JsonRpcSuccess { + jsonrpc: '2.0'; + id: number | string; + result: unknown; +} +export interface JsonRpcError { + jsonrpc: '2.0'; + id: number | string; + error: { code: number; message: string; data?: unknown }; +} +export type JsonRpcFrame = JsonRpcRequest | JsonRpcNotification | JsonRpcSuccess | JsonRpcError; + +/** An error carrying a JSON-RPC error code, thrown for a remote error reply. */ +export class RpcError extends Error { + constructor( + public readonly code: number, + message: string, + public readonly data?: unknown, + ) { + super(message); + this.name = 'RpcError'; + } +} + +export type RequestHandler = (params: unknown, signal: AbortSignal) => Promise | unknown; +export type NotificationHandler = (params: unknown) => void; + +interface Pending { + resolve: (value: unknown) => void; + reject: (err: Error) => void; + onAbort?: () => void; + signal?: AbortSignal; +} + +export interface PeerOptions { + /** Emit a frame to the remote. */ + send: (frame: JsonRpcFrame) => void; + /** Called for any inbound method with no registered handler. */ + onUnhandled?: (frame: JsonRpcRequest | JsonRpcNotification) => void; +} + +export class Peer { + private nextId = 1; + private readonly pending = new Map(); + private readonly requestHandlers = new Map(); + private readonly notificationHandlers = new Map(); + /** In-flight inbound requests we can cancel when the remote sends `$/cancel`. */ + private readonly inflight = new Map(); + private closed = false; + + constructor(private readonly opts: PeerOptions) {} + + setRequestHandler(name: string, handler: RequestHandler): void { + this.requestHandlers.set(name, handler); + } + + setNotificationHandler(name: string, handler: NotificationHandler): void { + this.notificationHandlers.set(name, handler); + } + + /** Issue a request; resolves with the remote's result or rejects with RpcError. */ + request(name: string, params?: unknown, signal?: AbortSignal): Promise { + if (this.closed) return Promise.reject(new Error('peer is closed')); + const id = this.nextId++; + return new Promise((resolve, reject) => { + if (signal?.aborted) { + reject(new RpcError(errorCode.Cancelled, 'cancelled before send')); + return; + } + const onAbort = signal + ? () => { + const p = this.pending.get(id); + if (!p) return; + this.pending.delete(id); + this.opts.send({ jsonrpc: '2.0', method: method.CANCEL, params: { id } }); + reject(new RpcError(errorCode.Cancelled, 'cancelled')); + } + : undefined; + this.pending.set(id, { resolve: resolve as (v: unknown) => void, reject, onAbort, signal }); + signal?.addEventListener('abort', onAbort!, { once: true }); + this.opts.send({ jsonrpc: '2.0', id, method: name, params }); + }); + } + + /** Send a fire-and-forget notification. */ + notify(name: string, params?: unknown): void { + if (this.closed) return; + this.opts.send({ jsonrpc: '2.0', method: name, params }); + } + + /** Feed one inbound frame. */ + receive(frame: JsonRpcFrame): void { + if ('id' in frame && 'method' in frame) { + void this.handleRequest(frame); + } else if ('method' in frame) { + this.handleNotification(frame); + } else if ('id' in frame) { + this.handleResponse(frame); + } + } + + /** Reject every pending request; used on transport close. */ + close(reason = 'peer closed'): void { + this.closed = true; + for (const [id, p] of this.pending) { + if (p.onAbort && p.signal) p.signal.removeEventListener('abort', p.onAbort); + p.reject(new Error(reason)); + this.pending.delete(id); + } + for (const ctrl of this.inflight.values()) ctrl.abort(); + this.inflight.clear(); + } + + private async handleRequest(frame: JsonRpcRequest): Promise { + const handler = this.requestHandlers.get(frame.method); + if (!handler) { + this.opts.onUnhandled?.(frame); + this.opts.send({ jsonrpc: '2.0', id: frame.id, error: { code: errorCode.MethodNotFound, message: `method not found: ${frame.method}` } }); + return; + } + const ctrl = new AbortController(); + this.inflight.set(frame.id, ctrl); + try { + const result = await handler(frame.params, ctrl.signal); + if (ctrl.signal.aborted) { + this.opts.send({ jsonrpc: '2.0', id: frame.id, error: { code: errorCode.Cancelled, message: 'cancelled' } }); + } else { + this.opts.send({ jsonrpc: '2.0', id: frame.id, result: result ?? {} }); + } + } catch (err) { + if (ctrl.signal.aborted) { + this.opts.send({ jsonrpc: '2.0', id: frame.id, error: { code: errorCode.Cancelled, message: 'cancelled' } }); + } else if (err instanceof RpcError) { + this.opts.send({ jsonrpc: '2.0', id: frame.id, error: { code: err.code, message: err.message, data: err.data } }); + } else { + this.opts.send({ jsonrpc: '2.0', id: frame.id, error: { code: errorCode.InternalError, message: err instanceof Error ? err.message : String(err) } }); + } + } finally { + this.inflight.delete(frame.id); + } + } + + private handleNotification(frame: JsonRpcNotification): void { + // `$/cancel` aborts the matching in-flight inbound request. + if (frame.method === method.CANCEL) { + const id = (frame.params as { id?: number | string } | undefined)?.id; + if (id !== undefined) this.inflight.get(id)?.abort(); + return; + } + const handler = this.notificationHandlers.get(frame.method); + if (handler) handler(frame.params); + else this.opts.onUnhandled?.(frame); + } + + private handleResponse(frame: JsonRpcSuccess | JsonRpcError): void { + const p = this.pending.get(frame.id); + if (!p) return; // late reply for a cancelled/unknown request — drop it. + this.pending.delete(frame.id); + if (p.onAbort && p.signal) p.signal.removeEventListener('abort', p.onAbort); + if ('error' in frame) p.reject(new RpcError(frame.error.code, frame.error.message, frame.error.data)); + else p.resolve(frame.result); + } +} diff --git a/typescript/extension-sdk/src/protocol.ts b/typescript/extension-sdk/src/protocol.ts new file mode 100644 index 0000000..741e357 --- /dev/null +++ b/typescript/extension-sdk/src/protocol.ts @@ -0,0 +1,117 @@ +/** + * SEP (Smooth Extension Protocol) wire types + method/error constants. + * + * The source of truth is the JSON Schemas in `spec/extension/`; these types are + * the hand-maintained TS view of the subset the SDK needs for Phase 1 (the tool + * path + lifecycle). Field names are `snake_case` because they ARE the wire. + */ + +/** Highest SEP version this SDK speaks. Effective version = min(host, ext). */ +export const PROTOCOL_VERSION = 1; + +/** Method names — namespaced with `/`; `$/` marks JSON-RPC meta methods. */ +export const method = { + INITIALIZE: 'initialize', + SHUTDOWN: 'shutdown', + PING: 'ping', + EVENT: 'event', + HOOK: 'hook', + TOOL_EXECUTE: 'tool/execute', + TOOL_UPDATE: 'tool/update', + REGISTRY_UPDATE: 'registry/update', + LOG: 'log', + CANCEL: '$/cancel', +} as const; + +/** JSON-RPC + SEP error codes (see spec/extension/envelope.md). */ +export const errorCode = { + ParseError: -32700, + InvalidRequest: -32600, + MethodNotFound: -32601, + InvalidParams: -32602, + InternalError: -32603, + Blocked: -32000, + NoUI: -32001, + NotTrusted: -32002, + ContextViolation: -32003, + CapabilityDisabled: -32004, + Cancelled: -32800, +} as const; + +/** The `context` carried by every dispatched event/hook/tool/execute. */ +export interface Context { + token: string; + tier: 'event' | 'command'; +} + +export interface HostInfo { + name: string; + version: string; +} + +export interface Workspace { + root: string; + trusted: boolean; +} + +export interface InitializeParams { + protocol_version: number; + host: HostInfo; + workspace: Workspace; + session?: { id?: string }; + mode: 'tui' | 'web' | 'widget' | 'cli' | 'headless'; + ui_capabilities?: string[]; + capabilities_enabled?: Record; +} + +export interface ToolRegistration { + name: string; + description: string; + /** JSON Schema for the tool's arguments. */ + parameters: Record; + deferred?: boolean; +} + +export interface CommandRegistration { + name: string; + description: string; +} + +export interface Registrations { + tools?: ToolRegistration[]; + commands?: CommandRegistration[]; + flags?: string[]; + subscriptions?: string[]; +} + +export interface InitializeResult { + protocol_version: number; + extension: { name: string; version: string }; + registrations?: Registrations; +} + +export interface ToolExecuteParams { + call_id: string; + tool: string; + arguments: Record; + context: Context; +} + +export interface ToolExecuteResult { + content: string; + is_error?: boolean; + details?: unknown; +} + +export interface ToolUpdateParams { + call_id: string; + message?: string; + progress?: number; + details?: unknown; +} + +export interface EventParams { + event: string; + context: Context; + payload?: Record; +} diff --git a/typescript/extension-sdk/src/schema.ts b/typescript/extension-sdk/src/schema.ts new file mode 100644 index 0000000..c25da49 --- /dev/null +++ b/typescript/extension-sdk/src/schema.ts @@ -0,0 +1,29 @@ +/** + * Turn a tool's declared `parameters` into the JSON Schema that goes on the wire. + * + * Three accepted shapes (the wire truth is always JSON Schema): + * - a **zod v4** schema → converted with zod's built-in `z.toJSONSchema()`. + * - a **TypeBox** schema → TypeBox schemas ARE JSON Schema, passed through. + * - a **raw JSON Schema** object → passed through unchanged. + */ +import { z } from 'zod'; + +/** Anything acceptable as a tool's `parameters`. */ +export type ParameterSchema = z.ZodType | Record; + +/** A zod v4 schema carries the internal `_zod` marker; nothing else we accept does. */ +function isZodSchema(value: unknown): value is z.ZodType { + return typeof value === 'object' && value !== null && '_zod' in value; +} + +/** Normalize `schema` to a JSON Schema object (draft 2020-12 for zod). */ +export function toJsonSchema(schema: ParameterSchema): Record { + if (isZodSchema(schema)) { + // `io: 'input'` gives the schema the LLM should fill (pre-transform). + return z.toJSONSchema(schema, { io: 'input' }) as Record; + } + // TypeBox schemas and raw JSON Schema are already JSON Schema. Round-trip + // through JSON to drop any symbol keys (TypeBox's `[Kind]`) that would never + // survive the wire anyway. + return JSON.parse(JSON.stringify(schema)) as Record; +} diff --git a/typescript/extension-sdk/src/test-host.ts b/typescript/extension-sdk/src/test-host.ts new file mode 100644 index 0000000..7e95cd4 --- /dev/null +++ b/typescript/extension-sdk/src/test-host.ts @@ -0,0 +1,92 @@ +/** + * `createTestHost` — an in-process scripted SEP host for unit-testing an + * extension without spawning a subprocess. It plays the host side of the + * protocol over a `linkedPair`, so tests drive `initialize`, `tool/execute` + * (with progress + cancellation), events, ping and shutdown directly against a + * `defineExtension(...)` object. + */ +import { Peer } from './jsonrpc.js'; +import { PROTOCOL_VERSION, method } from './protocol.js'; +import type { Context, InitializeParams, InitializeResult, ToolExecuteResult, ToolUpdateParams } from './protocol.js'; +import type { Extension } from './extension.js'; +import { linkedPair } from './transport.js'; + +let callSeq = 0; + +export interface CallToolOptions { + /** Receives each `tool/update` the extension streams for this call. */ + onUpdate?: (update: ToolUpdateParams) => void; + /** Abort the call — the host sends `$/cancel` and the promise rejects. */ + signal?: AbortSignal; + /** Override the dispatch context (defaults to a command-tier test epoch). */ + context?: Context; +} + +export interface TestHost { + initialize(overrides?: Partial): Promise; + callTool(tool: string, args: Record, opts?: CallToolOptions): Promise; + ping(): Promise>; + sendEvent(event: string, payload?: Record, context?: Context): void; + shutdown(): Promise; + close(): void; +} + +const DEFAULT_CONTEXT: Context = { token: 'test-epoch', tier: 'command' }; + +export function createTestHost(extension: Extension): TestHost { + const [hostT, extT] = linkedPair(); + const extHandle = extension.connect(extT); + /** call_id → the caller's onUpdate, so streamed progress reaches the test. */ + const updateSinks = new Map void>(); + + const host = new Peer({ send: (frame) => hostT.send(frame) }); + host.setNotificationHandler(method.TOOL_UPDATE, (params) => { + const p = params as ToolUpdateParams; + updateSinks.get(p.call_id)?.(p); + }); + // Extension notifications the host just observes in tests. + host.setNotificationHandler(method.LOG, () => {}); + host.setNotificationHandler(method.REGISTRY_UPDATE, () => {}); + hostT.start((frame) => host.receive(frame)); + + return { + initialize(overrides) { + const params: InitializeParams = { + protocol_version: PROTOCOL_VERSION, + host: { name: 'smooth-test-host', version: '0.0.0' }, + workspace: { root: process.cwd(), trusted: true }, + mode: 'headless', + capabilities_enabled: { tools: true }, + ...overrides, + }; + return host.request(method.INITIALIZE, params); + }, + async callTool(tool, args, opts = {}) { + const call_id = `test-call-${++callSeq}`; + if (opts.onUpdate) updateSinks.set(call_id, opts.onUpdate); + try { + return await host.request( + method.TOOL_EXECUTE, + { call_id, tool, arguments: args, context: opts.context ?? DEFAULT_CONTEXT }, + opts.signal, + ); + } finally { + updateSinks.delete(call_id); + } + }, + ping() { + return host.request>(method.PING, {}); + }, + sendEvent(event, payload, context) { + host.notify(method.EVENT, { event, context: context ?? { token: DEFAULT_CONTEXT.token, tier: 'event' }, ...(payload ? { payload } : {}) }); + }, + async shutdown() { + await host.request(method.SHUTDOWN, {}); + }, + close() { + extHandle.close(); + host.close(); + hostT.close(); + }, + }; +} diff --git a/typescript/extension-sdk/src/transport.ts b/typescript/extension-sdk/src/transport.ts new file mode 100644 index 0000000..247bfb0 --- /dev/null +++ b/typescript/extension-sdk/src/transport.ts @@ -0,0 +1,88 @@ +/** + * Transports carry JSON-RPC frames between two peers. + * + * - `stdioTransport` — the real wire: ndjson over a process's stdin/stdout, + * byte-for-byte the framing MCP stdio uses. + * - `linkedPair` — two in-memory transports wired to each other, for driving an + * extension from an in-process test host with no subprocess. + */ +import { createInterface } from 'node:readline'; +import type { Readable, Writable } from 'node:stream'; +import type { JsonRpcFrame } from './jsonrpc.js'; + +export interface Transport { + send(frame: JsonRpcFrame): void; + /** Begin delivering inbound frames. Call once. */ + start(onFrame: (frame: JsonRpcFrame) => void): void; + close(): void; +} + +/** ndjson over a readable/writable pair (defaults to this process's stdio). */ +export function stdioTransport(input: Readable = process.stdin, output: Writable = process.stdout): Transport { + let rl: ReturnType | undefined; + return { + send(frame) { + output.write(`${JSON.stringify(frame)}\n`); + }, + start(onFrame) { + rl = createInterface({ input, terminal: false }); + rl.on('line', (line) => { + if (!line.trim()) return; + let frame: JsonRpcFrame; + try { + frame = JSON.parse(line) as JsonRpcFrame; + } catch { + // A malformed line is not a valid frame; stderr, not the wire. + process.stderr.write(`sep: dropping unparseable line: ${line}\n`); + return; + } + onFrame(frame); + }); + }, + close() { + rl?.close(); + }, + }; +} + +/** + * Two transports wired to each other. A frame `send`-ed on one is delivered to + * the other's `onFrame` on a microtask (mimicking the async wire and avoiding + * reentrancy). Frames sent before the peer calls `start` are buffered. + */ +export function linkedPair(): [Transport, Transport] { + const a = new InMemoryTransport(); + const b = new InMemoryTransport(); + a.peer = b; + b.peer = a; + return [a, b]; +} + +class InMemoryTransport implements Transport { + peer!: InMemoryTransport; + private onFrame?: (frame: JsonRpcFrame) => void; + private buffer: JsonRpcFrame[] = []; + private closed = false; + + send(frame: JsonRpcFrame): void { + if (this.peer.closed) return; + this.peer.deliver(frame); + } + + start(onFrame: (frame: JsonRpcFrame) => void): void { + this.onFrame = onFrame; + const pending = this.buffer; + this.buffer = []; + for (const f of pending) queueMicrotask(() => this.onFrame?.(f)); + } + + close(): void { + this.closed = true; + } + + private deliver(frame: JsonRpcFrame): void { + if (this.closed) return; + if (this.onFrame) queueMicrotask(() => this.onFrame?.(frame)); + else this.buffer.push(frame); + } +} diff --git a/typescript/extension-sdk/test/conformance.test.ts b/typescript/extension-sdk/test/conformance.test.ts new file mode 100644 index 0000000..ba3c832 --- /dev/null +++ b/typescript/extension-sdk/test/conformance.test.ts @@ -0,0 +1,24 @@ +/** + * `runConformance` replays the shared SEP fixtures against a REAL extension + * subprocess. Target: the canonical dependency-free `echo.mjs` peer that ships + * with the spec (it registers `say`, exactly what the tool_execute fixture + * calls). This is the SDK's live-wire gate — distinct from the schema-only + * fixture validation in the spec repo. + */ +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { describe, expect, it } from 'vitest'; +import { runConformance } from '../src/index.js'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const ECHO_PEER = join(__dirname, '..', '..', '..', 'spec', 'extension', 'conformance', 'echo.mjs'); + +describe('runConformance against the echo.mjs subprocess', () => { + it('handshakes and answers every method with a schema-valid reply', async () => { + const report = await runConformance({ command: process.execPath, args: [ECHO_PEER] }); + const failed = report.steps.filter((s) => !s.ok); + expect(failed, JSON.stringify(failed)).toHaveLength(0); + expect(report.passed).toBe(true); + expect(report.steps.map((s) => s.name)).toEqual(['initialize', 'ping', 'tool/execute', 'shutdown']); + }); +}); diff --git a/typescript/extension-sdk/test/jsonrpc.test.ts b/typescript/extension-sdk/test/jsonrpc.test.ts new file mode 100644 index 0000000..93bbc06 --- /dev/null +++ b/typescript/extension-sdk/test/jsonrpc.test.ts @@ -0,0 +1,47 @@ +/** The JSON-RPC leaf: request/reply, notifications, and unknown-method errors. */ +import { describe, expect, it } from 'vitest'; +import { Peer, RpcError, linkedPair } from '../src/index.js'; + +/** Wire two peers over a linked in-memory transport pair. */ +function connectedPeers(): [Peer, Peer] { + const [ta, tb] = linkedPair(); + const a = new Peer({ send: (f) => ta.send(f) }); + const b = new Peer({ send: (f) => tb.send(f) }); + ta.start((f) => a.receive(f)); + tb.start((f) => b.receive(f)); + return [a, b]; +} + +describe('Peer', () => { + it('round-trips a request to a handler', async () => { + const [a, b] = connectedPeers(); + b.setRequestHandler('add', (params) => { + const { x, y } = params as { x: number; y: number }; + return { sum: x + y }; + }); + await expect(a.request('add', { x: 2, y: 3 })).resolves.toEqual({ sum: 5 }); + }); + + it('rejects an unknown method with -32601', async () => { + const [a] = connectedPeers(); + await expect(a.request('bogus', {})).rejects.toBeInstanceOf(RpcError); + await expect(a.request('bogus', {})).rejects.toMatchObject({ code: -32601 }); + }); + + it('delivers notifications without a reply', async () => { + const [a, b] = connectedPeers(); + const got: unknown[] = []; + b.setNotificationHandler('log', (p) => got.push(p)); + a.notify('log', { level: 'info' }); + await new Promise((r) => setTimeout(r, 5)); + expect(got).toEqual([{ level: 'info' }]); + }); + + it('surfaces a handler-thrown RpcError with its code', async () => { + const [a, b] = connectedPeers(); + b.setRequestHandler('boom', () => { + throw new RpcError(-32002, 'not trusted'); + }); + await expect(a.request('boom', {})).rejects.toMatchObject({ code: -32002, message: 'not trusted' }); + }); +}); diff --git a/typescript/extension-sdk/test/schema.test.ts b/typescript/extension-sdk/test/schema.test.ts new file mode 100644 index 0000000..2e92f60 --- /dev/null +++ b/typescript/extension-sdk/test/schema.test.ts @@ -0,0 +1,24 @@ +/** `toJsonSchema` accepts zod v4, raw JSON Schema, and TypeBox-shaped schemas. */ +import { describe, expect, it } from 'vitest'; +import { z } from 'zod'; +import { toJsonSchema } from '../src/index.js'; + +describe('toJsonSchema', () => { + it('converts a zod v4 object schema to JSON Schema', () => { + const js = toJsonSchema(z.object({ phrase: z.string(), n: z.number().optional() })); + expect(js).toMatchObject({ type: 'object', properties: { phrase: { type: 'string' } }, required: ['phrase'] }); + }); + + it('passes raw JSON Schema through unchanged', () => { + const raw = { type: 'object', properties: { x: { type: 'boolean' } }, required: ['x'] }; + expect(toJsonSchema(raw)).toEqual(raw); + }); + + it('strips symbol keys (TypeBox `[Kind]`) so the result is wire-clean JSON', () => { + const typeboxLike: Record = { type: 'string', minLength: 1 }; + typeboxLike[Symbol.for('TypeBox.Kind')] = 'String'; + const js = toJsonSchema(typeboxLike as Record); + expect(js).toEqual({ type: 'string', minLength: 1 }); + expect(Object.getOwnPropertySymbols(js)).toHaveLength(0); + }); +}); diff --git a/typescript/extension-sdk/test/tool-path.test.ts b/typescript/extension-sdk/test/tool-path.test.ts new file mode 100644 index 0000000..6feebeb --- /dev/null +++ b/typescript/extension-sdk/test/tool-path.test.ts @@ -0,0 +1,104 @@ +/** + * The Phase 1 headline, SDK-side: an in-process host drives the `hello` demo + * extension through the full tool path — handshake → registration → execute → + * streamed progress → cancellation. + */ +import { afterEach, describe, expect, it } from 'vitest'; +import { z } from 'zod'; +import { createTestHost, defineExtension, defineTool, RpcError, type TestHost } from '../src/index.js'; +import { hello } from '../examples/hello.js'; + +let host: TestHost | undefined; +afterEach(() => host?.close()); + +describe('hello demo extension', () => { + it('registers hello.greet (bare `greet`) at handshake', async () => { + host = createTestHost(hello); + const result = await host.initialize(); + expect(result.extension).toEqual({ name: 'hello', version: '0.1.0' }); + const tools = result.registrations?.tools ?? []; + expect(tools.map((t) => t.name)).toContain('greet'); + // zod → JSON Schema landed on the wire. + expect(tools[0]!.parameters).toMatchObject({ type: 'object', properties: { name: { type: 'string' } } }); + expect(result.registrations?.subscriptions).toContain('turn_start'); + }); + + it('executes greet and returns the greeting', async () => { + host = createTestHost(hello); + await host.initialize(); + const res = await host.callTool('greet', { name: 'Ada' }); + expect(res).toEqual({ content: 'Hello, Ada!' }); + }); + + it('streams a tool/update progress notification during execute', async () => { + host = createTestHost(hello); + await host.initialize(); + const updates: unknown[] = []; + await host.callTool('greet', { name: 'Grace' }, { onUpdate: (u) => updates.push(u) }); + expect(updates).toHaveLength(1); + expect(updates[0]).toMatchObject({ message: 'greeting Grace', progress: 0.5 }); + }); +}); + +describe('tool cancellation', () => { + it('$/cancel aborts an in-flight tool and rejects with -32800', async () => { + // A tool that never resolves until aborted. + const ext = defineExtension((smooth) => { + smooth.name = 'slow'; + smooth.version = '0.0.1'; + smooth.registerTool( + defineTool({ + name: 'wait', + description: 'Wait until cancelled.', + parameters: z.object({}), + execute: (_args, ctx) => + new Promise((_resolve, reject) => { + ctx.signal.addEventListener('abort', () => reject(new Error('aborted')), { once: true }); + }), + }), + ); + }); + host = createTestHost(ext); + await host.initialize(); + + const controller = new AbortController(); + const pending = host.callTool('wait', {}, { signal: controller.signal }); + controller.abort(); + await expect(pending).rejects.toBeInstanceOf(RpcError); + await expect(pending).rejects.toMatchObject({ code: -32800 }); + }); +}); + +describe('unknown tool', () => { + it('returns an error result rather than throwing', async () => { + host = createTestHost(hello); + await host.initialize(); + const res = await host.callTool('nope', {}); + expect(res.is_error).toBe(true); + expect(res.content).toContain('unknown tool'); + }); +}); + +describe('lifecycle', () => { + it('answers ping and shutdown', async () => { + host = createTestHost(hello); + await host.initialize(); + await expect(host.ping()).resolves.toEqual({}); + await expect(host.shutdown()).resolves.toBeUndefined(); + }); + + it('delivers subscribed events to on() handlers', async () => { + const seen: string[] = []; + const ext = defineExtension((smooth) => { + smooth.name = 'watcher'; + smooth.version = '0.0.1'; + smooth.on('turn_start', (payload) => seen.push((payload?.turn_id as string) ?? '?')); + }); + host = createTestHost(ext); + await host.initialize(); + host.sendEvent('turn_start', { turn_id: 'turn-1' }); + // event is a notification delivered on a microtask; let it flush. + await new Promise((r) => setTimeout(r, 5)); + expect(seen).toEqual(['turn-1']); + }); +}); diff --git a/typescript/extension-sdk/tsconfig.json b/typescript/extension-sdk/tsconfig.json new file mode 100644 index 0000000..024b6e1 --- /dev/null +++ b/typescript/extension-sdk/tsconfig.json @@ -0,0 +1,23 @@ +{ + "compilerOptions": { + "target": "ES2022", + "lib": ["ES2022"], + "module": "NodeNext", + "moduleResolution": "NodeNext", + "outDir": "dist", + "rootDir": "src", + "declaration": true, + "declarationMap": true, + "sourceMap": true, + "strict": true, + "noUncheckedIndexedAccess": true, + "noImplicitOverride": true, + "noFallthroughCasesInSwitch": true, + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "skipLibCheck": true, + "verbatimModuleSyntax": true + }, + "include": ["src/**/*.ts"], + "exclude": ["dist", "node_modules", "test", "examples"] +} diff --git a/typescript/extension-sdk/tsconfig.test.json b/typescript/extension-sdk/tsconfig.test.json new file mode 100644 index 0000000..9883878 --- /dev/null +++ b/typescript/extension-sdk/tsconfig.test.json @@ -0,0 +1,9 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "noEmit": true, + "rootDir": ".", + "types": ["node"] + }, + "include": ["src/**/*.ts", "test/**/*.ts", "examples/**/*.ts"] +} diff --git a/typescript/extension-sdk/vitest.config.ts b/typescript/extension-sdk/vitest.config.ts new file mode 100644 index 0000000..9294dcc --- /dev/null +++ b/typescript/extension-sdk/vitest.config.ts @@ -0,0 +1,10 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + include: ['test/**/*.test.ts'], + exclude: ['node_modules/**', 'dist/**'], + environment: 'node', + reporters: ['default'], + }, +});