From a6d0044c1f539e734b1ea5977524b797ab4ffc6f Mon Sep 17 00:00:00 2001 From: Wolfgang Schoenberger <221313372+wolfiesch@users.noreply.github.com> Date: Mon, 20 Jul 2026 23:51:38 -0700 Subject: [PATCH] fix: harden runtime operation capability contracts --- .../lib/src/client/t4_client_controller.dart | 26 +++--- apps/flutter/lib/src/protocol/models.dart | 8 +- .../lib/src/protocol/wire_decoder.dart | 4 +- .../flutter/lib/src/ui/conversation_pane.dart | 1 + .../client/t4_client_controller_test.dart | 81 ++++++++++++++++++- .../test/protocol/wire_conformance_test.dart | 33 +++++++- apps/flutter/test/ui/host_flow_test.dart | 41 ++++++++++ apps/web/src/features/composer/slash.ts | 14 ++-- .../features/session-runtime/live-runtime.ts | 2 +- .../session-runtime/session-observer.test.ts | 14 +++- apps/web/test/live-composer.test.ts | 53 ++++++++++-- .../src/official-omp-capabilities.ts | 49 ++++++++--- packages/host-service/src/rpc-child.ts | 4 +- packages/host-service/src/server.ts | 5 +- .../test/official-omp-capabilities.test.ts | 29 +++++++ .../test/official-omp-catalog-server.test.ts | 2 + .../imports/f2-transcript-20260711.json | 2 +- 17 files changed, 314 insertions(+), 54 deletions(-) diff --git a/apps/flutter/lib/src/client/t4_client_controller.dart b/apps/flutter/lib/src/client/t4_client_controller.dart index c6da99da..19a8bc90 100644 --- a/apps/flutter/lib/src/client/t4_client_controller.dart +++ b/apps/flutter/lib/src/client/t4_client_controller.dart @@ -188,8 +188,7 @@ final class T4ClientController extends ChangeNotifier implements T4Actions { final choices = []; final seen = {}; final slashCommands = {}; - final operationCapabilities = - _catalogFrame?.operations ?? const []; + final operationCapabilities = _catalogFrame?.operations; for (final item in _catalogItems) { if (item.kind == 'model') { final selector = modelItemSelector(item); @@ -207,7 +206,11 @@ final class T4ClientController extends ChangeNotifier implements T4Actions { ); continue; } - if (item.kind != 'command' || operationCapabilities.isNotEmpty) continue; + if (item.kind != 'command' || operationCapabilities != null) continue; + final metadata = item.metadata ?? const {}; + if (!item.name.startsWith('/') && metadata['slashCommand'] != true) { + continue; + } final bareName = item.name.replaceFirst(RegExp(r'^/+'), ''); final missingCapability = item.capabilities ?.where((capability) => !_grantedCapabilities.contains(capability)) @@ -225,7 +228,8 @@ final class T4ClientController extends ChangeNotifier implements T4Actions { disabledReason: disabledReason, ); } - for (final operation in operationCapabilities) { + for (final operation + in operationCapabilities ?? const []) { if (!operation.operationId.startsWith('slash.')) continue; final bareName = operation.operationId.substring('slash.'.length); if (bareName.isEmpty) continue; @@ -241,8 +245,12 @@ final class T4ClientController extends ChangeNotifier implements T4Actions { .map((alias) => '/${alias.replaceFirst(RegExp(r'^/+'), '')}') .toList(growable: false) : const []; - final missingCapability = operation.capabilities - ?.where((capability) => !_grantedCapabilities.contains(capability)) + final requiredCapabilities = { + 'sessions.prompt', + ...?operation.capabilities, + }; + final missingCapability = requiredCapabilities + .where((capability) => !_grantedCapabilities.contains(capability)) .firstOrNull; String? disabledReason; if (!operation.supported) { @@ -252,11 +260,9 @@ final class T4ClientController extends ChangeNotifier implements T4Actions { disabledReason = missingCapability == 'terminal.io' ? 'Needs terminal access on this host' : 'Not granted on this host'; - } else if ((session.turnActive || _submitting) && - bareName == 'compact') { + } else if ((session.turnActive || _submitting) && bareName == 'compact') { disabledReason = 'Wait for the turn to finish'; - } else if ((session.turnActive || _submitting) && - bareName == 'retry') { + } else if ((session.turnActive || _submitting) && bareName == 'retry') { disabledReason = 'A turn is already running'; } slashCommands[name] = ComposerSlashCommand( diff --git a/apps/flutter/lib/src/protocol/models.dart b/apps/flutter/lib/src/protocol/models.dart index 1d977d5f..50dcc101 100644 --- a/apps/flutter/lib/src/protocol/models.dart +++ b/apps/flutter/lib/src/protocol/models.dart @@ -585,12 +585,12 @@ final class CatalogResult { const CatalogResult({ required this.revision, required this.items, - this.operations = const [], + this.operations, }); final String revision; final List items; - final List operations; + final List? operations; } final class SettingsResult { @@ -1235,14 +1235,14 @@ final class CatalogFrame extends WireFrame { required this.hostId, required this.revision, required this.items, - this.operations = const [], + this.operations, required super.raw, }); final String hostId; final String revision; final List items; - final List operations; + final List? operations; } final class SettingsFrame extends WireFrame { diff --git a/apps/flutter/lib/src/protocol/wire_decoder.dart b/apps/flutter/lib/src/protocol/wire_decoder.dart index e9b9f600..c3bbce92 100644 --- a/apps/flutter/lib/src/protocol/wire_decoder.dart +++ b/apps/flutter/lib/src/protocol/wire_decoder.dart @@ -1299,12 +1299,12 @@ OperationCapability _operationCapability(Object? value, String path) { ); } -List _operationCapabilities( +List? _operationCapabilities( Map raw, String path, ) { if (!raw.containsKey('operations')) { - return const []; + return null; } final values = _list(raw['operations'], path); return List.unmodifiable([ diff --git a/apps/flutter/lib/src/ui/conversation_pane.dart b/apps/flutter/lib/src/ui/conversation_pane.dart index 478ac48c..35a22e6e 100644 --- a/apps/flutter/lib/src/ui/conversation_pane.dart +++ b/apps/flutter/lib/src/ui/conversation_pane.dart @@ -930,6 +930,7 @@ final class _PromptComposerState extends State<_PromptComposer> { bool get _ready => widget.state.connectionPhase == ConnectionPhase.ready && widget.state.selectedSession != null && + widget.state.grantedCapabilities.contains('sessions.prompt') && !_sending; bool get _canSubmit => diff --git a/apps/flutter/test/client/t4_client_controller_test.dart b/apps/flutter/test/client/t4_client_controller_test.dart index c1ece049..06d87e6a 100644 --- a/apps/flutter/test/client/t4_client_controller_test.dart +++ b/apps/flutter/test/client/t4_client_controller_test.dart @@ -522,7 +522,6 @@ void main() { 'description': 'Compact the active conversation', 'execution': 'headless', 'supported': true, - 'capabilities': ['sessions.prompt'], 'metadata': { 'aliases': ['compress'], }, @@ -553,6 +552,86 @@ void main() { commands.last.disabledReason, '/plan requires the OMP terminal interface.', ); + + channel.emit({ + 'v': 'omp-app/1', + 'type': 'catalog', + 'hostId': 'host-alpha', + 'revision': 'catalog-authoritative-empty', + 'items': [ + { + 'id': 'command:legacy-compact', + 'kind': 'command', + 'name': '/compact', + }, + { + 'id': 'command:session.cancel', + 'kind': 'command', + 'name': 'session.cancel', + }, + ], + 'operations': [], + }); + await _flush(); + expect(controller.state.composer.slashCommands, isEmpty); + }, + ); + + test( + 'read-only catalog clients see official headless commands disabled', + () async { + final profile = _profile('alpha'); + final connector = _FakeConnector(); + final controller = _controller( + _MemoryDirectoryStore( + directory: const HostDirectory.empty().upsert(profile), + ), + _MemoryCredentialStore(), + connector, + ); + addTearDown(controller.dispose); + await controller.initialize(); + final channel = connector.channels.single; + + channel.emit( + _welcome( + 'host-alpha', + capabilities: const ['sessions.read', 'catalog.read'], + features: const ['catalog.metadata'], + ), + ); + await _flush(); + final list = channel.sentJson.firstWhere( + (frame) => frame['command'] == 'session.list', + ); + channel.emit( + _response( + list, + command: 'session.list', + result: _sessionListResult('host-alpha'), + ), + ); + channel.emit({ + 'v': 'omp-app/1', + 'type': 'catalog', + 'hostId': 'host-alpha', + 'revision': 'catalog-read-only', + 'items': [], + 'operations': [ + { + 'operationId': 'slash.compact', + 'label': '/compact', + 'execution': 'headless', + 'supported': true, + }, + ], + }); + await _flush(); + + expect( + controller.state.composer.slashCommands.single.disabledReason, + 'Not granted on this host', + ); }, ); diff --git a/apps/flutter/test/protocol/wire_conformance_test.dart b/apps/flutter/test/protocol/wire_conformance_test.dart index 19ffdedc..b155f326 100644 --- a/apps/flutter/test/protocol/wire_conformance_test.dart +++ b/apps/flutter/test/protocol/wire_conformance_test.dart @@ -207,7 +207,7 @@ void main() { expect(result.revision, 'capabilities-v1'); expect( - result.operations.map((operation) => operation.operationId), + result.operations!.map((operation) => operation.operationId), [ 'session.prompt', 'slash.compact', @@ -216,7 +216,7 @@ void main() { ], ); expect( - result.operations.map((operation) => operation.execution), + result.operations!.map((operation) => operation.execution), [ OperationExecution.typed, OperationExecution.headless, @@ -225,13 +225,13 @@ void main() { ], ); expect( - result.operations + result.operations! .skip(2) .map((operation) => operation.disabledReason!.code), ['terminal_only', 'capability_unavailable'], ); expect( - () => result.operations.add(result.operations.first), + () => result.operations!.add(result.operations!.first), throwsUnsupportedError, ); @@ -245,6 +245,31 @@ void main() { ]); }); + test( + 'catalog decoding preserves missing versus authoritative empty operations', + () { + Map catalog([List? operations]) { + final frame = { + 'v': 'omp-app/1', + 'type': 'catalog', + 'hostId': 'host-alpha', + 'revision': operations == null ? 'legacy' : 'authoritative-empty', + 'items': [], + }; + if (operations != null) frame['operations'] = operations; + return frame; + } + + final legacy = + WireDecoder.decode(jsonEncode(catalog())) as CatalogFrame; + final authoritative = + WireDecoder.decode(jsonEncode(catalog([]))) + as CatalogFrame; + expect(legacy.operations, isNull); + expect(authoritative.operations, isEmpty); + }, + ); + test( 'every non-corpus ServerFrame branch rejects a missing requirement', () { diff --git a/apps/flutter/test/ui/host_flow_test.dart b/apps/flutter/test/ui/host_flow_test.dart index 38dcf1ac..5b9ef14a 100644 --- a/apps/flutter/test/ui/host_flow_test.dart +++ b/apps/flutter/test/ui/host_flow_test.dart @@ -709,6 +709,47 @@ void main() { expect(actions.submittedPrompts, ['Alpha draft']); }, ); + testWidgets( + 'composer stays disabled when prompt permission was not granted', + (tester) async { + final profile = HostProfile.parseTailnetAddress( + 'https://alpha.tailnet-name.ts.net', + ); + final state = T4ViewState( + connectionPhase: ConnectionPhase.ready, + hostDirectory: HostDirectory.empty().upsert(profile), + authenticationPhase: AuthenticationPhase.paired, + grantedCapabilities: const {'sessions.read', 'catalog.read'}, + selectedSessionId: 'session-alpha', + sessions: const [ + SessionSummary( + hostId: 'host-alpha', + sessionId: 'session-alpha', + projectId: 'project-alpha', + projectName: 'Project Alpha', + title: 'Read-only session', + revision: 'revision-alpha', + status: 'idle', + ), + ], + ); + + await pumpApp( + tester, + state: state, + actions: _FakeActions(), + size: compactPhone, + ); + await tester.enterText(find.byType(TextField).last, 'Cannot send'); + await tester.pump(); + expect( + tester + .widget(find.widgetWithText(FilledButton, 'Send')) + .onPressed, + isNull, + ); + }, + ); testWidgets( 'Fast toggle reads as an inactive toggle when available and off, and selected when on', (tester) async { diff --git a/apps/web/src/features/composer/slash.ts b/apps/web/src/features/composer/slash.ts index b7616675..d6309089 100644 --- a/apps/web/src/features/composer/slash.ts +++ b/apps/web/src/features/composer/slash.ts @@ -42,7 +42,7 @@ export function slashCommandsFromCatalog( items: readonly CatalogItem[], context: SlashCatalogContext, granted: readonly string[], - operations: readonly OperationCapability[] = [], + operations?: readonly OperationCapability[], ): SlashCommand[] { const offlineReason = context.link === "cached" @@ -55,12 +55,13 @@ export function slashCommandsFromCatalog( // Fall back to legacy command items only when an older host does not expose // the new contract at all; otherwise typed commands such as session.cancel // would be mistaken for slash commands. - if (operations.length === 0) { + if (operations === undefined) { for (const item of items) { if (item.kind !== "command") continue; + const metadata = item.metadata ?? {}; + if (!item.name.startsWith("/") && metadata.slashCommand !== true) continue; const bareName = item.name.replace(/^\/+/, ""); const name = `/${bareName}`; - const metadata = item.metadata ?? {}; const rawAliases = Array.isArray(metadata.aliases) ? metadata.aliases : []; const aliases = rawAliases .filter((alias): alias is string => typeof alias === "string" && alias !== "") @@ -93,7 +94,7 @@ export function slashCommandsFromCatalog( }); } } - for (const operation of operations) { + for (const operation of operations ?? []) { const operationId = String(operation.operationId); if (!operationId.startsWith("slash.")) continue; const bareName = operationId.slice("slash.".length); @@ -107,7 +108,8 @@ export function slashCommandsFromCatalog( const aliases = rawAliases .filter((alias): alias is string => typeof alias === "string" && alias !== "") .map((alias) => `/${alias.replace(/^\/+/, "")}`); - const missingCapability = (operation.capabilities ?? []).find( + const requiredCapabilities = ["sessions.prompt", ...(operation.capabilities ?? [])]; + const missingCapability = requiredCapabilities.find( (capability) => !granted.includes(capability), ); const disabledReason = @@ -128,7 +130,7 @@ export function slashCommandsFromCatalog( name, aliases, description: operation.description ?? "", - argsHint: "", + argsHint: typeof metadata.inlineHint === "string" ? metadata.inlineHint : "", disabledReason, insert: `${name} `, }); diff --git a/apps/web/src/features/session-runtime/live-runtime.ts b/apps/web/src/features/session-runtime/live-runtime.ts index b2bb0184..f4574f62 100644 --- a/apps/web/src/features/session-runtime/live-runtime.ts +++ b/apps/web/src/features/session-runtime/live-runtime.ts @@ -1215,7 +1215,7 @@ export function createLiveSessionRuntime(options: LiveRuntimeOptions): SessionRu readOnlyReason: controlGate === null ? null : controlGate.slashReason, }, granted, - catalog.operations ?? [], + catalog.operations, ), contextUsedTokens: contextUsage?.used ?? 0, contextWindowTokens: contextUsage?.limit ?? 0, diff --git a/apps/web/src/features/session-runtime/session-observer.test.ts b/apps/web/src/features/session-runtime/session-observer.test.ts index e42cbf1c..9e6797b2 100644 --- a/apps/web/src/features/session-runtime/session-observer.test.ts +++ b/apps/web/src/features/session-runtime/session-observer.test.ts @@ -337,8 +337,18 @@ describe("gateComposerControls", () => { describe("slash gating", () => { const items = [ - { kind: "command", name: "retry", description: "Retry the last turn" }, - { kind: "command", name: "compact", description: "Compact context" }, + { + kind: "command", + name: "retry", + description: "Retry the last turn", + metadata: { slashCommand: true }, + }, + { + kind: "command", + name: "compact", + description: "Compact context", + metadata: { slashCommand: true }, + }, ] as unknown as readonly CatalogItem[]; it("gates every command with the observer reason on a live link", () => { diff --git a/apps/web/test/live-composer.test.ts b/apps/web/test/live-composer.test.ts index e22fd5c3..a66a9903 100644 --- a/apps/web/test/live-composer.test.ts +++ b/apps/web/test/live-composer.test.ts @@ -140,13 +140,18 @@ function durableEntryFrame(seq: number, value: DurableEntry): DurableEntryFrame }; } -function commandItem(name: string, capabilities?: readonly string[]): CatalogItem { +function commandItem( + name: string, + capabilities?: readonly string[], + slashCommand = false, +): CatalogItem { return { id: catalogId(`cmd-${name}`), kind: "command", name, description: `${name} command`, ...(capabilities === undefined ? {} : { capabilities: [...capabilities] }), + ...(slashCommand ? { metadata: { slashCommand: true } } : {}), }; } @@ -659,7 +664,10 @@ describe("stop affordance and slash catalog", () => { // empty (not the browser built-ins). expect(runtime.getSnapshot().slashCommands).toEqual([]); - shell.emitFrame({ targetId: "local", frame: catalogFrame("rev-1", [commandItem("compact")]) }); + shell.emitFrame({ + targetId: "local", + frame: catalogFrame("rev-1", [commandItem("compact", undefined, true)]), + }); expect(runtime.getSnapshot().slashCommands?.map((command) => command.name)).toEqual([ "/compact", ]); @@ -667,8 +675,8 @@ describe("stop affordance and slash catalog", () => { shell.emitFrame({ targetId: "local", frame: catalogFrame("rev-2", [ - commandItem("review"), - commandItem("terminal", ["terminal.io"]), + commandItem("review", undefined, true), + commandItem("terminal", ["terminal.io"], true), ]), }); const commands = runtime.getSnapshot().slashCommands ?? []; @@ -701,8 +709,7 @@ describe("stop affordance and slash catalog", () => { description: "Compact the active conversation", execution: "headless", supported: true, - capabilities: ["sessions.prompt"], - metadata: { aliases: ["compress"] }, + metadata: { aliases: ["compress"], inlineHint: "[focus]" }, }, { operationId: "slash.plan" as OperationCapability["operationId"], @@ -722,9 +729,39 @@ describe("stop affordance and slash catalog", () => { const commands = runtime.getSnapshot().slashCommands ?? []; expect(commands.map((command) => command.name)).toEqual(["/compact", "/plan"]); expect(commands[0]?.aliases).toEqual(["/compress"]); + expect(commands[0]?.argsHint).toBe("[focus]"); expect(commands[0]?.disabledReason).toBeNull(); expect(commands[1]?.disabledReason).toBe("/plan requires the OMP terminal interface."); }); + + it("treats an explicit empty operation list as authoritative", async () => { + const { shell, runtime } = await startedRuntime(); + shell.emitFrame({ + targetId: "local", + frame: catalogFrame("empty-operations", [commandItem("/legacy")], []), + }); + + expect(runtime.getSnapshot().slashCommands).toEqual([]); + }); + + it("disables operation-derived commands for read-only clients", async () => { + const { shell, runtime } = await startedRuntime([]); + shell.emitFrame({ + targetId: "local", + frame: catalogFrame("read-only-operations", [], [ + { + operationId: "slash.compact" as OperationCapability["operationId"], + label: "/compact", + execution: "headless", + supported: true, + }, + ]), + }); + + expect(runtime.getSnapshot().slashCommands?.[0]?.disabledReason).toBe( + "Not granted on this host", + ); + }); }); describe("confirmations", () => { @@ -1493,8 +1530,8 @@ describe("session lifecycle", () => { targetId: "local", frame: catalogFrame("rev-2", [ commandItem("session.cancel"), - commandItem("compact"), - commandItem("retry"), + commandItem("compact", undefined, true), + commandItem("retry", undefined, true), ]), }); shell.emitFrame({ targetId: "local", frame: indexed(1, "active") }); diff --git a/packages/host-service/src/official-omp-capabilities.ts b/packages/host-service/src/official-omp-capabilities.ts index 36c0f3c8..e4b740da 100644 --- a/packages/host-service/src/official-omp-capabilities.ts +++ b/packages/host-service/src/official-omp-capabilities.ts @@ -12,11 +12,12 @@ const MAX_AVAILABLE_COMMANDS = 1_000; const MAX_COMMAND_ALIASES = 32; const MAX_COMMAND_NAME_BYTES = 128; const MAX_COMMAND_DESCRIPTION_BYTES = 4_096; +const MAX_COMMAND_INPUT_HINT_BYTES = 512; const MAX_COMMAND_SOURCE_BYTES = 64; export const OFFICIAL_OMP_TERMINAL_ONLY_EVIDENCE = Object.freeze({ - packageVersion: "17.0.5", - sourceCommit: "772e5e41eb1537177349247add96a851721c5bfa", + packageVersion: "17.0.6", + sourceCommit: "89d6a8f6d14286f32f09ec9c8aa8af7b3451d2d6", }); interface TerminalOnlyCommand { @@ -64,7 +65,6 @@ const TERMINAL_ONLY_COMMANDS: readonly TerminalOnlyCommand[] = Object.freeze([ { name: "retry", description: "Retry the last failed agent turn" }, { name: "debug", description: "Open debug tools selector" }, { name: "exit", description: "Exit the application" }, - { name: "continue-in-t4", description: "Persist and continue this session in T4" }, { name: "pause", description: "Freeze all agents until resumed" }, { name: "quit", aliases: ["q"], description: "Quit the application" }, ]); @@ -73,6 +73,7 @@ interface HeadlessCommand { readonly name: string; readonly aliases: readonly string[]; readonly description?: string; + readonly inputHint?: string; readonly source: string; } @@ -122,8 +123,15 @@ function decodeHeadlessCommands(value: unknown): readonly HeadlessCommand[] { item.description === undefined ? undefined : controlFree(item.description, `${path}.description`, MAX_COMMAND_DESCRIPTION_BYTES); + const input = item.input === undefined ? undefined : boundedMap(item.input, `${path}.input`, 4); + const inputHint = + input?.hint === undefined + ? undefined + : controlFree(input.hint, `${path}.input.hint`, MAX_COMMAND_INPUT_HINT_BYTES); const source = controlFree(item.source, `${path}.source`, MAX_COMMAND_SOURCE_BYTES); - commands.push(Object.freeze({ name, aliases: Object.freeze(aliases), description, source })); + commands.push( + Object.freeze({ name, aliases: Object.freeze(aliases), description, inputHint, source }), + ); } return Object.freeze(commands); } @@ -140,8 +148,11 @@ export class OfficialOmpCapabilityAdapter { #operations: readonly OperationCapability[]; #operationsById = new Map(); #promptOperations = new Map(); + readonly #includePinnedTerminalOnly: boolean; - constructor() { + constructor(runtimeVersion: string = OFFICIAL_OMP_TERMINAL_ONLY_EVIDENCE.packageVersion) { + this.#includePinnedTerminalOnly = + runtimeVersion === OFFICIAL_OMP_TERMINAL_ONLY_EVIDENCE.packageVersion; this.#operations = this.buildCatalog([]); } @@ -202,11 +213,12 @@ export class OfficialOmpCapabilityAdapter { description: "Send a typed prompt to the active OMP session", execution: "typed", supported: true, + capabilities: ["sessions.prompt"], metadata: Object.freeze({ rpcCommand: "prompt" }), }), ]; const promptOperations = new Map(); - const headlessNames = new Set(); + const discoveredPromptNames = new Set(); for (const command of headlessCommands) { const capability: OperationCapability = Object.freeze({ operationId: operationId(`slash.${command.name}`), @@ -214,34 +226,45 @@ export class OfficialOmpCapabilityAdapter { ...(command.description ? { description: command.description } : {}), execution: "headless", supported: true, - metadata: Object.freeze({ source: command.source, aliases: command.aliases }), + capabilities: ["sessions.prompt"], + metadata: Object.freeze({ + source: command.source, + aliases: command.aliases, + ...(command.inputHint ? { inlineHint: command.inputHint } : {}), + }), }); operations.push(capability); - headlessNames.add(command.name); + discoveredPromptNames.add(command.name); promptOperations.set(command.name, capability); - for (const alias of command.aliases) + for (const alias of command.aliases) { + discoveredPromptNames.add(alias); if (!promptOperations.has(alias)) promptOperations.set(alias, capability); + } } for (const command of TERMINAL_ONLY_COMMANDS) { - if (headlessNames.has(command.name)) continue; + if (discoveredPromptNames.has(command.name)) continue; + const aliases = Object.freeze( + [...(command.aliases ?? [])].filter((alias) => !discoveredPromptNames.has(alias)), + ); const capability: OperationCapability = Object.freeze({ operationId: operationId(`slash.${command.name}`), label: `/${command.name}`, description: command.description, execution: "terminal-only", supported: false, + capabilities: ["sessions.prompt"], disabledReason: Object.freeze({ code: OPERATION_DISABLED_REASON_CODES.terminalOnly, message: `/${command.name} requires the OMP terminal interface.`, }), metadata: Object.freeze({ - aliases: Object.freeze([...(command.aliases ?? [])]), + aliases, evidence: OFFICIAL_OMP_TERMINAL_ONLY_EVIDENCE, }), }); - operations.push(capability); + if (this.#includePinnedTerminalOnly) operations.push(capability); if (!promptOperations.has(command.name)) promptOperations.set(command.name, capability); - for (const alias of command.aliases ?? []) + for (const alias of aliases) if (!promptOperations.has(alias)) promptOperations.set(alias, capability); } this.#operationsById = new Map( diff --git a/packages/host-service/src/rpc-child.ts b/packages/host-service/src/rpc-child.ts index 8a68b970..b10ade12 100644 --- a/packages/host-service/src/rpc-child.ts +++ b/packages/host-service/src/rpc-child.ts @@ -151,14 +151,16 @@ export class RpcChildSupervisor { #stderr = ""; #ready = false; #termination?: Promise; - #operationCapabilities = new OfficialOmpCapabilityAdapter(); + #operationCapabilities: OfficialOmpCapabilityAdapter; constructor( private readonly factory: RpcChildFactory, private readonly session: SessionRecord, private readonly callbacks: ChildCallbacks, private readonly argv = ["omp", "--mode", "rpc"], private readonly failureStopGraceMs = FAILURE_STOP_GRACE_MS, + private readonly runtimeVersion?: string, ) { + this.#operationCapabilities = new OfficialOmpCapabilityAdapter(runtimeVersion); if (!Number.isSafeInteger(failureStopGraceMs) || failureStopGraceMs <= 0 || failureStopGraceMs > 60_000) throw new Error("failureStopGraceMs must be between 1 and 60000"); } diff --git a/packages/host-service/src/server.ts b/packages/host-service/src/server.ts index 9bb31a82..2eff0633 100644 --- a/packages/host-service/src/server.ts +++ b/packages/host-service/src/server.ts @@ -760,7 +760,7 @@ export class LocalAppserver implements AppserverHandle { #createdPending = new Map(); #projections = new Map(); #supervisors = new Map(); - #baseOperationCapabilities = new OfficialOmpCapabilityAdapter().operations(); + #baseOperationCapabilities: readonly OperationCapability[]; #externalRuntimes = new Map(); readonly #openingExternalRuntimes = new Map(); readonly #archivingWorkspaces = new Set(); @@ -915,6 +915,7 @@ export class LocalAppserver implements AppserverHandle { throw new Error("usageReadTimeoutMs must be between 1 and 60000"); this.#ompVersion = options.ompVersion ?? "local"; this.#ompBuild = options.ompBuild ?? "local"; + this.#baseOperationCapabilities = new OfficialOmpCapabilityAdapter(this.#ompVersion).operations(); this.#appserverVersion = options.appserverVersion ?? "0.1.0"; this.#appserverBuild = options.appserverBuild ?? "local"; this.#supportedFeatures = new Set(appserverSupportedFeatures(options)); @@ -3517,6 +3518,8 @@ export class LocalAppserver implements AppserverHandle { }, }, this.#factory.argv(record.path), + undefined, + this.#ompVersion, ); this.#supervisors.set(sessionId, supervisor); try { diff --git a/packages/host-service/test/official-omp-capabilities.test.ts b/packages/host-service/test/official-omp-capabilities.test.ts index a4f0a97f..27b7fd98 100644 --- a/packages/host-service/test/official-omp-capabilities.test.ts +++ b/packages/host-service/test/official-omp-capabilities.test.ts @@ -24,13 +24,18 @@ describe("official OMP capability adapter", () => { expect(adapter.assertOperationSupported("session.prompt")).toMatchObject({ execution: "typed", supported: true, + capabilities: ["sessions.prompt"], }); expect(adapter.operations().find((item) => item.operationId === "slash.plan")).toMatchObject({ label: "/plan", execution: "terminal-only", supported: false, + capabilities: ["sessions.prompt"], disabledReason: { code: OPERATION_DISABLED_REASON_CODES.terminalOnly }, }); + expect(adapter.operations().some((item) => item.operationId === "slash.continue-in-t4")).toBe( + false, + ); expect(() => adapter.assertPromptSupported("/plan implement this")).toThrow( OfficialOmpOperationError, ); @@ -60,6 +65,7 @@ describe("official OMP capability adapter", () => { name: "compact", aliases: ["c"], description: "Compact the session", + input: { hint: "[focus]" }, source: "builtin", }, { @@ -74,6 +80,8 @@ describe("official OMP capability adapter", () => { operationId: "slash.compact", execution: "headless", supported: true, + capabilities: ["sessions.prompt"], + metadata: { inlineHint: "[focus]" }, }); expect(String(adapter.assertPromptSupported("/c now")?.operationId)).toBe("slash.compact"); expect(adapter.assertPromptSupported("/plan now")).toMatchObject({ @@ -86,6 +94,27 @@ describe("official OMP capability adapter", () => { ); }); + test("withholds pinned terminal rows from unreviewed OMP versions while retaining dispatch guards", () => { + const adapter = new OfficialOmpCapabilityAdapter("17.1.0"); + expect(adapter.operations().some((item) => item.operationId === "slash.plan")).toBe(false); + expect(() => adapter.assertPromptSupported("/plan inspect this")).toThrow( + OfficialOmpOperationError, + ); + }); + + test("lets discovered names and aliases win over pinned terminal aliases", () => { + const adapter = new OfficialOmpCapabilityAdapter(); + adapter.update([{ name: "status", aliases: ["settings"], source: "extension" }]); + expect(adapter.operations().filter((item) => item.operationId === "slash.status")).toHaveLength( + 1, + ); + expect(adapter.operations().some((item) => item.operationId === "slash.settings")).toBe(false); + expect(adapter.assertPromptSupported("/settings")).toMatchObject({ + operationId: "slash.status", + execution: "headless", + }); + }); + test("rejects ambiguous and malformed capability updates", () => { const adapter = new OfficialOmpCapabilityAdapter(); expect(() => diff --git a/packages/host-service/test/official-omp-catalog-server.test.ts b/packages/host-service/test/official-omp-catalog-server.test.ts index 4cddf20a..11798b09 100644 --- a/packages/host-service/test/official-omp-catalog-server.test.ts +++ b/packages/host-service/test/official-omp-catalog-server.test.ts @@ -110,6 +110,7 @@ test("catalog.get merges normalized official OMP operation capabilities", async const appserver = createAppserver({ hostId: host, socketPath, + ompVersion: "17.0.6", discovery: { list: async () => [] }, operationsAuthority: { catalogGet: async () => ({ @@ -209,6 +210,7 @@ test("attached catalog refresh and terminal-only rejection stay on the runtime b const appserver = createAppserver({ hostId: host, socketPath, + ompVersion: "17.0.6", discovery: { list: async () => [session] }, childFactory: factory, lockCheck: () => {}, diff --git a/provenance/t3code/imports/f2-transcript-20260711.json b/provenance/t3code/imports/f2-transcript-20260711.json index ce38b6fb..b5e2a2fb 100644 --- a/provenance/t3code/imports/f2-transcript-20260711.json +++ b/provenance/t3code/imports/f2-transcript-20260711.json @@ -30,7 +30,7 @@ "sourceBlobSha": "b2fb2e223d3b7b85f993b48558222f708c4c6b54;c4919b1924515d1611c33a01bc6029f50605c749", "targetPath": "apps/web/src/features/composer/slash.ts + apps/web/src/features/composer/match.ts", "classification": "adapted", - "checksum": "sha256:b3edcf2f93240be8d96a3c28183776a34b60a54350253e4974d66de32d4018e6;sha256:de8963354251eba7321b17b62e9b6de1aa550b5e3ad6be5f2bc9c9635697aefc" + "checksum": "sha256:9a4587b9f7266ab99c912494e028fd90f25b7b1ddd1dcde673e5100db51e39eb;sha256:de8963354251eba7321b17b62e9b6de1aa550b5e3ad6be5f2bc9c9635697aefc" }, { "sourcePath": "apps/web/src/components/chat/ContextWindowMeter.tsx",