@smooai/smooth-extension-sdk 0.3.0 → 0.4.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@smooai/smooth-extension-sdk",
3
- "version": "0.3.0",
3
+ "version": "0.4.0",
4
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
5
  "license": "MIT",
6
6
  "type": "module",
package/src/extension.ts CHANGED
@@ -12,7 +12,20 @@
12
12
  */
13
13
  import { Peer } from './jsonrpc.js';
14
14
  import { PROTOCOL_VERSION, method } from './protocol.js';
15
- import type { Context, EventParams, HookOutcome, HookParams, InitializeParams, InitializeResult, ToolExecuteParams, ToolExecuteResult, ToolUpdateParams } from './protocol.js';
15
+ import type {
16
+ Context,
17
+ EventParams,
18
+ HookOutcome,
19
+ HookParams,
20
+ InitializeParams,
21
+ InitializeResult,
22
+ ToolExecuteParams,
23
+ ToolExecuteResult,
24
+ ToolUpdateParams,
25
+ UiKind,
26
+ UiRequestParams,
27
+ UiRequestResult,
28
+ } from './protocol.js';
16
29
  import { toJsonSchema, type ParameterSchema } from './schema.js';
17
30
  import { stdioTransport, type Transport } from './transport.js';
18
31
 
@@ -20,6 +33,46 @@ import { stdioTransport, type Transport } from './transport.js';
20
33
  * is a fire-and-forget observe event. Kept in sync with the engine's HookType. */
21
34
  const HOOK_NAMES = new Set(['tool_call', 'tool_result', 'before_agent_start', 'message_end', 'context', 'before_provider_request', 'input', 'user_bash']);
22
35
 
36
+ /**
37
+ * The `ui/request` surface handed to tools (and to event handlers via
38
+ * `smooth.ui`). Each call is an ext→host request; the frontend renders it and
39
+ * replies. `select`/`confirm`/`input` return an answer (or `{ cancelled: true }`
40
+ * if dismissed); `notify`/`setStatus`/`setWidget`/`setTitle` resolve empty. A
41
+ * headless or uncapable host rejects with an `RpcError` of code -32001 (NoUI) —
42
+ * gate with `hasUI(kind)` to avoid it.
43
+ */
44
+ export interface UiApi {
45
+ select(prompt: string, options: string[]): Promise<UiRequestResult>;
46
+ confirm(prompt: string): Promise<UiRequestResult>;
47
+ input(prompt: string, opts?: { default?: string }): Promise<UiRequestResult>;
48
+ notify(message: string, level?: 'info' | 'warn' | 'error'): Promise<void>;
49
+ setStatus(status: string): Promise<void>;
50
+ setWidget(widget: Record<string, unknown>): Promise<void>;
51
+ setTitle(title: string): Promise<void>;
52
+ }
53
+
54
+ /** Build a [`UiApi`] that speaks `ui/request` over `peer`. */
55
+ function makeUi(peer: Peer): UiApi {
56
+ const req = (params: UiRequestParams) => peer.request<UiRequestResult>(method.UI_REQUEST, params);
57
+ return {
58
+ select: (prompt, options) => req({ kind: 'select', prompt, options }),
59
+ confirm: (prompt) => req({ kind: 'confirm', prompt }),
60
+ input: (prompt, opts) => req({ kind: 'input', prompt, ...(opts?.default !== undefined ? { default: opts.default } : {}) }),
61
+ notify: async (message, level) => {
62
+ await req({ kind: 'notify', message, ...(level ? { level } : {}) });
63
+ },
64
+ setStatus: async (status) => {
65
+ await req({ kind: 'set_status', status });
66
+ },
67
+ setWidget: async (widget) => {
68
+ await req({ kind: 'set_widget', widget });
69
+ },
70
+ setTitle: async (title) => {
71
+ await req({ kind: 'set_title', title });
72
+ },
73
+ };
74
+ }
75
+
23
76
  /** Progress + cancellation handed to a tool while it runs. */
