@smooai/smooth-extension-sdk 0.4.0 → 0.6.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 +325 -3
- package/src/index.ts +51 -4
- package/src/protocol.ts +234 -1
- package/src/render.ts +55 -0
- package/src/test-host.ts +132 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@smooai/smooth-extension-sdk",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.6.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
|
@@ -11,14 +11,32 @@
|
|
|
11
11
|
* into them without breaking the tool path.
|
|
12
12
|
*/
|
|
13
13
|
import { Peer } from './jsonrpc.js';
|
|
14
|
-
import { PROTOCOL_VERSION, method } from './protocol.js';
|
|
14
|
+
import { PROTOCOL_VERSION, eventName, 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
|
+
BusEventPayload,
|
|
30
|
+
MessageRendererRegistration,
|
|
31
|
+
RenderBlock,
|
|
32
|
+
ProviderCompleteParams,
|
|
33
|
+
ProviderCompleteResult,
|
|
34
|
+
ProviderCredentials,
|
|
35
|
+
ProviderModel,
|
|
36
|
+
ProviderOAuthParams,
|
|
37
|
+
ProviderRegistration,
|
|
38
|
+
ProviderStreamEvent,
|
|
39
|
+
ShortcutRegistration,
|
|
22
40
|
ToolExecuteParams,
|
|
23
41
|
ToolExecuteResult,
|
|
24
42
|
ToolUpdateParams,
|
|
@@ -47,10 +65,24 @@ export interface UiApi {
|
|
|
47
65
|
input(prompt: string, opts?: { default?: string }): Promise<UiRequestResult>;
|
|
48
66
|
notify(message: string, level?: 'info' | 'warn' | 'error'): Promise<void>;
|
|
49
67
|
setStatus(status: string): Promise<void>;
|
|
50
|
-
|
|
68
|
+
/** Render a Phase 8 render block. Use a `widget`-kind block (with keybindings)
|
|
69
|
+
* for the interactive tier and re-call on each `widget/key` to re-render. */
|
|
70
|
+
setWidget(widget: RenderBlock): Promise<void>;
|
|
51
71
|
setTitle(title: string): Promise<void>;
|
|
52
72
|
}
|
|
53
73
|
|
|
74
|
+
/**
|
|
75
|
+
* The inter-extension event bus (Phase 8), mirroring pi's `events`. `publish`
|
|
76
|
+
* fans a `{topic, payload}` out to every *other* extension subscribed to
|
|
77
|
+
* `bus/event`; `on` subscribes to one topic (a filtered `bus/event`). Requires
|
|
78
|
+
* the `bus` capability to publish and a `bus/event` events subscription (added
|
|
79
|
+
* automatically when you call `on`) to receive.
|
|
80
|
+
*/
|
|
81
|
+
export interface EventsApi {
|
|
82
|
+
publish(topic: string, payload?: unknown): void;
|
|
83
|
+
on(topic: string, handler: (payload: unknown, from: string) => void): void;
|
|
84
|
+
}
|
|
85
|
+
|
|
54
86
|
/** Build a [`UiApi`] that speaks `ui/request` over `peer`. */
|
|
55
87
|
function makeUi(peer: Peer): UiApi {
|
|
56
88
|
const req = (params: UiRequestParams) => peer.request<UiRequestResult>(method.UI_REQUEST, params);
|
|
@@ -73,6 +105,45 @@ function makeUi(peer: Peer): UiApi {
|
|
|
73
105
|
};
|
|
74
106
|
}
|
|
75
107
|
|
|
108
|
+
/**
|
|
109
|
+
* Session-mutating ext→host actions. Available only from a COMMAND-tier context
|
|
110
|
+
* (command handlers) — the host rejects them from an event-tier context with
|
|
111
|
+
* -32003 ContextViolation. `sendMessage` posts a message, `sendUserMessage`
|
|
112
|
+
* delivers a user message (steer/follow_up/next_turn), `appendEntry` persists an
|
|
113
|
+
* LLM-invisible transcript entry.
|
|
114
|
+
*/
|
|
115
|
+
export interface SessionApi {
|
|
116
|
+
sendMessage(text: string, opts?: { role?: 'user' | 'assistant' }): Promise<void>;
|
|
117
|
+
sendUserMessage(text: string, opts?: { deliverAs?: DeliverAs }): Promise<void>;
|
|
118
|
+
appendEntry(entry: Record<string, unknown>): Promise<void>;
|
|
119
|
+
/** Switch the active model (Phase 7). `provider` targets an extension-
|
|
120
|
+
* registered provider; `thinking` sets a reasoning level. */
|
|
121
|
+
setModel(model: string, opts?: { provider?: string; thinking?: string }): Promise<void>;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** Build a [`SessionApi`] bound to `context` (must be command-tier) over `peer`. */
|
|
125
|
+
function makeSession(peer: Peer, context: Context): SessionApi {
|
|
126
|
+
return {
|
|
127
|
+
sendMessage: async (text, opts) => {
|
|
128
|
+
await peer.request(method.SESSION_SEND_MESSAGE, { context, text, ...(opts?.role ? { role: opts.role } : {}) });
|
|
129
|
+
},
|
|
130
|
+
sendUserMessage: async (text, opts) => {
|
|
131
|
+
await peer.request(method.SESSION_SEND_USER_MESSAGE, { context, text, ...(opts?.deliverAs ? { deliver_as: opts.deliverAs } : {}) });
|
|
132
|
+
},
|
|
133
|
+
appendEntry: async (entry) => {
|
|
134
|
+
await peer.request(method.SESSION_APPEND_ENTRY, { context, entry });
|
|
135
|
+
},
|
|
136
|
+
setModel: async (model, opts) => {
|
|
137
|
+
await peer.request(method.SESSION_SET_MODEL, {
|
|
138
|
+
context,
|
|
139
|
+
model,
|
|
140
|
+
...(opts?.provider ? { provider: opts.provider } : {}),
|
|
141
|
+
...(opts?.thinking ? { thinking: opts.thinking } : {}),
|
|
142
|
+
});
|
|
143
|
+
},
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
|
|
76
147
|
/** Progress + cancellation handed to a tool while it runs. */
|
|
77
148
|
export interface ToolContext {
|
|
78
149
|
/** Correlates `onUpdate` calls with this execution. */
|
|
@@ -105,6 +176,100 @@ export function defineTool<TArgs = Record<string, unknown>>(def: ToolDef<TArgs>)
|
|
|
105
176
|
return def;
|
|
106
177
|
}
|
|
107
178
|
|
|
179
|
+
/** What a command handler receives: the command-tier context plus the session,
|
|
180
|
+
* ui, and args bound to it. Session actions are valid because a command runs at
|
|
181
|
+
* command tier. */
|
|
182
|
+
export interface CommandContext {
|
|
183
|
+
/** The dispatch context (command tier). */
|
|
184
|
+
context: Context;
|
|
185
|
+
/** Free-form arguments parsed from the invocation. */
|
|
186
|
+
args: Record<string, unknown> | undefined;
|
|
187
|
+
/** Session-mutating actions, bound to this command's context. */
|
|
188
|
+
session: SessionApi;
|
|
189
|
+
/** Ask the frontend to render a dialog/widget. See [`UiApi`]. */
|
|
190
|
+
ui: UiApi;
|
|
191
|
+
/** True if the host's frontend can render this `ui/request` kind. */
|
|
192
|
+
hasUI(kind: UiKind): boolean;
|
|
193
|
+
/** Structured log line into host tracing. */
|
|
194
|
+
log(level: 'debug' | 'info' | 'warn' | 'error', message: string, fields?: Record<string, unknown>): void;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/** What a command's `execute` may return: text to surface, a full result, or
|
|
198
|
+
* nothing. */
|
|
199
|
+
export type CommandReturn = CommandExecuteResult | string | void;
|
|
200
|
+
|
|
201
|
+
export interface CommandDef {
|
|
202
|
+
name: string;
|
|
203
|
+
description: string;
|
|
204
|
+
execute(ctx: CommandContext): Promise<CommandReturn> | CommandReturn;
|
|
205
|
+
/** Optional argument autocomplete: given the partial text, return candidates. */
|
|
206
|
+
complete?(partial: string, context: Context): Promise<Completion[]> | Completion[];
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/** Identity helper for `registerCommand` call sites. */
|
|
210
|
+
export function defineCommand(def: CommandDef): CommandDef {
|
|
211
|
+
return def;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/** A CLI/slash flag the extension declares. The host delivers its parsed value
|
|
215
|
+
* in `initialize`; read it with `smooth.getFlag(name)`. */
|
|
216
|
+
export interface FlagDef {
|
|
217
|
+
name: string;
|
|
218
|
+
description?: string;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
// --- providers (Phase 7) -------------------------------------------------
|
|
222
|
+
|
|
223
|
+
/** One LLM completion the host asked this provider to run. `messages`/`tools`
|
|
224
|
+
* are the host's opaque serialized shapes. */
|
|
225
|
+
export interface ProviderCompleteRequest {
|
|
226
|
+
/** Correlates streamed deltas with this request. */
|
|
227
|
+
requestId: string;
|
|
228
|
+
model: string;
|
|
229
|
+
messages: Record<string, unknown>[];
|
|
230
|
+
tools: Record<string, unknown>[];
|
|
231
|
+
/** OpenAI-compatible `response_format` when structured output is requested. */
|
|
232
|
+
responseFormat?: Record<string, unknown>;
|
|
233
|
+
/** Reasoning/thinking level (`off`/`low`/`medium`/`high`/provider token). */
|
|
234
|
+
thinking?: string;
|
|
235
|
+
/** True when the host wants streamed `delta`s. */
|
|
236
|
+
stream: boolean;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/** Progress + UI handed to a provider `complete`/oauth call. */
|
|
240
|
+
export interface ProviderContext {
|
|
241
|
+
/** The dispatch context (epoch token + tier). */
|
|
242
|
+
context: Context;
|
|
243
|
+
/** Fires if the host `$/cancel`s the request. */
|
|
244
|
+
signal: AbortSignal;
|
|
245
|
+
/** Stream one chunk back to the host. Only meaningful when `stream` is set;
|
|
246
|
+
* a no-op storm otherwise (the host drops deltas for a non-streamed call). */
|
|
247
|
+
delta(event: ProviderStreamEvent): void;
|
|
248
|
+
/** Ask the frontend to render a dialog (OAuth: open a URL, prompt for a code). */
|
|
249
|
+
ui: UiApi;
|
|
250
|
+
hasUI(kind: UiKind): boolean;
|
|
251
|
+
log(level: 'debug' | 'info' | 'warn' | 'error', message: string, fields?: Record<string, unknown>): void;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
export interface ProviderDef {
|
|
255
|
+
name: string;
|
|
256
|
+
baseUrl?: string;
|
|
257
|
+
apiKeyEnv?: string;
|
|
258
|
+
models: ProviderModel[];
|
|
259
|
+
/** Run one completion. Emit `ctx.delta(...)` while streaming, then return the
|
|
260
|
+
* final result. */
|
|
261
|
+
complete(req: ProviderCompleteRequest, ctx: ProviderContext): Promise<ProviderCompleteResult> | ProviderCompleteResult;
|
|
262
|
+
/** Run the OAuth login handshake, driving user interaction via `ctx.ui`. */
|
|
263
|
+
oauthLogin?(ctx: ProviderContext): Promise<ProviderCredentials> | ProviderCredentials;
|
|
264
|
+
/** Exchange a refresh token for fresh credentials. */
|
|
265
|
+
oauthRefresh?(refreshToken: string, ctx: ProviderContext): Promise<ProviderCredentials> | ProviderCredentials;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/** Identity helper for `registerProvider` call sites. */
|
|
269
|
+
export function defineProvider(def: ProviderDef): ProviderDef {
|
|
270
|
+
return def;
|
|
271
|
+
}
|
|
272
|
+
|
|
108
273
|
/** A hook handler's friendly return: veto the operation, or replace its input
|
|
109
274
|
* with a patch (shallow-merged onto the input). Returning nothing = continue. */
|
|
110
275
|
export type HookResult = { block: true; reason?: string } | { patch: Record<string, unknown> };
|
|
@@ -119,8 +284,24 @@ export interface SmoothApi {
|
|
|
119
284
|
name: string;
|
|
120
285
|
version: string;
|
|
121
286
|
registerTool(tool: ToolDef<any>): void;
|
|
287
|
+
/** Register a slash-command surfaced in the host's `/` palette. */
|
|
288
|
+
registerCommand(command: CommandDef): void;
|
|
289
|
+
/** Declare a CLI/slash flag; read its delivered value with [`getFlag`]. */
|
|
290
|
+
registerFlag(flag: FlagDef): void;
|
|
291
|
+
/** Bind a keyboard shortcut (TUI frontends) to a registered command. */
|
|
292
|
+
registerShortcut(shortcut: ShortcutRegistration): void;
|
|
293
|
+
/** Contribute an LLM provider to the host's model surface (Phase 7). */
|
|
294
|
+
registerProvider(provider: ProviderDef): void;
|
|
295
|
+
/** Register a declarative custom-message renderer (Phase 8): a message `tag`
|
|
296
|
+
* → render-block template with `{{path}}` placeholders. */
|
|
297
|
+
registerMessageRenderer(tag: string, template: RenderBlock): void;
|
|
298
|
+
/** The inter-extension event bus (Phase 8). */
|
|
299
|
+
readonly events: EventsApi;
|
|
122
300
|
on(event: string, handler: EventHandler): void;
|
|
123
301
|
log(level: 'debug' | 'info' | 'warn' | 'error', message: string, fields?: Record<string, unknown>): void;
|
|
302
|
+
/** The parsed value the host delivered for a declared flag (undefined before
|
|
303
|
+
* `initialize`, or when the host has no CLI surface). */
|
|
304
|
+
getFlag(name: string): unknown;
|
|
124
305
|
/** True if the host's frontend can render this `ui/request` kind. Only
|
|
125
306
|
* meaningful after `initialize` — returns false before the handshake. */
|
|
126
307
|
hasUI(kind: UiKind): boolean;
|
|
@@ -138,13 +319,20 @@ export interface ConnectHandle {
|
|
|
138
319
|
|
|
139
320
|
export class Extension {
|
|
140
321
|
private readonly tools = new Map<string, ToolDef<any>>();
|
|
322
|
+
private readonly commands = new Map<string, CommandDef>();
|
|
323
|
+
private readonly flagDefs = new Map<string, FlagDef>();
|
|
324
|
+
private readonly shortcuts: ShortcutRegistration[] = [];
|
|
325
|
+
private readonly providers = new Map<string, ProviderDef>();
|
|
141
326
|
private readonly events = new Map<string, EventHandler[]>();
|
|
327
|
+
private readonly messageRenderers: MessageRendererRegistration[] = [];
|
|
142
328
|
private name = 'extension';
|
|
143
329
|
private version = '0.0.0';
|
|
144
330
|
/** Set once connected so `log()` before connect is a safe no-op. */
|
|
145
331
|
private live?: Peer;
|
|
146
332
|
/** UI kinds the host declared answerable at `initialize`. */
|
|
147
333
|
private hostUiCaps: string[] = [];
|
|
334
|
+
/** Flag values the host delivered at `initialize` (name → value). */
|
|
335
|
+
private flagValues: Record<string, unknown> = {};
|
|
148
336
|
|
|
149
337
|
constructor(setup: ExtensionSetup) {
|
|
150
338
|
const api: SmoothApi = {
|
|
@@ -163,6 +351,41 @@ export class Extension {
|
|
|
163
351
|
registerTool: (tool) => {
|
|
164
352
|
this.tools.set(tool.name, tool);
|
|
165
353
|
},
|
|
354
|
+
registerCommand: (command) => {
|
|
355
|
+
this.commands.set(command.name, command);
|
|
356
|
+
},
|
|
357
|
+
registerFlag: (flag) => {
|
|
358
|
+
this.flagDefs.set(flag.name, flag);
|
|
359
|
+
},
|
|
360
|
+
registerShortcut: (shortcut) => {
|
|
361
|
+
this.shortcuts.push(shortcut);
|
|
362
|
+
},
|
|
363
|
+
registerProvider: (provider) => {
|
|
364
|
+
this.providers.set(provider.name, provider);
|
|
365
|
+
},
|
|
366
|
+
registerMessageRenderer: (tag, template) => {
|
|
367
|
+
this.messageRenderers.push({ tag, template });
|
|
368
|
+
},
|
|
369
|
+
get events(): EventsApi {
|
|
370
|
+
return {
|
|
371
|
+
publish: (topic, payload) => {
|
|
372
|
+
// A request (not a notification) so it reaches the host's
|
|
373
|
+
// bus/publish handler; fire-and-forget — we don't await it.
|
|
374
|
+
void self.live?.request(method.BUS_PUBLISH, { topic, ...(payload !== undefined ? { payload } : {}) });
|
|
375
|
+
},
|
|
376
|
+
on: (topic, handler) => {
|
|
377
|
+
// Subscribing to a topic is a filtered `bus/event` observe
|
|
378
|
+
// subscription; registering it puts `bus/event` in our
|
|
379
|
+
// subscriptions so the host delivers the fanout.
|
|
380
|
+
const list = self.events.get(eventName.BUS_EVENT) ?? [];
|
|
381
|
+
list.push((payload) => {
|
|
382
|
+
const p = payload as BusEventPayload | undefined;
|
|
383
|
+
if (p && p.topic === topic) handler(p.payload, p.from);
|
|
384
|
+
});
|
|
385
|
+
self.events.set(eventName.BUS_EVENT, list);
|
|
386
|
+
},
|
|
387
|
+
};
|
|
388
|
+
},
|
|
166
389
|
on: (event, handler) => {
|
|
167
390
|
const list = this.events.get(event) ?? [];
|
|
168
391
|
list.push(handler);
|
|
@@ -171,6 +394,7 @@ export class Extension {
|
|
|
171
394
|
log: (level, message, fields) => {
|
|
172
395
|
this.live?.notify(method.LOG, { level, message, ...(fields ? { fields } : {}) });
|
|
173
396
|
},
|
|
397
|
+
getFlag: (name) => self.flagValues[name],
|
|
174
398
|
hasUI: (kind) => self.hostUiCaps.includes(kind),
|
|
175
399
|
get ui() {
|
|
176
400
|
if (!self.live) throw new Error('smooth.ui is only available after the extension connects');
|
|
@@ -195,6 +419,11 @@ export class Extension {
|
|
|
195
419
|
});
|
|
196
420
|
peer.setRequestHandler(method.TOOL_EXECUTE, (params, signal) => this.executeTool(params as ToolExecuteParams, peer, signal));
|
|
197
421
|
peer.setRequestHandler(method.HOOK, (params) => this.dispatchHook(params as HookParams));
|
|
422
|
+
peer.setRequestHandler(method.COMMAND_EXECUTE, (params) => this.executeCommand(params as CommandExecuteParams, peer));
|
|
423
|
+
peer.setRequestHandler(method.COMMAND_COMPLETE, (params) => this.completeCommand(params as CommandCompleteParams));
|
|
424
|
+
peer.setRequestHandler(method.PROVIDER_COMPLETE, (params, signal) => this.providerComplete(params as ProviderCompleteParams, peer, signal));
|
|
425
|
+
peer.setRequestHandler(method.PROVIDER_OAUTH_LOGIN, (params, signal) => this.providerOAuth(params as ProviderOAuthParams, peer, signal, false));
|
|
426
|
+
peer.setRequestHandler(method.PROVIDER_OAUTH_REFRESH, (params, signal) => this.providerOAuth(params as ProviderOAuthParams, peer, signal, true));
|
|
198
427
|
peer.setNotificationHandler(method.EVENT, (params) => this.dispatchEvent(params as EventParams));
|
|
199
428
|
|
|
200
429
|
transport.start((frame) => peer.receive(frame));
|
|
@@ -217,22 +446,115 @@ export class Extension {
|
|
|
217
446
|
|
|
218
447
|
private initialize(params: InitializeParams): InitializeResult {
|
|
219
448
|
this.hostUiCaps = params.ui_capabilities ?? [];
|
|
449
|
+
this.flagValues = params.flags ?? {};
|
|
220
450
|
const tools = [...this.tools.values()].map((t) => ({
|
|
221
451
|
name: t.name,
|
|
222
452
|
description: t.description,
|
|
223
453
|
parameters: toJsonSchema(t.parameters),
|
|
224
454
|
...(t.deferred ? { deferred: true } : {}),
|
|
225
455
|
}));
|
|
456
|
+
const commands: CommandRegistration[] = [...this.commands.values()].map((c) => ({ name: c.name, description: c.description }));
|
|
457
|
+
const flags = [...this.flagDefs.keys()];
|
|
226
458
|
// Only observe events go in `subscriptions` — hook names are intercepts
|
|
227
459
|
// the host always calls, not events it filters by subscription.
|
|
228
460
|
const subscriptions = [...this.events.keys()].filter((name) => !HOOK_NAMES.has(name));
|
|
461
|
+
// Declare the hooks we handle (Phase 8) so the host can skip the per-turn
|
|
462
|
+
// `context` hook when nobody handles it.
|
|
463
|
+
const hooks = [...this.events.keys()].filter((name) => HOOK_NAMES.has(name));
|
|
464
|
+
const providers: ProviderRegistration[] = [...this.providers.values()].map((p) => ({
|
|
465
|
+
name: p.name,
|
|
466
|
+
...(p.baseUrl ? { base_url: p.baseUrl } : {}),
|
|
467
|
+
...(p.apiKeyEnv ? { api_key_env: p.apiKeyEnv } : {}),
|
|
468
|
+
// `oauth` is derived: an extension supports it iff it implements login.
|
|
469
|
+
...(p.oauthLogin ? { oauth: true } : {}),
|
|
470
|
+
models: p.models,
|
|
471
|
+
}));
|
|
229
472
|
return {
|
|
230
473
|
protocol_version: PROTOCOL_VERSION,
|
|
231
474
|
extension: { name: this.name, version: this.version },
|
|
232
|
-
registrations: {
|
|
475
|
+
registrations: {
|
|
476
|
+
tools,
|
|
477
|
+
...(commands.length ? { commands } : {}),
|
|
478
|
+
...(flags.length ? { flags } : {}),
|
|
479
|
+
...(this.shortcuts.length ? { shortcuts: this.shortcuts } : {}),
|
|
480
|
+
...(providers.length ? { providers } : {}),
|
|
481
|
+
...(hooks.length ? { hooks } : {}),
|
|
482
|
+
...(this.messageRenderers.length ? { message_renderers: this.messageRenderers } : {}),
|
|
483
|
+
subscriptions,
|
|
484
|
+
},
|
|
233
485
|
};
|
|
234
486
|
}
|
|
235
487
|
|
|
488
|
+
/** Build the shared context for a provider request. `delta` is a no-op here —
|
|
489
|
+
* only `providerComplete` streams, and it overrides `delta` with the bound
|
|
490
|
+
* request_id. */
|
|
491
|
+
private providerContext(peer: Peer, context: Context, signal: AbortSignal): ProviderContext {
|
|
492
|
+
return {
|
|
493
|
+
context,
|
|
494
|
+
signal,
|
|
495
|
+
delta: () => {},
|
|
496
|
+
ui: makeUi(peer),
|
|
497
|
+
hasUI: (kind) => this.hostUiCaps.includes(kind),
|
|
498
|
+
log: (level, message, fields) => peer.notify(method.LOG, { level, message, ...(fields ? { fields } : {}) }),
|
|
499
|
+
};
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
private async providerComplete(params: ProviderCompleteParams, peer: Peer, signal: AbortSignal): Promise<ProviderCompleteResult> {
|
|
503
|
+
const provider = this.providers.get(params.provider);
|
|
504
|
+
if (!provider) throw new Error(`unknown provider: ${params.provider}`);
|
|
505
|
+
const base = this.providerContext(peer, params.context ?? { token: '', tier: 'command' }, signal);
|
|
506
|
+
// Bind the request_id into delta so handlers can just call `ctx.delta(event)`.
|
|
507
|
+
const ctx: ProviderContext = {
|
|
508
|
+
...base,
|
|
509
|
+
delta: (event) => peer.notify(method.PROVIDER_DELTA, { request_id: params.request_id, event }),
|
|
510
|
+
};
|
|
511
|
+
const req: ProviderCompleteRequest = {
|
|
512
|
+
requestId: params.request_id,
|
|
513
|
+
model: params.model,
|
|
514
|
+
messages: params.messages,
|
|
515
|
+
tools: params.tools ?? [],
|
|
516
|
+
...(params.response_format ? { responseFormat: params.response_format } : {}),
|
|
517
|
+
...(params.thinking ? { thinking: params.thinking } : {}),
|
|
518
|
+
stream: params.stream ?? false,
|
|
519
|
+
};
|
|
520
|
+
return provider.complete(req, ctx);
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
private async providerOAuth(params: ProviderOAuthParams, peer: Peer, signal: AbortSignal, refresh: boolean): Promise<ProviderCredentials> {
|
|
524
|
+
const provider = this.providers.get(params.provider);
|
|
525
|
+
if (!provider) throw new Error(`unknown provider: ${params.provider}`);
|
|
526
|
+
const ctx = this.providerContext(peer, params.context ?? { token: '', tier: 'command' }, signal);
|
|
527
|
+
if (refresh) {
|
|
528
|
+
if (!provider.oauthRefresh) throw new Error(`provider ${params.provider} does not support oauth refresh`);
|
|
529
|
+
return provider.oauthRefresh(params.refresh_token ?? '', ctx);
|
|
530
|
+
}
|
|
531
|
+
if (!provider.oauthLogin) throw new Error(`provider ${params.provider} does not support oauth login`);
|
|
532
|
+
return provider.oauthLogin(ctx);
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
private async executeCommand(params: CommandExecuteParams, peer: Peer): Promise<CommandExecuteResult> {
|
|
536
|
+
const command = this.commands.get(params.command);
|
|
537
|
+
if (!command) return { content: `unknown command: ${params.command}` };
|
|
538
|
+
const ctx: CommandContext = {
|
|
539
|
+
context: params.context,
|
|
540
|
+
args: params.arguments,
|
|
541
|
+
session: makeSession(peer, params.context),
|
|
542
|
+
ui: makeUi(peer),
|
|
543
|
+
hasUI: (kind) => this.hostUiCaps.includes(kind),
|
|
544
|
+
log: (level, message, fields) => peer.notify(method.LOG, { level, message, ...(fields ? { fields } : {}) }),
|
|
545
|
+
};
|
|
546
|
+
const out = await command.execute(ctx);
|
|
547
|
+
if (out === undefined) return {};
|
|
548
|
+
return typeof out === 'string' ? { content: out } : out;
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
private async completeCommand(params: CommandCompleteParams): Promise<CommandCompleteResult> {
|
|
552
|
+
const command = this.commands.get(params.command);
|
|
553
|
+
if (!command?.complete) return { completions: [] };
|
|
554
|
+
const completions = await command.complete(params.partial ?? '', params.context);
|
|
555
|
+
return { completions };
|
|
556
|
+
}
|
|
557
|
+
|
|
236
558
|
private async executeTool(params: ToolExecuteParams, peer: Peer, signal: AbortSignal): Promise<ToolExecuteResult> {
|
|
237
559
|
const tool = this.tools.get(params.tool);
|
|
238
560
|
if (!tool) return { content: `unknown tool: ${params.tool}`, is_error: true };
|
package/src/index.ts
CHANGED
|
@@ -8,10 +8,30 @@
|
|
|
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
|
|
11
|
+
export { defineExtension, defineTool, defineCommand, defineProvider, Extension } from './extension.js';
|
|
12
|
+
export { render } from './render.js';
|
|
13
|
+
export type {
|
|
14
|
+
ExtensionSetup,
|
|
15
|
+
SmoothApi,
|
|
16
|
+
EventsApi,
|
|
17
|
+
ToolDef,
|
|
18
|
+
ToolContext,
|
|
19
|
+
ToolReturn,
|
|
20
|
+
CommandDef,
|
|
21
|
+
CommandContext,
|
|
22
|
+
CommandReturn,
|
|
23
|
+
FlagDef,
|
|
24
|
+
ProviderDef,
|
|
25
|
+
ProviderContext,
|
|
26
|
+
ProviderCompleteRequest,
|
|
27
|
+
SessionApi,
|
|
28
|
+
EventHandler,
|
|
29
|
+
HookResult,
|
|
30
|
+
ConnectHandle,
|
|
31
|
+
UiApi,
|
|
32
|
+
} from './extension.js';
|
|
13
33
|
export { createTestHost } from './test-host.js';
|
|
14
|
-
export type { TestHost, CallToolOptions, CreateTestHostOptions, UiResponder } from './test-host.js';
|
|
34
|
+
export type { TestHost, CallToolOptions, CreateTestHostOptions, UiResponder, BusPublish, SessionCall } from './test-host.js';
|
|
15
35
|
export { runConformance, DEFAULT_SPEC_DIR } from './conformance.js';
|
|
16
36
|
export type { ConformanceReport, ConformanceStep, RunConformanceOptions } from './conformance.js';
|
|
17
37
|
export { toJsonSchema } from './schema.js';
|
|
@@ -20,12 +40,17 @@ export { Peer, RpcError } from './jsonrpc.js';
|
|
|
20
40
|
export type { JsonRpcFrame } from './jsonrpc.js';
|
|
21
41
|
export { stdioTransport, linkedPair } from './transport.js';
|
|
22
42
|
export type { Transport } from './transport.js';
|
|
23
|
-
export { PROTOCOL_VERSION, method, errorCode } from './protocol.js';
|
|
43
|
+
export { PROTOCOL_VERSION, eventName, method, errorCode } from './protocol.js';
|
|
24
44
|
export type {
|
|
25
45
|
Context,
|
|
26
46
|
InitializeParams,
|
|
27
47
|
InitializeResult,
|
|
28
48
|
Registrations,
|
|
49
|
+
MessageRendererRegistration,
|
|
50
|
+
RenderBlock,
|
|
51
|
+
Keybinding,
|
|
52
|
+
BusEventPayload,
|
|
53
|
+
WidgetKeyPayload,
|
|
29
54
|
ToolRegistration,
|
|
30
55
|
ToolExecuteParams,
|
|
31
56
|
ToolExecuteResult,
|
|
@@ -36,4 +61,26 @@ export type {
|
|
|
36
61
|
UiKind,
|
|
37
62
|
UiRequestParams,
|
|
38
63
|
UiRequestResult,
|
|
64
|
+
ShortcutRegistration,
|
|
65
|
+
CommandRegistration,
|
|
66
|
+
CommandExecuteParams,
|
|
67
|
+
CommandExecuteResult,
|
|
68
|
+
CommandCompleteParams,
|
|
69
|
+
CommandCompleteResult,
|
|
70
|
+
Completion,
|
|
71
|
+
DeliverAs,
|
|
72
|
+
SessionSendMessageParams,
|
|
73
|
+
SessionSendUserMessageParams,
|
|
74
|
+
SessionAppendEntryParams,
|
|
75
|
+
SessionSetModelParams,
|
|
76
|
+
ProviderModel,
|
|
77
|
+
ProviderRegistration,
|
|
78
|
+
ProviderCompleteParams,
|
|
79
|
+
ProviderCompleteResult,
|
|
80
|
+
ProviderDeltaParams,
|
|
81
|
+
ProviderOAuthParams,
|
|
82
|
+
ProviderCredentials,
|
|
83
|
+
ProviderStreamEvent,
|
|
84
|
+
ProviderToolCall,
|
|
85
|
+
ProviderUsage,
|
|
39
86
|
} from './protocol.js';
|
package/src/protocol.ts
CHANGED
|
@@ -22,6 +22,26 @@ 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',
|
|
30
|
+
SESSION_SET_MODEL: 'session/set_model',
|
|
31
|
+
PROVIDER_COMPLETE: 'provider/complete',
|
|
32
|
+
PROVIDER_DELTA: 'provider/delta',
|
|
33
|
+
PROVIDER_OAUTH_LOGIN: 'provider/oauth_login',
|
|
34
|
+
PROVIDER_OAUTH_REFRESH: 'provider/oauth_refresh',
|
|
35
|
+
/** Ext → host: publish onto the inter-extension bus (Phase 8). */
|
|
36
|
+
BUS_PUBLISH: 'bus/publish',
|
|
37
|
+
} as const;
|
|
38
|
+
|
|
39
|
+
/** SEP observe-event names the host fans out (Phase 8 additions included). */
|
|
40
|
+
export const eventName = {
|
|
41
|
+
/** Inter-extension bus fanout — payload `{ from, topic, payload }`. */
|
|
42
|
+
BUS_EVENT: 'bus/event',
|
|
43
|
+
/** Targeted render-block v2 keypress — payload `{ widget_id?, key }`. */
|
|
44
|
+
WIDGET_KEY: 'widget/key',
|
|
25
45
|
} as const;
|
|
26
46
|
|
|
27
47
|
/** JSON-RPC + SEP error codes (see spec/extension/envelope.md). */
|
|
@@ -62,6 +82,8 @@ export interface InitializeParams {
|
|
|
62
82
|
session?: { id?: string };
|
|
63
83
|
mode: 'tui' | 'web' | 'widget' | 'cli' | 'headless';
|
|
64
84
|
ui_capabilities?: string[];
|
|
85
|
+
/** Parsed values for the flags the extension declares (name → value). */
|
|
86
|
+
flags?: Record<string, unknown>;
|
|
65
87
|
capabilities_enabled?: Record<string, boolean>;
|
|
66
88
|
}
|
|
67
89
|
|
|
@@ -78,11 +100,94 @@ export interface CommandRegistration {
|
|
|
78
100
|
description: string;
|
|
79
101
|
}
|
|
80
102
|
|
|
103
|
+
export interface ShortcutRegistration {
|
|
104
|
+
/** A human-typed chord, e.g. `ctrl+p`; the frontend parses it. */
|
|
105
|
+
key: string;
|
|
106
|
+
/** The registered command this chord invokes (no leading `/`). */
|
|
107
|
+
command: string;
|
|
108
|
+
description?: string;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export interface ProviderModel {
|
|
112
|
+
/** Model id the host passes back in `provider/complete`. */
|
|
113
|
+
id: string;
|
|
114
|
+
/** Human-facing label for model pickers. */
|
|
115
|
+
display_name?: string;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export interface ProviderRegistration {
|
|
119
|
+
/** Provider name, unique within the host's merged model surface. */
|
|
120
|
+
name: string;
|
|
121
|
+
/** Informational upstream base URL (the extension does the real call). */
|
|
122
|
+
base_url?: string;
|
|
123
|
+
/** Env var the extension reads its API key from — informational to the host. */
|
|
124
|
+
api_key_env?: string;
|
|
125
|
+
/** Whether the extension implements `provider/oauth_login` + `oauth_refresh`. */
|
|
126
|
+
oauth?: boolean;
|
|
127
|
+
models?: ProviderModel[];
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** A declarative message renderer (Phase 8, pi's `registerMessageRenderer`):
|
|
131
|
+
* a custom message `tag` → render-block `template`. When a session entry carries
|
|
132
|
+
* the tag, the frontend renders the template with `{{path}}` placeholders
|
|
133
|
+
* resolved against the entry's data. Data-only — the host never runs it. */
|
|
134
|
+
export interface MessageRendererRegistration {
|
|
135
|
+
tag: string;
|
|
136
|
+
template: RenderBlock;
|
|
137
|
+
}
|
|
138
|
+
|
|
81
139
|
export interface Registrations {
|
|
82
140
|
tools?: ToolRegistration[];
|
|
83
141
|
commands?: CommandRegistration[];
|
|
84
142
|
flags?: string[];
|
|
143
|
+
shortcuts?: ShortcutRegistration[];
|
|
85
144
|
subscriptions?: string[];
|
|
145
|
+
providers?: ProviderRegistration[];
|
|
146
|
+
/** Intercept hooks this extension handles (Phase 8) — lets the host skip the
|
|
147
|
+
* per-turn `context` hook when no extension handles it. Empty = unknown. */
|
|
148
|
+
hooks?: string[];
|
|
149
|
+
/** Declarative custom-message renderers (Phase 8). */
|
|
150
|
+
message_renderers?: MessageRendererRegistration[];
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// --- render blocks (Phase 8) --------------------------------------------
|
|
154
|
+
|
|
155
|
+
/** One key an interactive `widget` render block declares. The host routes the
|
|
156
|
+
* matching keypress back as a `widget/key` event ({@link WidgetKeyPayload}). */
|
|
157
|
+
export interface Keybinding {
|
|
158
|
+
/** A human chord the frontend matches, e.g. `ArrowUp`, `space`, `q`. */
|
|
159
|
+
key: string;
|
|
160
|
+
description?: string;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* The declarative render-block DSL (Phase 8) — replaces pi's function renderers.
|
|
165
|
+
* The host/frontend renders each `kind` natively (TUI/web/widget); `text` is the
|
|
166
|
+
* always-available plain fallback (frontends may derive one when omitted). The
|
|
167
|
+
* `widget` kind is the interactive tier: it wraps a `body` block and declares
|
|
168
|
+
* `keybindings`; the host routes matching keys back as `widget/key` events and
|
|
169
|
+
* the extension re-renders via `ui.setWidget`.
|
|
170
|
+
*/
|
|
171
|
+
export type RenderBlock =
|
|
172
|
+
| { kind: 'markdown'; text: string }
|
|
173
|
+
| { kind: 'keyvalue'; rows: { key: string; value: string }[]; title?: string; text?: string }
|
|
174
|
+
| { kind: 'table'; columns: string[]; rows: string[][]; text?: string }
|
|
175
|
+
| { kind: 'diff'; patch: string; text?: string }
|
|
176
|
+
| { kind: 'progress'; value: number; label?: string; text?: string }
|
|
177
|
+
| { kind: 'stack'; children: RenderBlock[]; text?: string }
|
|
178
|
+
| { kind: 'widget'; widget_id: string; body: RenderBlock; keybindings: Keybinding[]; text?: string };
|
|
179
|
+
|
|
180
|
+
/** Payload of the `bus/event` observe event (inter-extension bus, Phase 8). */
|
|
181
|
+
export interface BusEventPayload {
|
|
182
|
+
from: string;
|
|
183
|
+
topic: string;
|
|
184
|
+
payload?: unknown;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/** Payload of the `widget/key` observe event (render-block v2, Phase 8). */
|
|
188
|
+
export interface WidgetKeyPayload {
|
|
189
|
+
widget_id?: string;
|
|
190
|
+
key: string;
|
|
86
191
|
}
|
|
87
192
|
|
|
88
193
|
export interface InitializeResult {
|
|
@@ -142,7 +247,7 @@ export type UiRequestParams =
|
|
|
142
247
|
| { kind: 'input'; prompt: string; default?: string }
|
|
143
248
|
| { kind: 'notify'; message: string; level?: 'info' | 'warn' | 'error' }
|
|
144
249
|
| { kind: 'set_status'; status: string }
|
|
145
|
-
| { kind: 'set_widget'; widget:
|
|
250
|
+
| { kind: 'set_widget'; widget: RenderBlock }
|
|
146
251
|
| { kind: 'set_title'; title: string };
|
|
147
252
|
|
|
148
253
|
/**
|
|
@@ -156,3 +261,131 @@ export interface UiRequestResult {
|
|
|
156
261
|
text?: string;
|
|
157
262
|
cancelled?: boolean;
|
|
158
263
|
}
|
|
264
|
+
|
|
265
|
+
/** Host → ext `command/execute`: run a registered slash-command (command tier). */
|
|
266
|
+
export interface CommandExecuteParams {
|
|
267
|
+
command: string;
|
|
268
|
+
context: Context;
|
|
269
|
+
arguments?: Record<string, unknown>;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
export interface CommandExecuteResult {
|
|
273
|
+
content?: string;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
/** Host → ext `command/complete`: argument autocomplete for a slash-command. */
|
|
277
|
+
export interface CommandCompleteParams {
|
|
278
|
+
command: string;
|
|
279
|
+
context: Context;
|
|
280
|
+
partial?: string;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
export interface Completion {
|
|
284
|
+
value: string;
|
|
285
|
+
description?: string;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
export interface CommandCompleteResult {
|
|
289
|
+
completions: Completion[];
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/** How a `session/send_user_message` is delivered relative to the current turn. */
|
|
293
|
+
export type DeliverAs = 'steer' | 'follow_up' | 'next_turn';
|
|
294
|
+
|
|
295
|
+
/** Ext → host `session/send_message` params (command tier — carries `context`). */
|
|
296
|
+
export interface SessionSendMessageParams {
|
|
297
|
+
context: Context;
|
|
298
|
+
text: string;
|
|
299
|
+
role?: 'user' | 'assistant';
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
export interface SessionSendUserMessageParams {
|
|
303
|
+
context: Context;
|
|
304
|
+
text: string;
|
|
305
|
+
deliver_as?: DeliverAs;
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
export interface SessionAppendEntryParams {
|
|
309
|
+
context: Context;
|
|
310
|
+
entry: Record<string, unknown>;
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
export interface SessionSetModelParams {
|
|
314
|
+
context: Context;
|
|
315
|
+
model: string;
|
|
316
|
+
/** Provider name when the model belongs to an extension-registered provider. */
|
|
317
|
+
provider?: string;
|
|
318
|
+
/** Reasoning/thinking level, e.g. `off`, `low`, `medium`, `high`. */
|
|
319
|
+
thinking?: string;
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
// --- provider/* (Phase 7) ------------------------------------------------
|
|
323
|
+
|
|
324
|
+
/** A serialized host stream event, tagged by `type`. Emitted as `provider/delta`
|
|
325
|
+
* `event` while a streaming `provider/complete` runs. */
|
|
326
|
+
export type ProviderStreamEvent =
|
|
327
|
+
| { type: 'Delta'; content: string }
|
|
328
|
+
| { type: 'Reasoning'; content: string }
|
|
329
|
+
| { type: 'ToolCallStart'; index: number; id: string; name: string }
|
|
330
|
+
| { type: 'ToolCallArgumentsDelta'; index: number; arguments_chunk: string }
|
|
331
|
+
| { type: 'Usage'; prompt_tokens?: number; completion_tokens?: number; total_tokens?: number; cached_tokens?: number }
|
|
332
|
+
| { type: 'Model'; name: string }
|
|
333
|
+
| { type: 'Done'; finish_reason: string };
|
|
334
|
+
|
|
335
|
+
/** Host → ext `provider/complete`: run one completion. `messages`/`tools` are the
|
|
336
|
+
* host's opaque serialized shapes. */
|
|
337
|
+
export interface ProviderCompleteParams {
|
|
338
|
+
request_id: string;
|
|
339
|
+
provider: string;
|
|
340
|
+
model: string;
|
|
341
|
+
messages: Record<string, unknown>[];
|
|
342
|
+
tools?: Record<string, unknown>[];
|
|
343
|
+
stream?: boolean;
|
|
344
|
+
response_format?: Record<string, unknown>;
|
|
345
|
+
thinking?: string;
|
|
346
|
+
context?: Context;
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
export interface ProviderToolCall {
|
|
350
|
+
id: string;
|
|
351
|
+
name: string;
|
|
352
|
+
arguments: unknown;
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
export interface ProviderUsage {
|
|
356
|
+
prompt_tokens?: number;
|
|
357
|
+
completion_tokens?: number;
|
|
358
|
+
total_tokens?: number;
|
|
359
|
+
cached_tokens?: number;
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
/** The final reply to `provider/complete`, mapping onto the host's LlmResponse. */
|
|
363
|
+
export interface ProviderCompleteResult {
|
|
364
|
+
content?: string;
|
|
365
|
+
tool_calls?: ProviderToolCall[];
|
|
366
|
+
finish_reason?: string;
|
|
367
|
+
usage?: ProviderUsage;
|
|
368
|
+
reasoning_content?: string;
|
|
369
|
+
resolved_model?: string;
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
/** Ext → host `provider/delta` notification: one streamed chunk. */
|
|
373
|
+
export interface ProviderDeltaParams {
|
|
374
|
+
request_id: string;
|
|
375
|
+
event: ProviderStreamEvent;
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
/** Host → ext `provider/oauth_login` / `provider/oauth_refresh`. */
|
|
379
|
+
export interface ProviderOAuthParams {
|
|
380
|
+
provider: string;
|
|
381
|
+
refresh_token?: string;
|
|
382
|
+
context?: Context;
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
export interface ProviderCredentials {
|
|
386
|
+
api_key?: string;
|
|
387
|
+
access_token?: string;
|
|
388
|
+
refresh_token?: string;
|
|
389
|
+
expires_at?: number;
|
|
390
|
+
extra?: Record<string, unknown>;
|
|
391
|
+
}
|
package/src/render.ts
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Typed builders for the Phase 8 render-block DSL. These are thin — a render
|
|
3
|
+
* block is plain data on the wire — but they keep an extension's UI code
|
|
4
|
+
* type-checked and let a pi port replace `render*` functions mechanically.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import type { Keybinding, RenderBlock } from './protocol.js';
|
|
8
|
+
|
|
9
|
+
export const render = {
|
|
10
|
+
markdown: (text: string): RenderBlock => ({ kind: 'markdown', text }),
|
|
11
|
+
|
|
12
|
+
keyvalue: (rows: { key: string; value: string }[], opts?: { title?: string; text?: string }): RenderBlock => ({
|
|
13
|
+
kind: 'keyvalue',
|
|
14
|
+
rows,
|
|
15
|
+
...(opts?.title !== undefined ? { title: opts.title } : {}),
|
|
16
|
+
...(opts?.text !== undefined ? { text: opts.text } : {}),
|
|
17
|
+
}),
|
|
18
|
+
|
|
19
|
+
table: (columns: string[], rows: string[][], opts?: { text?: string }): RenderBlock => ({
|
|
20
|
+
kind: 'table',
|
|
21
|
+
columns,
|
|
22
|
+
rows,
|
|
23
|
+
...(opts?.text !== undefined ? { text: opts.text } : {}),
|
|
24
|
+
}),
|
|
25
|
+
|
|
26
|
+
diff: (patch: string, opts?: { text?: string }): RenderBlock => ({
|
|
27
|
+
kind: 'diff',
|
|
28
|
+
patch,
|
|
29
|
+
...(opts?.text !== undefined ? { text: opts.text } : {}),
|
|
30
|
+
}),
|
|
31
|
+
|
|
32
|
+
/** `value` is a 0..1 fraction. */
|
|
33
|
+
progress: (value: number, opts?: { label?: string; text?: string }): RenderBlock => ({
|
|
34
|
+
kind: 'progress',
|
|
35
|
+
value,
|
|
36
|
+
...(opts?.label !== undefined ? { label: opts.label } : {}),
|
|
37
|
+
...(opts?.text !== undefined ? { text: opts.text } : {}),
|
|
38
|
+
}),
|
|
39
|
+
|
|
40
|
+
stack: (children: RenderBlock[], opts?: { text?: string }): RenderBlock => ({
|
|
41
|
+
kind: 'stack',
|
|
42
|
+
children,
|
|
43
|
+
...(opts?.text !== undefined ? { text: opts.text } : {}),
|
|
44
|
+
}),
|
|
45
|
+
|
|
46
|
+
/** The interactive tier: wraps `body` and declares the keys the host should
|
|
47
|
+
* route back as `widget/key` events. Correlate re-renders with `widgetId`. */
|
|
48
|
+
widget: (widgetId: string, body: RenderBlock, keybindings: Keybinding[], opts?: { text?: string }): RenderBlock => ({
|
|
49
|
+
kind: 'widget',
|
|
50
|
+
widget_id: widgetId,
|
|
51
|
+
body,
|
|
52
|
+
keybindings,
|
|
53
|
+
...(opts?.text !== undefined ? { text: opts.text } : {}),
|
|
54
|
+
}),
|
|
55
|
+
} as const;
|
package/src/test-host.ts
CHANGED
|
@@ -7,7 +7,21 @@
|
|
|
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
|
+
ProviderCompleteResult,
|
|
17
|
+
ProviderCredentials,
|
|
18
|
+
ProviderDeltaParams,
|
|
19
|
+
ProviderStreamEvent,
|
|
20
|
+
ToolExecuteResult,
|
|
21
|
+
ToolUpdateParams,
|
|
22
|
+
UiRequestParams,
|
|
23
|
+
UiRequestResult,
|
|
24
|
+
} from './protocol.js';
|
|
11
25
|
import type { Extension } from './extension.js';
|
|
12
26
|
import { linkedPair } from './transport.js';
|
|
13
27
|
|
|
@@ -17,6 +31,18 @@ let callSeq = 0;
|
|
|
17
31
|
* `RpcError` (e.g. code -32001) to simulate a headless/uncapable frontend. */
|
|
18
32
|
export type UiResponder = (params: UiRequestParams) => UiRequestResult | Promise<UiRequestResult>;
|
|
19
33
|
|
|
34
|
+
/** A recorded ext→host `session/*` request the test can assert on. */
|
|
35
|
+
export interface SessionCall {
|
|
36
|
+
method: string;
|
|
37
|
+
params: Record<string, unknown>;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** A recorded ext→host `bus/publish` (Phase 8) the test can assert on. */
|
|
41
|
+
export interface BusPublish {
|
|
42
|
+
topic: string;
|
|
43
|
+
payload?: unknown;
|
|
44
|
+
}
|
|
45
|
+
|
|
20
46
|
export interface CreateTestHostOptions {
|
|
21
47
|
/** Answers `ui/request`. Default: reject every call with -32001 NoUI. */
|
|
22
48
|
onUiRequest?: UiResponder;
|
|
@@ -31,13 +57,41 @@ export interface CallToolOptions {
|
|
|
31
57
|
context?: Context;
|
|
32
58
|
}
|
|
33
59
|
|
|
60
|
+
export interface CompleteOptions {
|
|
61
|
+
/** Ask for streaming; each `provider/delta` reaches `onDelta`. */
|
|
62
|
+
stream?: boolean;
|
|
63
|
+
/** Receives each streamed `provider/delta` event for this request. */
|
|
64
|
+
onDelta?: (event: ProviderStreamEvent) => void;
|
|
65
|
+
/** Tool schemas to offer the model (opaque JSON). */
|
|
66
|
+
tools?: Record<string, unknown>[];
|
|
67
|
+
responseFormat?: Record<string, unknown>;
|
|
68
|
+
thinking?: string;
|
|
69
|
+
signal?: AbortSignal;
|
|
70
|
+
context?: Context;
|
|
71
|
+
}
|
|
72
|
+
|
|
34
73
|
export interface TestHost {
|
|
35
74
|
initialize(overrides?: Partial<InitializeParams>): Promise<InitializeResult>;
|
|
36
75
|
callTool(tool: string, args: Record<string, unknown>, opts?: CallToolOptions): Promise<ToolExecuteResult>;
|
|
37
76
|
/** Drive a `hook` request and get back the extension's folded outcome. */
|
|
38
77
|
callHook(hook: string, input: Record<string, unknown>, context?: Context): Promise<HookOutcome>;
|
|
78
|
+
/** Dispatch a `command/execute` with a command-tier context by default. */
|
|
79
|
+
runCommand(command: string, args?: Record<string, unknown>, context?: Context): Promise<CommandExecuteResult>;
|
|
80
|
+
/** Dispatch a `command/complete` for argument autocomplete. */
|
|
81
|
+
completeCommand(command: string, partial: string, context?: Context): Promise<{ completions: { value: string; description?: string }[] }>;
|
|
39
82
|
ping(): Promise<Record<string, unknown>>;
|
|
40
83
|
sendEvent(event: string, payload?: Record<string, unknown>, context?: Context): void;
|
|
84
|
+
/** Drive `provider/complete` against a registered provider, collecting any
|
|
85
|
+
* streamed `provider/delta` events via `opts.onDelta`. */
|
|
86
|
+
complete(provider: string, model: string, messages: Record<string, unknown>[], opts?: CompleteOptions): Promise<ProviderCompleteResult>;
|
|
87
|
+
/** Drive `provider/oauth_login` for a provider. */
|
|
88
|
+
oauthLogin(provider: string, context?: Context): Promise<ProviderCredentials>;
|
|
89
|
+
/** Drive `provider/oauth_refresh` for a provider. */
|
|
90
|
+
oauthRefresh(provider: string, refreshToken: string, context?: Context): Promise<ProviderCredentials>;
|
|
91
|
+
/** Every `session/*` request the extension made, in order — for assertions. */
|
|
92
|
+
readonly sessionCalls: SessionCall[];
|
|
93
|
+
/** Every `bus/publish` (Phase 8) the extension made, in order. */
|
|
94
|
+
readonly busPublishes: BusPublish[];
|
|
41
95
|
shutdown(): Promise<void>;
|
|
42
96
|
close(): void;
|
|
43
97
|
}
|
|
@@ -63,6 +117,38 @@ export function createTestHost(extension: Extension, options: CreateTestHostOpti
|
|
|
63
117
|
// Extension notifications the host just observes in tests.
|
|
64
118
|
host.setNotificationHandler(method.LOG, () => {});
|
|
65
119
|
host.setNotificationHandler(method.REGISTRY_UPDATE, () => {});
|
|
120
|
+
// Route `provider/delta` chunks to the in-flight completion's sink, keyed by
|
|
121
|
+
// request_id — the in-process mirror of the engine's ProviderStreams.
|
|
122
|
+
const deltaSinks = new Map<string, (event: ProviderStreamEvent) => void>();
|
|
123
|
+
host.setNotificationHandler(method.PROVIDER_DELTA, (params) => {
|
|
124
|
+
const p = params as ProviderDeltaParams;
|
|
125
|
+
deltaSinks.get(p.request_id)?.(p.event);
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
// Service ext→host `session/*` requests, enforcing the same command-tier
|
|
129
|
+
// guard the real host does (event-tier → -32003) so a demo's session calls
|
|
130
|
+
// are exercised realistically. Every call is recorded for assertions.
|
|
131
|
+
const sessionCalls: SessionCall[] = [];
|
|
132
|
+
const sessionHandler = (params: unknown) => {
|
|
133
|
+
const p = (params ?? {}) as Record<string, unknown>;
|
|
134
|
+
const tier = (p.context as { tier?: string } | undefined)?.tier;
|
|
135
|
+
if (tier !== 'command') throw new RpcError(errorCode.ContextViolation, 'session action requires a command-tier context');
|
|
136
|
+
return p;
|
|
137
|
+
};
|
|
138
|
+
for (const m of [method.SESSION_SEND_MESSAGE, method.SESSION_SEND_USER_MESSAGE, method.SESSION_APPEND_ENTRY, method.SESSION_SET_MODEL]) {
|
|
139
|
+
host.setRequestHandler(m, (params) => {
|
|
140
|
+
const recorded = sessionHandler(params);
|
|
141
|
+
sessionCalls.push({ method: m, params: recorded });
|
|
142
|
+
return {};
|
|
143
|
+
});
|
|
144
|
+
}
|
|
145
|
+
// Record ext→host `bus/publish` (Phase 8); a real host would fan it out.
|
|
146
|
+
const busPublishes: BusPublish[] = [];
|
|
147
|
+
host.setRequestHandler(method.BUS_PUBLISH, (params) => {
|
|
148
|
+
const p = (params ?? {}) as { topic?: string; payload?: unknown };
|
|
149
|
+
busPublishes.push({ topic: p.topic ?? '', ...(p.payload !== undefined ? { payload: p.payload } : {}) });
|
|
150
|
+
return {};
|
|
151
|
+
});
|
|
66
152
|
hostT.start((frame) => host.receive(frame));
|
|
67
153
|
|
|
68
154
|
return {
|
|
@@ -93,12 +179,57 @@ export function createTestHost(extension: Extension, options: CreateTestHostOpti
|
|
|
93
179
|
callHook(hook, input, context) {
|
|
94
180
|
return host.request<HookOutcome>(method.HOOK, { hook, input, context: context ?? DEFAULT_CONTEXT });
|
|
95
181
|
},
|
|
182
|
+
runCommand(command, args, context) {
|
|
183
|
+
return host.request<CommandExecuteResult>(method.COMMAND_EXECUTE, {
|
|
184
|
+
command,
|
|
185
|
+
context: context ?? DEFAULT_CONTEXT,
|
|
186
|
+
...(args ? { arguments: args } : {}),
|
|
187
|
+
});
|
|
188
|
+
},
|
|
189
|
+
completeCommand(command, partial, context) {
|
|
190
|
+
return host.request(method.COMMAND_COMPLETE, { command, context: context ?? DEFAULT_CONTEXT, partial });
|
|
191
|
+
},
|
|
192
|
+
sessionCalls,
|
|
193
|
+
busPublishes,
|
|
96
194
|
ping() {
|
|
97
195
|
return host.request<Record<string, unknown>>(method.PING, {});
|
|
98
196
|
},
|
|
99
197
|
sendEvent(event, payload, context) {
|
|
100
198
|
host.notify(method.EVENT, { event, context: context ?? { token: DEFAULT_CONTEXT.token, tier: 'event' }, ...(payload ? { payload } : {}) });
|
|
101
199
|
},
|
|
200
|
+
async complete(provider, model, messages, opts = {}) {
|
|
201
|
+
const request_id = `test-req-${++callSeq}`;
|
|
202
|
+
if (opts.onDelta) deltaSinks.set(request_id, opts.onDelta);
|
|
203
|
+
try {
|
|
204
|
+
return await host.request<ProviderCompleteResult>(
|
|
205
|
+
method.PROVIDER_COMPLETE,
|
|
206
|
+
{
|
|
207
|
+
request_id,
|
|
208
|
+
provider,
|
|
209
|
+
model,
|
|
210
|
+
messages,
|
|
211
|
+
tools: opts.tools ?? [],
|
|
212
|
+
stream: opts.stream ?? false,
|
|
213
|
+
...(opts.responseFormat ? { response_format: opts.responseFormat } : {}),
|
|
214
|
+
...(opts.thinking ? { thinking: opts.thinking } : {}),
|
|
215
|
+
context: opts.context ?? DEFAULT_CONTEXT,
|
|
216
|
+
},
|
|
217
|
+
opts.signal,
|
|
218
|
+
);
|
|
219
|
+
} finally {
|
|
220
|
+
deltaSinks.delete(request_id);
|
|
221
|
+
}
|
|
222
|
+
},
|
|
223
|
+
oauthLogin(provider, context) {
|
|
224
|
+
return host.request<ProviderCredentials>(method.PROVIDER_OAUTH_LOGIN, { provider, context: context ?? DEFAULT_CONTEXT });
|
|
225
|
+
},
|
|
226
|
+
oauthRefresh(provider, refreshToken, context) {
|
|
227
|
+
return host.request<ProviderCredentials>(method.PROVIDER_OAUTH_REFRESH, {
|
|
228
|
+
provider,
|
|
229
|
+
refresh_token: refreshToken,
|
|
230
|
+
context: context ?? DEFAULT_CONTEXT,
|
|
231
|
+
});
|
|
232
|
+
},
|
|
102
233
|
async shutdown() {
|
|
103
234
|
await host.request(method.SHUTDOWN, {});
|
|
104
235
|
},
|