@smooai/smooth-extension-sdk 0.5.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/extension.ts +188 -2
- package/src/index.ts +24 -3
- package/src/protocol.ts +170 -1
- package/src/render.ts +55 -0
- package/src/test-host.ts +81 -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/extension.ts
CHANGED
|
@@ -11,7 +11,7 @@
|
|
|
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
16
|
CommandCompleteParams,
|
|
17
17
|
CommandCompleteResult,
|
|
@@ -26,6 +26,16 @@ import type {
|
|
|
26
26
|
HookParams,
|
|
27
27
|
InitializeParams,
|
|
28
28
|
InitializeResult,
|
|
29
|
+
BusEventPayload,
|
|
30
|
+
MessageRendererRegistration,
|
|
31
|
+
RenderBlock,
|
|
32
|
+
ProviderCompleteParams,
|
|
33
|
+
ProviderCompleteResult,
|
|
34
|
+
ProviderCredentials,
|
|
35
|
+
ProviderModel,
|
|
36
|
+
ProviderOAuthParams,
|
|
37
|
+
ProviderRegistration,
|
|
38
|
+
ProviderStreamEvent,
|
|
29
39
|
ShortcutRegistration,
|
|
30
40
|
ToolExecuteParams,
|
|
31
41
|
ToolExecuteResult,
|
|
@@ -55,10 +65,24 @@ export interface UiApi {
|
|
|
55
65
|
input(prompt: string, opts?: { default?: string }): Promise<UiRequestResult>;
|
|
56
66
|
notify(message: string, level?: 'info' | 'warn' | 'error'): Promise<void>;
|
|
57
67
|
setStatus(status: string): Promise<void>;
|
|
58
|
-
|
|
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>;
|
|
59
71
|
setTitle(title: string): Promise<void>;
|
|
60
72
|
}
|
|
61
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
|
+
|
|
62
86
|
/** Build a [`UiApi`] that speaks `ui/request` over `peer`. */
|
|
63
87
|
function makeUi(peer: Peer): UiApi {
|
|
64
88
|
const req = (params: UiRequestParams) => peer.request<UiRequestResult>(method.UI_REQUEST, params);
|
|
@@ -92,6 +116,9 @@ export interface SessionApi {
|
|
|
92
116
|
sendMessage(text: string, opts?: { role?: 'user' | 'assistant' }): Promise<void>;
|
|
93
117
|
sendUserMessage(text: string, opts?: { deliverAs?: DeliverAs }): Promise<void>;
|
|
94
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>;
|
|
95
122
|
}
|
|
96
123
|
|
|
97
124
|
/** Build a [`SessionApi`] bound to `context` (must be command-tier) over `peer`. */
|
|
@@ -106,6 +133,14 @@ function makeSession(peer: Peer, context: Context): SessionApi {
|
|
|
106
133
|
appendEntry: async (entry) => {
|
|
107
134
|
await peer.request(method.SESSION_APPEND_ENTRY, { context, entry });
|
|
108
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
|
+
},
|
|
109
144
|
};
|
|
110
145
|
}
|
|
111
146
|
|
|
@@ -183,6 +218,58 @@ export interface FlagDef {
|
|
|
183
218
|
description?: string;
|
|
184
219
|
}
|
|
185
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
|
+
|
|
186
273
|
/** A hook handler's friendly return: veto the operation, or replace its input
|
|
187
274
|
* with a patch (shallow-merged onto the input). Returning nothing = continue. */
|
|
188
275
|
export type HookResult = { block: true; reason?: string } | { patch: Record<string, unknown> };
|
|
@@ -203,6 +290,13 @@ export interface SmoothApi {
|
|
|
203
290
|
registerFlag(flag: FlagDef): void;
|
|
204
291
|
/** Bind a keyboard shortcut (TUI frontends) to a registered command. */
|
|
205
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;
|
|
206
300
|
on(event: string, handler: EventHandler): void;
|
|
207
301
|
log(level: 'debug' | 'info' | 'warn' | 'error', message: string, fields?: Record<string, unknown>): void;
|
|
208
302
|
/** The parsed value the host delivered for a declared flag (undefined before
|
|
@@ -228,7 +322,9 @@ export class Extension {
|
|
|
228
322
|
private readonly commands = new Map<string, CommandDef>();
|
|
229
323
|
private readonly flagDefs = new Map<string, FlagDef>();
|
|
230
324
|
private readonly shortcuts: ShortcutRegistration[] = [];
|
|
325
|
+
private readonly providers = new Map<string, ProviderDef>();
|
|
231
326
|
private readonly events = new Map<string, EventHandler[]>();
|
|
327
|
+
private readonly messageRenderers: MessageRendererRegistration[] = [];
|
|
232
328
|
private name = 'extension';
|
|
233
329
|
private version = '0.0.0';
|
|
234
330
|
/** Set once connected so `log()` before connect is a safe no-op. */
|
|
@@ -264,6 +360,32 @@ export class Extension {
|
|
|
264
360
|
registerShortcut: (shortcut) => {
|
|
265
361
|
this.shortcuts.push(shortcut);
|
|
266
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
|
+
},
|
|
267
389
|
on: (event, handler) => {
|
|
268
390
|
const list = this.events.get(event) ?? [];
|
|
269
391
|
list.push(handler);
|
|
@@ -299,6 +421,9 @@ export class Extension {
|
|
|
299
421
|
peer.setRequestHandler(method.HOOK, (params) => this.dispatchHook(params as HookParams));
|
|
300
422
|
peer.setRequestHandler(method.COMMAND_EXECUTE, (params) => this.executeCommand(params as CommandExecuteParams, peer));
|
|
301
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));
|
|
302
427
|
peer.setNotificationHandler(method.EVENT, (params) => this.dispatchEvent(params as EventParams));
|
|
303
428
|
|
|
304
429
|
transport.start((frame) => peer.receive(frame));
|
|
@@ -333,6 +458,17 @@ export class Extension {
|
|
|
333
458
|
// Only observe events go in `subscriptions` — hook names are intercepts
|
|
334
459
|
// the host always calls, not events it filters by subscription.
|
|
335
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
|
+
}));
|
|
336
472
|
return {
|
|
337
473
|
protocol_version: PROTOCOL_VERSION,
|
|
338
474
|
extension: { name: this.name, version: this.version },
|
|
@@ -341,11 +477,61 @@ export class Extension {
|
|
|
341
477
|
...(commands.length ? { commands } : {}),
|
|
342
478
|
...(flags.length ? { flags } : {}),
|
|
343
479
|
...(this.shortcuts.length ? { shortcuts: this.shortcuts } : {}),
|
|
480
|
+
...(providers.length ? { providers } : {}),
|
|
481
|
+
...(hooks.length ? { hooks } : {}),
|
|
482
|
+
...(this.messageRenderers.length ? { message_renderers: this.messageRenderers } : {}),
|
|
344
483
|
subscriptions,
|
|
345
484
|
},
|
|
346
485
|
};
|
|
347
486
|
}
|
|
348
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
|
+
|
|
349
535
|
private async executeCommand(params: CommandExecuteParams, peer: Peer): Promise<CommandExecuteResult> {
|
|
350
536
|
const command = this.commands.get(params.command);
|
|
351
537
|
if (!command) return { content: `unknown command: ${params.command}` };
|
package/src/index.ts
CHANGED
|
@@ -8,10 +8,12 @@
|
|
|
8
8
|
* `createTestHost`, and gate it against the shared fixtures with
|
|
9
9
|
* `runConformance`.
|
|
10
10
|
*/
|
|
11
|
-
export { defineExtension, defineTool, defineCommand, Extension } from './extension.js';
|
|
11
|
+
export { defineExtension, defineTool, defineCommand, defineProvider, Extension } from './extension.js';
|
|
12
|
+
export { render } from './render.js';
|
|
12
13
|
export type {
|
|
13
14
|
ExtensionSetup,
|
|
14
15
|
SmoothApi,
|
|
16
|
+
EventsApi,
|
|
15
17
|
ToolDef,
|
|
16
18
|
ToolContext,
|
|
17
19
|
ToolReturn,
|
|
@@ -19,6 +21,9 @@ export type {
|
|
|
19
21
|
CommandContext,
|
|
20
22
|
CommandReturn,
|
|
21
23
|
FlagDef,
|
|
24
|
+
ProviderDef,
|
|
25
|
+
ProviderContext,
|
|
26
|
+
ProviderCompleteRequest,
|
|
22
27
|
SessionApi,
|
|
23
28
|
EventHandler,
|
|
24
29
|
HookResult,
|
|
@@ -26,7 +31,7 @@ export type {
|
|
|
26
31
|
UiApi,
|
|
27
32
|
} from './extension.js';
|
|
28
33
|
export { createTestHost } from './test-host.js';
|
|
29
|
-
export type { TestHost, CallToolOptions, CreateTestHostOptions, UiResponder } from './test-host.js';
|
|
34
|
+
export type { TestHost, CallToolOptions, CreateTestHostOptions, UiResponder, BusPublish, SessionCall } from './test-host.js';
|
|
30
35
|
export { runConformance, DEFAULT_SPEC_DIR } from './conformance.js';
|
|
31
36
|
export type { ConformanceReport, ConformanceStep, RunConformanceOptions } from './conformance.js';
|
|
32
37
|
export { toJsonSchema } from './schema.js';
|
|
@@ -35,12 +40,17 @@ export { Peer, RpcError } from './jsonrpc.js';
|
|
|
35
40
|
export type { JsonRpcFrame } from './jsonrpc.js';
|
|
36
41
|
export { stdioTransport, linkedPair } from './transport.js';
|
|
37
42
|
export type { Transport } from './transport.js';
|
|
38
|
-
export { PROTOCOL_VERSION, method, errorCode } from './protocol.js';
|
|
43
|
+
export { PROTOCOL_VERSION, eventName, method, errorCode } from './protocol.js';
|
|
39
44
|
export type {
|
|
40
45
|
Context,
|
|
41
46
|
InitializeParams,
|
|
42
47
|
InitializeResult,
|
|
43
48
|
Registrations,
|
|
49
|
+
MessageRendererRegistration,
|
|
50
|
+
RenderBlock,
|
|
51
|
+
Keybinding,
|
|
52
|
+
BusEventPayload,
|
|
53
|
+
WidgetKeyPayload,
|
|
44
54
|
ToolRegistration,
|
|
45
55
|
ToolExecuteParams,
|
|
46
56
|
ToolExecuteResult,
|
|
@@ -62,4 +72,15 @@ export type {
|
|
|
62
72
|
SessionSendMessageParams,
|
|
63
73
|
SessionSendUserMessageParams,
|
|
64
74
|
SessionAppendEntryParams,
|
|
75
|
+
SessionSetModelParams,
|
|
76
|
+
ProviderModel,
|
|
77
|
+
ProviderRegistration,
|
|
78
|
+
ProviderCompleteParams,
|
|
79
|
+
ProviderCompleteResult,
|
|
80
|
+
ProviderDeltaParams,
|
|
81
|
+
ProviderOAuthParams,
|
|
82
|
+
ProviderCredentials,
|
|
83
|
+
ProviderStreamEvent,
|
|
84
|
+
ProviderToolCall,
|
|
85
|
+
ProviderUsage,
|
|
65
86
|
} from './protocol.js';
|
package/src/protocol.ts
CHANGED
|
@@ -27,6 +27,21 @@ export const method = {
|
|
|
27
27
|
SESSION_SEND_MESSAGE: 'session/send_message',
|
|
28
28
|
SESSION_SEND_USER_MESSAGE: 'session/send_user_message',
|
|
29
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',
|
|
30
45
|
} as const;
|
|
31
46
|
|
|
32
47
|
/** JSON-RPC + SEP error codes (see spec/extension/envelope.md). */
|
|
@@ -93,12 +108,86 @@ export interface ShortcutRegistration {
|
|
|
93
108
|
description?: string;
|
|
94
109
|
}
|
|
95
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
|
+
|
|
96
139
|
export interface Registrations {
|
|
97
140
|
tools?: ToolRegistration[];
|
|
98
141
|
commands?: CommandRegistration[];
|
|
99
142
|
flags?: string[];
|
|
100
143
|
shortcuts?: ShortcutRegistration[];
|
|
101
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;
|
|
102
191
|
}
|
|
103
192
|
|
|
104
193
|
export interface InitializeResult {
|
|
@@ -158,7 +247,7 @@ export type UiRequestParams =
|
|
|
158
247
|
| { kind: 'input'; prompt: string; default?: string }
|
|
159
248
|
| { kind: 'notify'; message: string; level?: 'info' | 'warn' | 'error' }
|
|
160
249
|
| { kind: 'set_status'; status: string }
|
|
161
|
-
| { kind: 'set_widget'; widget:
|
|
250
|
+
| { kind: 'set_widget'; widget: RenderBlock }
|
|
162
251
|
| { kind: 'set_title'; title: string };
|
|
163
252
|
|
|
164
253
|
/**
|
|
@@ -220,3 +309,83 @@ export interface SessionAppendEntryParams {
|
|
|
220
309
|
context: Context;
|
|
221
310
|
entry: Record<string, unknown>;
|
|
222
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
|
@@ -13,6 +13,10 @@ import type {
|
|
|
13
13
|
HookOutcome,
|
|
14
14
|
InitializeParams,
|
|
15
15
|
InitializeResult,
|
|
16
|
+
ProviderCompleteResult,
|
|
17
|
+
ProviderCredentials,
|
|
18
|
+
ProviderDeltaParams,
|
|
19
|
+
ProviderStreamEvent,
|
|
16
20
|
ToolExecuteResult,
|
|
17
21
|
ToolUpdateParams,
|
|
18
22
|
UiRequestParams,
|
|
@@ -33,6 +37,12 @@ export interface SessionCall {
|
|
|
33
37
|
params: Record<string, unknown>;
|
|
34
38
|
}
|
|
35
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
|
+
|
|
36
46
|
export interface CreateTestHostOptions {
|
|
37
47
|
/** Answers `ui/request`. Default: reject every call with -32001 NoUI. */
|
|
38
48
|
onUiRequest?: UiResponder;
|
|
@@ -47,6 +57,19 @@ export interface CallToolOptions {
|
|
|
47
57
|
context?: Context;
|
|
48
58
|
}
|
|
49
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
|
+
|
|
50
73
|
export interface TestHost {
|
|
51
74
|
initialize(overrides?: Partial<InitializeParams>): Promise<InitializeResult>;
|
|
52
75
|
callTool(tool: string, args: Record<string, unknown>, opts?: CallToolOptions): Promise<ToolExecuteResult>;
|
|
@@ -58,8 +81,17 @@ export interface TestHost {
|
|
|
58
81
|
completeCommand(command: string, partial: string, context?: Context): Promise<{ completions: { value: string; description?: string }[] }>;
|
|
59
82
|
ping(): Promise<Record<string, unknown>>;
|
|
60
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>;
|
|
61
91
|
/** Every `session/*` request the extension made, in order — for assertions. */
|
|
62
92
|
readonly sessionCalls: SessionCall[];
|
|
93
|
+
/** Every `bus/publish` (Phase 8) the extension made, in order. */
|
|
94
|
+
readonly busPublishes: BusPublish[];
|
|
63
95
|
shutdown(): Promise<void>;
|
|
64
96
|
close(): void;
|
|
65
97
|
}
|
|
@@ -85,6 +117,13 @@ export function createTestHost(extension: Extension, options: CreateTestHostOpti
|
|
|
85
117
|
// Extension notifications the host just observes in tests.
|
|
86
118
|
host.setNotificationHandler(method.LOG, () => {});
|
|
87
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
|
+
});
|
|
88
127
|
|
|
89
128
|
// Service ext→host `session/*` requests, enforcing the same command-tier
|
|
90
129
|
// guard the real host does (event-tier → -32003) so a demo's session calls
|
|
@@ -96,13 +135,20 @@ export function createTestHost(extension: Extension, options: CreateTestHostOpti
|
|
|
96
135
|
if (tier !== 'command') throw new RpcError(errorCode.ContextViolation, 'session action requires a command-tier context');
|
|
97
136
|
return p;
|
|
98
137
|
};
|
|
99
|
-
for (const m of [method.SESSION_SEND_MESSAGE, method.SESSION_SEND_USER_MESSAGE, method.SESSION_APPEND_ENTRY]) {
|
|
138
|
+
for (const m of [method.SESSION_SEND_MESSAGE, method.SESSION_SEND_USER_MESSAGE, method.SESSION_APPEND_ENTRY, method.SESSION_SET_MODEL]) {
|
|
100
139
|
host.setRequestHandler(m, (params) => {
|
|
101
140
|
const recorded = sessionHandler(params);
|
|
102
141
|
sessionCalls.push({ method: m, params: recorded });
|
|
103
142
|
return {};
|
|
104
143
|
});
|
|
105
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
|
+
});
|
|
106
152
|
hostT.start((frame) => host.receive(frame));
|
|
107
153
|
|
|
108
154
|
return {
|
|
@@ -144,12 +190,46 @@ export function createTestHost(extension: Extension, options: CreateTestHostOpti
|
|
|
144
190
|
return host.request(method.COMMAND_COMPLETE, { command, context: context ?? DEFAULT_CONTEXT, partial });
|
|
145
191
|
},
|
|
146
192
|
sessionCalls,
|
|
193
|
+
busPublishes,
|
|
147
194
|
ping() {
|
|
148
195
|
return host.request<Record<string, unknown>>(method.PING, {});
|
|
149
196
|
},
|
|
150
197
|
sendEvent(event, payload, context) {
|
|
151
198
|
host.notify(method.EVENT, { event, context: context ?? { token: DEFAULT_CONTEXT.token, tier: 'event' }, ...(payload ? { payload } : {}) });
|
|
152
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
|
+
},
|
|
153
233
|
async shutdown() {
|
|
154
234
|
await host.request(method.SHUTDOWN, {});
|
|
155
235
|
},
|