24
77
  export interface ToolContext {
25
78
  /** Correlates `onUpdate` calls with this execution. */
@@ -30,6 +83,10 @@ export interface ToolContext {
30
83
  signal: AbortSignal;
31
84
  /** Stream a progress notification back to the host. */
32
85
  onUpdate(update: Omit<ToolUpdateParams, 'call_id'>): void;
86
+ /** Ask the frontend to render a dialog/widget. See [`UiApi`]. */
87
+ ui: UiApi;
88
+ /** True if the host's frontend can render this `ui/request` kind. */
89
+ hasUI(kind: UiKind): boolean;
33
90
  }
34
91
 
35
92
  /** What a tool's `execute` may return: a full result or just its `content`. */
@@ -64,6 +121,12 @@ export interface SmoothApi {
64
121
  registerTool(tool: ToolDef<any>): void;
65
122
  on(event: string, handler: EventHandler): void;
66
123
  log(level: 'debug' | 'info' | 'warn' | 'error', message: string, fields?: Record<string, unknown>): void;
124
+ /** True if the host's frontend can render this `ui/request` kind. Only
125
+ * meaningful after `initialize` — returns false before the handshake. */
126
+ hasUI(kind: UiKind): boolean;
127
+ /** The `ui/request` surface. Available after the extension connects; throws
128
+ * if read before then. Gate with [`hasUI`]. */
129
+ readonly ui: UiApi;
67
130
  }
68
131
 
69
132
  export type ExtensionSetup = (smooth: SmoothApi) => void;
@@ -80,6 +143,8 @@ export class Extension {
80
143
  private version = '0.0.0';
81
144
  /** Set once connected so `log()` before connect is a safe no-op. */
82
145
  private live?: Peer;
146
+ /** UI kinds the host declared answerable at `initialize`. */
147
+ private hostUiCaps: string[] = [];
83
148
 
84
149
  constructor(setup: ExtensionSetup) {
85
150
  const api: SmoothApi = {
@@ -106,6 +171,11 @@ export class Extension {
106
171
  log: (level, message, fields) => {
107
172
  this.live?.notify(method.LOG, { level, message, ...(fields ? { fields } : {}) });
108
173
  },
174
+ hasUI: (kind) => self.hostUiCaps.includes(kind),
175
+ get ui() {
176
+ if (!self.live) throw new Error('smooth.ui is only available after the extension connects');
177
+ return makeUi(self.live);
178
+ },
109
179
  };
110
180
  // `self` alias so the getter/setter pair above closes over the instance.
111
181
  const self = this;
@@ -145,7 +215,8 @@ export class Extension {
145
215
  });
146
216
  }
147
217
 
148
- private initialize(_params: InitializeParams): InitializeResult {
218
+ private initialize(params: InitializeParams): InitializeResult {
219
+ this.hostUiCaps = params.ui_capabilities ?? [];
149
220
  const tools = [...this.tools.values()].map((t) => ({
150
221
  name: t.name,
151
222
  description: t.description,
@@ -170,6 +241,8 @@ export class Extension {
170
241
  context: params.context,
171
242
  signal,
172
243
  onUpdate: (update) => peer.notify(method.TOOL_UPDATE, { call_id: params.call_id, ...update }),
244
+ ui: makeUi(peer),
245
+ hasUI: (kind) => this.hostUiCaps.includes(kind),
173
246
  };
174
247
  const out = await tool.execute(params.arguments, ctx);
175
248
  return typeof out === 'string' ? { content: out } : out;
package/src/index.ts CHANGED
@@ -9,9 +9,9 @@
9
9
  * `runConformance`.
10
10
  */
11
11
  export { defineExtension, defineTool, Extension } from './extension.js';
12
- export type { ExtensionSetup, SmoothApi, ToolDef, ToolContext, ToolReturn, EventHandler, HookResult, ConnectHandle } from './extension.js';
12
+ export type { ExtensionSetup, SmoothApi, ToolDef, ToolContext, ToolReturn, EventHandler, HookResult, ConnectHandle, UiApi } from './extension.js';
13
13
  export { createTestHost } from './test-host.js';
14
- export type { TestHost, CallToolOptions } from './test-host.js';
14
+ export type { TestHost, CallToolOptions, CreateTestHostOptions, UiResponder } from './test-host.js';
15
15
  export { runConformance, DEFAULT_SPEC_DIR } from './conformance.js';
16
16
  export type { ConformanceReport, ConformanceStep, RunConformanceOptions } from './conformance.js';
17
17
  export { toJsonSchema } from './schema.js';
@@ -33,4 +33,7 @@ export type {
33
33
  EventParams,
34
34
  HookParams,
35
35
  HookOutcome,
36
+ UiKind,
37
+ UiRequestParams,
38
+ UiRequestResult,
36
39
  } from './protocol.js';
package/src/protocol.ts CHANGED
@@ -18,6 +18,7 @@ export const method = {
18
18
  HOOK: 'hook',
19
19
  TOOL_EXECUTE: 'tool/execute',
20
20
  TOOL_UPDATE: 'tool/update',
21
+ UI_REQUEST: 'ui/request',
21
22
  REGISTRY_UPDATE: 'registry/update',
22
23
  LOG: 'log',
23
24
  CANCEL: '$/cancel',
@@ -130,3 +131,28 @@ export type HookOutcome =
130
131
  | { action: 'continue' }
131
132
  | { action: 'block'; reason?: string }
132
133
  | { action: 'modify'; patch: Record<string, unknown> };
134
+
135
+ /** The seven `ui/request` kinds (snake_case wire names). */
136
+ export type UiKind = 'select' | 'confirm' | 'input' | 'notify' | 'set_status' | 'set_widget' | 'set_title';
137
+
138
+ /** Params of `ui/request` (ext → host), discriminated by `kind`. */
139
+ export type UiRequestParams =
140
+ | { kind: 'select'; prompt: string; options: string[] }
141
+ | { kind: 'confirm'; prompt: string }
142
+ | { kind: 'input'; prompt: string; default?: string }
143
+ | { kind: 'notify'; message: string; level?: 'info' | 'warn' | 'error' }
144
+ | { kind: 'set_status'; status: string }
145
+ | { kind: 'set_widget'; widget: Record<string, unknown> }
146
+ | { kind: 'set_title'; title: string };
147
+
148
+ /**
149
+ * Reply to a `ui/request`. Which field is set depends on the request `kind`:
150
+ * `select` → `value`, `confirm` → `confirmed`, `input` → `text`; the rest are
151
+ * empty. Any kind may set `cancelled` if the user dismissed the UI.
152
+ */
153
+ export interface UiRequestResult {
154
+ value?: string;
155
+ confirmed?: boolean;
156
+ text?: string;
157
+ cancelled?: boolean;
158
+ }
package/src/test-host.ts CHANGED
@@ -5,14 +5,23 @@
5
5
  * (with progress + cancellation), events, ping and shutdown directly against a
6
6
  * `defineExtension(...)` object.
7
7
  */
8
- import { Peer } from './jsonrpc.js';
9
- import { PROTOCOL_VERSION, method } from './protocol.js';
10
- import type { Context, HookOutcome, InitializeParams, InitializeResult, ToolExecuteResult, ToolUpdateParams } from './protocol.js';
8
+ import { Peer, RpcError } from './jsonrpc.js';
9
+ import { PROTOCOL_VERSION, errorCode, method } from './protocol.js';
10
+ import type { Context, HookOutcome, InitializeParams, InitializeResult, ToolExecuteResult, ToolUpdateParams, UiRequestParams, UiRequestResult } from './protocol.js';
11
11
  import type { Extension } from './extension.js';
12
12
  import { linkedPair } from './transport.js';
13
13
 
14
14
  let callSeq = 0;
15
15
 
16
+ /** Answers the extension's `ui/request` calls. Return a result, or throw an
17
+ * `RpcError` (e.g. code -32001) to simulate a headless/uncapable frontend. */
18
+ export type UiResponder = (params: UiRequestParams) => UiRequestResult | Promise<UiRequestResult>;
19
+
20
+ export interface CreateTestHostOptions {
21
+ /** Answers `ui/request`. Default: reject every call with -32001 NoUI. */
22
+ onUiRequest?: UiResponder;
23
+ }
24
+
16
25
  export interface CallToolOptions {
17
26
  /** Receives each `tool/update` the extension streams for this call. */
18
27
  onUpdate?: (update: ToolUpdateParams) => void;
@@ -35,7 +44,7 @@ export interface TestHost {
35
44
 
36
45
  const DEFAULT_CONTEXT: Context = { token: 'test-epoch', tier: 'command' };
37
46
 
38
- export function createTestHost(extension: Extension): TestHost {
47
+ export function createTestHost(extension: Extension, options: CreateTestHostOptions = {}): TestHost {
39
48
  const [hostT, extT] = linkedPair();
40
49
  const extHandle = extension.connect(extT);
41
50
  /** call_id → the caller's onUpdate, so streamed progress reaches the test. */
@@ -46,6 +55,11 @@ export function createTestHost(extension: Extension): TestHost {
46
55
  const p = params as ToolUpdateParams;
47
56
  updateSinks.get(p.call_id)?.(p);
48
57
  });
58
+ // Answer ext→host `ui/request`. Default mimics a headless frontend (NoUI).
59
+ host.setRequestHandler(method.UI_REQUEST, async (params) => {
60
+ if (!options.onUiRequest) throw new RpcError(errorCode.NoUI, 'no UI available (headless test host)');
61
+ return options.onUiRequest(params as UiRequestParams);
62
+ });
49
63
  // Extension notifications the host just observes in tests.
50
64
  host.setNotificationHandler(method.LOG, () => {});
51
65
  host.setNotificationHandler(method.REGISTRY_UPDATE, () => {});