Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions .changeset/sep-phase3-ui-sdk.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
---
"@smooai/smooth-extension-sdk": minor
"@smooai/smooth-operator": patch
---

SEP Phase 3 (SDK + spec) — the `ui/request` surface.

The extension SDK now exposes the capability-negotiated UI surface. An extension
reads the host's declared `ui_capabilities` from the `initialize` handshake and
gates on `smooth.hasUI(kind)` / `ctx.hasUI(kind)`; `ctx.ui` (and `smooth.ui`)
speak `ui/request` back to the host: `select`/`confirm`/`input` return the user's
answer (or `{ cancelled: true }`), and `notify`/`setStatus`/`setWidget`/`setTitle`
push to the frontend. A headless or uncapable host rejects with `RpcError` code
-32001 (NoUI). `createTestHost(ext, { onUiRequest })` scripts the host side; its
default mimics a headless frontend.

Ships the `todo` demo extension (pi's todo, ported): stateful list whose tools
push a `keyvalue` `set_widget` render block and whose `clear` asks for `confirm`
first — both `hasUI`-gated, so it degrades cleanly headless.

Extends `spec/extension/conformance/fixtures.json` with the remaining `ui/request`
kinds (input/notify/set_status/set_widget/set_title), select/input/cancelled
results, and invalid cases (unknown kind, missing `options`/`message`, extra
property).
68 changes: 68 additions & 0 deletions spec/extension/conformance/fixtures.json
Original file line number Diff line number Diff line change
Expand Up @@ -281,6 +281,54 @@
"instance": { "confirmed": true }
},

"ui_request_input_params": {
"$schema_ref": "methods/ui-request.schema.json#/$defs/Params",
"description": "An input-kind UI request with a default value.",
"instance": { "kind": "input", "prompt": "Branch name?", "default": "main" }
},

"ui_request_notify_params": {
"$schema_ref": "methods/ui-request.schema.json#/$defs/Params",
"description": "A notify-kind UI request at warn level.",
"instance": { "kind": "notify", "message": "Build finished with warnings", "level": "warn" }
},

"ui_request_set_status_params": {
"$schema_ref": "methods/ui-request.schema.json#/$defs/Params",
"description": "A set_status-kind UI request.",
"instance": { "kind": "set_status", "status": "indexing…" }
},

"ui_request_set_widget_params": {
"$schema_ref": "methods/ui-request.schema.json#/$defs/Params",
"description": "A set_widget-kind UI request carrying a keyvalue render block.",
"instance": { "kind": "set_widget", "widget": { "kind": "keyvalue", "title": "Todos", "rows": [{ "key": "1", "value": "○ ship phase 3" }], "text": "1. [ ] ship phase 3" } }
},

"ui_request_set_title_params": {
"$schema_ref": "methods/ui-request.schema.json#/$defs/Params",
"description": "A set_title-kind UI request.",
"instance": { "kind": "set_title", "title": "todo — 3 open" }
},

"ui_request_result_select": {
"$schema_ref": "methods/ui-request.schema.json#/$defs/Result",
"description": "Reply to a select-kind UI request.",
"instance": { "value": "main" }
},

"ui_request_result_input": {
"$schema_ref": "methods/ui-request.schema.json#/$defs/Result",
"description": "Reply to an input-kind UI request.",
"instance": { "text": "feature-x" }
},

"ui_request_result_cancelled": {
"$schema_ref": "methods/ui-request.schema.json#/$defs/Result",
"description": "The user dismissed the UI without answering.",
"instance": { "cancelled": true }
},

