@smooai/smooth-extension-sdk 0.3.0 → 0.5.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 +1 -1
- package/src/conformance.ts +2 -0
- package/src/extension.ts +212 -3
- package/src/index.ts +32 -3
- package/src/protocol.ts +90 -0
- package/src/test-host.ts +69 -4
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@smooai/smooth-extension-sdk",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.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/conformance.ts
CHANGED
|
@@ -115,6 +115,8 @@ export async function runConformance(opts: RunConformanceOptions): Promise<Confo
|
|
|
115
115
|
await check('initialize', method.INITIALIZE, initParams(fixtures), 'methods/initialize.schema.json#/$defs/Result');
|
|
116
116
|
await check('ping', method.PING, {}, 'methods/ping.schema.json#/$defs/Result');
|
|
117
117
|
await check('tool/execute', method.TOOL_EXECUTE, fixtures.tool_execute_params!.instance, 'methods/tool-execute.schema.json#/$defs/Result');
|
|
118
|
+
await check('command/execute', method.COMMAND_EXECUTE, fixtures.command_execute_params!.instance, 'methods/command-execute.schema.json#/$defs/Result');
|
|
119
|
+
await check('command/complete', method.COMMAND_COMPLETE, fixtures.command_complete_params!.instance, 'methods/command-complete.schema.json#/$defs/Result');
|
|
118
120
|
await check('shutdown', method.SHUTDOWN, {}, 'methods/shutdown.schema.json#/$defs/Result');
|
|
119
121
|
} finally {
|
|
120
122
|
peer.close();
|
package/src/extension.ts
CHANGED
|
@@ -12,7 +12,28 @@
|
|
|
12
12
|
*/
|
|
13
13
|
import { Peer } from './jsonrpc.js';
|
|
14
14
|
import { PROTOCOL_VERSION, method } from './protocol.js';
|
|
15
|
-
import type {
|
|
15
|
+
import type {
|
|
16
|
+
CommandCompleteParams,
|
|
17
|
+
CommandCompleteResult,
|
|
18
|
+
CommandExecuteParams,
|
|
19
|
+
CommandExecuteResult,
|
|
20
|
+
CommandRegistration,
|
|
21
|
+
Completion,
|
|
22
|
+
Context,
|
|
23
|
+
DeliverAs,
|
|
24
|
+
EventParams,
|
|
25
|
+
HookOutcome,
|
|
26
|
+
HookParams,
|
|
27
|
+
InitializeParams,
|
|
28
|
+
InitializeResult,
|
|
29
|
+
ShortcutRegistration,
|
|
30
|
+
ToolExecuteParams,
|
|
31
|
+
ToolExecuteResult,
|
|
32
|
+
ToolUpdateParams,
|
|
33
|
+
UiKind,
|
|
34
|
+
UiRequestParams,
|
|
35
|
+
UiRequestResult,
|
|
36
|
+
} from './protocol.js';
|
|
16
37
|
import { toJsonSchema, type ParameterSchema } from './schema.js';
|
|
17
38
|
import { stdioTransport, type Transport } from './transport.js';
|
|
18
39
|
|
|
@@ -20,6 +41,74 @@ import { stdioTransport, type Transport } from './transport.js';
|
|
|
20
41
|
* is a fire-and-forget observe event. Kept in sync with the engine's HookType. */
|
|
21
42
|
const HOOK_NAMES = new Set(['tool_call', 'tool_result', 'before_agent_start', 'message_end', 'context', 'before_provider_request', 'input', 'user_bash']);
|
|
22
43
|
|
|
44
|
+
/**
|
|
45
|
+
* The `ui/request` surface handed to tools (and to event handlers via
|
|
46
|
+
* `smooth.ui`). Each call is an ext→host request; the frontend renders it and
|
|
47
|
+
* replies. `select`/`confirm`/`input` return an answer (or `{ cancelled: true }`
|
|
48
|
+
* if dismissed); `notify`/`setStatus`/`setWidget`/`setTitle` resolve empty. A
|
|
49
|
+
* headless or uncapable host rejects with an `RpcError` of code -32001 (NoUI) —
|
|
50
|
+
* gate with `hasUI(kind)` to avoid it.
|
|
51
|
+
*/
|
|
52
|
+
export interface UiApi {
|
|
53
|
+
select(prompt: string, options: string[]): Promise<UiRequestResult>;
|
|
54
|
+
confirm(prompt: string): Promise<UiRequestResult>;
|
|
55
|
+
input(prompt: string, opts?: { default?: string }): Promise<UiRequestResult>;
|
|
56
|
+
notify(message: string, level?: 'info' | 'warn' | 'error'): Promise<void>;
|
|
57
|
+
setStatus(status: string): Promise<void>;
|
|
58
|
+
setWidget(widget: Record<string, unknown>): Promise<void>;
|
|
59
|
+
setTitle(title: string): Promise<void>;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Build a [`UiApi`] that speaks `ui/request` over `peer`. */
|
|
63
|
+
function makeUi(peer: Peer): UiApi {
|
|
64
|
+
const req = (params: UiRequestParams) => peer.request<UiRequestResult>(method.UI_REQUEST, params);
|
|
65
|
+
return {
|
|
66
|
+
select: (prompt, options) => req({ kind: 'select', prompt, options }),
|
|
67
|
+
confirm: (prompt) => req({ kind: 'confirm', prompt }),
|
|
68
|
+
input: (prompt, opts) => req({ kind: 'input', prompt, ...(opts?.default !== undefined ? { default: opts.default } : {}) }),
|
|
69
|
+
notify: async (message, level) => {
|
|
70
|
+
await req({ kind: 'notify', message, ...(level ? { level } : {}) });
|
|
71
|
+
},
|
|
72
|
+
setStatus: async (status) => {
|
|
73
|
+
await req({ kind: 'set_status', status });
|
|
74
|
+
},
|
|
75
|
+
setWidget: async (widget) => {
|
|
76
|
+
await req({ kind: 'set_widget', widget });
|
|
77
|
+
},
|
|
78
|
+
setTitle: async (title) => {
|
|
79
|
+
await req({ kind: 'set_title', title });
|
|
80
|
+
},
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Session-mutating ext→host actions. Available only from a COMMAND-tier context
|
|
86
|
+
* (command handlers) — the host rejects them from an event-tier context with
|
|
87
|
+
* -32003 ContextViolation. `sendMessage` posts a message, `sendUserMessage`
|
|
88
|
+
* delivers a user message (steer/follow_up/next_turn), `appendEntry` persists an
|
|
89
|
+
* LLM-invisible transcript entry.
|
|
90
|
+
*/
|
|
91
|
+
export interface SessionApi {
|
|
92
|
+
sendMessage(text: string, opts?: { role?: 'user' | 'assistant' }): Promise<void>;
|
|
93
|
+
sendUserMessage(text: string, opts?: { deliverAs?: DeliverAs }): Promise<void>;
|
|
94
|
+
appendEntry(entry: Record<string, unknown>): Promise<void>;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** Build a [`SessionApi`] bound to `context` (must be command-tier) over `peer`. */
|
|
98
|
+
function makeSession(peer: Peer, context: Context): SessionApi {
|
|
99
|
+
return {
|
|
100
|
+
sendMessage: async (text, opts) => {
|
|
101
|
+
await peer.request(method.SESSION_SEND_MESSAGE, { context, text, ...(opts?.role ? { role: opts.role } : {}) });
|
|
102
|
+
},
|
|
103
|
+
sendUserMessage: async (text, opts) => {
|
|
104
|
+
await peer.request(method.SESSION_SEND_USER_MESSAGE, { context, text, ...(opts?.deliverAs ? { deliver_as: opts.deliverAs } : {}) });
|
|
105
|
+
},
|
|
106
|
+
appendEntry: async (entry) => {
|
|
107
|
+
await peer.request(method.SESSION_APPEND_ENTRY, { context, entry });
|
|
108
|
+
},
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
|
|
23
112
|
/** Progress + cancellation handed to a tool while it runs. */
|
|
24
113
|
export interface ToolContext {
|
|
25
114
|
/** Correlates `onUpdate` calls with this execution. */
|
|
@@ -30,6 +119,10 @@ export interface ToolContext {
|
|
|
30
119
|
signal: AbortSignal;
|
|
31
120
|
/** Stream a progress notification back to the host. */
|
|
32
121
|
onUpdate(update: Omit<ToolUpdateParams, 'call_id'>): void;
|
|
122
|
+
/** Ask the frontend to render a dialog/widget. See [`UiApi`]. */
|
|
123
|
+
ui: UiApi;
|
|
124
|
+
/** True if the host's frontend can render this `ui/request` kind. */
|
|
125
|
+
hasUI(kind: UiKind): boolean;
|
|
33
126
|
}
|
|
34
127
|
|
|
35
128
|
/** What a tool's `execute` may return: a full result or just its `content`. */
|
|
@@ -48,6 +141,48 @@ export function defineTool<TArgs = Record<string, unknown>>(def: ToolDef<TArgs>)
|
|
|
48
141
|
return def;
|
|
49
142
|
}
|
|
50
143
|
|
|
144
|
+
/** What a command handler receives: the command-tier context plus the session,
|
|
145
|
+
* ui, and args bound to it. Session actions are valid because a command runs at
|
|
146
|
+
* command tier. */
|
|
147
|
+
export interface CommandContext {
|
|
148
|
+
/** The dispatch context (command tier). */
|
|
149
|
+
context: Context;
|
|
150
|
+
/** Free-form arguments parsed from the invocation. */
|
|
151
|
+
args: Record<string, unknown> | undefined;
|
|
152
|
+
/** Session-mutating actions, bound to this command's context. */
|
|
153
|
+
session: SessionApi;
|
|
154
|
+
/** Ask the frontend to render a dialog/widget. See [`UiApi`]. */
|
|
155
|
+
ui: UiApi;
|
|
156
|
+
/** True if the host's frontend can render this `ui/request` kind. */
|
|
157
|
+
hasUI(kind: UiKind): boolean;
|
|
158
|
+
/** Structured log line into host tracing. */
|
|
159
|
+
log(level: 'debug' | 'info' | 'warn' | 'error', message: string, fields?: Record<string, unknown>): void;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/** What a command's `execute` may return: text to surface, a full result, or
|
|
163
|
+
* nothing. */
|
|
164
|
+
export type CommandReturn = CommandExecuteResult | string | void;
|
|
165
|
+
|
|
166
|
+
export interface CommandDef {
|
|
167
|
+
name: string;
|
|
168
|
+
description: string;
|
|
169
|
+
execute(ctx: CommandContext): Promise<CommandReturn> | CommandReturn;
|
|
170
|
+
/** Optional argument autocomplete: given the partial text, return candidates. */
|
|
171
|
+
complete?(partial: string, context: Context): Promise<Completion[]> | Completion[];
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/** Identity helper for `registerCommand` call sites. */
|
|
175
|
+
export function defineCommand(def: CommandDef): CommandDef {
|
|
176
|
+
return def;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/** A CLI/slash flag the extension declares. The host delivers its parsed value
|
|
180
|
+
* in `initialize`; read it with `smooth.getFlag(name)`. */
|
|
181
|
+
export interface FlagDef {
|
|
182
|
+
name: string;
|
|
183
|
+
description?: string;
|
|
184
|
+
}
|
|
185
|
+
|
|
51
186
|
/** A hook handler's friendly return: veto the operation, or replace its input
|
|
52
187
|
* with a patch (shallow-merged onto the input). Returning nothing = continue. */
|
|
53
188
|
export type HookResult = { block: true; reason?: string } | { patch: Record<string, unknown> };
|
|
@@ -62,8 +197,23 @@ export interface SmoothApi {
|
|
|
62
197
|
name: string;
|
|
63
198
|
version: string;
|
|
64
199
|
registerTool(tool: ToolDef<any>): void;
|
|
200
|
+
/** Register a slash-command surfaced in the host's `/` palette. */
|
|
201
|
+
registerCommand(command: CommandDef): void;
|
|
202
|
+
/** Declare a CLI/slash flag; read its delivered value with [`getFlag`]. */
|
|
203
|
+
registerFlag(flag: FlagDef): void;
|
|
204
|
+
/** Bind a keyboard shortcut (TUI frontends) to a registered command. */
|
|
205
|
+
registerShortcut(shortcut: ShortcutRegistration): void;
|
|
65
206
|
on(event: string, handler: EventHandler): void;
|
|
66
207
|
log(level: 'debug' | 'info' | 'warn' | 'error', message: string, fields?: Record<string, unknown>): void;
|
|
208
|
+
/** The parsed value the host delivered for a declared flag (undefined before
|
|
209
|
+
* `initialize`, or when the host has no CLI surface). */
|
|
210
|
+
getFlag(name: string): unknown;
|
|
211
|
+
/** True if the host's frontend can render this `ui/request` kind. Only
|
|
212
|
+
* meaningful after `initialize` — returns false before the handshake. */
|
|
213
|
+
hasUI(kind: UiKind): boolean;
|
|
214
|
+
/** The `ui/request` surface. Available after the extension connects; throws
|
|
215
|
+
* if read before then. Gate with [`hasUI`]. */
|
|
216
|
+
readonly ui: UiApi;
|
|
67
217
|
}
|
|
68
218
|
|
|
69
219
|
export type ExtensionSetup = (smooth: SmoothApi) => void;
|
|
@@ -75,11 +225,18 @@ export interface ConnectHandle {
|
|
|
75
225
|
|
|
76
226
|
export class Extension {
|
|
77
227
|
private readonly tools = new Map<string, ToolDef<any>>();
|
|
228
|
+
private readonly commands = new Map<string, CommandDef>();
|
|
229
|
+
private readonly flagDefs = new Map<string, FlagDef>();
|
|
230
|
+
private readonly shortcuts: ShortcutRegistration[] = [];
|
|
78
231
|
private readonly events = new Map<string, EventHandler[]>();
|
|
79
232
|
private name = 'extension';
|
|
80
233
|
private version = '0.0.0';
|
|
81
234
|
/** Set once connected so `log()` before connect is a safe no-op. */
|
|
82
235
|
private live?: Peer;
|
|
236
|
+
/** UI kinds the host declared answerable at `initialize`. */
|
|
237
|
+
private hostUiCaps: string[] = [];
|
|
238
|
+
/** Flag values the host delivered at `initialize` (name → value). */
|
|
239
|
+
private flagValues: Record<string, unknown> = {};
|
|
83
240
|
|
|
84
241
|
constructor(setup: ExtensionSetup) {
|
|
85
242
|
const api: SmoothApi = {
|
|
@@ -98,6 +255,15 @@ export class Extension {
|
|
|
98
255
|
registerTool: (tool) => {
|
|
99
256
|
this.tools.set(tool.name, tool);
|
|
100
257
|
},
|
|
258
|
+
registerCommand: (command) => {
|
|
259
|
+
this.commands.set(command.name, command);
|
|
260
|
+
},
|
|
261
|
+
registerFlag: (flag) => {
|
|
262
|
+
this.flagDefs.set(flag.name, flag);
|
|
263
|
+
},
|
|
264
|
+
registerShortcut: (shortcut) => {
|
|
265
|
+
this.shortcuts.push(shortcut);
|
|
266
|
+
},
|
|
101
267
|
on: (event, handler) => {
|
|
102
268
|
const list = this.events.get(event) ?? [];
|
|
103
269
|
list.push(handler);
|
|
@@ -106,6 +272,12 @@ export class Extension {
|
|
|
106
272
|
log: (level, message, fields) => {
|
|
107
273
|
this.live?.notify(method.LOG, { level, message, ...(fields ? { fields } : {}) });
|
|
108
274
|
},
|
|
275
|
+
getFlag: (name) => self.flagValues[name],
|
|
276
|
+
hasUI: (kind) => self.hostUiCaps.includes(kind),
|
|
277
|
+
get ui() {
|
|
278
|
+
if (!self.live) throw new Error('smooth.ui is only available after the extension connects');
|
|
279
|
+
return makeUi(self.live);
|
|
280
|
+
},
|
|
109
281
|
};
|
|
110
282
|
// `self` alias so the getter/setter pair above closes over the instance.
|
|
111
283
|
const self = this;
|
|
@@ -125,6 +297,8 @@ export class Extension {
|
|
|
125
297
|
});
|
|
126
298
|
peer.setRequestHandler(method.TOOL_EXECUTE, (params, signal) => this.executeTool(params as ToolExecuteParams, peer, signal));
|
|
127
299
|
peer.setRequestHandler(method.HOOK, (params) => this.dispatchHook(params as HookParams));
|
|
300
|
+
peer.setRequestHandler(method.COMMAND_EXECUTE, (params) => this.executeCommand(params as CommandExecuteParams, peer));
|
|
301
|
+
peer.setRequestHandler(method.COMMAND_COMPLETE, (params) => this.completeCommand(params as CommandCompleteParams));
|
|
128
302
|
peer.setNotificationHandler(method.EVENT, (params) => this.dispatchEvent(params as EventParams));
|
|
129
303
|
|
|
130
304
|
transport.start((frame) => peer.receive(frame));
|
|
@@ -145,23 +319,56 @@ export class Extension {
|
|
|
145
319
|
});
|
|
146
320
|
}
|
|
147
321
|
|
|
148
|
-
private initialize(
|
|
322
|
+
private initialize(params: InitializeParams): InitializeResult {
|
|
323
|
+
this.hostUiCaps = params.ui_capabilities ?? [];
|
|
324
|
+
this.flagValues = params.flags ?? {};
|
|
149
325
|
const tools = [...this.tools.values()].map((t) => ({
|
|
150
326
|
name: t.name,
|
|
151
327
|
description: t.description,
|
|
152
328
|
parameters: toJsonSchema(t.parameters),
|
|
153
329
|
...(t.deferred ? { deferred: true } : {}),
|
|
154
330
|
}));
|
|
331
|
+
const commands: CommandRegistration[] = [...this.commands.values()].map((c) => ({ name: c.name, description: c.description }));
|
|
332
|
+
const flags = [...this.flagDefs.keys()];
|
|
155
333
|
// Only observe events go in `subscriptions` — hook names are intercepts
|
|
156
334
|
// the host always calls, not events it filters by subscription.
|
|
157
335
|
const subscriptions = [...this.events.keys()].filter((name) => !HOOK_NAMES.has(name));
|
|
158
336
|
return {
|
|
159
337
|
protocol_version: PROTOCOL_VERSION,
|
|
160
338
|
extension: { name: this.name, version: this.version },
|
|
161
|
-
registrations: {
|
|
339
|
+
registrations: {
|
|
340
|
+
tools,
|
|
341
|
+
...(commands.length ? { commands } : {}),
|
|
342
|
+
...(flags.length ? { flags } : {}),
|
|
343
|
+
...(this.shortcuts.length ? { shortcuts: this.shortcuts } : {}),
|
|
344
|
+
subscriptions,
|
|
345
|
+
},
|
|
162
346
|
};
|
|
163
347
|
}
|
|
164
348
|
|
|
349
|
+
private async executeCommand(params: CommandExecuteParams, peer: Peer): Promise<CommandExecuteResult> {
|
|
350
|
+
const command = this.commands.get(params.command);
|
|
351
|
+
if (!command) return { content: `unknown command: ${params.command}` };
|
|
352
|
+
const ctx: CommandContext = {
|
|
353
|
+
context: params.context,
|
|
354
|
+
args: params.arguments,
|
|
355
|
+
session: makeSession(peer, params.context),
|
|
356
|
+
ui: makeUi(peer),
|
|
357
|
+
hasUI: (kind) => this.hostUiCaps.includes(kind),
|
|
358
|
+
log: (level, message, fields) => peer.notify(method.LOG, { level, message, ...(fields ? { fields } : {}) }),
|
|
359
|
+
};
|
|
360
|
+
const out = await command.execute(ctx);
|
|
361
|
+
if (out === undefined) return {};
|
|
362
|
+
return typeof out === 'string' ? { content: out } : out;
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
private async completeCommand(params: CommandCompleteParams): Promise<CommandCompleteResult> {
|
|
366
|
+
const command = this.commands.get(params.command);
|
|
367
|
+
if (!command?.complete) return { completions: [] };
|
|
368
|
+
const completions = await command.complete(params.partial ?? '', params.context);
|
|
369
|
+
return { completions };
|
|
370
|
+
}
|
|
371
|
+
|
|
165
372
|
private async executeTool(params: ToolExecuteParams, peer: Peer, signal: AbortSignal): Promise<ToolExecuteResult> {
|
|
166
373
|
const tool = this.tools.get(params.tool);
|
|
167
374
|
if (!tool) return { content: `unknown tool: ${params.tool}`, is_error: true };
|
|
@@ -170,6 +377,8 @@ export class Extension {
|
|
|
170
377
|
context: params.context,
|
|
171
378
|
signal,
|
|
172
379
|
onUpdate: (update) => peer.notify(method.TOOL_UPDATE, { call_id: params.call_id, ...update }),
|
|
380
|
+
ui: makeUi(peer),
|
|
381
|
+
hasUI: (kind) => this.hostUiCaps.includes(kind),
|
|
173
382
|
};
|
|
174
383
|
const out = await tool.execute(params.arguments, ctx);
|
|
175
384
|
return typeof out === 'string' ? { content: out } : out;
|
package/src/index.ts
CHANGED
|
@@ -8,10 +8,25 @@
|
|
|
8
8
|
* `createTestHost`, and gate it against the shared fixtures with
|
|
9
9
|
* `runConformance`.
|
|
10
10
|
*/
|
|
11
|
-
export { defineExtension, defineTool, Extension } from './extension.js';
|
|
12
|
-
export type {
|
|
11
|
+
export { defineExtension, defineTool, defineCommand, Extension } from './extension.js';
|
|
12
|
+
export type {
|
|
13
|
+
ExtensionSetup,
|
|
14
|
+
SmoothApi,
|
|
15
|
+
ToolDef,
|
|
16
|
+
ToolContext,
|
|
17
|
+
ToolReturn,
|
|
18
|
+
CommandDef,
|
|
19
|
+
CommandContext,
|
|
20
|
+
CommandReturn,
|
|
21
|
+
FlagDef,
|
|
22
|
+
SessionApi,
|
|
23
|
+
EventHandler,
|
|
24
|
+
HookResult,
|
|
25
|
+
ConnectHandle,
|
|
26
|
+
UiApi,
|
|
27
|
+
} from './extension.js';
|
|
13
28
|
export { createTestHost } from './test-host.js';
|
|
14
|
-
export type { TestHost, CallToolOptions } from './test-host.js';
|
|
29
|
+
export type { TestHost, CallToolOptions, CreateTestHostOptions, UiResponder } from './test-host.js';
|
|
15
30
|
export { runConformance, DEFAULT_SPEC_DIR } from './conformance.js';
|
|
16
31
|
export type { ConformanceReport, ConformanceStep, RunConformanceOptions } from './conformance.js';
|
|
17
32
|
export { toJsonSchema } from './schema.js';
|
|
@@ -33,4 +48,18 @@ export type {
|
|
|
33
48
|
EventParams,
|
|
34
49
|
HookParams,
|
|
35
50
|
HookOutcome,
|
|
51
|
+
UiKind,
|
|
52
|
+
UiRequestParams,
|
|
53
|
+
UiRequestResult,
|
|
54
|
+
ShortcutRegistration,
|
|
55
|
+
CommandRegistration,
|
|
56
|
+
CommandExecuteParams,
|
|
57
|
+
CommandExecuteResult,
|
|
58
|
+
CommandCompleteParams,
|
|
59
|
+
CommandCompleteResult,
|
|
60
|
+
Completion,
|
|
61
|
+
DeliverAs,
|
|
62
|
+
SessionSendMessageParams,
|
|
63
|
+
SessionSendUserMessageParams,
|
|
64
|
+
SessionAppendEntryParams,
|
|
36
65
|
} from './protocol.js';
|
package/src/protocol.ts
CHANGED
|
@@ -18,9 +18,15 @@ 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',
|
|
25
|
+
COMMAND_EXECUTE: 'command/execute',
|
|
26
|
+
COMMAND_COMPLETE: 'command/complete',
|
|
27
|
+
SESSION_SEND_MESSAGE: 'session/send_message',
|
|
28
|
+
SESSION_SEND_USER_MESSAGE: 'session/send_user_message',
|
|
29
|
+
SESSION_APPEND_ENTRY: 'session/append_entry',
|
|
24
30
|
} as const;
|
|
25
31
|
|
|
26
32
|
/** JSON-RPC + SEP error codes (see spec/extension/envelope.md). */
|
|
@@ -61,6 +67,8 @@ export interface InitializeParams {
|
|
|
61
67
|
session?: { id?: string };
|
|
62
68
|
mode: 'tui' | 'web' | 'widget' | 'cli' | 'headless';
|
|
63
69
|
ui_capabilities?: string[];
|
|
70
|
+
/** Parsed values for the flags the extension declares (name → value). */
|
|
71
|
+
flags?: Record<string, unknown>;
|
|
64
72
|
capabilities_enabled?: Record<string, boolean>;
|
|
65
73
|
}
|
|
66
74
|
|
|
@@ -77,10 +85,19 @@ export interface CommandRegistration {
|
|
|
77
85
|
description: string;
|
|
78
86
|
}
|
|
79
87
|
|
|
88
|
+
export interface ShortcutRegistration {
|
|
89
|
+
/** A human-typed chord, e.g. `ctrl+p`; the frontend parses it. */
|
|
90
|
+
key: string;
|
|
91
|
+
/** The registered command this chord invokes (no leading `/`). */
|
|
92
|
+
command: string;
|
|
93
|
+
description?: string;
|
|
94
|
+
}
|
|
95
|
+
|
|
80
96
|
export interface Registrations {
|
|
81
97
|
tools?: ToolRegistration[];
|
|
82
98
|
commands?: CommandRegistration[];
|
|
83
99
|
flags?: string[];
|
|
100
|
+
shortcuts?: ShortcutRegistration[];
|
|
84
101
|
subscriptions?: string[];
|
|
85
102
|
}
|
|
86
103
|
|
|
@@ -130,3 +147,76 @@ export type HookOutcome =
|
|
|
130
147
|
| { action: 'continue' }
|
|
131
148
|
| { action: 'block'; reason?: string }
|
|
132
149
|
| { action: 'modify'; patch: Record<string, unknown> };
|
|
150
|
+
|
|
151
|
+
/** The seven `ui/request` kinds (snake_case wire names). */
|
|
152
|
+
export type UiKind = 'select' | 'confirm' | 'input' | 'notify' | 'set_status' | 'set_widget' | 'set_title';
|
|
153
|
+
|
|
154
|
+
/** Params of `ui/request` (ext → host), discriminated by `kind`. */
|
|
155
|
+
export type UiRequestParams =
|
|
156
|
+
| { kind: 'select'; prompt: string; options: string[] }
|
|
157
|
+
| { kind: 'confirm'; prompt: string }
|
|
158
|
+
| { kind: 'input'; prompt: string; default?: string }
|
|
159
|
+
| { kind: 'notify'; message: string; level?: 'info' | 'warn' | 'error' }
|
|
160
|
+
| { kind: 'set_status'; status: string }
|
|
161
|
+
| { kind: 'set_widget'; widget: Record<string, unknown> }
|
|
162
|
+
| { kind: 'set_title'; title: string };
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* Reply to a `ui/request`. Which field is set depends on the request `kind`:
|
|
166
|
+
* `select` → `value`, `confirm` → `confirmed`, `input` → `text`; the rest are
|
|
167
|
+
* empty. Any kind may set `cancelled` if the user dismissed the UI.
|
|
168
|
+
*/
|
|
169
|
+
export interface UiRequestResult {
|
|
170
|
+
value?: string;
|
|
171
|
+
confirmed?: boolean;
|
|
172
|
+
text?: string;
|
|
173
|
+
cancelled?: boolean;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/** Host → ext `command/execute`: run a registered slash-command (command tier). */
|
|
177
|
+
export interface CommandExecuteParams {
|
|
178
|
+
command: string;
|
|
179
|
+
context: Context;
|
|
180
|
+
arguments?: Record<string, unknown>;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
export interface CommandExecuteResult {
|
|
184
|
+
content?: string;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/** Host → ext `command/complete`: argument autocomplete for a slash-command. */
|
|
188
|
+
export interface CommandCompleteParams {
|
|
189
|
+
command: string;
|
|
190
|
+
context: Context;
|
|
191
|
+
partial?: string;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
export interface Completion {
|
|
195
|
+
value: string;
|
|
196
|
+
description?: string;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
export interface CommandCompleteResult {
|
|
200
|
+
completions: Completion[];
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/** How a `session/send_user_message` is delivered relative to the current turn. */
|
|
204
|
+
export type DeliverAs = 'steer' | 'follow_up' | 'next_turn';
|
|
205
|
+
|
|
206
|
+
/** Ext → host `session/send_message` params (command tier — carries `context`). */
|
|
207
|
+
export interface SessionSendMessageParams {
|
|
208
|
+
context: Context;
|
|
209
|
+
text: string;
|
|
210
|
+
role?: 'user' | 'assistant';
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
export interface SessionSendUserMessageParams {
|
|
214
|
+
context: Context;
|
|
215
|
+
text: string;
|
|
216
|
+
deliver_as?: DeliverAs;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
export interface SessionAppendEntryParams {
|
|
220
|
+
context: Context;
|
|
221
|
+
entry: Record<string, unknown>;
|
|
222
|
+
}
|
package/src/test-host.ts
CHANGED
|
@@ -5,14 +5,39 @@
|
|
|
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 {
|
|
8
|
+
import { Peer, RpcError } from './jsonrpc.js';
|
|
9
|
+
import { PROTOCOL_VERSION, errorCode, method } from './protocol.js';
|
|
10
|
+
import type {
|
|
11
|
+
CommandExecuteResult,
|
|
12
|
+
Context,
|
|
13
|
+
HookOutcome,
|
|
14
|
+
InitializeParams,
|
|
15
|
+
InitializeResult,
|
|
16
|
+
ToolExecuteResult,
|
|
17
|
+
ToolUpdateParams,
|
|
18
|
+
UiRequestParams,
|
|
19
|
+
UiRequestResult,
|
|
20
|
+
} from './protocol.js';
|
|
11
21
|
import type { Extension } from './extension.js';
|
|
12
22
|
import { linkedPair } from './transport.js';
|
|
13
23
|
|
|
14
24
|
let callSeq = 0;
|
|
15
25
|
|
|
26
|
+
/** Answers the extension's `ui/request` calls. Return a result, or throw an
|
|
27
|
+
* `RpcError` (e.g. code -32001) to simulate a headless/uncapable frontend. */
|
|
28
|
+
export type UiResponder = (params: UiRequestParams) => UiRequestResult | Promise<UiRequestResult>;
|
|
29
|
+
|
|
30
|
+
/** A recorded ext→host `session/*` request the test can assert on. */
|
|
31
|
+
export interface SessionCall {
|
|
32
|
+
method: string;
|
|
33
|
+
params: Record<string, unknown>;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface CreateTestHostOptions {
|
|
37
|
+
/** Answers `ui/request`. Default: reject every call with -32001 NoUI. */
|
|
38
|
+
onUiRequest?: UiResponder;
|
|
39
|
+
}
|
|
40
|
+
|
|
16
41
|
export interface CallToolOptions {
|
|
17
42
|
/** Receives each `tool/update` the extension streams for this call. */
|
|
18
43
|
onUpdate?: (update: ToolUpdateParams) => void;
|
|
@@ -27,15 +52,21 @@ export interface TestHost {
|
|
|
27
52
|
callTool(tool: string, args: Record<string, unknown>, opts?: CallToolOptions): Promise<ToolExecuteResult>;
|
|
28
53
|
/** Drive a `hook` request and get back the extension's folded outcome. */
|
|
29
54
|
callHook(hook: string, input: Record<string, unknown>, context?: Context): Promise<HookOutcome>;
|
|
55
|
+
/** Dispatch a `command/execute` with a command-tier context by default. */
|
|
56
|
+
runCommand(command: string, args?: Record<string, unknown>, context?: Context): Promise<CommandExecuteResult>;
|
|
57
|
+
/** Dispatch a `command/complete` for argument autocomplete. */
|
|
58
|
+
completeCommand(command: string, partial: string, context?: Context): Promise<{ completions: { value: string; description?: string }[] }>;
|
|
30
59
|
ping(): Promise<Record<string, unknown>>;
|
|
31
60
|
sendEvent(event: string, payload?: Record<string, unknown>, context?: Context): void;
|
|
61
|
+
/** Every `session/*` request the extension made, in order — for assertions. */
|
|
62
|
+
readonly sessionCalls: SessionCall[];
|
|
32
63
|
shutdown(): Promise<void>;
|
|
33
64
|
close(): void;
|
|
34
65
|
}
|
|
35
66
|
|
|
36
67
|
const DEFAULT_CONTEXT: Context = { token: 'test-epoch', tier: 'command' };
|
|
37
68
|
|
|
38
|
-
export function createTestHost(extension: Extension): TestHost {
|
|
69
|
+
export function createTestHost(extension: Extension, options: CreateTestHostOptions = {}): TestHost {
|
|
39
70
|
const [hostT, extT] = linkedPair();
|
|
40
71
|
const extHandle = extension.connect(extT);
|
|
41
72
|
/** call_id → the caller's onUpdate, so streamed progress reaches the test. */
|
|
@@ -46,9 +77,32 @@ export function createTestHost(extension: Extension): TestHost {
|
|
|
46
77
|
const p = params as ToolUpdateParams;
|
|
47
78
|
updateSinks.get(p.call_id)?.(p);
|
|
48
79
|
});
|
|
80
|
+
// Answer ext→host `ui/request`. Default mimics a headless frontend (NoUI).
|
|
81
|
+
host.setRequestHandler(method.UI_REQUEST, async (params) => {
|
|
82
|
+
if (!options.onUiRequest) throw new RpcError(errorCode.NoUI, 'no UI available (headless test host)');
|
|
83
|
+
return options.onUiRequest(params as UiRequestParams);
|
|
84
|
+
});
|
|
49
85
|
// Extension notifications the host just observes in tests.
|
|
50
86
|
host.setNotificationHandler(method.LOG, () => {});
|
|
51
87
|
host.setNotificationHandler(method.REGISTRY_UPDATE, () => {});
|
|
88
|
+
|
|
89
|
+
// Service ext→host `session/*` requests, enforcing the same command-tier
|
|
90
|
+
// guard the real host does (event-tier → -32003) so a demo's session calls
|
|
91
|
+
// are exercised realistically. Every call is recorded for assertions.
|
|
92
|
+
const sessionCalls: SessionCall[] = [];
|
|
93
|
+
const sessionHandler = (params: unknown) => {
|
|
94
|
+
const p = (params ?? {}) as Record<string, unknown>;
|
|
95
|
+
const tier = (p.context as { tier?: string } | undefined)?.tier;
|
|
96
|
+
if (tier !== 'command') throw new RpcError(errorCode.ContextViolation, 'session action requires a command-tier context');
|
|
97
|
+
return p;
|
|
98
|
+
};
|
|
99
|
+
for (const m of [method.SESSION_SEND_MESSAGE, method.SESSION_SEND_USER_MESSAGE, method.SESSION_APPEND_ENTRY]) {
|
|
100
|
+
host.setRequestHandler(m, (params) => {
|
|
101
|
+
const recorded = sessionHandler(params);
|
|
102
|
+
sessionCalls.push({ method: m, params: recorded });
|
|
103
|
+
return {};
|
|
104
|
+
});
|
|
105
|
+
}
|
|
52
106
|
hostT.start((frame) => host.receive(frame));
|
|
53
107
|
|
|
54
108
|
return {
|
|
@@ -79,6 +133,17 @@ export function createTestHost(extension: Extension): TestHost {
|
|
|
79
133
|
callHook(hook, input, context) {
|
|
80
134
|
return host.request<HookOutcome>(method.HOOK, { hook, input, context: context ?? DEFAULT_CONTEXT });
|
|
81
135
|
},
|
|
136
|
+
runCommand(command, args, context) {
|
|
137
|
+
return host.request<CommandExecuteResult>(method.COMMAND_EXECUTE, {
|
|
138
|
+
command,
|
|
139
|
+
context: context ?? DEFAULT_CONTEXT,
|
|
140
|
+
...(args ? { arguments: args } : {}),
|
|
141
|
+
});
|
|
142
|
+
},
|
|
143
|
+
completeCommand(command, partial, context) {
|
|
144
|
+
return host.request(method.COMMAND_COMPLETE, { command, context: context ?? DEFAULT_CONTEXT, partial });
|
|
145
|
+
},
|
|
146
|
+
sessionCalls,
|
|
82
147
|
ping() {
|
|
83
148
|
return host.request<Record<string, unknown>>(method.PING, {});
|
|
84
149
|
},
|