@smooai/smooth-extension-sdk 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +94 -0
- package/package.json +58 -0
- package/src/conformance.ts +132 -0
- package/src/extension.ts +174 -0
- package/src/index.ts +34 -0
- package/src/jsonrpc.ts +192 -0
- package/src/protocol.ts +117 -0
- package/src/schema.ts +29 -0
- package/src/test-host.ts +92 -0
- package/src/transport.ts +88 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Smoo AI
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
# @smooai/smooth-extension-sdk
|
|
2
|
+
|
|
3
|
+
Build **SEP** (Smooth Extension Protocol) extensions in TypeScript.
|
|
4
|
+
|
|
5
|
+
An extension is a long-lived subprocess that speaks JSON-RPC 2.0 over ndjson on
|
|
6
|
+
its stdin/stdout to a SEP host (`smooth-operator-core` and its polyglot servers).
|
|
7
|
+
This SDK is the DX centerpiece: describe your extension declaratively, `serve()`
|
|
8
|
+
it, test it in-process, and gate it against the shared conformance fixtures.
|
|
9
|
+
|
|
10
|
+
## Quick start
|
|
11
|
+
|
|
12
|
+
```ts
|
|
13
|
+
import { z } from 'zod';
|
|
14
|
+
import { defineExtension, defineTool } from '@smooai/smooth-extension-sdk';
|
|
15
|
+
|
|
16
|
+
export const hello = defineExtension((smooth) => {
|
|
17
|
+
smooth.name = 'hello';
|
|
18
|
+
smooth.version = '0.1.0';
|
|
19
|
+
|
|
20
|
+
smooth.registerTool(
|
|
21
|
+
defineTool({
|
|
22
|
+
name: 'greet',
|
|
23
|
+
description: 'Greet someone by name.',
|
|
24
|
+
parameters: z.object({ name: z.string() }),
|
|
25
|
+
async execute(args, ctx) {
|
|
26
|
+
ctx.onUpdate({ message: `greeting ${args.name}`, progress: 0.5 });
|
|
27
|
+
return { content: `Hello, ${args.name}!` };
|
|
28
|
+
},
|
|
29
|
+
}),
|
|
30
|
+
);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
hello.serve(); // wire to stdin/stdout and run
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
The host exposes the tool to the LLM as `hello.greet`.
|
|
37
|
+
|
|
38
|
+
## Schemas
|
|
39
|
+
|
|
40
|
+
`parameters` accepts three shapes — the wire truth is always JSON Schema:
|
|
41
|
+
|
|
42
|
+
- a **zod v4** schema → converted with `z.toJSONSchema()`
|
|
43
|
+
- a **TypeBox** schema → TypeBox schemas already ARE JSON Schema, passed through
|
|
44
|
+
- a **raw JSON Schema** object → passed through unchanged
|
|
45
|
+
|
|
46
|
+
## Tool context
|
|
47
|
+
|
|
48
|
+
`execute(args, ctx)` receives a `ctx` with:
|
|
49
|
+
|
|
50
|
+
- `ctx.onUpdate({ message?, progress?, details? })` — stream `tool/update` progress
|
|
51
|
+
- `ctx.signal` — an `AbortSignal` that fires when the host sends `$/cancel`
|
|
52
|
+
- `ctx.callId` / `ctx.context` — the call id and dispatch context (epoch token + tier)
|
|
53
|
+
|
|
54
|
+
Return a `{ content, is_error?, details? }` result, or just a string shorthand for
|
|
55
|
+
`{ content }`.
|
|
56
|
+
|
|
57
|
+
## Testing
|
|
58
|
+
|
|
59
|
+
```ts
|
|
60
|
+
import { createTestHost } from '@smooai/smooth-extension-sdk';
|
|
61
|
+
import { hello } from './hello.js';
|
|
62
|
+
|
|
63
|
+
const host = createTestHost(hello); // in-process, no subprocess
|
|
64
|
+
await host.initialize();
|
|
65
|
+
const res = await host.callTool('greet', { name: 'Ada' });
|
|
66
|
+
// res === { content: 'Hello, Ada!' }
|
|
67
|
+
host.close();
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
`runConformance` replays the shared SEP fixtures against a **real** extension
|
|
71
|
+
subprocess, validating every reply against its schema:
|
|
72
|
+
|
|
73
|
+
```ts
|
|
74
|
+
import { runConformance } from '@smooai/smooth-extension-sdk';
|
|
75
|
+
|
|
76
|
+
const report = await runConformance({ command: 'node', args: ['./hello.js'] });
|
|
77
|
+
// report.passed === true
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
## API
|
|
81
|
+
|
|
82
|
+
- `defineExtension((smooth) => void)` — set `smooth.name`/`version`, `registerTool`, `on(event)`, `log`.
|
|
83
|
+
- `defineTool({ name, description, parameters, deferred?, execute })`
|
|
84
|
+
- `createTestHost(extension)` → `{ initialize, callTool, ping, sendEvent, shutdown, close }`
|
|
85
|
+
- `runConformance({ command, args?, env?, cwd?, specDir? })` → `ConformanceReport`
|
|
86
|
+
- `Peer`, `stdioTransport`, `linkedPair`, `toJsonSchema` — the building blocks
|
|
87
|
+
- `PROTOCOL_VERSION`, `method`, `errorCode` and the wire types
|
|
88
|
+
|
|
89
|
+
## Scope (Phase 1)
|
|
90
|
+
|
|
91
|
+
The tool path: registration, execute, streamed progress, cancellation, plus
|
|
92
|
+
observe `on(event)` subscriptions and lifecycle (`initialize`/`ping`/`shutdown`).
|
|
93
|
+
Hooks, commands, ui/kv/session/exec land in later phases — the wire and API were
|
|
94
|
+
shaped to grow into them without breaking the tool path.
|
package/package.json
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@smooai/smooth-extension-sdk",
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"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).",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"engines": {
|
|
8
|
+
"node": ">=22"
|
|
9
|
+
},
|
|
10
|
+
"main": "./dist/index.js",
|
|
11
|
+
"module": "./dist/index.js",
|
|
12
|
+
"types": "./dist/index.d.ts",
|
|
13
|
+
"exports": {
|
|
14
|
+
".": {
|
|
15
|
+
"types": "./dist/index.d.ts",
|
|
16
|
+
"import": "./dist/index.js"
|
|
17
|
+
}
|
|
18
|
+
},
|
|
19
|
+
"publishConfig": {
|
|
20
|
+
"access": "public"
|
|
21
|
+
},
|
|
22
|
+
"repository": {
|
|
23
|
+
"type": "git",
|
|
24
|
+
"url": "git+https://github.com/SmooAI/smooth-operator.git",
|
|
25
|
+
"directory": "typescript/extension-sdk"
|
|
26
|
+
},
|
|
27
|
+
"homepage": "https://github.com/SmooAI/smooth-operator/tree/main/typescript/extension-sdk",
|
|
28
|
+
"keywords": [
|
|
29
|
+
"smooth-operator",
|
|
30
|
+
"sep",
|
|
31
|
+
"extension",
|
|
32
|
+
"json-rpc",
|
|
33
|
+
"agent",
|
|
34
|
+
"tools",
|
|
35
|
+
"sdk"
|
|
36
|
+
],
|
|
37
|
+
"files": [
|
|
38
|
+
"dist",
|
|
39
|
+
"src"
|
|
40
|
+
],
|
|
41
|
+
"dependencies": {
|
|
42
|
+
"ajv": "^8.17.1",
|
|
43
|
+
"ajv-formats": "^3.0.1",
|
|
44
|
+
"zod": "^4.0.0"
|
|
45
|
+
},
|
|
46
|
+
"devDependencies": {
|
|
47
|
+
"@types/node": "^25.9.2",
|
|
48
|
+
"tsx": "^4.19.2",
|
|
49
|
+
"typescript": "^5.7.3",
|
|
50
|
+
"vitest": "^2.1.8"
|
|
51
|
+
},
|
|
52
|
+
"scripts": {
|
|
53
|
+
"build": "tsc -p tsconfig.json",
|
|
54
|
+
"typecheck": "tsc -p tsconfig.json --noEmit && tsc -p tsconfig.test.json",
|
|
55
|
+
"test": "vitest run",
|
|
56
|
+
"test:watch": "vitest"
|
|
57
|
+
}
|
|
58
|
+
}
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `runConformance` — replay the shared SEP conformance fixtures against a REAL
|
|
3
|
+
* extension subprocess. Where the schema-only conformance test (in the spec
|
|
4
|
+
* repo) proves the fixtures match the schemas, this proves a live extension,
|
|
5
|
+
* spawned and handshaken over stdio, answers each method with a schema-valid
|
|
6
|
+
* reply. It is the SDK's dogfood gate and the template every polyglot SDK's
|
|
7
|
+
* conformance runner follows.
|
|
8
|
+
*/
|
|
9
|
+
import { spawn } from 'node:child_process';
|
|
10
|
+
import { readFile, readdir } from 'node:fs/promises';
|
|
11
|
+
import { dirname, join } from 'node:path';
|
|
12
|
+
import { fileURLToPath } from 'node:url';
|
|
13
|
+
import _Ajv2020, { Ajv2020 as AjvClass, type ErrorObject } from 'ajv/dist/2020.js';
|
|
14
|
+
import _addFormats from 'ajv-formats';
|
|
15
|
+
import { Peer } from './jsonrpc.js';
|
|
16
|
+
import { PROTOCOL_VERSION, method } from './protocol.js';
|
|
17
|
+
import { stdioTransport } from './transport.js';
|
|
18
|
+
|
|
19
|
+
// ajv/ajv-formats ship CJS with a double-default under NodeNext; normalize both
|
|
20
|
+
// to the actual callable (same trick as the spec repo's validate.ts).
|
|
21
|
+
type Ajv = AjvClass;
|
|
22
|
+
const Ajv2020 = ((_Ajv2020 as unknown as { default?: unknown }).default ?? _Ajv2020) as typeof AjvClass;
|
|
23
|
+
const addFormats = ((_addFormats as unknown as { default?: unknown }).default ?? _addFormats) as (ajv: Ajv) => Ajv;
|
|
24
|
+
|
|
25
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
26
|
+
/** Repo-relative default: <repo>/spec/extension (works from src/ and dist/). */
|
|
27
|
+
export const DEFAULT_SPEC_DIR = join(__dirname, '..', '..', '..', 'spec', 'extension');
|
|
28
|
+
|
|
29
|
+
export interface ConformanceStep {
|
|
30
|
+
name: string;
|
|
31
|
+
ok: boolean;
|
|
32
|
+
detail?: string;
|
|
33
|
+
}
|
|
34
|
+
export interface ConformanceReport {
|
|
35
|
+
passed: boolean;
|
|
36
|
+
steps: ConformanceStep[];
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export interface RunConformanceOptions {
|
|
40
|
+
command: string;
|
|
41
|
+
args?: string[];
|
|
42
|
+
env?: Record<string, string>;
|
|
43
|
+
cwd?: string;
|
|
44
|
+
/** Where spec/extension lives; defaults to the in-repo copy. */
|
|
45
|
+
specDir?: string;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
interface Fixture {
|
|
49
|
+
$schema_ref: string;
|
|
50
|
+
instance: unknown;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Load every methods/*.schema.json under `specDir` into one ajv instance. */
|
|
54
|
+
async function loadValidator(specDir: string): Promise<(ref: string, value: unknown) => ErrorObject[]> {
|
|
55
|
+
const ajv = new Ajv2020({ allErrors: true, strict: false });
|
|
56
|
+
addFormats(ajv);
|
|
57
|
+
const fileToId = new Map<string, string>();
|
|
58
|
+
const methodsDir = join(specDir, 'methods');
|
|
59
|
+
for (const e of await readdir(methodsDir, { withFileTypes: true })) {
|
|
60
|
+
if (!e.isFile() || !e.name.endsWith('.schema.json')) continue;
|
|
61
|
+
const schema = JSON.parse(await readFile(join(methodsDir, e.name), 'utf8')) as { $id?: string };
|
|
62
|
+
const id = schema.$id ?? `urn:sep:${e.name}`;
|
|
63
|
+
if (!ajv.getSchema(id)) ajv.addSchema(schema, id);
|
|
64
|
+
fileToId.set(e.name, id);
|
|
65
|
+
}
|
|
66
|
+
return (ref, value) => {
|
|
67
|
+
const [path, pointer] = ref.split('#');
|
|
68
|
+
const file = path!.split('/').pop()!;
|
|
69
|
+
const id = fileToId.get(file);
|
|
70
|
+
if (!id) throw new Error(`no schema for ref ${ref}`);
|
|
71
|
+
const validate = ajv.getSchema(pointer ? `${id}#${pointer}` : id);
|
|
72
|
+
if (!validate) throw new Error(`ajv could not resolve ${ref}`);
|
|
73
|
+
return validate(value) ? [] : (validate.errors ?? []);
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function fmt(errors: ErrorObject[]): string {
|
|
78
|
+
return errors.map((e) => `${e.instancePath || '<root>'} ${e.message ?? ''}`.trim()).join('; ');
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Spawn `command`, handshake, and replay the request/reply fixtures against the
|
|
83
|
+
* live process, validating every reply against its `Result` schema. Resolves a
|
|
84
|
+
* report; also returns non-zero via `passed: false` rather than throwing so a
|
|
85
|
+
* caller can assert on the detail.
|
|
86
|
+
*/
|
|
87
|
+
export async function runConformance(opts: RunConformanceOptions): Promise<ConformanceReport> {
|
|
88
|
+
const specDir = opts.specDir ?? DEFAULT_SPEC_DIR;
|
|
89
|
+
const validate = await loadValidator(specDir);
|
|
90
|
+
const fixtures = JSON.parse(await readFile(join(specDir, 'conformance', 'fixtures.json'), 'utf8')) as Record<string, Fixture>;
|
|
91
|
+
|
|
92
|
+
const child = spawn(opts.command, opts.args ?? [], {
|
|
93
|
+
stdio: ['pipe', 'pipe', 'inherit'],
|
|
94
|
+
env: { ...process.env, ...opts.env },
|
|
95
|
+
cwd: opts.cwd,
|
|
96
|
+
});
|
|
97
|
+
const transport = stdioTransport(child.stdout!, child.stdin!);
|
|
98
|
+
const peer = new Peer({ send: (frame) => transport.send(frame) });
|
|
99
|
+
peer.setNotificationHandler(method.TOOL_UPDATE, () => {});
|
|
100
|
+
peer.setNotificationHandler(method.LOG, () => {});
|
|
101
|
+
transport.start((frame) => peer.receive(frame));
|
|
102
|
+
|
|
103
|
+
const steps: ConformanceStep[] = [];
|
|
104
|
+
const check = async (name: string, requestMethod: string, params: unknown, resultRef: string) => {
|
|
105
|
+
try {
|
|
106
|
+
const result = await peer.request(requestMethod, params);
|
|
107
|
+
const errors = validate(resultRef, result);
|
|
108
|
+
steps.push({ name, ok: errors.length === 0, detail: errors.length ? fmt(errors) : undefined });
|
|
109
|
+
} catch (err) {
|
|
110
|
+
steps.push({ name, ok: false, detail: err instanceof Error ? err.message : String(err) });
|
|
111
|
+
}
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
try {
|
|
115
|
+
await check('initialize', method.INITIALIZE, initParams(fixtures), 'methods/initialize.schema.json#/$defs/Result');
|
|
116
|
+
await check('ping', method.PING, {}, 'methods/ping.schema.json#/$defs/Result');
|
|
117
|
+
await check('tool/execute', method.TOOL_EXECUTE, fixtures.tool_execute_params!.instance, 'methods/tool-execute.schema.json#/$defs/Result');
|
|
118
|
+
await check('shutdown', method.SHUTDOWN, {}, 'methods/shutdown.schema.json#/$defs/Result');
|
|
119
|
+
} finally {
|
|
120
|
+
peer.close();
|
|
121
|
+
transport.close();
|
|
122
|
+
child.kill();
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
return { passed: steps.every((s) => s.ok), steps };
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** Handshake params from the fixture, pinned to the version this SDK speaks. */
|
|
129
|
+
function initParams(fixtures: Record<string, Fixture>): unknown {
|
|
130
|
+
const base = fixtures.initialize_params!.instance as Record<string, unknown>;
|
|
131
|
+
return { ...base, protocol_version: PROTOCOL_VERSION };
|
|
132
|
+
}
|
package/src/extension.ts
ADDED
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `defineExtension` / `defineTool` — the DX centerpiece.
|
|
3
|
+
*
|
|
4
|
+
* An extension is a long-lived subprocess speaking SEP over its stdio. You
|
|
5
|
+
* describe it declaratively; `serve()` wires it to `process.stdin/stdout`, and
|
|
6
|
+
* `createTestHost` (test-host.ts) drives the same object in-process.
|
|
7
|
+
*
|
|
8
|
+
* Phase 1 surface: `registerTool` (schema + execute + streaming progress +
|
|
9
|
+
* cancellation) and `on(event)` observe subscriptions. Hooks, commands, ui/kv/
|
|
10
|
+
* session/exec land in later phases; the wire and this API were shaped to grow
|
|
11
|
+
* into them without breaking the tool path.
|
|
12
|
+
*/
|
|
13
|
+
import { Peer } from './jsonrpc.js';
|
|
14
|
+
import { PROTOCOL_VERSION, method } from './protocol.js';
|
|
15
|
+
import type { Context, EventParams, InitializeParams, InitializeResult, ToolExecuteParams, ToolExecuteResult, ToolUpdateParams } from './protocol.js';
|
|
16
|
+
import { toJsonSchema, type ParameterSchema } from './schema.js';
|
|
17
|
+
import { stdioTransport, type Transport } from './transport.js';
|
|
18
|
+
|
|
19
|
+
/** Progress + cancellation handed to a tool while it runs. */
|
|
20
|
+
export interface ToolContext {
|
|
21
|
+
/** Correlates `onUpdate` calls with this execution. */
|
|
22
|
+
callId: string;
|
|
23
|
+
/** The dispatch context (epoch token + tier). */
|
|
24
|
+
context: Context;
|
|
25
|
+
/** Fires when the host sends `$/cancel` for this call. */
|
|
26
|
+
signal: AbortSignal;
|
|
27
|
+
/** Stream a progress notification back to the host. */
|
|
28
|
+
onUpdate(update: Omit<ToolUpdateParams, 'call_id'>): void;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** What a tool's `execute` may return: a full result or just its `content`. */
|
|
32
|
+
export type ToolReturn = ToolExecuteResult | string;
|
|
33
|
+
|
|
34
|
+
export interface ToolDef<TArgs = Record<string, unknown>> {
|
|
35
|
+
name: string;
|
|
36
|
+
description: string;
|
|
37
|
+
parameters: ParameterSchema;
|
|
38
|
+
deferred?: boolean;
|
|
39
|
+
execute(args: TArgs, ctx: ToolContext): Promise<ToolReturn> | ToolReturn;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Identity for `defineTool` — keeps the generic arg inferred at the call site. */
|
|
43
|
+
export function defineTool<TArgs = Record<string, unknown>>(def: ToolDef<TArgs>): ToolDef<TArgs> {
|
|
44
|
+
return def;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Handler for an observe `event`. Fire-and-forget; return value ignored. */
|
|
48
|
+
export type EventHandler = (payload: Record<string, unknown> | undefined, ctx: Context) => void | Promise<void>;
|
|
49
|
+
|
|
50
|
+
/** The builder passed to `defineExtension`'s setup. Mirrors pi's `ExtensionAPI`. */
|
|
51
|
+
export interface SmoothApi {
|
|
52
|
+
name: string;
|
|
53
|
+
version: string;
|
|
54
|
+
registerTool(tool: ToolDef<any>): void;
|
|
55
|
+
on(event: string, handler: EventHandler): void;
|
|
56
|
+
log(level: 'debug' | 'info' | 'warn' | 'error', message: string, fields?: Record<string, unknown>): void;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export type ExtensionSetup = (smooth: SmoothApi) => void;
|
|
60
|
+
|
|
61
|
+
export interface ConnectHandle {
|
|
62
|
+
peer: Peer;
|
|
63
|
+
close(): void;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export class Extension {
|
|
67
|
+
private readonly tools = new Map<string, ToolDef<any>>();
|
|
68
|
+
private readonly events = new Map<string, EventHandler[]>();
|
|
69
|
+
private name = 'extension';
|
|
70
|
+
private version = '0.0.0';
|
|
71
|
+
/** Set once connected so `log()` before connect is a safe no-op. */
|
|
72
|
+
private live?: Peer;
|
|
73
|
+
|
|
74
|
+
constructor(setup: ExtensionSetup) {
|
|
75
|
+
const api: SmoothApi = {
|
|
76
|
+
get name() {
|
|
77
|
+
return self.name;
|
|
78
|
+
},
|
|
79
|
+
set name(v: string) {
|
|
80
|
+
self.name = v;
|
|
81
|
+
},
|
|
82
|
+
get version() {
|
|
83
|
+
return self.version;
|
|
84
|
+
},
|
|
85
|
+
set version(v: string) {
|
|
86
|
+
self.version = v;
|
|
87
|
+
},
|
|
88
|
+
registerTool: (tool) => {
|
|
89
|
+
this.tools.set(tool.name, tool);
|
|
90
|
+
},
|
|
91
|
+
on: (event, handler) => {
|
|
92
|
+
const list = this.events.get(event) ?? [];
|
|
93
|
+
list.push(handler);
|
|
94
|
+
this.events.set(event, list);
|
|
95
|
+
},
|
|
96
|
+
log: (level, message, fields) => {
|
|
97
|
+
this.live?.notify(method.LOG, { level, message, ...(fields ? { fields } : {}) });
|
|
98
|
+
},
|
|
99
|
+
};
|
|
100
|
+
// `self` alias so the getter/setter pair above closes over the instance.
|
|
101
|
+
const self = this;
|
|
102
|
+
setup(api);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** Wire this extension to a transport. Returns a handle to close it. */
|
|
106
|
+
connect(transport: Transport, onShutdown: () => void = () => {}): ConnectHandle {
|
|
107
|
+
const peer = new Peer({ send: (frame) => transport.send(frame) });
|
|
108
|
+
this.live = peer;
|
|
109
|
+
|
|
110
|
+
peer.setRequestHandler(method.INITIALIZE, (params) => this.initialize(params as InitializeParams));
|
|
111
|
+
peer.setRequestHandler(method.PING, () => ({}));
|
|
112
|
+
peer.setRequestHandler(method.SHUTDOWN, () => {
|
|
113
|
+
queueMicrotask(onShutdown);
|
|
114
|
+
return {};
|
|
115
|
+
});
|
|
116
|
+
peer.setRequestHandler(method.TOOL_EXECUTE, (params, signal) => this.executeTool(params as ToolExecuteParams, peer, signal));
|
|
117
|
+
peer.setNotificationHandler(method.EVENT, (params) => this.dispatchEvent(params as EventParams));
|
|
118
|
+
|
|
119
|
+
transport.start((frame) => peer.receive(frame));
|
|
120
|
+
return {
|
|
121
|
+
peer,
|
|
122
|
+
close() {
|
|
123
|
+
peer.close();
|
|
124
|
+
transport.close();
|
|
125
|
+
},
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** Connect over this process's stdin/stdout and keep the process alive. */
|
|
130
|
+
serve(): ConnectHandle {
|
|
131
|
+
return this.connect(stdioTransport(), () => {
|
|
132
|
+
// Give the shutdown reply a tick to flush, then exit.
|
|
133
|
+
setTimeout(() => process.exit(0), 10);
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
private initialize(_params: InitializeParams): InitializeResult {
|
|
138
|
+
const tools = [...this.tools.values()].map((t) => ({
|
|
139
|
+
name: t.name,
|
|
140
|
+
description: t.description,
|
|
141
|
+
parameters: toJsonSchema(t.parameters),
|
|
142
|
+
...(t.deferred ? { deferred: true } : {}),
|
|
143
|
+
}));
|
|
144
|
+
return {
|
|
145
|
+
protocol_version: PROTOCOL_VERSION,
|
|
146
|
+
extension: { name: this.name, version: this.version },
|
|
147
|
+
registrations: { tools, subscriptions: [...this.events.keys()] },
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
private async executeTool(params: ToolExecuteParams, peer: Peer, signal: AbortSignal): Promise<ToolExecuteResult> {
|
|
152
|
+
const tool = this.tools.get(params.tool);
|
|
153
|
+
if (!tool) return { content: `unknown tool: ${params.tool}`, is_error: true };
|
|
154
|
+
const ctx: ToolContext = {
|
|
155
|
+
callId: params.call_id,
|
|
156
|
+
context: params.context,
|
|
157
|
+
signal,
|
|
158
|
+
onUpdate: (update) => peer.notify(method.TOOL_UPDATE, { call_id: params.call_id, ...update }),
|
|
159
|
+
};
|
|
160
|
+
const out = await tool.execute(params.arguments, ctx);
|
|
161
|
+
return typeof out === 'string' ? { content: out } : out;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
private dispatchEvent(params: EventParams): void {
|
|
165
|
+
for (const handler of this.events.get(params.event) ?? []) {
|
|
166
|
+
void handler(params.payload, params.context);
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/** Define an extension. Set `smooth.name`/`smooth.version` and register tools. */
|
|
172
|
+
export function defineExtension(setup: ExtensionSetup): Extension {
|
|
173
|
+
return new Extension(setup);
|
|
174
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @smooai/smooth-extension-sdk — build SEP (Smooth Extension Protocol)
|
|
3
|
+
* extensions in TypeScript.
|
|
4
|
+
*
|
|
5
|
+
* An extension is a subprocess speaking JSON-RPC 2.0 ndjson over stdio to any
|
|
6
|
+
* SEP host (smooth-operator-core and its polyglot servers). Describe it with
|
|
7
|
+
* `defineExtension`/`defineTool`, `serve()` it, test it in-process with
|
|
8
|
+
* `createTestHost`, and gate it against the shared fixtures with
|
|
9
|
+
* `runConformance`.
|
|
10
|
+
*/
|
|
11
|
+
export { defineExtension, defineTool, Extension } from './extension.js';
|
|
12
|
+
export type { ExtensionSetup, SmoothApi, ToolDef, ToolContext, ToolReturn, EventHandler, ConnectHandle } from './extension.js';
|
|
13
|
+
export { createTestHost } from './test-host.js';
|
|
14
|
+
export type { TestHost, CallToolOptions } from './test-host.js';
|
|
15
|
+
export { runConformance, DEFAULT_SPEC_DIR } from './conformance.js';
|
|
16
|
+
export type { ConformanceReport, ConformanceStep, RunConformanceOptions } from './conformance.js';
|
|
17
|
+
export { toJsonSchema } from './schema.js';
|
|
18
|
+
export type { ParameterSchema } from './schema.js';
|
|
19
|
+
export { Peer, RpcError } from './jsonrpc.js';
|
|
20
|
+
export type { JsonRpcFrame } from './jsonrpc.js';
|
|
21
|
+
export { stdioTransport, linkedPair } from './transport.js';
|
|
22
|
+
export type { Transport } from './transport.js';
|
|
23
|
+
export { PROTOCOL_VERSION, method, errorCode } from './protocol.js';
|
|
24
|
+
export type {
|
|
25
|
+
Context,
|
|
26
|
+
InitializeParams,
|
|
27
|
+
InitializeResult,
|
|
28
|
+
Registrations,
|
|
29
|
+
ToolRegistration,
|
|
30
|
+
ToolExecuteParams,
|
|
31
|
+
ToolExecuteResult,
|
|
32
|
+
ToolUpdateParams,
|
|
33
|
+
EventParams,
|
|
34
|
+
} from './protocol.js';
|
package/src/jsonrpc.ts
ADDED
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A symmetric JSON-RPC 2.0 peer over a message-passing transport.
|
|
3
|
+
*
|
|
4
|
+
* Both ends of SEP are peers: each issues requests, replies to the other's
|
|
5
|
+
* requests, and sends fire-and-forget notifications. This one `Peer` class is
|
|
6
|
+
* the shared core — the extension runtime and the in-process test host are both
|
|
7
|
+
* just a `Peer` with different handlers. It is transport-agnostic: it emits
|
|
8
|
+
* frame objects via `send` and is fed inbound frames via `receive`; a codec
|
|
9
|
+
* turns those into ndjson lines (see `transport.ts`).
|
|
10
|
+
*
|
|
11
|
+
* Cancellation is wired both ways:
|
|
12
|
+
* - Outbound: pass an `AbortSignal`; on abort the Peer sends `$/cancel { id }`
|
|
13
|
+
* and rejects the pending request.
|
|
14
|
+
* - Inbound: a request handler receives an `AbortSignal` that fires when the
|
|
15
|
+
* remote sends `$/cancel` for that request's id.
|
|
16
|
+
*/
|
|
17
|
+
import { errorCode, method } from './protocol.js';
|
|
18
|
+
|
|
19
|
+
export interface JsonRpcRequest {
|
|
20
|
+
jsonrpc: '2.0';
|
|
21
|
+
id: number | string;
|
|
22
|
+
method: string;
|
|
23
|
+
params?: unknown;
|
|
24
|
+
}
|
|
25
|
+
export interface JsonRpcNotification {
|
|
26
|
+
jsonrpc: '2.0';
|
|
27
|
+
method: string;
|
|
28
|
+
params?: unknown;
|
|
29
|
+
}
|
|
30
|
+
export interface JsonRpcSuccess {
|
|
31
|
+
jsonrpc: '2.0';
|
|
32
|
+
id: number | string;
|
|
33
|
+
result: unknown;
|
|
34
|
+
}
|
|
35
|
+
export interface JsonRpcError {
|
|
36
|
+
jsonrpc: '2.0';
|
|
37
|
+
id: number | string;
|
|
38
|
+
error: { code: number; message: string; data?: unknown };
|
|
39
|
+
}
|
|
40
|
+
export type JsonRpcFrame = JsonRpcRequest | JsonRpcNotification | JsonRpcSuccess | JsonRpcError;
|
|
41
|
+
|
|
42
|
+
/** An error carrying a JSON-RPC error code, thrown for a remote error reply. */
|
|
43
|
+
export class RpcError extends Error {
|
|
44
|
+
constructor(
|
|
45
|
+
public readonly code: number,
|
|
46
|
+
message: string,
|
|
47
|
+
public readonly data?: unknown,
|
|
48
|
+
) {
|
|
49
|
+
super(message);
|
|
50
|
+
this.name = 'RpcError';
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export type RequestHandler = (params: unknown, signal: AbortSignal) => Promise<unknown> | unknown;
|
|
55
|
+
export type NotificationHandler = (params: unknown) => void;
|
|
56
|
+
|
|
57
|
+
interface Pending {
|
|
58
|
+
resolve: (value: unknown) => void;
|
|
59
|
+
reject: (err: Error) => void;
|
|
60
|
+
onAbort?: () => void;
|
|
61
|
+
signal?: AbortSignal;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export interface PeerOptions {
|
|
65
|
+
/** Emit a frame to the remote. */
|
|
66
|
+
send: (frame: JsonRpcFrame) => void;
|
|
67
|
+
/** Called for any inbound method with no registered handler. */
|
|
68
|
+
onUnhandled?: (frame: JsonRpcRequest | JsonRpcNotification) => void;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export class Peer {
|
|
72
|
+
private nextId = 1;
|
|
73
|
+
private readonly pending = new Map<number | string, Pending>();
|
|
74
|
+
private readonly requestHandlers = new Map<string, RequestHandler>();
|
|
75
|
+
private readonly notificationHandlers = new Map<string, NotificationHandler>();
|
|
76
|
+
/** In-flight inbound requests we can cancel when the remote sends `$/cancel`. */
|
|
77
|
+
private readonly inflight = new Map<number | string, AbortController>();
|
|
78
|
+
private closed = false;
|
|
79
|
+
|
|
80
|
+
constructor(private readonly opts: PeerOptions) {}
|
|
81
|
+
|
|
82
|
+
setRequestHandler(name: string, handler: RequestHandler): void {
|
|
83
|
+
this.requestHandlers.set(name, handler);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
setNotificationHandler(name: string, handler: NotificationHandler): void {
|
|
87
|
+
this.notificationHandlers.set(name, handler);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** Issue a request; resolves with the remote's result or rejects with RpcError. */
|
|
91
|
+
request<T = unknown>(name: string, params?: unknown, signal?: AbortSignal): Promise<T> {
|
|
92
|
+
if (this.closed) return Promise.reject(new Error('peer is closed'));
|
|
93
|
+
const id = this.nextId++;
|
|
94
|
+
return new Promise<T>((resolve, reject) => {
|
|
95
|
+
if (signal?.aborted) {
|
|
96
|
+
reject(new RpcError(errorCode.Cancelled, 'cancelled before send'));
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
99
|
+
const onAbort = signal
|
|
100
|
+
? () => {
|
|
101
|
+
const p = this.pending.get(id);
|
|
102
|
+
if (!p) return;
|
|
103
|
+
this.pending.delete(id);
|
|
104
|
+
this.opts.send({ jsonrpc: '2.0', method: method.CANCEL, params: { id } });
|
|
105
|
+
reject(new RpcError(errorCode.Cancelled, 'cancelled'));
|
|
106
|
+
}
|
|
107
|
+
: undefined;
|
|
108
|
+
this.pending.set(id, { resolve: resolve as (v: unknown) => void, reject, onAbort, signal });
|
|
109
|
+
signal?.addEventListener('abort', onAbort!, { once: true });
|
|
110
|
+
this.opts.send({ jsonrpc: '2.0', id, method: name, params });
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** Send a fire-and-forget notification. */
|
|
115
|
+
notify(name: string, params?: unknown): void {
|
|
116
|
+
if (this.closed) return;
|
|
117
|
+
this.opts.send({ jsonrpc: '2.0', method: name, params });
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** Feed one inbound frame. */
|
|
121
|
+
receive(frame: JsonRpcFrame): void {
|
|
122
|
+
if ('id' in frame && 'method' in frame) {
|
|
123
|
+
void this.handleRequest(frame);
|
|
124
|
+
} else if ('method' in frame) {
|
|
125
|
+
this.handleNotification(frame);
|
|
126
|
+
} else if ('id' in frame) {
|
|
127
|
+
this.handleResponse(frame);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** Reject every pending request; used on transport close. */
|
|
132
|
+
close(reason = 'peer closed'): void {
|
|
133
|
+
this.closed = true;
|
|
134
|
+
for (const [id, p] of this.pending) {
|
|
135
|
+
if (p.onAbort && p.signal) p.signal.removeEventListener('abort', p.onAbort);
|
|
136
|
+
p.reject(new Error(reason));
|
|
137
|
+
this.pending.delete(id);
|
|
138
|
+
}
|
|
139
|
+
for (const ctrl of this.inflight.values()) ctrl.abort();
|
|
140
|
+
this.inflight.clear();
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
private async handleRequest(frame: JsonRpcRequest): Promise<void> {
|
|
144
|
+
const handler = this.requestHandlers.get(frame.method);
|
|
145
|
+
if (!handler) {
|
|
146
|
+
this.opts.onUnhandled?.(frame);
|
|
147
|
+
this.opts.send({ jsonrpc: '2.0', id: frame.id, error: { code: errorCode.MethodNotFound, message: `method not found: ${frame.method}` } });
|
|
148
|
+
return;
|
|
149
|
+
}
|
|
150
|
+
const ctrl = new AbortController();
|
|
151
|
+
this.inflight.set(frame.id, ctrl);
|
|
152
|
+
try {
|
|
153
|
+
const result = await handler(frame.params, ctrl.signal);
|
|
154
|
+
if (ctrl.signal.aborted) {
|
|
155
|
+
this.opts.send({ jsonrpc: '2.0', id: frame.id, error: { code: errorCode.Cancelled, message: 'cancelled' } });
|
|
156
|
+
} else {
|
|
157
|
+
this.opts.send({ jsonrpc: '2.0', id: frame.id, result: result ?? {} });
|
|
158
|
+
}
|
|
159
|
+
} catch (err) {
|
|
160
|
+
if (ctrl.signal.aborted) {
|
|
161
|
+
this.opts.send({ jsonrpc: '2.0', id: frame.id, error: { code: errorCode.Cancelled, message: 'cancelled' } });
|
|
162
|
+
} else if (err instanceof RpcError) {
|
|
163
|
+
this.opts.send({ jsonrpc: '2.0', id: frame.id, error: { code: err.code, message: err.message, data: err.data } });
|
|
164
|
+
} else {
|
|
165
|
+
this.opts.send({ jsonrpc: '2.0', id: frame.id, error: { code: errorCode.InternalError, message: err instanceof Error ? err.message : String(err) } });
|
|
166
|
+
}
|
|
167
|
+
} finally {
|
|
168
|
+
this.inflight.delete(frame.id);
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
private handleNotification(frame: JsonRpcNotification): void {
|
|
173
|
+
// `$/cancel` aborts the matching in-flight inbound request.
|
|
174
|
+
if (frame.method === method.CANCEL) {
|
|
175
|
+
const id = (frame.params as { id?: number | string } | undefined)?.id;
|
|
176
|
+
if (id !== undefined) this.inflight.get(id)?.abort();
|
|
177
|
+
return;
|
|
178
|
+
}
|
|
179
|
+
const handler = this.notificationHandlers.get(frame.method);
|
|
180
|
+
if (handler) handler(frame.params);
|
|
181
|
+
else this.opts.onUnhandled?.(frame);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
private handleResponse(frame: JsonRpcSuccess | JsonRpcError): void {
|
|
185
|
+
const p = this.pending.get(frame.id);
|
|
186
|
+
if (!p) return; // late reply for a cancelled/unknown request — drop it.
|
|
187
|
+
this.pending.delete(frame.id);
|
|
188
|
+
if (p.onAbort && p.signal) p.signal.removeEventListener('abort', p.onAbort);
|
|
189
|
+
if ('error' in frame) p.reject(new RpcError(frame.error.code, frame.error.message, frame.error.data));
|
|
190
|
+
else p.resolve(frame.result);
|
|
191
|
+
}
|
|
192
|
+
}
|
package/src/protocol.ts
ADDED
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SEP (Smooth Extension Protocol) wire types + method/error constants.
|
|
3
|
+
*
|
|
4
|
+
* The source of truth is the JSON Schemas in `spec/extension/`; these types are
|
|
5
|
+
* the hand-maintained TS view of the subset the SDK needs for Phase 1 (the tool
|
|
6
|
+
* path + lifecycle). Field names are `snake_case` because they ARE the wire.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
/** Highest SEP version this SDK speaks. Effective version = min(host, ext). */
|
|
10
|
+
export const PROTOCOL_VERSION = 1;
|
|
11
|
+
|
|
12
|
+
/** Method names — namespaced with `/`; `$/` marks JSON-RPC meta methods. */
|
|
13
|
+
export const method = {
|
|
14
|
+
INITIALIZE: 'initialize',
|
|
15
|
+
SHUTDOWN: 'shutdown',
|
|
16
|
+
PING: 'ping',
|
|
17
|
+
EVENT: 'event',
|
|
18
|
+
HOOK: 'hook',
|
|
19
|
+
TOOL_EXECUTE: 'tool/execute',
|
|
20
|
+
TOOL_UPDATE: 'tool/update',
|
|
21
|
+
REGISTRY_UPDATE: 'registry/update',
|
|
22
|
+
LOG: 'log',
|
|
23
|
+
CANCEL: '$/cancel',
|
|
24
|
+
} as const;
|
|
25
|
+
|
|
26
|
+
/** JSON-RPC + SEP error codes (see spec/extension/envelope.md). */
|
|
27
|
+
export const errorCode = {
|
|
28
|
+
ParseError: -32700,
|
|
29
|
+
InvalidRequest: -32600,
|
|
30
|
+
MethodNotFound: -32601,
|
|
31
|
+
InvalidParams: -32602,
|
|
32
|
+
InternalError: -32603,
|
|
33
|
+
Blocked: -32000,
|
|
34
|
+
NoUI: -32001,
|
|
35
|
+
NotTrusted: -32002,
|
|
36
|
+
ContextViolation: -32003,
|
|
37
|
+
CapabilityDisabled: -32004,
|
|
38
|
+
Cancelled: -32800,
|
|
39
|
+
} as const;
|
|
40
|
+
|
|
41
|
+
/** The `context` carried by every dispatched event/hook/tool/execute. */
|
|
42
|
+
export interface Context {
|
|
43
|
+
token: string;
|
|
44
|
+
tier: 'event' | 'command';
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export interface HostInfo {
|
|
48
|
+
name: string;
|
|
49
|
+
version: string;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export interface Workspace {
|
|
53
|
+
root: string;
|
|
54
|
+
trusted: boolean;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export interface InitializeParams {
|
|
58
|
+
protocol_version: number;
|
|
59
|
+
host: HostInfo;
|
|
60
|
+
workspace: Workspace;
|
|
61
|
+
session?: { id?: string };
|
|
62
|
+
mode: 'tui' | 'web' | 'widget' | 'cli' | 'headless';
|
|
63
|
+
ui_capabilities?: string[];
|
|
64
|
+
capabilities_enabled?: Record<string, boolean>;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export interface ToolRegistration {
|
|
68
|
+
name: string;
|
|
69
|
+
description: string;
|
|
70
|
+
/** JSON Schema for the tool's arguments. */
|
|
71
|
+
parameters: Record<string, unknown>;
|
|
72
|
+
deferred?: boolean;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export interface CommandRegistration {
|
|
76
|
+
name: string;
|
|
77
|
+
description: string;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export interface Registrations {
|
|
81
|
+
tools?: ToolRegistration[];
|
|
82
|
+
commands?: CommandRegistration[];
|
|
83
|
+
flags?: string[];
|
|
84
|
+
subscriptions?: string[];
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export interface InitializeResult {
|
|
88
|
+
protocol_version: number;
|
|
89
|
+
extension: { name: string; version: string };
|
|
90
|
+
registrations?: Registrations;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export interface ToolExecuteParams {
|
|
94
|
+
call_id: string;
|
|
95
|
+
tool: string;
|
|
96
|
+
arguments: Record<string, unknown>;
|
|
97
|
+
context: Context;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export interface ToolExecuteResult {
|
|
101
|
+
content: string;
|
|
102
|
+
is_error?: boolean;
|
|
103
|
+
details?: unknown;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export interface ToolUpdateParams {
|
|
107
|
+
call_id: string;
|
|
108
|
+
message?: string;
|
|
109
|
+
progress?: number;
|
|
110
|
+
details?: unknown;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export interface EventParams {
|
|
114
|
+
event: string;
|
|
115
|
+
context: Context;
|
|
116
|
+
payload?: Record<string, unknown>;
|
|
117
|
+
}
|
package/src/schema.ts
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Turn a tool's declared `parameters` into the JSON Schema that goes on the wire.
|
|
3
|
+
*
|
|
4
|
+
* Three accepted shapes (the wire truth is always JSON Schema):
|
|
5
|
+
* - a **zod v4** schema → converted with zod's built-in `z.toJSONSchema()`.
|
|
6
|
+
* - a **TypeBox** schema → TypeBox schemas ARE JSON Schema, passed through.
|
|
7
|
+
* - a **raw JSON Schema** object → passed through unchanged.
|
|
8
|
+
*/
|
|
9
|
+
import { z } from 'zod';
|
|
10
|
+
|
|
11
|
+
/** Anything acceptable as a tool's `parameters`. */
|
|
12
|
+
export type ParameterSchema = z.ZodType | Record<string, unknown>;
|
|
13
|
+
|
|
14
|
+
/** A zod v4 schema carries the internal `_zod` marker; nothing else we accept does. */
|
|
15
|
+
function isZodSchema(value: unknown): value is z.ZodType {
|
|
16
|
+
return typeof value === 'object' && value !== null && '_zod' in value;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** Normalize `schema` to a JSON Schema object (draft 2020-12 for zod). */
|
|
20
|
+
export function toJsonSchema(schema: ParameterSchema): Record<string, unknown> {
|
|
21
|
+
if (isZodSchema(schema)) {
|
|
22
|
+
// `io: 'input'` gives the schema the LLM should fill (pre-transform).
|
|
23
|
+
return z.toJSONSchema(schema, { io: 'input' }) as Record<string, unknown>;
|
|
24
|
+
}
|
|
25
|
+
// TypeBox schemas and raw JSON Schema are already JSON Schema. Round-trip
|
|
26
|
+
// through JSON to drop any symbol keys (TypeBox's `[Kind]`) that would never
|
|
27
|
+
// survive the wire anyway.
|
|
28
|
+
return JSON.parse(JSON.stringify(schema)) as Record<string, unknown>;
|
|
29
|
+
}
|
package/src/test-host.ts
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `createTestHost` — an in-process scripted SEP host for unit-testing an
|
|
3
|
+
* extension without spawning a subprocess. It plays the host side of the
|
|
4
|
+
* protocol over a `linkedPair`, so tests drive `initialize`, `tool/execute`
|
|
5
|
+
* (with progress + cancellation), events, ping and shutdown directly against a
|
|
6
|
+
* `defineExtension(...)` object.
|
|
7
|
+
*/
|
|
8
|
+
import { Peer } from './jsonrpc.js';
|
|
9
|
+
import { PROTOCOL_VERSION, method } from './protocol.js';
|
|
10
|
+
import type { Context, InitializeParams, InitializeResult, ToolExecuteResult, ToolUpdateParams } from './protocol.js';
|
|
11
|
+
import type { Extension } from './extension.js';
|
|
12
|
+
import { linkedPair } from './transport.js';
|
|
13
|
+
|
|
14
|
+
let callSeq = 0;
|
|
15
|
+
|
|
16
|
+
export interface CallToolOptions {
|
|
17
|
+
/** Receives each `tool/update` the extension streams for this call. */
|
|
18
|
+
onUpdate?: (update: ToolUpdateParams) => void;
|
|
19
|
+
/** Abort the call — the host sends `$/cancel` and the promise rejects. */
|
|
20
|
+
signal?: AbortSignal;
|
|
21
|
+
/** Override the dispatch context (defaults to a command-tier test epoch). */
|
|
22
|
+
context?: Context;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface TestHost {
|
|
26
|
+
initialize(overrides?: Partial<InitializeParams>): Promise<InitializeResult>;
|
|
27
|
+
callTool(tool: string, args: Record<string, unknown>, opts?: CallToolOptions): Promise<ToolExecuteResult>;
|
|
28
|
+
ping(): Promise<Record<string, unknown>>;
|
|
29
|
+
sendEvent(event: string, payload?: Record<string, unknown>, context?: Context): void;
|
|
30
|
+
shutdown(): Promise<void>;
|
|
31
|
+
close(): void;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const DEFAULT_CONTEXT: Context = { token: 'test-epoch', tier: 'command' };
|
|
35
|
+
|
|
36
|
+
export function createTestHost(extension: Extension): TestHost {
|
|
37
|
+
const [hostT, extT] = linkedPair();
|
|
38
|
+
const extHandle = extension.connect(extT);
|
|
39
|
+
/** call_id → the caller's onUpdate, so streamed progress reaches the test. */
|
|
40
|
+
const updateSinks = new Map<string, (u: ToolUpdateParams) => void>();
|
|
41
|
+
|
|
42
|
+
const host = new Peer({ send: (frame) => hostT.send(frame) });
|
|
43
|
+
host.setNotificationHandler(method.TOOL_UPDATE, (params) => {
|
|
44
|
+
const p = params as ToolUpdateParams;
|
|
45
|
+
updateSinks.get(p.call_id)?.(p);
|
|
46
|
+
});
|
|
47
|
+
// Extension notifications the host just observes in tests.
|
|
48
|
+
host.setNotificationHandler(method.LOG, () => {});
|
|
49
|
+
host.setNotificationHandler(method.REGISTRY_UPDATE, () => {});
|
|
50
|
+
hostT.start((frame) => host.receive(frame));
|
|
51
|
+
|
|
52
|
+
return {
|
|
53
|
+
initialize(overrides) {
|
|
54
|
+
const params: InitializeParams = {
|
|
55
|
+
protocol_version: PROTOCOL_VERSION,
|
|
56
|
+
host: { name: 'smooth-test-host', version: '0.0.0' },
|
|
57
|
+
workspace: { root: process.cwd(), trusted: true },
|
|
58
|
+
mode: 'headless',
|
|
59
|
+
capabilities_enabled: { tools: true },
|
|
60
|
+
...overrides,
|
|
61
|
+
};
|
|
62
|
+
return host.request<InitializeResult>(method.INITIALIZE, params);
|
|
63
|
+
},
|
|
64
|
+
async callTool(tool, args, opts = {}) {
|
|
65
|
+
const call_id = `test-call-${++callSeq}`;
|
|
66
|
+
if (opts.onUpdate) updateSinks.set(call_id, opts.onUpdate);
|
|
67
|
+
try {
|
|
68
|
+
return await host.request<ToolExecuteResult>(
|
|
69
|
+
method.TOOL_EXECUTE,
|
|
70
|
+
{ call_id, tool, arguments: args, context: opts.context ?? DEFAULT_CONTEXT },
|
|
71
|
+
opts.signal,
|
|
72
|
+
);
|
|
73
|
+
} finally {
|
|
74
|
+
updateSinks.delete(call_id);
|
|
75
|
+
}
|
|
76
|
+
},
|
|
77
|
+
ping() {
|
|
78
|
+
return host.request<Record<string, unknown>>(method.PING, {});
|
|
79
|
+
},
|
|
80
|
+
sendEvent(event, payload, context) {
|
|
81
|
+
host.notify(method.EVENT, { event, context: context ?? { token: DEFAULT_CONTEXT.token, tier: 'event' }, ...(payload ? { payload } : {}) });
|
|
82
|
+
},
|
|
83
|
+
async shutdown() {
|
|
84
|
+
await host.request(method.SHUTDOWN, {});
|
|
85
|
+
},
|
|
86
|
+
close() {
|
|
87
|
+
extHandle.close();
|
|
88
|
+
host.close();
|
|
89
|
+
hostT.close();
|
|
90
|
+
},
|
|
91
|
+
};
|
|
92
|
+
}
|
package/src/transport.ts
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Transports carry JSON-RPC frames between two peers.
|
|
3
|
+
*
|
|
4
|
+
* - `stdioTransport` — the real wire: ndjson over a process's stdin/stdout,
|
|
5
|
+
* byte-for-byte the framing MCP stdio uses.
|
|
6
|
+
* - `linkedPair` — two in-memory transports wired to each other, for driving an
|
|
7
|
+
* extension from an in-process test host with no subprocess.
|
|
8
|
+
*/
|
|
9
|
+
import { createInterface } from 'node:readline';
|
|
10
|
+
import type { Readable, Writable } from 'node:stream';
|
|
11
|
+
import type { JsonRpcFrame } from './jsonrpc.js';
|
|
12
|
+
|
|
13
|
+
export interface Transport {
|
|
14
|
+
send(frame: JsonRpcFrame): void;
|
|
15
|
+
/** Begin delivering inbound frames. Call once. */
|
|
16
|
+
start(onFrame: (frame: JsonRpcFrame) => void): void;
|
|
17
|
+
close(): void;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** ndjson over a readable/writable pair (defaults to this process's stdio). */
|
|
21
|
+
export function stdioTransport(input: Readable = process.stdin, output: Writable = process.stdout): Transport {
|
|
22
|
+
let rl: ReturnType<typeof createInterface> | undefined;
|
|
23
|
+
return {
|
|
24
|
+
send(frame) {
|
|
25
|
+
output.write(`${JSON.stringify(frame)}\n`);
|
|
26
|
+
},
|
|
27
|
+
start(onFrame) {
|
|
28
|
+
rl = createInterface({ input, terminal: false });
|
|
29
|
+
rl.on('line', (line) => {
|
|
30
|
+
if (!line.trim()) return;
|
|
31
|
+
let frame: JsonRpcFrame;
|
|
32
|
+
try {
|
|
33
|
+
frame = JSON.parse(line) as JsonRpcFrame;
|
|
34
|
+
} catch {
|
|
35
|
+
// A malformed line is not a valid frame; stderr, not the wire.
|
|
36
|
+
process.stderr.write(`sep: dropping unparseable line: ${line}\n`);
|
|
37
|
+
return;
|
|
38
|
+
}
|
|
39
|
+
onFrame(frame);
|
|
40
|
+
});
|
|
41
|
+
},
|
|
42
|
+
close() {
|
|
43
|
+
rl?.close();
|
|
44
|
+
},
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Two transports wired to each other. A frame `send`-ed on one is delivered to
|
|
50
|
+
* the other's `onFrame` on a microtask (mimicking the async wire and avoiding
|
|
51
|
+
* reentrancy). Frames sent before the peer calls `start` are buffered.
|
|
52
|
+
*/
|
|
53
|
+
export function linkedPair(): [Transport, Transport] {
|
|
54
|
+
const a = new InMemoryTransport();
|
|
55
|
+
const b = new InMemoryTransport();
|
|
56
|
+
a.peer = b;
|
|
57
|
+
b.peer = a;
|
|
58
|
+
return [a, b];
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
class InMemoryTransport implements Transport {
|
|
62
|
+
peer!: InMemoryTransport;
|
|
63
|
+
private onFrame?: (frame: JsonRpcFrame) => void;
|
|
64
|
+
private buffer: JsonRpcFrame[] = [];
|
|
65
|
+
private closed = false;
|
|
66
|
+
|
|
67
|
+
send(frame: JsonRpcFrame): void {
|
|
68
|
+
if (this.peer.closed) return;
|
|
69
|
+
this.peer.deliver(frame);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
start(onFrame: (frame: JsonRpcFrame) => void): void {
|
|
73
|
+
this.onFrame = onFrame;
|
|
74
|
+
const pending = this.buffer;
|
|
75
|
+
this.buffer = [];
|
|
76
|
+
for (const f of pending) queueMicrotask(() => this.onFrame?.(f));
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
close(): void {
|
|
80
|
+
this.closed = true;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
private deliver(frame: JsonRpcFrame): void {
|
|
84
|
+
if (this.closed) return;
|
|
85
|
+
if (this.onFrame) queueMicrotask(() => this.onFrame?.(frame));
|
|
86
|
+
else this.buffer.push(frame);
|
|
87
|
+
}
|
|
88
|
+
}
|