diff --git a/.changeset/sep-phase3-ui-sdk.md b/.changeset/sep-phase3-ui-sdk.md new file mode 100644 index 0000000..fb60bef --- /dev/null +++ b/.changeset/sep-phase3-ui-sdk.md @@ -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). diff --git a/spec/extension/conformance/fixtures.json b/spec/extension/conformance/fixtures.json index 0206436..10beb6f 100644 --- a/spec/extension/conformance/fixtures.json +++ b/spec/extension/conformance/fixtures.json @@ -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.", @@ -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"] } } ] } diff --git a/typescript/extension-sdk/examples/todo.ts b/typescript/extension-sdk/examples/todo.ts new file mode 100644 index 0000000..d8ae645 --- /dev/null +++ b/typescript/extension-sdk/examples/todo.ts @@ -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>({ + 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(); +} diff --git a/typescript/extension-sdk/src/extension.ts b/typescript/extension-sdk/src/extension.ts index d59ffec..28b7734 100644 --- a/typescript/extension-sdk/src/extension.ts +++ b/typescript/extension-sdk/src/extension.ts @@ -12,7 +12,20 @@ */ 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'; @@ -20,6 +33,46 @@ import { stdioTransport, type Transport } from './transport.js'; * 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; + confirm(prompt: string): Promise; + input(prompt: string, opts?: { default?: string }): Promise; + notify(message: string, level?: 'info' | 'warn' | 'error'): Promise; + setStatus(status: string): Promise; + setWidget(widget: Record): Promise; + setTitle(title: string): Promise; +} + +/** Build a [`UiApi`] that speaks `ui/request` over `peer`. */ +function makeUi(peer: Peer): UiApi { + const req = (params: UiRequestParams) => peer.request(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. */ @@ -30,6 +83,10 @@ export interface ToolContext { signal: AbortSignal; /** Stream a progress notification back to the host. */ onUpdate(update: Omit): 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`. */ @@ -64,6 +121,12 @@ export interface SmoothApi { registerTool(tool: ToolDef): void; on(event: string, handler: EventHandler): void; log(level: 'debug' | 'info' | 'warn' | 'error', message: string, fields?: Record): 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; @@ -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 = { @@ -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; @@ -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, @@ -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; diff --git a/typescript/extension-sdk/src/index.ts b/typescript/extension-sdk/src/index.ts index 91fd20b..fb89af7 100644 --- a/typescript/extension-sdk/src/index.ts +++ b/typescript/extension-sdk/src/index.ts @@ -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'; @@ -33,4 +33,7 @@ export type { EventParams, HookParams, HookOutcome, + UiKind, + UiRequestParams, + UiRequestResult, } from './protocol.js'; diff --git a/typescript/extension-sdk/src/protocol.ts b/typescript/extension-sdk/src/protocol.ts index 241ae55..a6f10eb 100644 --- a/typescript/extension-sdk/src/protocol.ts +++ b/typescript/extension-sdk/src/protocol.ts @@ -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', @@ -130,3 +131,28 @@ export type HookOutcome = | { action: 'continue' } | { action: 'block'; reason?: string } | { action: 'modify'; patch: Record }; + +/** 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 } + | { 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; +} diff --git a/typescript/extension-sdk/src/test-host.ts b/typescript/extension-sdk/src/test-host.ts index bdc9a59..261b2df 100644 --- a/typescript/extension-sdk/src/test-host.ts +++ b/typescript/extension-sdk/src/test-host.ts @@ -5,14 +5,23 @@ * (with progress + cancellation), events, ping and shutdown directly against a * `defineExtension(...)` object. */ -import { Peer } from './jsonrpc.js'; -import { PROTOCOL_VERSION, method } from './protocol.js'; -import type { Context, HookOutcome, InitializeParams, InitializeResult, ToolExecuteResult, ToolUpdateParams } from './protocol.js'; +import { Peer, RpcError } from './jsonrpc.js'; +import { PROTOCOL_VERSION, errorCode, method } from './protocol.js'; +import type { Context, HookOutcome, InitializeParams, InitializeResult, ToolExecuteResult, ToolUpdateParams, UiRequestParams, UiRequestResult } from './protocol.js'; import type { Extension } from './extension.js'; import { linkedPair } from './transport.js'; let callSeq = 0; +/** Answers the extension's `ui/request` calls. Return a result, or throw an + * `RpcError` (e.g. code -32001) to simulate a headless/uncapable frontend. */ +export type UiResponder = (params: UiRequestParams) => UiRequestResult | Promise; + +export interface CreateTestHostOptions { + /** Answers `ui/request`. Default: reject every call with -32001 NoUI. */ + onUiRequest?: UiResponder; +} + export interface CallToolOptions { /** Receives each `tool/update` the extension streams for this call. */ onUpdate?: (update: ToolUpdateParams) => void; @@ -35,7 +44,7 @@ export interface TestHost { const DEFAULT_CONTEXT: Context = { token: 'test-epoch', tier: 'command' }; -export function createTestHost(extension: Extension): TestHost { +export function createTestHost(extension: Extension, options: CreateTestHostOptions = {}): TestHost { const [hostT, extT] = linkedPair(); const extHandle = extension.connect(extT); /** call_id → the caller's onUpdate, so streamed progress reaches the test. */ @@ -46,6 +55,11 @@ export function createTestHost(extension: Extension): TestHost { const p = params as ToolUpdateParams; updateSinks.get(p.call_id)?.(p); }); + // Answer ext→host `ui/request`. Default mimics a headless frontend (NoUI). + host.setRequestHandler(method.UI_REQUEST, async (params) => { + if (!options.onUiRequest) throw new RpcError(errorCode.NoUI, 'no UI available (headless test host)'); + return options.onUiRequest(params as UiRequestParams); + }); // Extension notifications the host just observes in tests. host.setNotificationHandler(method.LOG, () => {}); host.setNotificationHandler(method.REGISTRY_UPDATE, () => {}); diff --git a/typescript/extension-sdk/test/ui.test.ts b/typescript/extension-sdk/test/ui.test.ts new file mode 100644 index 0000000..6570edb --- /dev/null +++ b/typescript/extension-sdk/test/ui.test.ts @@ -0,0 +1,144 @@ +/** + * The Phase 3 UI surface, SDK-side: `hasUI` gating reflects the handshake's + * `ui_capabilities`, `ctx.ui.*` round-trips `ui/request` to the host, and a + * headless host answers -32001 NoUI. Driven through the `todo` demo extension. + */ +import { afterEach, describe, expect, it } from 'vitest'; +import { z } from 'zod'; +import { createTestHost, defineExtension, defineTool, errorCode, RpcError, type TestHost, type UiRequestParams } from '../src/index.js'; +import { createTodo } from '../examples/todo.js'; + +let host: TestHost | undefined; +afterEach(() => host?.close()); + +describe('hasUI gating', () => { + it('reflects the ui_capabilities the host declared at initialize', async () => { + const seen: string[] = []; + const ext = defineExtension((smooth) => { + smooth.name = 'probe'; + smooth.version = '0.0.1'; + smooth.registerTool( + defineTool({ + name: 'probe', + description: 'Report which ui kinds are available.', + parameters: z.object({}), + execute(_args, ctx) { + for (const k of ['select', 'confirm', 'input', 'notify', 'set_status', 'set_widget', 'set_title'] as const) { + if (ctx.hasUI(k)) seen.push(k); + } + return { content: 'ok' }; + }, + }), + ); + }); + host = createTestHost(ext); + await host.initialize({ mode: 'tui', ui_capabilities: ['confirm', 'set_widget'] }); + await host.callTool('probe', {}); + expect(seen).toEqual(['confirm', 'set_widget']); + }); + + it('reports no UI on a headless handshake (empty ui_capabilities)', async () => { + let any = true; + const ext = defineExtension((smooth) => { + smooth.name = 'probe'; + smooth.version = '0.0.1'; + smooth.registerTool( + defineTool({ + name: 'probe', + description: 'Report UI availability.', + parameters: z.object({}), + execute(_args, ctx) { + any = ctx.hasUI('confirm') || ctx.hasUI('set_widget'); + return { content: 'ok' }; + }, + }), + ); + }); + host = createTestHost(ext); + await host.initialize(); // defaults to mode: 'headless', no ui_capabilities + await host.callTool('probe', {}); + expect(any).toBe(false); + }); +}); + +describe('ctx.ui round-trips ui/request', () => { + it('todo.clear asks the host to confirm and honors the answer', async () => { + const asked: UiRequestParams[] = []; + host = createTestHost(createTodo(), { + onUiRequest: (params) => { + asked.push(params); + if (params.kind === 'confirm') return { confirmed: true }; + return {}; // set_widget etc. + }, + }); + await host.initialize({ mode: 'tui', ui_capabilities: ['confirm', 'set_widget'] }); + await host.callTool('add', { text: 'ship phase 3' }); + const res = await host.callTool('clear', {}); + expect(res.content).toMatch(/Cleared 1 todos/); + // The confirm was asked before clearing. + expect(asked.some((p) => p.kind === 'confirm')).toBe(true); + }); + + it('a declined confirm cancels the clear', async () => { + host = createTestHost(createTodo(), { + onUiRequest: (params) => (params.kind === 'confirm' ? { confirmed: false } : {}), + }); + await host.initialize({ mode: 'tui', ui_capabilities: ['confirm', 'set_widget'] }); + await host.callTool('add', { text: 'keep me' }); + const res = await host.callTool('clear', {}); + expect(res.content).toBe('Cancelled.'); + }); + + it('todo.add pushes a set_widget render block with a text fallback', async () => { + const widgets: Record[] = []; + host = createTestHost(createTodo(), { + onUiRequest: (params) => { + if (params.kind === 'set_widget') widgets.push(params.widget); + return {}; + }, + }); + await host.initialize({ mode: 'tui', ui_capabilities: ['set_widget'] }); + await host.callTool('add', { text: 'render me' }); + expect(widgets).toHaveLength(1); + expect(widgets[0]).toMatchObject({ kind: 'keyvalue', title: 'Todos' }); + expect(typeof widgets[0]!.text).toBe('string'); + }); +}); + +describe('headless host', () => { + it('todo degrades: no widget, no confirm, clear proceeds', async () => { + host = createTestHost(createTodo()); // no onUiRequest → NoUI + await host.initialize(); // headless + await host.callTool('add', { text: 'a' }); + const res = await host.callTool('clear', {}); + expect(res.content).toMatch(/Cleared 1 todos/); + }); + + it('calling ctx.ui.* against a headless host rejects with -32001 NoUI', async () => { + let caught: unknown; + const ext = defineExtension((smooth) => { + smooth.name = 'ungated'; + smooth.version = '0.0.1'; + smooth.registerTool( + defineTool({ + name: 'ask', + description: 'Ask without gating on hasUI.', + parameters: z.object({}), + async execute(_args, ctx) { + try { + await ctx.ui.confirm('proceed?'); + } catch (err) { + caught = err; + } + return { content: 'done' }; + }, + }), + ); + }); + host = createTestHost(ext); // headless: ui/request → NoUI + await host.initialize(); + await host.callTool('ask', {}); + expect(caught).toBeInstanceOf(RpcError); + expect((caught as RpcError).code).toBe(errorCode.NoUI); + }); +});