@smooai/smooth-extension-sdk 0.4.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 +137 -1
- package/src/index.ts +28 -2
- package/src/protocol.ts +64 -0
- package/src/test-host.ts +52 -1
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
|
@@ -13,12 +13,20 @@
|
|
|
13
13
|
import { Peer } from './jsonrpc.js';
|
|
14
14
|
import { PROTOCOL_VERSION, method } from './protocol.js';
|
|
15
15
|
import type {
|
|
16
|
+
CommandCompleteParams,
|
|
17
|
+
CommandCompleteResult,
|
|
18
|
+
CommandExecuteParams,
|
|
19
|
+
CommandExecuteResult,
|
|
20
|
+
CommandRegistration,
|
|
21
|
+
Completion,
|
|
16
22
|
Context,
|
|
23
|
+
DeliverAs,
|
|
17
24
|
EventParams,
|
|
18
25
|
HookOutcome,
|
|
19
26
|
HookParams,
|
|
20
27
|
InitializeParams,
|
|
21
28
|
InitializeResult,
|
|
29
|
+
ShortcutRegistration,
|
|
22
30
|
ToolExecuteParams,
|
|
23
31
|
ToolExecuteResult,
|
|
24
32
|
ToolUpdateParams,
|
|
@@ -73,6 +81,34 @@ function makeUi(peer: Peer): UiApi {
|
|
|
73
81
|
};
|
|
74
82
|
}
|
|
75
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
|
+
|
|
76
112
|
/** Progress + cancellation handed to a tool while it runs. */
|
|
77
113
|
export interface ToolContext {
|
|
78
114
|
/** Correlates `onUpdate` calls with this execution. */
|
|
@@ -105,6 +141,48 @@ export function defineTool<TArgs = Record<string, unknown>>(def: ToolDef<TArgs>)
|
|
|
105
141
|
return def;
|
|
106
142
|
}
|
|
107
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
|
+
|
|
108
186
|
/** A hook handler's friendly return: veto the operation, or replace its input
|
|
109
187
|
* with a patch (shallow-merged onto the input). Returning nothing = continue. */
|
|
110
188
|
export type HookResult = { block: true; reason?: string } | { patch: Record<string, unknown> };
|
|
@@ -119,8 +197,17 @@ export interface SmoothApi {
|
|
|
119
197
|
name: string;
|
|
120
198
|
version: string;
|
|
121
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;
|
|
122
206
|
on(event: string, handler: EventHandler): void;
|
|
123
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;
|
|
124
211
|
/** True if the host's frontend can render this `ui/request` kind. Only
|
|
125
212
|
* meaningful after `initialize` — returns false before the handshake. */
|
|
126
213
|
hasUI(kind: UiKind): boolean;
|
|
@@ -138,6 +225,9 @@ export interface ConnectHandle {
|
|
|
138
225
|
|
|
139
226
|
export class Extension {
|
|
140
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[] = [];
|
|
141
231
|
private readonly events = new Map<string, EventHandler[]>();
|
|
142
232
|
private name = 'extension';
|
|
143
233
|
private version = '0.0.0';
|
|
@@ -145,6 +235,8 @@ export class Extension {
|
|
|
145
235
|
private live?: Peer;
|
|
146
236
|
/** UI kinds the host declared answerable at `initialize`. */
|
|
147
237
|
private hostUiCaps: string[] = [];
|
|
238
|
+
/** Flag values the host delivered at `initialize` (name → value). */
|
|
239
|
+
private flagValues: Record<string, unknown> = {};
|
|
148
240
|
|
|
149
241
|
constructor(setup: ExtensionSetup) {
|
|
150
242
|
const api: SmoothApi = {
|
|
@@ -163,6 +255,15 @@ export class Extension {
|
|
|
163
255
|
registerTool: (tool) => {
|
|
164
256
|
this.tools.set(tool.name, tool);
|
|
165
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
|
+
},
|
|
166
267
|
on: (event, handler) => {
|
|
167
268
|
const list = this.events.get(event) ?? [];
|
|
168
269
|
list.push(handler);
|
|
@@ -171,6 +272,7 @@ export class Extension {
|
|
|
171
272
|
log: (level, message, fields) => {
|
|
172
273
|
this.live?.notify(method.LOG, { level, message, ...(fields ? { fields } : {}) });
|
|
173
274
|
},
|
|
275
|
+
getFlag: (name) => self.flagValues[name],
|
|
174
276
|
hasUI: (kind) => self.hostUiCaps.includes(kind),
|
|
175
277
|
get ui() {
|
|
176
278
|
if (!self.live) throw new Error('smooth.ui is only available after the extension connects');
|
|
@@ -195,6 +297,8 @@ export class Extension {
|
|
|
195
297
|
});
|
|
196
298
|
peer.setRequestHandler(method.TOOL_EXECUTE, (params, signal) => this.executeTool(params as ToolExecuteParams, peer, signal));
|
|
197
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));
|
|
198
302
|
peer.setNotificationHandler(method.EVENT, (params) => this.dispatchEvent(params as EventParams));
|
|
199
303
|
|
|
200
304
|
transport.start((frame) => peer.receive(frame));
|
|
@@ -217,20 +321,52 @@ export class Extension {
|
|
|
217
321
|
|
|
218
322
|
private initialize(params: InitializeParams): InitializeResult {
|
|
219
323
|
this.hostUiCaps = params.ui_capabilities ?? [];
|
|
324
|
+
this.flagValues = params.flags ?? {};
|
|
220
325
|
const tools = [...this.tools.values()].map((t) => ({
|
|
221
326
|
name: t.name,
|
|
222
327
|
description: t.description,
|
|
223
328
|
parameters: toJsonSchema(t.parameters),
|
|
224
329
|
...(t.deferred ? { deferred: true } : {}),
|
|
225
330
|
}));
|
|
331
|
+
const commands: CommandRegistration[] = [...this.commands.values()].map((c) => ({ name: c.name, description: c.description }));
|
|
332
|
+
const flags = [...this.flagDefs.keys()];
|
|
226
333
|
// Only observe events go in `subscriptions` — hook names are intercepts
|
|
227
334
|
// the host always calls, not events it filters by subscription.
|
|
228
335
|
const subscriptions = [...this.events.keys()].filter((name) => !HOOK_NAMES.has(name));
|
|
229
336
|
return {
|
|
230
337
|
protocol_version: PROTOCOL_VERSION,
|
|
231
338
|
extension: { name: this.name, version: this.version },
|
|
232
|
-
registrations: {
|
|
339
|
+
registrations: {
|
|
340
|
+
tools,
|
|
341
|
+
...(commands.length ? { commands } : {}),
|
|
342
|
+
...(flags.length ? { flags } : {}),
|
|
343
|
+
...(this.shortcuts.length ? { shortcuts: this.shortcuts } : {}),
|
|
344
|
+
subscriptions,
|
|
345
|
+
},
|
|
346
|
+
};
|
|
347
|
+
}
|
|
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 } : {}) }),
|
|
233
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 };
|
|
234
370
|
}
|
|
235
371
|
|
|
236
372
|
private async executeTool(params: ToolExecuteParams, peer: Peer, signal: AbortSignal): Promise<ToolExecuteResult> {
|
package/src/index.ts
CHANGED
|
@@ -8,8 +8,23 @@
|
|
|
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
29
|
export type { TestHost, CallToolOptions, CreateTestHostOptions, UiResponder } from './test-host.js';
|
|
15
30
|
export { runConformance, DEFAULT_SPEC_DIR } from './conformance.js';
|
|
@@ -36,4 +51,15 @@ export type {
|
|
|
36
51
|
UiKind,
|
|
37
52
|
UiRequestParams,
|
|
38
53
|
UiRequestResult,
|
|
54
|
+
ShortcutRegistration,
|
|
55
|
+
CommandRegistration,
|
|
56
|
+
CommandExecuteParams,
|
|
57
|
+
CommandExecuteResult,
|
|
58
|
+
CommandCompleteParams,
|
|
59
|
+
CommandCompleteResult,
|
|
60
|
+
Completion,
|
|
61
|
+
DeliverAs,
|
|
62
|
+
SessionSendMessageParams,
|
|
63
|
+
SessionSendUserMessageParams,
|
|
64
|
+
SessionAppendEntryParams,
|
|
39
65
|
} from './protocol.js';
|
package/src/protocol.ts
CHANGED
|
@@ -22,6 +22,11 @@ export const method = {
|
|
|
22
22
|
REGISTRY_UPDATE: 'registry/update',
|
|
23
23
|
LOG: 'log',
|
|
24
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',
|
|
25
30
|
} as const;
|
|
26
31
|
|
|
27
32
|
/** JSON-RPC + SEP error codes (see spec/extension/envelope.md). */
|
|
@@ -62,6 +67,8 @@ export interface InitializeParams {
|
|
|
62
67
|
session?: { id?: string };
|
|
63
68
|
mode: 'tui' | 'web' | 'widget' | 'cli' | 'headless';
|
|
64
69
|
ui_capabilities?: string[];
|
|
70
|
+
/** Parsed values for the flags the extension declares (name → value). */
|
|
71
|
+
flags?: Record<string, unknown>;
|
|
65
72
|
capabilities_enabled?: Record<string, boolean>;
|
|
66
73
|
}
|
|
67
74
|
|
|
@@ -78,10 +85,19 @@ export interface CommandRegistration {
|
|
|
78
85
|
description: string;
|
|
79
86
|
}
|
|
80
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
|
+
|
|
81
96
|
export interface Registrations {
|
|
82
97
|
tools?: ToolRegistration[];
|
|
83
98
|
commands?: CommandRegistration[];
|
|
84
99
|
flags?: string[];
|
|
100
|
+
shortcuts?: ShortcutRegistration[];
|
|
85
101
|
subscriptions?: string[];
|
|
86
102
|
}
|
|
87
103
|
|
|
@@ -156,3 +172,51 @@ export interface UiRequestResult {
|
|
|
156
172
|
text?: string;
|
|
157
173
|
cancelled?: boolean;
|
|
158
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
|
@@ -7,7 +7,17 @@
|
|
|
7
7
|
*/
|
|
8
8
|
import { Peer, RpcError } from './jsonrpc.js';
|
|
9
9
|
import { PROTOCOL_VERSION, errorCode, method } from './protocol.js';
|
|
10
|
-
import type {
|
|
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
|
|
|
@@ -17,6 +27,12 @@ let callSeq = 0;
|
|
|
17
27
|
* `RpcError` (e.g. code -32001) to simulate a headless/uncapable frontend. */
|
|
18
28
|
export type UiResponder = (params: UiRequestParams) => UiRequestResult | Promise<UiRequestResult>;
|
|
19
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
|
+
|
|
20
36
|
export interface CreateTestHostOptions {
|
|
21
37
|
/** Answers `ui/request`. Default: reject every call with -32001 NoUI. */
|
|
22
38
|
onUiRequest?: UiResponder;
|
|
@@ -36,8 +52,14 @@ export interface TestHost {
|
|
|
36
52
|
callTool(tool: string, args: Record<string, unknown>, opts?: CallToolOptions): Promise<ToolExecuteResult>;
|
|
37
53
|
/** Drive a `hook` request and get back the extension's folded outcome. */
|
|
38
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 }[] }>;
|
|
39
59
|
ping(): Promise<Record<string, unknown>>;
|
|
40
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[];
|
|
41
63
|
shutdown(): Promise<void>;
|
|
42
64
|
close(): void;
|
|
43
65
|
}
|
|
@@ -63,6 +85,24 @@ export function createTestHost(extension: Extension, options: CreateTestHostOpti
|
|
|
63
85
|
// Extension notifications the host just observes in tests.
|
|
64
86
|
host.setNotificationHandler(method.LOG, () => {});
|
|
65
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
|
+
}
|
|
66
106
|
hostT.start((frame) => host.receive(frame));
|
|
67
107
|
|
|
68
108
|
return {
|
|
@@ -93,6 +133,17 @@ export function createTestHost(extension: Extension, options: CreateTestHostOpti
|
|
|
93
133
|
callHook(hook, input, context) {
|
|
94
134
|
return host.request<HookOutcome>(method.HOOK, { hook, input, context: context ?? DEFAULT_CONTEXT });
|
|
95
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,
|
|
96
147
|
ping() {
|
|
97
148
|
return host.request<Record<string, unknown>>(method.PING, {});
|
|
98
149
|
},
|