"kv_get_params": {
"$schema_ref": "methods/kv.schema.json#/$defs/GetParams",
"description": "Fetch a value by key.",
Expand Down Expand Up @@ -419,6 +467,26 @@
"name": "envelope_request_missing_method",
"$schema_ref": "methods/envelope.schema.json#/$defs/Request",
"instance": { "jsonrpc": "2.0", "id": 1, "params": {} }
},
{
"name": "ui_request_unknown_kind",
"$schema_ref": "methods/ui-request.schema.json#/$defs/Params",
"instance": { "kind": "toast", "message": "hi" }
},
{
"name": "ui_request_select_missing_options",
"$schema_ref": "methods/ui-request.schema.json#/$defs/Params",
"instance": { "kind": "select", "prompt": "Pick one" }
},
{
"name": "ui_request_notify_missing_message",
"$schema_ref": "methods/ui-request.schema.json#/$defs/Params",
"instance": { "kind": "notify", "level": "info" }
},
{
"name": "ui_request_confirm_extra_property",
"$schema_ref": "methods/ui-request.schema.json#/$defs/Params",
"instance": { "kind": "confirm", "prompt": "Sure?", "options": ["yes", "no"] }
}
]
}
95 changes: 95 additions & 0 deletions typescript/extension-sdk/examples/todo.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
/**
* `todo` — the Phase 3 demo extension, pi's todo ported. A stateful checklist
* whose tools drive the `ui/request` surface: every mutation pushes a
* `set_widget` render block, and `clear` asks for a `confirm` first — both gated
* on `hasUI`, so the same extension degrades cleanly on a headless host.
*
* State lives in-process (the extension is a long-lived subprocess, so the list
* survives across tool calls within a session). Durable kv/appendEntry
* persistence is Phase 4.
*
* Run it as a real SEP subprocess: `tsx examples/todo.ts`
*/
import { z } from 'zod';
import { defineExtension, defineTool, type ToolContext } from '../src/index.js';

interface Item {
text: string;
done: boolean;
}

/** Build a fresh `todo` extension (its own empty list). Tests use this for
* isolation; the served singleton below shares one list across a session. */
export const createTodo = () =>
defineExtension((smooth) => {
smooth.name = 'todo';
smooth.version = '0.1.0';

const items: Item[] = [];

/** A `keyvalue` render block with a mandatory `text` fallback. */
const widget = () => ({
kind: 'keyvalue',
title: 'Todos',
rows: items.map((it, i) => ({ key: `${i + 1}`, value: `${it.done ? '✓' : '○'} ${it.text}` })),
text: items.length ? items.map((it, i) => `${i + 1}. [${it.done ? 'x' : ' '}] ${it.text}`).join('\n') : '(no todos)',
});

const render = async (ctx: ToolContext) => {
if (ctx.hasUI('set_widget')) await ctx.ui.setWidget(widget());
};

smooth.registerTool(
defineTool<{ text: string }>({
name: 'add',
description: 'Add a todo item.',
parameters: z.object({ text: z.string().describe('The task to add.') }),
async execute(args, ctx) {
items.push({ text: args.text, done: false });
await render(ctx);
return { content: `Added: ${args.text} (${items.length} total)` };
},
}),
);

smooth.registerTool(
defineTool<{ index: number }>({
name: 'done',
description: 'Mark a todo done by its 1-based number.',
parameters: z.object({ index: z.number().int().min(1).describe('1-based item number.') }),
async execute(args, ctx) {
const it = items[args.index - 1];
if (!it) return { content: `No todo #${args.index}`, is_error: true };
it.done = true;
await render(ctx);
return { content: `Done: ${it.text}` };
},
}),
);

smooth.registerTool(
defineTool<Record<string, never>>({
name: 'clear',
description: 'Clear all todos (asks for confirmation when a UI is available).',
parameters: z.object({}),
async execute(_args, ctx) {
if (ctx.hasUI('confirm')) {
const { confirmed, cancelled } = await ctx.ui.confirm(`Clear all ${items.length} todos?`);
if (cancelled || !confirmed) return { content: 'Cancelled.' };
}
const n = items.length;
items.length = 0;
await render(ctx);
return { content: `Cleared ${n} todos.` };
},
}),
);
});

/** The served singleton — one shared list for the process's lifetime. */
export const todo = createTodo();

// When run directly (not imported by a test), serve over stdio.
if (import.meta.url === `file://${process.argv[1]}`) {
todo.serve();
}
77 changes: 75 additions & 2 deletions typescript/extension-sdk/src/extension.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,14 +12,67 @@
*/
import { Peer } from './jsonrpc.js';
import { PROTOCOL_VERSION, method } from './protocol.js';
import type { Context, EventParams, HookOutcome, HookParams, InitializeParams, InitializeResult, ToolExecuteParams, ToolExecuteResult, ToolUpdateParams } from './protocol.js';
import type {
Context,
EventParams,
HookOutcome,
HookParams,
InitializeParams,
InitializeResult,
ToolExecuteParams,
ToolExecuteResult,
ToolUpdateParams,
UiKind,
UiRequestParams,
UiRequestResult,
} from './protocol.js';
import { toJsonSchema, type ParameterSchema } from './schema.js';
import { stdioTransport, type Transport } from './transport.js';

