@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@smooai/smooth-extension-sdk",
3
- "version": "0.5.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
- setWidget(widget: Record<string, unknown>): Promise<void>;
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: Record<string, unknown> }
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
  },