From e859bc4664c6410cb0facd918a58685b068cb986 Mon Sep 17 00:00:00 2001 From: Brent Rager Date: Fri, 3 Jul 2026 04:31:52 -0400 Subject: [PATCH] SEP Phase 4 (spec + SDK): commands, flags, shortcuts, session actions Spec: command-complete schema, context on session params (+ send_user_message deliver_as), flags delivery + shortcuts in initialize, new fixtures + $invalid cases, echo.mjs command support. SDK: registerCommand/registerFlag/ registerShortcut, CommandContext with bound session (sendMessage/ sendUserMessage/appendEntry), createTestHost runCommand/completeCommand + tier-guarded session service, runConformance command steps. Demo: plan-mode (flag + command + tool_call intercept + widget + appendEntry across reload). Co-Authored-By: Claude Fable 5 --- .changeset/sep-phase4-commands-sdk.md | 12 ++ spec/extension/conformance/echo.mjs | 10 ++ spec/extension/conformance/fixtures.json | 50 ++++++- .../methods/command-complete.schema.json | 58 ++++++++ spec/extension/methods/initialize.schema.json | 19 ++- spec/extension/methods/session.schema.json | 54 ++++++- .../extension-sdk/examples/plan-mode.ts | 81 ++++++++++ typescript/extension-sdk/src/conformance.ts | 2 + typescript/extension-sdk/src/extension.ts | 138 +++++++++++++++++- typescript/extension-sdk/src/index.ts | 30 +++- typescript/extension-sdk/src/protocol.ts | 64 ++++++++ typescript/extension-sdk/src/test-host.ts | 53 ++++++- .../extension-sdk/test/command-path.test.ts | 136 +++++++++++++++++ .../extension-sdk/test/conformance.test.ts | 2 +- 14 files changed, 690 insertions(+), 19 deletions(-) create mode 100644 .changeset/sep-phase4-commands-sdk.md create mode 100644 spec/extension/methods/command-complete.schema.json create mode 100644 typescript/extension-sdk/examples/plan-mode.ts create mode 100644 typescript/extension-sdk/test/command-path.test.ts diff --git a/.changeset/sep-phase4-commands-sdk.md b/.changeset/sep-phase4-commands-sdk.md new file mode 100644 index 0000000..4f3c2a8 --- /dev/null +++ b/.changeset/sep-phase4-commands-sdk.md @@ -0,0 +1,12 @@ +--- +"@smooai/smooth-extension-sdk": minor +"@smooai/smooth-operator": patch +--- + +SEP Phase 4 (spec + SDK) — commands, flags, shortcuts, and session actions. + +**Spec.** New `command-complete.schema.json` (argument autocomplete). `session.schema.json` now carries the dispatch `context` on every params object (the wire form of the command-tier + epoch guard the host enforces) and adds `send_user_message` (`deliver_as` steer/follow_up/next_turn). `initialize.schema.json` gains a `flags` delivery map on the params and a `shortcuts` list (+ `ShortcutRegistration`) on the registrations. New conformance fixtures for command/complete, session send_user_message/append_entry, shortcuts, and flag delivery; new `$invalid` cases proving `context` is required on a session action and `value` on a completion. The reference `echo.mjs` registers a command + shortcut and answers command/execute + command/complete. + +**SDK.** `smooth.registerCommand` (with an optional `complete` completer), `registerFlag` (+ `smooth.getFlag`), and `registerShortcut`. Command handlers receive a `CommandContext` bound to their command-tier context, exposing `session.sendMessage` / `sendUserMessage` / `appendEntry`, `ui`, `hasUI`, and `args`. `createTestHost` gains `runCommand`, `completeCommand`, and a `session/*` service that enforces the same command-tier guard the engine does (event-tier → -32003), recording every session call for assertions. `runConformance` now replays command/execute + command/complete. + +**Demo.** `plan-mode` — the flagship extension that exercises phases 2–4 together: a `--plan` flag and a `/plan` command toggle plan mode; a `tool_call` intercept blocks write/edit/apply_patch/bash while it is on; each toggle pushes a `set_widget` render block and persists an LLM-invisible `appendEntry`, so the state survives a hot reload (the flag re-seeds it, the transcript keeps the history). diff --git a/spec/extension/conformance/echo.mjs b/spec/extension/conformance/echo.mjs index c62261b..853cff4 100755 --- a/spec/extension/conformance/echo.mjs +++ b/spec/extension/conformance/echo.mjs @@ -38,6 +38,8 @@ rl.on('line', (line) => { parameters: { type: 'object', properties: { phrase: { type: 'string' } }, required: ['phrase'] }, }, ], + commands: [{ name: 'echo-cmd', description: 'Echo a slash-command back.' }], + shortcuts: [{ key: 'ctrl+e', command: 'echo-cmd', description: 'Run echo-cmd' }], subscriptions: ['turn_start', 'turn_end', 'message_end'], }, }); @@ -55,6 +57,14 @@ rl.on('line', (line) => { reply(id, { content: params?.arguments?.phrase ?? '' }); break; + case 'command/execute': + reply(id, { content: `ran ${params?.command ?? ''}` }); + break; + + case 'command/complete': + reply(id, { completions: [{ value: `${params?.partial ?? ''}-done`, description: 'echo completion' }] }); + break; + case 'shutdown': reply(id, {}); process.exit(0); diff --git a/spec/extension/conformance/fixtures.json b/spec/extension/conformance/fixtures.json index 10beb6f..9566876 100644 --- a/spec/extension/conformance/fixtures.json +++ b/spec/extension/conformance/fixtures.json @@ -11,16 +11,17 @@ "session": { "id": "sess-abc123" }, "mode": "tui", "ui_capabilities": ["select", "confirm", "input", "notify", "set_status", "set_widget", "set_title"], + "flags": { "plan": true }, "capabilities_enabled": { "tools": true, "commands": true, "ui": true, "exec": true, "kv": true, "bus": true, "session": true } } }, "initialize_result": { "$schema_ref": "methods/initialize.schema.json#/$defs/Result", - "description": "Echo extension's handshake reply: negotiated protocol version, identity, and one registered tool plus one event subscription.", + "description": "An extension's handshake reply: negotiated protocol version, identity, a tool, a slash-command, a declared flag, a keyboard shortcut, and event subscriptions.", "instance": { "protocol_version": 1, - "extension": { "name": "echo", "version": "0.1.0" }, + "extension": { "name": "plan-mode", "version": "0.1.0" }, "registrations": { "tools": [ { @@ -29,9 +30,10 @@ "parameters": { "type": "object", "properties": { "phrase": { "type": "string" } }, "required": ["phrase"] } } ], - "commands": [], - "flags": [], - "subscriptions": ["turn_start"] + "commands": [{ "name": "plan", "description": "Toggle plan mode." }], + "flags": ["plan"], + "shortcuts": [{ "key": "ctrl+p", "command": "plan", "description": "Toggle plan mode" }], + "subscriptions": ["turn_start", "session_start", "session_shutdown"] } } }, @@ -236,8 +238,32 @@ "session_send_message_params": { "$schema_ref": "methods/session.schema.json#/$defs/SendMessageParams", - "description": "Extension posting an assistant message into the session.", - "instance": { "text": "Done processing.", "role": "assistant" } + "description": "Extension posting an assistant message into the session. Carries the command-tier context so the host can validate the tier + epoch.", + "instance": { "context": { "token": "epoch-7", "tier": "command" }, "text": "Done processing.", "role": "assistant" } + }, + + "session_send_user_message_params": { + "$schema_ref": "methods/session.schema.json#/$defs/SendUserMessageParams", + "description": "Extension delivering a user message that steers the in-flight turn.", + "instance": { "context": { "token": "epoch-7", "tier": "command" }, "text": "Actually, focus on the tests first.", "deliver_as": "steer" } + }, + + "session_append_entry_params": { + "$schema_ref": "methods/session.schema.json#/$defs/AppendEntryParams", + "description": "Extension appending an LLM-invisible transcript entry (persisted, not sent to the model).", + "instance": { "context": { "token": "epoch-7", "tier": "command" }, "entry": { "kind": "plan_mode", "enabled": true } } + }, + + "command_complete_params": { + "$schema_ref": "methods/command-complete.schema.json#/$defs/Params", + "description": "Host asking the owning extension for argument completions on a partial slash-command.", + "instance": { "command": "plan", "context": { "token": "epoch-7", "tier": "command" }, "partial": "on" } + }, + + "command_complete_result": { + "$schema_ref": "methods/command-complete.schema.json#/$defs/Result", + "description": "Two argument completions offered by the extension.", + "instance": { "completions": [{ "value": "on", "description": "enable plan mode" }, { "value": "off", "description": "disable plan mode" }] } }, "session_state_result": { @@ -487,6 +513,16 @@ "name": "ui_request_confirm_extra_property", "$schema_ref": "methods/ui-request.schema.json#/$defs/Params", "instance": { "kind": "confirm", "prompt": "Sure?", "options": ["yes", "no"] } + }, + { + "name": "session_send_message_missing_context", + "$schema_ref": "methods/session.schema.json#/$defs/SendMessageParams", + "instance": { "text": "no context — the tier/epoch guard field is required" } + }, + { + "name": "command_complete_result_completion_missing_value", + "$schema_ref": "methods/command-complete.schema.json#/$defs/Result", + "instance": { "completions": [{ "description": "missing the required value" }] } } ] } diff --git a/spec/extension/methods/command-complete.schema.json b/spec/extension/methods/command-complete.schema.json new file mode 100644 index 0000000..62becb7 --- /dev/null +++ b/spec/extension/methods/command-complete.schema.json @@ -0,0 +1,58 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://smooth-agent.dev/spec/extension/methods/command-complete.schema.json", + "title": "CommandComplete", + "description": "Method `command/complete` (host → ext, request). Asks the extension that owns a registered slash-command for argument completions given the partial text typed so far. Best-effort: an extension that does not implement completion replies with an empty `completions` array. The frontend surfaces the returned values in its autocomplete popup.", + + "$defs": { + "Params": { + "title": "CommandCompleteParams", + "type": "object", + "required": ["command", "context"], + "additionalProperties": false, + "properties": { + "command": { "type": "string", "description": "Registered slash-command name, without the leading `/`." }, + "context": { + "type": "object", + "required": ["token", "tier"], + "additionalProperties": false, + "properties": { + "token": { "type": "string" }, + "tier": { "type": "string", "enum": ["event", "command"] } + } + }, + "partial": { "type": "string", "description": "The partial argument text typed after the command name." } + } + }, + + "Completion": { + "title": "Completion", + "type": "object", + "required": ["value"], + "additionalProperties": false, + "properties": { + "value": { "type": "string", "description": "The completion candidate to insert." }, + "description": { "type": "string", "description": "Optional label shown alongside the candidate." } + } + }, + + "Result": { + "title": "CommandCompleteResult", + "type": "object", + "additionalProperties": false, + "properties": { + "completions": { + "type": "array", + "items": { "$ref": "#/$defs/Completion" }, + "description": "Argument completion candidates; empty when the extension offers none." + } + } + } + }, + + "oneOf": [ + { "$ref": "#/$defs/Params" }, + { "$ref": "#/$defs/Completion" }, + { "$ref": "#/$defs/Result" } + ] +} diff --git a/spec/extension/methods/initialize.schema.json b/spec/extension/methods/initialize.schema.json index c9b86d8..0c71fa4 100644 --- a/spec/extension/methods/initialize.schema.json +++ b/spec/extension/methods/initialize.schema.json @@ -48,6 +48,10 @@ "items": { "type": "string" }, "description": "UI request kinds the frontend can render, e.g. `select`, `confirm`, `input`, `notify`, `set_status`, `set_widget`, `set_title`. Empty ⇒ headless." }, + "flags": { + "type": "object", + "description": "Parsed values for the flags the extension declares (name → value). A host with a CLI surface fills this; hosts without one send it empty or omit it. The extension reads its flag values here at startup." + }, "capabilities_enabled": { "type": "object", "description": "Which host capability groups the extension may call. A method in a disabled group is rejected with -32004 CapabilityDisabled.", @@ -93,7 +97,8 @@ "properties": { "tools": { "type": "array", "items": { "$ref": "#/$defs/ToolRegistration" } }, "commands": { "type": "array", "items": { "$ref": "#/$defs/CommandRegistration" } }, - "flags": { "type": "array", "items": { "type": "string" }, "description": "CLI/slash flags the extension owns." }, + "flags": { "type": "array", "items": { "type": "string" }, "description": "CLI/slash flag names the extension owns; the host delivers their parsed values in `initialize` params `flags`." }, + "shortcuts": { "type": "array", "items": { "$ref": "#/$defs/ShortcutRegistration" }, "description": "Keyboard shortcuts binding a chord to a registered command. Only frontends with a key surface (the TUI) honor them." }, "subscriptions": { "type": "array", "items": { "type": "string" }, @@ -124,6 +129,18 @@ "name": { "type": "string", "description": "Slash-command name, without the leading `/`." }, "description": { "type": "string" } } + }, + + "ShortcutRegistration": { + "title": "SepShortcutRegistration", + "type": "object", + "required": ["key", "command"], + "additionalProperties": false, + "properties": { + "key": { "type": "string", "description": "Human-typed chord, e.g. `ctrl+p` or `f2`; the frontend parses it." }, + "command": { "type": "string", "description": "The registered command name this chord invokes (no leading `/`)." }, + "description": { "type": "string" } + } } }, diff --git a/spec/extension/methods/session.schema.json b/spec/extension/methods/session.schema.json index 0ccbf6c..0fd61e9 100644 --- a/spec/extension/methods/session.schema.json +++ b/spec/extension/methods/session.schema.json @@ -2,15 +2,27 @@ "$schema": "https://json-schema.org/draft/2020-12/schema", "$id": "https://smooth-agent.dev/spec/extension/methods/session.schema.json", "title": "Session", - "description": "The `session/*` methods (ext → host, requests): `session/send_message` posts a message into the session, `session/append_entry` appends a raw transcript entry, `session/set_model` switches the session's model, and `session/state` reads back session state. All four require the **command** context tier — an extension attempting them from an event-tier context is rejected with -32003 ContextViolation.", + "description": "The `session/*` methods (ext → host, requests): `session/send_message` posts a message into the session, `session/send_user_message` delivers a user message (steer/follow_up/next_turn), `session/append_entry` appends a raw transcript entry (persisted but NOT sent to the model), `session/set_model` switches the session's model, and `session/state` reads back session state. All require the **command** context tier — an extension attempting them from an event-tier context, or with a token minted before a reload bumped the epoch, is rejected with -32003 ContextViolation. Every params object therefore carries the `context` it was dispatched with, so the host can validate the tier and epoch.", "$defs": { + "Context": { + "title": "Context", + "type": "object", + "required": ["token", "tier"], + "additionalProperties": false, + "properties": { + "token": { "type": "string" }, + "tier": { "type": "string", "enum": ["event", "command"] } + } + }, + "SendMessageParams": { "title": "SessionSendMessageParams", "type": "object", - "required": ["text"], + "required": ["context", "text"], "additionalProperties": false, "properties": { + "context": { "$ref": "#/$defs/Context" }, "text": { "type": "string", "description": "Message text to post into the session." }, "role": { "type": "string", "enum": ["user", "assistant"], "default": "assistant", "description": "Who the message is attributed to." } } @@ -22,13 +34,37 @@ "properties": {} }, + "SendUserMessageParams": { + "title": "SessionSendUserMessageParams", + "type": "object", + "required": ["context", "text"], + "additionalProperties": false, + "properties": { + "context": { "$ref": "#/$defs/Context" }, + "text": { "type": "string", "description": "User message text to deliver into the session." }, + "deliver_as": { + "type": "string", + "enum": ["steer", "follow_up", "next_turn"], + "default": "follow_up", + "description": "When to deliver: `steer` interrupts the in-flight turn, `follow_up` queues after it, `next_turn` starts the next turn." + } + } + }, + "SendUserMessageResult": { + "title": "SessionSendUserMessageResult", + "type": "object", + "additionalProperties": false, + "properties": {} + }, + "AppendEntryParams": { "title": "SessionAppendEntryParams", "type": "object", - "required": ["entry"], + "required": ["context", "entry"], "additionalProperties": false, "properties": { - "entry": { "type": "object", "description": "Raw transcript entry to append; shape mirrors the host's own transcript entry format." } + "context": { "$ref": "#/$defs/Context" }, + "entry": { "type": "object", "description": "Raw transcript entry to append; persisted but NOT sent to the model. Shape mirrors the host's own transcript entry format." } } }, "AppendEntryResult": { @@ -41,9 +77,10 @@ "SetModelParams": { "title": "SessionSetModelParams", "type": "object", - "required": ["model"], + "required": ["context", "model"], "additionalProperties": false, "properties": { + "context": { "$ref": "#/$defs/Context" }, "model": { "type": "string", "description": "Model identifier to switch the session to." } } }, @@ -57,8 +94,11 @@ "StateParams": { "title": "SessionStateParams", "type": "object", + "required": ["context"], "additionalProperties": false, - "properties": {} + "properties": { + "context": { "$ref": "#/$defs/Context" } + } }, "StateResult": { "title": "SessionStateResult", @@ -73,6 +113,8 @@ "oneOf": [ { "$ref": "#/$defs/SendMessageParams" }, { "$ref": "#/$defs/SendMessageResult" }, + { "$ref": "#/$defs/SendUserMessageParams" }, + { "$ref": "#/$defs/SendUserMessageResult" }, { "$ref": "#/$defs/AppendEntryParams" }, { "$ref": "#/$defs/AppendEntryResult" }, { "$ref": "#/$defs/SetModelParams" }, diff --git a/typescript/extension-sdk/examples/plan-mode.ts b/typescript/extension-sdk/examples/plan-mode.ts new file mode 100644 index 0000000..ea8a027 --- /dev/null +++ b/typescript/extension-sdk/examples/plan-mode.ts @@ -0,0 +1,81 @@ +/** + * `plan-mode` — the Phase 4 flagship demo. One extension that exercises phases + * 2–4 together: + * + * - **flag** (`--plan`): the host delivers it at `initialize`; the session starts + * in plan mode when set. + * - **command** (`/plan`): toggles plan mode, with argument autocomplete + * (`on`/`off`). + * - **tool_call intercept** (Phase 2 hook): while plan mode is on, mutating tools + * (write/edit/apply_patch/bash) are BLOCKED — the agent can read and think but + * not change the workspace. + * - **widget** (Phase 3 ui): each toggle pushes a `set_widget` render block + * showing the current state (gated on `hasUI`, so it degrades headless). + * - **appendEntry** (Phase 4 session): each toggle persists an LLM-invisible + * transcript entry, so the plan-mode history survives a hot reload of the + * extension (the host keeps the transcript; the flag re-establishes state). + * + * Run it as a real SEP subprocess: `tsx examples/plan-mode.ts --plan` + */ +import { defineExtension } from '../src/index.js'; + +/** The tools plan mode blocks — anything that mutates the workspace. */ +const WRITE_TOOLS = new Set(['write', 'edit', 'apply_patch', 'bash']); + +/** Build a fresh `plan-mode` extension. Tests use this for isolation; the served + * singleton below shares one state for the process's lifetime. */ +export const createPlanMode = () => + defineExtension((smooth) => { + smooth.name = 'plan-mode'; + smooth.version = '0.1.0'; + + // null = follow the `--plan` flag; true/false once explicitly toggled. + let toggled: boolean | null = null; + const active = () => toggled ?? smooth.getFlag('plan') === true; + + smooth.registerFlag({ name: 'plan', description: 'Start the session in plan mode (file writes blocked).' }); + smooth.registerShortcut({ key: 'ctrl+p', command: 'plan', description: 'Toggle plan mode' }); + + const widget = () => ({ + kind: 'keyvalue', + title: 'Plan mode', + rows: [{ key: 'status', value: active() ? 'ON — writes blocked' : 'off' }], + text: `plan mode: ${active() ? 'ON' : 'off'}`, + }); + + smooth.registerCommand({ + name: 'plan', + description: 'Toggle plan mode on/off (blocks file writes while on).', + async execute(ctx) { + const arg = String(ctx.args?.state ?? '').toLowerCase(); + toggled = arg === 'on' ? true : arg === 'off' ? false : !active(); + // Persist the toggle as an LLM-invisible entry (survives reload). + await ctx.session.appendEntry({ kind: 'plan_mode', enabled: active() }); + if (ctx.hasUI('set_widget')) await ctx.ui.setWidget(widget()); + return { content: `Plan mode ${active() ? 'enabled — file writes are blocked' : 'disabled'}.` }; + }, + complete: (partial) => ['on', 'off'].filter((v) => v.startsWith(partial)).map((value) => ({ value })), + }); + + // Phase 2: veto mutating tool calls while plan mode is on. + smooth.on('tool_call', (input) => { + const tool = String((input as { tool?: string }).tool ?? ''); + if (active() && WRITE_TOOLS.has(tool)) { + return { block: true, reason: `plan mode is on — \`${tool}\` is blocked. Toggle it off with /plan.` }; + } + }); + + // On a hot reload the host re-runs initialize (re-delivering `--plan`) + // then fires session_start; re-render the widget for the fresh process. + smooth.on('session_start', () => { + if (smooth.hasUI('set_widget')) void smooth.ui.setWidget(widget()); + }); + }); + +/** The served singleton. */ +export const planMode = createPlanMode(); + +// When run directly (not imported by a test), serve over stdio. +if (import.meta.url === `file://${process.argv[1]}`) { + planMode.serve(); +} diff --git a/typescript/extension-sdk/src/conformance.ts b/typescript/extension-sdk/src/conformance.ts index 2415416..bc80b28 100644 --- a/typescript/extension-sdk/src/conformance.ts +++ b/typescript/extension-sdk/src/conformance.ts @@ -115,6 +115,8 @@ export async function runConformance(opts: RunConformanceOptions): Promise; + sendUserMessage(text: string, opts?: { deliverAs?: DeliverAs }): Promise; + appendEntry(entry: Record): Promise; +} + +/** Build a [`SessionApi`] bound to `context` (must be command-tier) over `peer`. */ +function makeSession(peer: Peer, context: Context): SessionApi { + return { + sendMessage: async (text, opts) => { + await peer.request(method.SESSION_SEND_MESSAGE, { context, text, ...(opts?.role ? { role: opts.role } : {}) }); + }, + sendUserMessage: async (text, opts) => { + await peer.request(method.SESSION_SEND_USER_MESSAGE, { context, text, ...(opts?.deliverAs ? { deliver_as: opts.deliverAs } : {}) }); + }, + appendEntry: async (entry) => { + await peer.request(method.SESSION_APPEND_ENTRY, { context, entry }); + }, + }; +} + /** Progress + cancellation handed to a tool while it runs. */ export interface ToolContext { /** Correlates `onUpdate` calls with this execution. */ @@ -105,6 +141,48 @@ export function defineTool>(def: ToolDef) return def; } +/** What a command handler receives: the command-tier context plus the session, + * ui, and args bound to it. Session actions are valid because a command runs at + * command tier. */ +export interface CommandContext { + /** The dispatch context (command tier). */ + context: Context; + /** Free-form arguments parsed from the invocation. */ + args: Record | undefined; + /** Session-mutating actions, bound to this command's context. */ + session: SessionApi; + /** 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; + /** Structured log line into host tracing. */ + log(level: 'debug' | 'info' | 'warn' | 'error', message: string, fields?: Record): void; +} + +/** What a command's `execute` may return: text to surface, a full result, or + * nothing. */ +export type CommandReturn = CommandExecuteResult | string | void; + +export interface CommandDef { + name: string; + description: string; + execute(ctx: CommandContext): Promise | CommandReturn; + /** Optional argument autocomplete: given the partial text, return candidates. */ + complete?(partial: string, context: Context): Promise | Completion[]; +} + +/** Identity helper for `registerCommand` call sites. */ +export function defineCommand(def: CommandDef): CommandDef { + return def; +} + +/** A CLI/slash flag the extension declares. The host delivers its parsed value + * in `initialize`; read it with `smooth.getFlag(name)`. */ +export interface FlagDef { + name: string; + description?: string; +} + /** A hook handler's friendly return: veto the operation, or replace its input * with a patch (shallow-merged onto the input). Returning nothing = continue. */ export type HookResult = { block: true; reason?: string } | { patch: Record }; @@ -119,8 +197,17 @@ export interface SmoothApi { name: string; version: string; registerTool(tool: ToolDef): void; + /** Register a slash-command surfaced in the host's `/` palette. */ + registerCommand(command: CommandDef): void; + /** Declare a CLI/slash flag; read its delivered value with [`getFlag`]. */ + registerFlag(flag: FlagDef): void; + /** Bind a keyboard shortcut (TUI frontends) to a registered command. */ + registerShortcut(shortcut: ShortcutRegistration): void; on(event: string, handler: EventHandler): void; log(level: 'debug' | 'info' | 'warn' | 'error', message: string, fields?: Record): void; + /** The parsed value the host delivered for a declared flag (undefined before + * `initialize`, or when the host has no CLI surface). */ + getFlag(name: string): unknown; /** 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; @@ -138,6 +225,9 @@ export interface ConnectHandle { export class Extension { private readonly tools = new Map>(); + private readonly commands = new Map(); + private readonly flagDefs = new Map(); + private readonly shortcuts: ShortcutRegistration[] = []; private readonly events = new Map(); private name = 'extension'; private version = '0.0.0'; @@ -145,6 +235,8 @@ export class Extension { private live?: Peer; /** UI kinds the host declared answerable at `initialize`. */ private hostUiCaps: string[] = []; + /** Flag values the host delivered at `initialize` (name → value). */ + private flagValues: Record = {}; constructor(setup: ExtensionSetup) { const api: SmoothApi = { @@ -163,6 +255,15 @@ export class Extension { registerTool: (tool) => { this.tools.set(tool.name, tool); }, + registerCommand: (command) => { + this.commands.set(command.name, command); + }, + registerFlag: (flag) => { + this.flagDefs.set(flag.name, flag); + }, + registerShortcut: (shortcut) => { + this.shortcuts.push(shortcut); + }, on: (event, handler) => { const list = this.events.get(event) ?? []; list.push(handler); @@ -171,6 +272,7 @@ export class Extension { log: (level, message, fields) => { this.live?.notify(method.LOG, { level, message, ...(fields ? { fields } : {}) }); }, + getFlag: (name) => self.flagValues[name], hasUI: (kind) => self.hostUiCaps.includes(kind), get ui() { if (!self.live) throw new Error('smooth.ui is only available after the extension connects'); @@ -195,6 +297,8 @@ export class Extension { }); peer.setRequestHandler(method.TOOL_EXECUTE, (params, signal) => this.executeTool(params as ToolExecuteParams, peer, signal)); peer.setRequestHandler(method.HOOK, (params) => this.dispatchHook(params as HookParams)); + peer.setRequestHandler(method.COMMAND_EXECUTE, (params) => this.executeCommand(params as CommandExecuteParams, peer)); + peer.setRequestHandler(method.COMMAND_COMPLETE, (params) => this.completeCommand(params as CommandCompleteParams)); peer.setNotificationHandler(method.EVENT, (params) => this.dispatchEvent(params as EventParams)); transport.start((frame) => peer.receive(frame)); @@ -217,20 +321,52 @@ export class Extension { private initialize(params: InitializeParams): InitializeResult { this.hostUiCaps = params.ui_capabilities ?? []; + this.flagValues = params.flags ?? {}; const tools = [...this.tools.values()].map((t) => ({ name: t.name, description: t.description, parameters: toJsonSchema(t.parameters), ...(t.deferred ? { deferred: true } : {}), })); + const commands: CommandRegistration[] = [...this.commands.values()].map((c) => ({ name: c.name, description: c.description })); + const flags = [...this.flagDefs.keys()]; // Only observe events go in `subscriptions` — hook names are intercepts // the host always calls, not events it filters by subscription. const subscriptions = [...this.events.keys()].filter((name) => !HOOK_NAMES.has(name)); return { protocol_version: PROTOCOL_VERSION, extension: { name: this.name, version: this.version }, - registrations: { tools, subscriptions }, + registrations: { + tools, + ...(commands.length ? { commands } : {}), + ...(flags.length ? { flags } : {}), + ...(this.shortcuts.length ? { shortcuts: this.shortcuts } : {}), + subscriptions, + }, + }; + } + + private async executeCommand(params: CommandExecuteParams, peer: Peer): Promise { + const command = this.commands.get(params.command); + if (!command) return { content: `unknown command: ${params.command}` }; + const ctx: CommandContext = { + context: params.context, + args: params.arguments, + session: makeSession(peer, params.context), + ui: makeUi(peer), + hasUI: (kind) => this.hostUiCaps.includes(kind), + log: (level, message, fields) => peer.notify(method.LOG, { level, message, ...(fields ? { fields } : {}) }), }; + const out = await command.execute(ctx); + if (out === undefined) return {}; + return typeof out === 'string' ? { content: out } : out; + } + + private async completeCommand(params: CommandCompleteParams): Promise { + const command = this.commands.get(params.command); + if (!command?.complete) return { completions: [] }; + const completions = await command.complete(params.partial ?? '', params.context); + return { completions }; } private async executeTool(params: ToolExecuteParams, peer: Peer, signal: AbortSignal): Promise { diff --git a/typescript/extension-sdk/src/index.ts b/typescript/extension-sdk/src/index.ts index fb89af7..cd2097b 100644 --- a/typescript/extension-sdk/src/index.ts +++ b/typescript/extension-sdk/src/index.ts @@ -8,8 +8,23 @@ * `createTestHost`, and gate it against the shared fixtures with * `runConformance`. */ -export { defineExtension, defineTool, Extension } from './extension.js'; -export type { ExtensionSetup, SmoothApi, ToolDef, ToolContext, ToolReturn, EventHandler, HookResult, ConnectHandle, UiApi } from './extension.js'; +export { defineExtension, defineTool, defineCommand, Extension } from './extension.js'; +export type { + ExtensionSetup, + SmoothApi, + ToolDef, + ToolContext, + ToolReturn, + CommandDef, + CommandContext, + CommandReturn, + FlagDef, + SessionApi, + EventHandler, + HookResult, + ConnectHandle, + UiApi, +} from './extension.js'; export { createTestHost } from './test-host.js'; export type { TestHost, CallToolOptions, CreateTestHostOptions, UiResponder } from './test-host.js'; export { runConformance, DEFAULT_SPEC_DIR } from './conformance.js'; @@ -36,4 +51,15 @@ export type { UiKind, UiRequestParams, UiRequestResult, + ShortcutRegistration, + CommandRegistration, + CommandExecuteParams, + CommandExecuteResult, + CommandCompleteParams, + CommandCompleteResult, + Completion, + DeliverAs, + SessionSendMessageParams, + SessionSendUserMessageParams, + SessionAppendEntryParams, } from './protocol.js'; diff --git a/typescript/extension-sdk/src/protocol.ts b/typescript/extension-sdk/src/protocol.ts index a6f10eb..45ec296 100644 --- a/typescript/extension-sdk/src/protocol.ts +++ b/typescript/extension-sdk/src/protocol.ts @@ -22,6 +22,11 @@ export const method = { REGISTRY_UPDATE: 'registry/update', LOG: 'log', CANCEL: '$/cancel', + COMMAND_EXECUTE: 'command/execute', + COMMAND_COMPLETE: 'command/complete', + SESSION_SEND_MESSAGE: 'session/send_message', + SESSION_SEND_USER_MESSAGE: 'session/send_user_message', + SESSION_APPEND_ENTRY: 'session/append_entry', } as const; /** JSON-RPC + SEP error codes (see spec/extension/envelope.md). */ @@ -62,6 +67,8 @@ export interface InitializeParams { session?: { id?: string }; mode: 'tui' | 'web' | 'widget' | 'cli' | 'headless'; ui_capabilities?: string[]; + /** Parsed values for the flags the extension declares (name → value). */ + flags?: Record; capabilities_enabled?: Record; } @@ -78,10 +85,19 @@ export interface CommandRegistration { description: string; } +export interface ShortcutRegistration { + /** A human-typed chord, e.g. `ctrl+p`; the frontend parses it. */ + key: string; + /** The registered command this chord invokes (no leading `/`). */ + command: string; + description?: string; +} + export interface Registrations { tools?: ToolRegistration[]; commands?: CommandRegistration[]; flags?: string[]; + shortcuts?: ShortcutRegistration[]; subscriptions?: string[]; } @@ -156,3 +172,51 @@ export interface UiRequestResult { text?: string; cancelled?: boolean; } + +/** Host → ext `command/execute`: run a registered slash-command (command tier). */ +export interface CommandExecuteParams { + command: string; + context: Context; + arguments?: Record; +} + +export interface CommandExecuteResult { + content?: string; +} + +/** Host → ext `command/complete`: argument autocomplete for a slash-command. */ +export interface CommandCompleteParams { + command: string; + context: Context; + partial?: string; +} + +export interface Completion { + value: string; + description?: string; +} + +export interface CommandCompleteResult { + completions: Completion[]; +} + +/** How a `session/send_user_message` is delivered relative to the current turn. */ +export type DeliverAs = 'steer' | 'follow_up' | 'next_turn'; + +/** Ext → host `session/send_message` params (command tier — carries `context`). */ +export interface SessionSendMessageParams { + context: Context; + text: string; + role?: 'user' | 'assistant'; +} + +export interface SessionSendUserMessageParams { + context: Context; + text: string; + deliver_as?: DeliverAs; +} + +export interface SessionAppendEntryParams { + context: Context; + entry: Record; +} diff --git a/typescript/extension-sdk/src/test-host.ts b/typescript/extension-sdk/src/test-host.ts index 261b2df..5c8a2d5 100644 --- a/typescript/extension-sdk/src/test-host.ts +++ b/typescript/extension-sdk/src/test-host.ts @@ -7,7 +7,17 @@ */ 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 { + CommandExecuteResult, + Context, + HookOutcome, + InitializeParams, + InitializeResult, + ToolExecuteResult, + ToolUpdateParams, + UiRequestParams, + UiRequestResult, +} from './protocol.js'; import type { Extension } from './extension.js'; import { linkedPair } from './transport.js'; @@ -17,6 +27,12 @@ let callSeq = 0; * `RpcError` (e.g. code -32001) to simulate a headless/uncapable frontend. */ export type UiResponder = (params: UiRequestParams) => UiRequestResult | Promise; +/** A recorded ext→host `session/*` request the test can assert on. */ +export interface SessionCall { + method: string; + params: Record; +} + export interface CreateTestHostOptions { /** Answers `ui/request`. Default: reject every call with -32001 NoUI. */ onUiRequest?: UiResponder; @@ -36,8 +52,14 @@ export interface TestHost { callTool(tool: string, args: Record, opts?: CallToolOptions): Promise; /** Drive a `hook` request and get back the extension's folded outcome. */ callHook(hook: string, input: Record, context?: Context): Promise; + /** Dispatch a `command/execute` with a command-tier context by default. */ + runCommand(command: string, args?: Record, context?: Context): Promise; + /** Dispatch a `command/complete` for argument autocomplete. */ + completeCommand(command: string, partial: string, context?: Context): Promise<{ completions: { value: string; description?: string }[] }>; ping(): Promise>; sendEvent(event: string, payload?: Record, context?: Context): void; + /** Every `session/*` request the extension made, in order — for assertions. */ + readonly sessionCalls: SessionCall[]; shutdown(): Promise; close(): void; } @@ -63,6 +85,24 @@ export function createTestHost(extension: Extension, options: CreateTestHostOpti // Extension notifications the host just observes in tests. host.setNotificationHandler(method.LOG, () => {}); host.setNotificationHandler(method.REGISTRY_UPDATE, () => {}); + + // Service ext→host `session/*` requests, enforcing the same command-tier + // guard the real host does (event-tier → -32003) so a demo's session calls + // are exercised realistically. Every call is recorded for assertions. + const sessionCalls: SessionCall[] = []; + const sessionHandler = (params: unknown) => { + const p = (params ?? {}) as Record; + const tier = (p.context as { tier?: string } | undefined)?.tier; + if (tier !== 'command') throw new RpcError(errorCode.ContextViolation, 'session action requires a command-tier context'); + return p; + }; + for (const m of [method.SESSION_SEND_MESSAGE, method.SESSION_SEND_USER_MESSAGE, method.SESSION_APPEND_ENTRY]) { + host.setRequestHandler(m, (params) => { + const recorded = sessionHandler(params); + sessionCalls.push({ method: m, params: recorded }); + return {}; + }); + } hostT.start((frame) => host.receive(frame)); return { @@ -93,6 +133,17 @@ export function createTestHost(extension: Extension, options: CreateTestHostOpti callHook(hook, input, context) { return host.request(method.HOOK, { hook, input, context: context ?? DEFAULT_CONTEXT }); }, + runCommand(command, args, context) { + return host.request(method.COMMAND_EXECUTE, { + command, + context: context ?? DEFAULT_CONTEXT, + ...(args ? { arguments: args } : {}), + }); + }, + completeCommand(command, partial, context) { + return host.request(method.COMMAND_COMPLETE, { command, context: context ?? DEFAULT_CONTEXT, partial }); + }, + sessionCalls, ping() { return host.request>(method.PING, {}); }, diff --git a/typescript/extension-sdk/test/command-path.test.ts b/typescript/extension-sdk/test/command-path.test.ts new file mode 100644 index 0000000..dde54d1 --- /dev/null +++ b/typescript/extension-sdk/test/command-path.test.ts @@ -0,0 +1,136 @@ +/** + * The Phase 4 surface, SDK-side: command registration + dispatch + autocomplete, + * flag delivery, shortcut declarations, and the session-action tier guard — + * driven through the `plan-mode` flagship demo, which exercises phases 2–4 + * together (flag + command + tool_call intercept + widget + appendEntry). + */ +import { afterEach, describe, expect, it } from 'vitest'; +import { createTestHost, defineExtension, errorCode, type TestHost, type UiRequestParams } from '../src/index.js'; +import { createPlanMode } from '../examples/plan-mode.js'; + +let host: TestHost | undefined; +afterEach(() => host?.close()); + +describe('registrations surface commands, flags, and shortcuts', () => { + it('emits commands/flags/shortcuts from the handshake', async () => { + host = createTestHost(createPlanMode()); + const result = await host.initialize({ mode: 'tui', ui_capabilities: ['set_widget'] }); + const regs = result.registrations!; + expect(regs.commands).toEqual([{ name: 'plan', description: expect.any(String) }]); + expect(regs.flags).toEqual(['plan']); + expect(regs.shortcuts).toEqual([{ key: 'ctrl+p', command: 'plan', description: 'Toggle plan mode' }]); + }); +}); + +describe('command/execute + command/complete', () => { + it('runs a registered command and returns its content', async () => { + // Headless: no set_widget capability, so the command skips the widget. + host = createTestHost(createPlanMode()); + await host.initialize({ mode: 'headless' }); + const out = await host.runCommand('plan', { state: 'on' }); + expect(out.content).toContain('enabled'); + }); + + it('returns a friendly message for an unknown command', async () => { + host = createTestHost(createPlanMode()); + await host.initialize(); + const out = await host.runCommand('nope'); + expect(out.content).toContain('unknown command'); + }); + + it('round-trips argument autocomplete', async () => { + host = createTestHost(createPlanMode()); + await host.initialize(); + const { completions } = await host.completeCommand('plan', 'o'); + expect(completions.map((c) => c.value)).toEqual(['on', 'off']); + const none = await host.completeCommand('plan', 'zzz'); + expect(none.completions).toEqual([]); + }); +}); + +describe('session actions from a command handler', () => { + it('appendEntry from a command reaches the host (command tier)', async () => { + host = createTestHost(createPlanMode()); + await host.initialize({ mode: 'headless' }); + await host.runCommand('plan', { state: 'on' }); + const entries = host.sessionCalls.filter((c) => c.method === 'session/append_entry'); + expect(entries).toHaveLength(1); + expect(entries[0]!.params.entry).toEqual({ kind: 'plan_mode', enabled: true }); + // The recorded context is command-tier — the guard let it through. + expect((entries[0]!.params.context as { tier: string }).tier).toBe('command'); + }); + + it('rejects a session action presented from an event-tier context (-32003)', async () => { + // The test host enforces the same guard the engine does. A command whose + // handler calls appendEntry, dispatched with an EVENT-tier context, has + // its session call bounced with -32003 ContextViolation. + const ext = defineExtension((smooth) => { + smooth.name = 'probe'; + smooth.version = '0.0.1'; + smooth.registerCommand({ + name: 'leak', + description: 'Try a session action with the wrong tier.', + async execute(ctx) { + await ctx.session.appendEntry({ leaked: true }); + return {}; + }, + }); + }); + host = createTestHost(ext); + await host.initialize(); + await expect(host.runCommand('leak', {}, { token: 'epoch-1', tier: 'event' })).rejects.toMatchObject({ + code: errorCode.ContextViolation, + }); + }); +}); + +describe('plan-mode exercises phases 2–4 together', () => { + /** A test host whose set_widget calls are captured. */ + const widgetHost = () => { + const widgets: Record[] = []; + const onUiRequest = (p: UiRequestParams) => { + if (p.kind === 'set_widget') widgets.push(p.widget); + return {}; + }; + return { widgets, host: createTestHost(createPlanMode(), { onUiRequest }) }; + }; + + it('the --plan flag blocks a write tool; toggling off unblocks it', async () => { + const { host: h } = widgetHost(); + host = h; + // Flag delivered at initialize → plan mode active. + await h.initialize({ mode: 'tui', ui_capabilities: ['set_widget'], flags: { plan: true } }); + + const blocked = await h.callHook('tool_call', { tool: 'write', arguments: { path: 'a.ts' } }); + expect(blocked.action).toBe('block'); + + // A read tool is never blocked. + const allowed = await h.callHook('tool_call', { tool: 'read', arguments: { path: 'a.ts' } }); + expect(allowed.action).toBe('continue'); + + // Toggle off via the command → write is allowed again. + await h.runCommand('plan', { state: 'off' }); + const nowAllowed = await h.callHook('tool_call', { tool: 'write', arguments: { path: 'a.ts' } }); + expect(nowAllowed.action).toBe('continue'); + }); + + it('a toggle pushes a widget and persists an appendEntry', async () => { + const { widgets, host: h } = widgetHost(); + host = h; + await h.initialize({ mode: 'tui', ui_capabilities: ['set_widget'] }); + await h.runCommand('plan', { state: 'on' }); + expect(widgets).toHaveLength(1); + expect(widgets[0]!.text).toContain('ON'); + expect(h.sessionCalls.some((c) => c.method === 'session/append_entry')).toBe(true); + }); + + it('a hot reload re-establishes plan mode from the re-delivered flag', async () => { + // Simulate reload: a fresh extension process handshaken with the flag + // still set restores plan mode without any prior toggle (the appendEntry + // history lives on the host; the flag re-seeds the extension's state). + host = createTestHost(createPlanMode()); + await host.initialize({ mode: 'headless', flags: { plan: true } }); + const stillBlocked = await host.callHook('tool_call', { tool: 'edit', arguments: {} }); + expect(stillBlocked.action).toBe('block'); + }); +}); diff --git a/typescript/extension-sdk/test/conformance.test.ts b/typescript/extension-sdk/test/conformance.test.ts index ba3c832..a684b77 100644 --- a/typescript/extension-sdk/test/conformance.test.ts +++ b/typescript/extension-sdk/test/conformance.test.ts @@ -19,6 +19,6 @@ describe('runConformance against the echo.mjs subprocess', () => { const failed = report.steps.filter((s) => !s.ok); expect(failed, JSON.stringify(failed)).toHaveLength(0); expect(report.passed).toBe(true); - expect(report.steps.map((s) => s.name)).toEqual(['initialize', 'ping', 'tool/execute', 'shutdown']); + expect(report.steps.map((s) => s.name)).toEqual(['initialize', 'ping', 'tool/execute', 'command/execute', 'command/complete', 'shutdown']); }); });