/** The intercept hooks (awaited, host-orchestrated); every other `on(...)` name
* is a fire-and-forget observe event. Kept in sync with the engine's HookType. */
const HOOK_NAMES = new Set(['tool_call', 'tool_result', 'before_agent_start', 'message_end', 'context', 'before_provider_request', 'input', 'user_bash']);

/**
* The `ui/request` surface handed to tools (and to event handlers via
* `smooth.ui`). Each call is an ext→host request; the frontend renders it and
* replies. `select`/`confirm`/`input` return an answer (or `{ cancelled: true }`
* if dismissed); `notify`/`setStatus`/`setWidget`/`setTitle` resolve empty. A
* headless or uncapable host rejects with an `RpcError` of code -32001 (NoUI) —
* gate with `hasUI(kind)` to avoid it.
*/
export interface UiApi {
select(prompt: string, options: string[]): Promise<UiRequestResult>;
confirm(prompt: string): Promise<UiRequestResult>;
input(prompt: string, opts?: { default?: string }): Promise<UiRequestResult>;
notify(message: string, level?: 'info' | 'warn' | 'error'): Promise<void>;
setStatus(status: string): Promise<void>;
setWidget(widget: Record<string, unknown>): Promise<void>;
setTitle(title: string): Promise<void>;
}

/** Build a [`UiApi`] that speaks `ui/request` over `peer`. */
function makeUi(peer: Peer): UiApi {
const req = (params: UiRequestParams) => peer.request<UiRequestResult>(method.UI_REQUEST, params);
return {
select: (prompt, options) => req({ kind: 'select', prompt, options }),
confirm: (prompt) => req({ kind: 'confirm', prompt }),
input: (prompt, opts) => req({ kind: 'input', prompt, ...(opts?.default !== undefined ? { default: opts.default } : {}) }),
notify: async (message, level) => {
await req({ kind: 'notify', message, ...(level ? { level } : {}) });
},
setStatus: async (status) => {
await req({ kind: 'set_status', status });
},
setWidget: async (widget) => {
await req({ kind: 'set_widget', widget });
},
setTitle: async (title) => {
await req({ kind: 'set_title', title });
},
};
}

/** Progress + cancellation handed to a tool while it runs. */
export interface ToolContext {
/** Correlates `onUpdate` calls with this execution. */
Expand All @@ -30,6 +83,10 @@ export interface ToolContext {
signal: AbortSignal;
/** Stream a progress notification back to the host. */
onUpdate(update: Omit<ToolUpdateParams, 'call_id'>): void;
/** Ask the frontend to render a dialog/widget. See [`UiApi`]. */
ui: UiApi;
/** True if the host's frontend can render this `ui/request` kind. */
hasUI(kind: UiKind): boolean;
}

/** What a tool's `execute` may return: a full result or just its `content`. */
Expand Down Expand Up @@ -64,6 +121,12 @@ export interface SmoothApi {
registerTool(tool: ToolDef<any>): void;
on(event: string, handler: EventHandler): void;
log(level: 'debug' | 'info' | 'warn' | 'error', message: string, fields?: Record<string, unknown>): void;
/** True if the host's frontend can render this `ui/request` kind. Only
* meaningful after `initialize` — returns false before the handshake. */
hasUI(kind: UiKind): boolean;
/** The `ui/request` surface. Available after the extension connects; throws
* if read before then. Gate with [`hasUI`]. */
readonly ui: UiApi;
}

export type ExtensionSetup = (smooth: SmoothApi) => void;
Expand All @@ -80,6 +143,8 @@ export class Extension {
private version = '0.0.0';
/** Set once connected so `log()` before connect is a safe no-op. */
private live?: Peer;
/** UI kinds the host declared answerable at `initialize`. */
private hostUiCaps: string[] = [];

constructor(setup: ExtensionSetup) {
const api: SmoothApi = {
Expand All @@ -106,6 +171,11 @@ export class Extension {
log: (level, message, fields) => {
this.live?.notify(method.LOG, { level, message, ...(fields ? { fields } : {}) });
},
hasUI: (kind) => self.hostUiCaps.includes(kind),
get ui() {
if (!self.live) throw new Error('smooth.ui is only available after the extension connects');
return makeUi(self.live);
},
};
// `self` alias so the getter/setter pair above closes over the instance.
const self = this;
Expand Down Expand Up @@ -145,7 +215,8 @@ export class Extension {
});
}

private initialize(_params: InitializeParams): InitializeResult {
private initialize(params: InitializeParams): InitializeResult {
this.hostUiCaps = params.ui_capabilities ?? [];
const tools = [...this.tools.values()].map((t) => ({
name: t.name,
description: t.description,
Expand All @@ -170,6 +241,8 @@ export class Extension {
context: params.context,
signal,
onUpdate: (update) => peer.notify(method.TOOL_UPDATE, { call_id: params.call_id, ...update }),
ui: makeUi(peer),
hasUI: (kind) => this.hostUiCaps.includes(kind),
};
const out = await tool.execute(params.arguments, ctx);
return typeof out === 'string' ? { content: out } : out;
Expand Down
7 changes: 5 additions & 2 deletions typescript/extension-sdk/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,9 @@
* `runConformance`.
*/
export { defineExtension, defineTool, Extension } from './extension.js';
export type { ExtensionSetup, SmoothApi, ToolDef, ToolContext, ToolReturn, EventHandler, HookResult, ConnectHandle } from './extension.js';
export type { ExtensionSetup, SmoothApi, ToolDef, ToolContext, ToolReturn, EventHandler, HookResult, ConnectHandle, UiApi } from './extension.js';
export { createTestHost } from './test-host.js';
export type { TestHost, CallToolOptions } from './test-host.js';
export type { TestHost, CallToolOptions, CreateTestHostOptions, UiResponder } from './test-host.js';
export { runConformance, DEFAULT_SPEC_DIR } from './conformance.js';
export type { ConformanceReport, ConformanceStep, RunConformanceOptions } from './conformance.js';
export { toJsonSchema } from './schema.js';
Expand All @@ -33,4 +33,7 @@ export type {
EventParams,
HookParams,
HookOutcome,
UiKind,
UiRequestParams,
UiRequestResult,
} from './protocol.js';
26 changes: 26 additions & 0 deletions typescript/extension-sdk/src/protocol.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ export const method = {
HOOK: 'hook',
TOOL_EXECUTE: 'tool/execute',
TOOL_UPDATE: 'tool/update',
UI_REQUEST: 'ui/request',
REGISTRY_UPDATE: 'registry/update',
LOG: 'log',
CANCEL: '$/cancel',
Expand Down Expand Up @@ -130,3 +131,28 @@ export type HookOutcome =
| { action: 'continue' }
| { action: 'block'; reason?: string }
| { action: 'modify'; patch: Record<string, unknown> };

/** The seven `ui/request` kinds (snake_case wire names). */
export type UiKind = 'select' | 'confirm' | 'input' | 'notify' | 'set_status' | 'set_widget' | 'set_title';

/** Params of `ui/request` (ext → host), discriminated by `kind`. */
export type UiRequestParams =
| { kind: 'select'; prompt: string; options: string[] }
| { kind: 'confirm'; prompt: string }
| { kind: 'input'; prompt: string; default?: string }
| { kind: 'notify'; message: string; level?: 'info' | 'warn' | 'error' }
| { kind: 'set_status'; status: string }
| { kind: 'set_widget'; widget: Record<string, unknown> }
| { kind: 'set_title'; title: string };

/**
* Reply to a `ui/request`. Which field is set depends on the request `kind`:
* `select` → `value`, `confirm` → `confirmed`, `input` → `text`; the rest are
* empty. Any kind may set `cancelled` if the user dismissed the UI.
*/
export interface UiRequestResult {
value?: string;
confirmed?: boolean;
text?: string;
cancelled?: boolean;
}
Loading
Loading