From f6306d1920039a2fba508fe9d570d68d6a314dd3 Mon Sep 17 00:00:00 2001 From: teknium1 <127238744+teknium1@users.noreply.github.com> Date: Mon, 14 Sep 2026 02:02:30 -0700 Subject: [PATCH] feat(contracts): TypeScript consumes the generated contract; hand-typed wire shapes deleted apps/shared/src/gateway-events.ts is now a thin layer over gateway-contract.generated.ts (client-local synthetic events + the GatewayEvent envelope); gateway-events.json, its two rendezvous tests and the duplicated BillingBlock / SessionInfo / ProjectInfo hand copies are gone. Desktop, TUI, web and shared typecheck against the generated RpcMethods / ServerRequestMap / BackendGatewayEventMap. What tsc found once the types were honest: three phantom fields the backend never sent (tool.start.todos, error.reason, voice.transcript.voice_stopped) - the TUI todo tests were driving the list through the phantom and are retargeted to tool.complete, where the wire actually carries it; nullable fields (`None` on the wire) were typed as plain optionals in eight places and now coerce at the boundary; SessionResumeResult had a stale generic. Contract fixes from the consumer pass: TranscriptMessage is the gateway projection (text/row_id/context/args), not the stored row; SkinPayload matches HermesSkin (empty-string defaults, never null); SessionLiveInfo model/tools/skills are required (always emitted); BillingBlock.billing_url is required-nullable (dataclass asdict). tui_gateway/AGENTS.md documents the declare -> regenerate -> tsc loop. --- apps/desktop/src/api/models.ts | 6 +- apps/desktop/src/app/chat/index.tsx | 4 +- .../hooks/use-session-tile-delegate.ts | 6 +- .../app/session/hooks/use-model-controls.ts | 8 +- .../hooks/use-session-actions.test.tsx | 6 +- .../hooks/use-session-actions/index.ts | 16 +- .../restore-pending-clarify.ts | 4 +- .../hooks/use-session-actions/utils.test.ts | 6 +- .../hooks/use-session-actions/utils.ts | 10 +- .../src/app/shell/model-catalog-menu.tsx | 6 +- .../src/app/shell/model-menu-panel.tsx | 4 +- .../src/app/shell/reasoning-menu-panel.tsx | 2 +- .../app/shell/use-model-menu-controller.ts | 4 +- .../src/components/model-picker.test.tsx | 4 +- .../components/model-visibility-dialog.tsx | 4 +- apps/desktop/src/hermes.ts | 2 +- apps/desktop/src/lib/chat-runtime.ts | 4 +- apps/desktop/src/lib/model-options.ts | 14 +- .../src/plugins/hermes-bots/group-turns.ts | 2 + .../src/plugins/hermes-bots/model-picker.tsx | 6 +- apps/desktop/src/types/hermes.ts | 2 +- apps/shared/src/billing-types.ts | 9 +- apps/shared/src/gateway-contract.openrpc.json | 259 +++--- apps/shared/src/gateway-events.json | 86 -- apps/shared/src/gateway-events.test.ts | 29 - apps/shared/src/gateway-events.ts | 782 +----------------- apps/shared/src/index.ts | 54 +- .../test_gateway_event_contract.py | 117 --- tui_gateway/AGENTS.md | 18 +- tui_gateway/contracts/common.py | 22 +- tui_gateway/contracts/events.py | 21 +- .../__tests__/activeSessionSwitcher.test.ts | 4 +- .../createGatewayEventHandler.test.ts | 14 +- ui-tui/src/app/createGatewayEventHandler.ts | 19 +- ui-tui/src/app/turnController.ts | 12 +- ui-tui/src/app/useSessionLifecycle.ts | 12 +- .../src/components/activeSessionSwitcher.tsx | 14 +- ui-tui/src/components/appChrome.tsx | 6 +- ui-tui/src/components/modelPicker.tsx | 6 +- ui-tui/src/gatewayTypes.ts | 10 +- ui-tui/src/types.ts | 33 +- 41 files changed, 286 insertions(+), 1361 deletions(-) delete mode 100644 apps/shared/src/gateway-events.json delete mode 100644 apps/shared/src/gateway-events.test.ts delete mode 100644 tests/tui_gateway/test_gateway_event_contract.py diff --git a/apps/desktop/src/api/models.ts b/apps/desktop/src/api/models.ts index 744456db98..9f3e34ecf8 100644 --- a/apps/desktop/src/api/models.ts +++ b/apps/desktop/src/api/models.ts @@ -1,4 +1,4 @@ -import type { ModelOptionsResponse } from '@hermes/shared' +import type { ModelOptionsResult } from '@hermes/shared' import type { AnalyticsResponse, @@ -33,7 +33,7 @@ export function getGlobalModelOptions( explicitOnly?: boolean }, profile?: null | string -): Promise { +): Promise { const params = new URLSearchParams() if (opts?.refresh) { @@ -48,7 +48,7 @@ export function getGlobalModelOptions( params.set('explicit_only', '1') } - return hermesApi({ + return hermesApi({ ...profileScoped(profile), path: params.size > 0 ? `/api/model/options?${params.toString()}` : '/api/model/options', timeoutMs: STARTUP_REQUEST_TIMEOUT_MS diff --git a/apps/desktop/src/app/chat/index.tsx b/apps/desktop/src/app/chat/index.tsx index d7a32a1cf3..899596a3a6 100644 --- a/apps/desktop/src/app/chat/index.tsx +++ b/apps/desktop/src/app/chat/index.tsx @@ -1,5 +1,5 @@ import { type AppendMessage, AssistantRuntimeProvider, type ThreadMessage } from '@assistant-ui/react' -import type { ModelOptionsResponse } from '@hermes/shared' +import type { ModelOptionsResult } from '@hermes/shared' import { useStore } from '@nanostores/react' import { useQuery } from '@tanstack/react-query' import type { ReadableAtom } from 'nanostores' @@ -576,7 +576,7 @@ const ChatViewContent = memo(function ChatViewContent({ const showChatBar = !loadingSession && !resumeExhausted && !isWatchWindow() const threadKey = selectedSessionId || activeSessionId || (isRoutedSessionView ? location.pathname : 'new') - const modelOptionsQuery = useQuery({ + const modelOptionsQuery = useQuery({ queryKey: modelOptionsQueryKey( modelOptionsProfile || activeGatewayProfile, activeSessionId, diff --git a/apps/desktop/src/app/contrib/hooks/use-session-tile-delegate.ts b/apps/desktop/src/app/contrib/hooks/use-session-tile-delegate.ts index 5ec8a4944c..d58578e6df 100644 --- a/apps/desktop/src/app/contrib/hooks/use-session-tile-delegate.ts +++ b/apps/desktop/src/app/contrib/hooks/use-session-tile-delegate.ts @@ -23,7 +23,7 @@ import { sessionTileOwnerRoute, setSessionTileDelegate } from '@/store/session-states' -import type { SessionResumeResponse } from '@/types/hermes' +import type { SessionResumeResult } from '@/types/hermes' import type { usePromptActions } from '../../session/hooks/use-prompt-actions' import { singleFlightSessionResume } from '../../session/hooks/use-prompt-actions/single-flight-resume' @@ -41,7 +41,7 @@ type SessionStateCache = ReturnType function mergeTileTranscript( previous: ChatMessage[], - prefetchMessages: SessionResumeResponse['messages'] | undefined, + prefetchMessages: SessionResumeResult['messages'] | undefined, streamId?: null | string ): ChatMessage[] { const prefetched = toChatMessages(prefetchMessages ?? []) @@ -339,7 +339,7 @@ export function useSessionTileDelegate({ assertSessionOwnerResolved(owner, { method: 'session.resume', sessionId: storedSessionId }) return singleFlightSessionResume(storedSessionId, () => - requestForSessionProfile(owner, requestGateway, 'session.resume', { + requestForSessionProfile(owner, requestGateway, 'session.resume', { session_id: storedSessionId, cols: 96, omit_messages: true, diff --git a/apps/desktop/src/app/session/hooks/use-model-controls.ts b/apps/desktop/src/app/session/hooks/use-model-controls.ts index cf0576e322..99aa536cc6 100644 --- a/apps/desktop/src/app/session/hooks/use-model-controls.ts +++ b/apps/desktop/src/app/session/hooks/use-model-controls.ts @@ -1,4 +1,4 @@ -import type { ModelOptionsResponse } from '@hermes/shared' +import type { ModelOptionsResult } from '@hermes/shared' import { type QueryClient } from '@tanstack/react-query' import { useCallback, useRef } from 'react' @@ -60,7 +60,7 @@ export function useModelControls({ profile = cacheProfile || $activeGatewayProfile.get(), ownerConnectionId = cacheOwnerConnectionId ) => { - const patch = (prev: ModelOptionsResponse | undefined) => { + const patch = (prev: ModelOptionsResult | undefined) => { // Selection state can update before the catalog query has resolved. // Keep that optimistic cache structurally complete; the composer // interprets a response without `providers` as an empty catalog. @@ -73,10 +73,10 @@ export function useModelControls({ return { ...prev, provider, model, providers } } - queryClient.setQueryData(modelOptionsQueryKey(profile, sessionId, ownerConnectionId), patch) + queryClient.setQueryData(modelOptionsQueryKey(profile, sessionId, ownerConnectionId), patch) if (includeGlobal) { - queryClient.setQueryData(modelOptionsQueryKey(profile, null, ownerConnectionId), patch) + queryClient.setQueryData(modelOptionsQueryKey(profile, null, ownerConnectionId), patch) } }, [cacheOwnerConnectionId, cacheProfile, queryClient] diff --git a/apps/desktop/src/app/session/hooks/use-session-actions.test.tsx b/apps/desktop/src/app/session/hooks/use-session-actions.test.tsx index 905eb38954..8d46e2d345 100644 --- a/apps/desktop/src/app/session/hooks/use-session-actions.test.tsx +++ b/apps/desktop/src/app/session/hooks/use-session-actions.test.tsx @@ -16,7 +16,7 @@ import { getSession, type ProfileScope, type SessionInfo, - type SessionResumeResponse, + type SessionResumeResult, setSessionArchived } from '@/hermes' import { createClientSessionState } from '@/lib/chat-runtime' @@ -2774,7 +2774,7 @@ describe('resumeSession warm-cache mapping integrity', () => { session_id: 'stored-A' }) - const deferredResume = deferred() + const deferredResume = deferred() const requestGatewayMock = vi.fn((method: string, _params?: Record) => { if (method === 'session.resume') { @@ -3094,7 +3094,7 @@ describe('resumeSession warm-cache mapping integrity', () => { current: new Map([['rt-A', clientState('stored-A')]]) } - const activated = deferred() + const activated = deferred() const requestGateway = vi.fn((method: string) => method === 'session.activate' ? activated.promise : Promise.resolve({}) diff --git a/apps/desktop/src/app/session/hooks/use-session-actions/index.ts b/apps/desktop/src/app/session/hooks/use-session-actions/index.ts index 9a7cd3218e..7bbb4c14a3 100644 --- a/apps/desktop/src/app/session/hooks/use-session-actions/index.ts +++ b/apps/desktop/src/app/session/hooks/use-session-actions/index.ts @@ -131,7 +131,7 @@ import { saveTranscriptTail } from '@/store/transcript-tail-cache' import { isWatchWindow } from '@/store/windows' -import type { SessionCreateResponse, SessionMessage, SessionResumeResponse, UsageStats } from '@/types/hermes' +import type { SessionCreateResponse, SessionMessage, SessionResumeResult, UsageStats } from '@/types/hermes' import { navigateToWorkspacePage, NEW_CHAT_ROUTE, sessionRoute, SETTINGS_ROUTE } from '../../../routes' import type { ClientSessionState, SidebarNavItem } from '../../../types' @@ -258,7 +258,7 @@ function applyStoredUsage(stored: { input_tokens?: number | null; output_tokens? function reconcileAuthoritativeChatMessages( authoritativeMessages: ChatMessage[], previousMessages: ChatMessage[], - liveProjection?: Pick + liveProjection?: Pick ): ChatMessage[] { const withLiveProjection = liveProjection ? appendLiveSessionProjection(authoritativeMessages, liveProjection) @@ -271,9 +271,9 @@ function reconcileAuthoritativeChatMessages( } function reconcileAuthoritativeMessages( - authoritativeMessages: SessionResumeResponse['messages'], + authoritativeMessages: SessionResumeResult['messages'], previousMessages: ChatMessage[], - liveProjection?: Pick + liveProjection?: Pick ): ChatMessage[] { return reconcileAuthoritativeChatMessages(toChatMessages(authoritativeMessages), previousMessages, liveProjection) } @@ -338,7 +338,7 @@ interface FreshSessionDraftOptions { workspaceTarget?: NewChatWorkspaceTarget } -function restorePendingApproval(response: SessionResumeResponse, sessionId: string): boolean { +function restorePendingApproval(response: SessionResumeResult, sessionId: string): boolean { const pending = response.pending_approval if (!pending) { @@ -1197,13 +1197,13 @@ export function useSessionActions({ setSessionStartedAt(Date.now()) try { - let activated: SessionResumeResponse | null = null + let activated: SessionResumeResult | null = null const activateStartedAt = Date.now() / 1000 const activateBaselineState = sessionStateByRuntimeIdRef.current.get(cachedRuntimeId) ?? cachedViewState const clarifyRequestIdAtActivateStart = $clarifyRequests.get()[cachedRuntimeId]?.requestId try { - activated = await requestForSession('session.activate', { + activated = await requestForSession('session.activate', { session_id: cachedRuntimeId, cols: 96, omit_messages: true @@ -1602,7 +1602,7 @@ export function useSessionActions({ const resumeStartedAt = Date.now() / 1000 const resumePromise = singleFlightSessionResume(storedSessionId, () => - requestForSession('session.resume', { + requestForSession('session.resume', { session_id: storedSessionId, cols: 96, source: 'desktop', diff --git a/apps/desktop/src/app/session/hooks/use-session-actions/restore-pending-clarify.ts b/apps/desktop/src/app/session/hooks/use-session-actions/restore-pending-clarify.ts index 0f1144a510..f375ac056a 100644 --- a/apps/desktop/src/app/session/hooks/use-session-actions/restore-pending-clarify.ts +++ b/apps/desktop/src/app/session/hooks/use-session-actions/restore-pending-clarify.ts @@ -1,6 +1,6 @@ import type { GatewayEventPayload } from '@/lib/chat-messages' import { $clarifyRequests, type ClarifyRequest, clearClarifyRequest } from '@/store/clarify' -import type { SessionResumeResponse } from '@/types/hermes' +import type { SessionResumeResult } from '@/types/hermes' export interface PendingClarifyResumeState { authoritativeAbsent: boolean @@ -21,7 +21,7 @@ export interface PendingClarifyResumeState { * newer request that arrived while the response was in flight is left alone. */ export function restorePendingClarifyFromSnapshot( - response: Pick, + response: Pick, sessionId: string, resumeStartedAt: number, requestIdAtStart?: string diff --git a/apps/desktop/src/app/session/hooks/use-session-actions/utils.test.ts b/apps/desktop/src/app/session/hooks/use-session-actions/utils.test.ts index 19bff08385..340a44955c 100644 --- a/apps/desktop/src/app/session/hooks/use-session-actions/utils.test.ts +++ b/apps/desktop/src/app/session/hooks/use-session-actions/utils.test.ts @@ -13,7 +13,7 @@ import { setSelectedStoredSessionId, workspaceCwdBelongsToSelectedSession } from '@/store/session' -import type { SessionInfo, SessionResumeResponse } from '@/types/hermes' +import type { SessionInfo, SessionResumeResult } from '@/types/hermes' import { appendLiveSessionProjection, @@ -1545,7 +1545,7 @@ describe('resolveResumedBusy', () => { }) }) -const runningProjection = (user: string): SessionResumeResponse => +const runningProjection = (user: string): SessionResumeResult => ({ session_id: 'runtime-1', session_key: 'stored-1', @@ -1554,7 +1554,7 @@ const runningProjection = (user: string): SessionResumeResponse => messages: [], running: true, inflight: { user, assistant: 'partial answer', streaming: true } - }) as SessionResumeResponse + }) as SessionResumeResult describe('dedupeInflightUserAgainstTranscript', () => { it('retains the in-flight user source only when it already exists after the runtime anchor', () => { diff --git a/apps/desktop/src/app/session/hooks/use-session-actions/utils.ts b/apps/desktop/src/app/session/hooks/use-session-actions/utils.ts index 7970c86d32..5e9322c3e4 100644 --- a/apps/desktop/src/app/session/hooks/use-session-actions/utils.ts +++ b/apps/desktop/src/app/session/hooks/use-session-actions/utils.ts @@ -46,7 +46,7 @@ import { sessionTileOwnerRoute } from '@/store/session-states' export { sessionMatchesStoredId } import { sessionOwnerRouteFromRow, type SessionOwnerScope } from '@/store/session-request-router' import { reportBackendContract, reportInstallMethodWarning } from '@/store/updates' -import type { SessionCreateResponse, SessionInfo, SessionResumeResponse, SessionRuntimeInfo } from '@/types/hermes' +import type { SessionCreateResponse, SessionInfo, SessionResumeResult, SessionRuntimeInfo } from '@/types/hermes' import type { ClientSessionState } from '../../../types' @@ -747,11 +747,11 @@ export function preserveLocalPendingTurnMessages( */ const safelyPersistedInflightUser = Symbol('safelyPersistedInflightUser') -type LiveSessionProjection = Pick & { +type LiveSessionProjection = Pick & { [safelyPersistedInflightUser]?: true } -type ReconciledSessionResumeResponse = SessionResumeResponse & { +type ReconciledSessionResumeResponse = SessionResumeResult & { [safelyPersistedInflightUser]?: true } @@ -1005,7 +1005,7 @@ function transcriptAnchorMatches(a: ChatMessage, b: ChatMessage): boolean { export function dedupeInflightUserAgainstTranscript( persistedMessages: ChatMessage[], runtimeMessages: ChatMessage[], - projection: SessionResumeResponse + projection: SessionResumeResult ): ReconciledSessionResumeResponse { const inflightUser = projection.inflight?.user?.replace(/\s+/g, ' ').trim() ?? '' @@ -1054,7 +1054,7 @@ export function dedupeInflightUserAgainstTranscript( */ export function removeRepresentedLocalLiveProjection( previousMessages: ChatMessage[], - projection: Pick + projection: Pick ): ChatMessage[] { const inflightUser = projection.inflight?.user?.replace(/\s+/g, ' ').trim() ?? '' const inflightAssistant = projection.inflight?.assistant?.replace(/\s+/g, ' ').trim() ?? '' diff --git a/apps/desktop/src/app/shell/model-catalog-menu.tsx b/apps/desktop/src/app/shell/model-catalog-menu.tsx index bffbaa0265..f99d8862c0 100644 --- a/apps/desktop/src/app/shell/model-catalog-menu.tsx +++ b/apps/desktop/src/app/shell/model-catalog-menu.tsx @@ -1,4 +1,4 @@ -import type { ModelOptionProvider, ModelOptionsResponse } from '@hermes/shared' +import type { ModelOptionProvider, ModelOptionsResult } from '@hermes/shared' import { DEFAULT_REASONING_EFFORT } from '@hermes/shared' import { useStore } from '@nanostores/react' import { useQuery } from '@tanstack/react-query' @@ -149,7 +149,7 @@ export function ModelCatalogMenu({ // Gateway-first even with no session: a connected (possibly remote) // gateway owns the model catalog, including virtual providers the local // REST fallback can't know about (#53817). - queryFn: (): Promise => requestModelOptions({ gateway, profile, request, sessionId }) + queryFn: (): Promise => requestModelOptions({ gateway, profile, request, sessionId }) }) const loading = modelOptions.isPending && !modelOptions.data @@ -573,7 +573,7 @@ export function ModelCatalogMenu({ ) : null} (queryKey, next) + queryClient.setQueryData(queryKey, next) } catch { // Network/backend hiccup — fall back to a plain invalidate so the next // open re-fetches (still cached, but no worse than before). diff --git a/apps/desktop/src/app/shell/reasoning-menu-panel.tsx b/apps/desktop/src/app/shell/reasoning-menu-panel.tsx index 4c382f5fff..b50078df87 100644 --- a/apps/desktop/src/app/shell/reasoning-menu-panel.tsx +++ b/apps/desktop/src/app/shell/reasoning-menu-panel.tsx @@ -39,7 +39,7 @@ export function ReasoningMenuPanel(props: ModelMenuHostProps) { return ( => + queryFn: (): Promise => requestModelOptions({ gateway, profile, request: requestGateway, sessionId: activeSessionId }) }) diff --git a/apps/desktop/src/components/model-picker.test.tsx b/apps/desktop/src/components/model-picker.test.tsx index 1f309bee7f..f6a73ebba5 100644 --- a/apps/desktop/src/components/model-picker.test.tsx +++ b/apps/desktop/src/components/model-picker.test.tsx @@ -1,4 +1,4 @@ -import type { ModelOptionsResponse } from '@hermes/shared' +import type { ModelOptionsResult } from '@hermes/shared' import { fuzzyRank, modelSearchText } from '@hermes/shared' import { QueryClient, QueryClientProvider } from '@tanstack/react-query' import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' @@ -26,7 +26,7 @@ import { requestModelOptions } from '@/lib/model-options' stubResizeObserver() stubMenuDomApis() -const OPTIONS: ModelOptionsResponse = { +const OPTIONS: ModelOptionsResult = { model: 'Qwen3.6-27B-UD-Q4_K_XL', provider: 'llamacpp', providers: [ diff --git a/apps/desktop/src/components/model-visibility-dialog.tsx b/apps/desktop/src/components/model-visibility-dialog.tsx index ea31f38bfe..b830591228 100644 --- a/apps/desktop/src/components/model-visibility-dialog.tsx +++ b/apps/desktop/src/components/model-visibility-dialog.tsx @@ -1,4 +1,4 @@ -import type { ModelOptionProvider, ModelOptionsResponse } from '@hermes/shared' +import type { ModelOptionProvider, ModelOptionsResult } from '@hermes/shared' import { useStore } from '@nanostores/react' import { useQuery } from '@tanstack/react-query' import { useMemo, useState } from 'react' @@ -54,7 +54,7 @@ export function ModelVisibilityDialog({ const modelOptions = useQuery({ queryKey: modelOptionsQueryKey(profile, sessionId, ownerConnectionId), - queryFn: (): Promise => requestModelOptions({ gateway: gw, profile, sessionId }), + queryFn: (): Promise => requestModelOptions({ gateway: gw, profile, sessionId }), enabled: open }) diff --git a/apps/desktop/src/hermes.ts b/apps/desktop/src/hermes.ts index 4c86d44057..09eabaaa06 100644 --- a/apps/desktop/src/hermes.ts +++ b/apps/desktop/src/hermes.ts @@ -102,7 +102,7 @@ export type { SessionInfo, SessionMessage, SessionMessagesResponse, - SessionResumeResponse, + SessionResumeResult, SessionRuntimeInfo, SessionSearchResponse, SessionSearchResult, diff --git a/apps/desktop/src/lib/chat-runtime.ts b/apps/desktop/src/lib/chat-runtime.ts index 6fed15ba61..44f8fe5fb3 100644 --- a/apps/desktop/src/lib/chat-runtime.ts +++ b/apps/desktop/src/lib/chat-runtime.ts @@ -1,5 +1,5 @@ import type { ThreadMessage } from '@assistant-ui/react' -import type { ModelOptionsResponse } from '@hermes/shared' +import type { ModelOptionsResult } from '@hermes/shared' import type { QuickModelOption } from '@/app/chat/composer/types' import type { ClientSessionState } from '@/app/types' @@ -296,7 +296,7 @@ export function normalizePersonalityValue(value: string): string { } export function quickModelOptions( - data: ModelOptionsResponse | undefined, + data: ModelOptionsResult | undefined, currentProvider: string, currentModel: string ): QuickModelOption[] { diff --git a/apps/desktop/src/lib/model-options.ts b/apps/desktop/src/lib/model-options.ts index be8a40a3b1..6ed2cf2f81 100644 --- a/apps/desktop/src/lib/model-options.ts +++ b/apps/desktop/src/lib/model-options.ts @@ -1,4 +1,4 @@ -import type { ModelCapabilities, ModelOptionProvider, ModelOptionsResponse } from '@hermes/shared' +import type { ModelCapabilities, ModelOptionProvider, ModelOptionsResult } from '@hermes/shared' import { getGlobalModelOptions, type HermesGateway } from '@/hermes' @@ -22,7 +22,7 @@ export function catalogProviderMatches(provider: CatalogProviderIdentity, curren * catalog is loading / doesn't say. Callers treat undefined as "assume * reasoning" so controls never flicker away during the fetch. */ export function currentModelCapabilities( - options: ModelOptionsResponse | null | undefined, + options: ModelOptionsResult | null | undefined, provider: string, model: string ): ModelCapabilities | undefined { @@ -64,7 +64,7 @@ export function modelOptionsQueryKey( return ['model-options', profileKey, sessionId || 'global', ...(ownerKey ? ['owner', ownerKey] : [])] as const } -function hasSelectableModels(options: ModelOptionsResponse | null | undefined): boolean { +function hasSelectableModels(options: ModelOptionsResult | null | undefined): boolean { return options?.providers?.some(provider => (provider.models?.length ?? 0) > 0) ?? false } @@ -72,7 +72,7 @@ function restModelOptions( explicitOnly: boolean, refresh: boolean, profile?: null | string -): Promise { +): Promise { const opts = { explicitOnly, ...(refresh ? { refresh: true } : {}) } const profileKey = (profile ?? '').trim() @@ -86,7 +86,7 @@ export async function requestModelOptions({ refresh = false, request, sessionId -}: ModelOptionsRequest): Promise { +}: ModelOptionsRequest): Promise { const dispatch = request ?? (gateway ? gateway.request.bind(gateway) : null) if (dispatch) { @@ -111,10 +111,10 @@ export async function requestModelOptions({ } let gatewayError: unknown - let gatewayOptions: ModelOptionsResponse | undefined + let gatewayOptions: ModelOptionsResult | undefined try { - gatewayOptions = await dispatch('model.options', params) + gatewayOptions = await dispatch('model.options', params) } catch (error) { gatewayError = error } diff --git a/apps/desktop/src/plugins/hermes-bots/group-turns.ts b/apps/desktop/src/plugins/hermes-bots/group-turns.ts index 3f9c8a9e5f..ee5de72f1b 100644 --- a/apps/desktop/src/plugins/hermes-bots/group-turns.ts +++ b/apps/desktop/src/plugins/hermes-bots/group-turns.ts @@ -445,9 +445,11 @@ const GROUP_TURN_HARD_CAP_MS = 20 * 60000 * blocker). */ export function syncGroupClarify(group: string, member: GroupMember, state: GroupSessionSnapshot | null): boolean { const key = `${group}::${groupMemberKey(member)}` + const openClarify = Array.isArray(state?.open_requests) ? state.open_requests.find(entry => entry?.method === 'clarify' && typeof entry.id === 'string' && entry.id) : null + const clarify: GroupPendingClarify | null = openClarify ? { ...(openClarify.params as GroupPendingClarify), request_id: openClarify.id } : null diff --git a/apps/desktop/src/plugins/hermes-bots/model-picker.tsx b/apps/desktop/src/plugins/hermes-bots/model-picker.tsx index b9645b81c1..ecebdbb9ea 100644 --- a/apps/desktop/src/plugins/hermes-bots/model-picker.tsx +++ b/apps/desktop/src/plugins/hermes-bots/model-picker.tsx @@ -65,7 +65,7 @@ interface ModelProviderOption { name?: string slug: string } -interface ModelOptionsResponse { +interface ModelOptionsResult { providers?: ModelProviderOption[] } @@ -76,7 +76,7 @@ function useModelOptions(bot: null | RosterRow = null) { const route = resolved?.status === 'resolved' ? resolved.route : null const orphaned = resolved?.status === 'owner_removed' - return useQuery({ + return useQuery({ queryKey: [ID, 'model-options', route ? botRouteKey(route) : 'active'], // No forced `refresh`: forcing a network read on EVERY mount bypassed the // staleTime cache, so each Bots view remount (tab re-front, dialog reopen, @@ -88,7 +88,7 @@ function useModelOptions(bot: null | RosterRow = null) { requestForBot(bot, 'model.options', { include_unconfigured: true, explicit_only: false - }) as Promise + }) as Promise ), enabled: !orphaned, staleTime: 120000, diff --git a/apps/desktop/src/types/hermes.ts b/apps/desktop/src/types/hermes.ts index 4516c9a43a..03007c4226 100644 --- a/apps/desktop/src/types/hermes.ts +++ b/apps/desktop/src/types/hermes.ts @@ -628,7 +628,7 @@ export interface SessionMessagesResponse { session_id: string } -export interface SessionResumeResponse { +export interface SessionResumeResult { /** Present when the backend found a fresh crash-interrupted turn and * scheduled its automatic continuation; the turn arrives as a normal * message.start stream right after this resume. */ diff --git a/apps/shared/src/billing-types.ts b/apps/shared/src/billing-types.ts index 21b356e2f7..b3a7b9cd5b 100644 --- a/apps/shared/src/billing-types.ts +++ b/apps/shared/src/billing-types.ts @@ -20,14 +20,7 @@ * the managed route with in-app billing (desktop Settings → Billing, TUI * `/topup`), while third-party providers deep-link to `billing_url`. */ -export interface BillingBlock { - provider: string - provider_label: string - model: string - billing_url: string | null - is_nous: boolean - message: string -} +export type { BillingBlock } from './gateway-contract.generated.js' // ── Remote Spending (Phase 2b) ─────────────────────────────────────── diff --git a/apps/shared/src/gateway-contract.openrpc.json b/apps/shared/src/gateway-contract.openrpc.json index 37eab38bbd..7875ed1921 100644 --- a/apps/shared/src/gateway-contract.openrpc.json +++ b/apps/shared/src/gateway-contract.openrpc.json @@ -4880,7 +4880,6 @@ "type": "null" } ], - "default": null, "title": "Billing Url" }, "is_nous": { @@ -4908,6 +4907,7 @@ "provider", "provider_label", "model", + "billing_url", "is_nous", "message" ], @@ -24503,7 +24503,6 @@ "description": "The refreshed ``session.info`` view (full agent view, or the lazy shape).", "properties": { "model": { - "default": "", "title": "Model", "type": "string" }, @@ -24548,7 +24547,12 @@ "type": "object" }, "skills": { - "additionalProperties": true, + "additionalProperties": { + "items": { + "type": "string" + }, + "type": "array" + }, "title": "Skills", "type": "object" }, @@ -24724,6 +24728,11 @@ "title": "Lazy" } }, + "required": [ + "model", + "tools", + "skills" + ], "title": "SessionCwdSetResult", "type": "object" }, @@ -25348,7 +25357,6 @@ "description": "``tui_gateway/server.py::_session_info`` \u2014 the ``session.info`` event and the ``info`` field of\n``session.create`` / ``session.resume`` / ``session.activate`` results.", "properties": { "model": { - "default": "", "title": "Model", "type": "string" }, @@ -25393,7 +25401,12 @@ "type": "object" }, "skills": { - "additionalProperties": true, + "additionalProperties": { + "items": { + "type": "string" + }, + "type": "array" + }, "title": "Skills", "type": "object" }, @@ -25569,6 +25582,11 @@ "title": "Lazy" } }, + "required": [ + "model", + "tools", + "skills" + ], "title": "SessionLiveInfo", "type": "object" }, @@ -27483,124 +27501,62 @@ "description": "``tui_gateway/change_watcher.py::resolve_skin`` \u2014 the resolved active skin (``HermesSkin``).\n``{}`` when the skin engine failed to load. Colour maps are token \u2192 colour string.", "properties": { "name": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "default": null, - "title": "Name" + "default": "", + "title": "Name", + "type": "string" + }, + "description": { + "default": "", + "title": "Description", + "type": "string" }, "colors": { - "anyOf": [ - { - "additionalProperties": { - "type": "string" - }, - "type": "object" - }, - { - "type": "null" - } - ], - "default": null, - "title": "Colors" + "additionalProperties": { + "type": "string" + }, + "title": "Colors", + "type": "object" }, "light_colors": { - "anyOf": [ - { - "additionalProperties": { - "type": "string" - }, - "type": "object" - }, - { - "type": "null" - } - ], - "default": null, - "title": "Light Colors" + "additionalProperties": { + "type": "string" + }, + "title": "Light Colors", + "type": "object" }, "dark_colors": { - "anyOf": [ - { - "additionalProperties": { - "type": "string" - }, - "type": "object" - }, - { - "type": "null" - } - ], - "default": null, - "title": "Dark Colors" + "additionalProperties": { + "type": "string" + }, + "title": "Dark Colors", + "type": "object" }, "branding": { - "anyOf": [ - { - "additionalProperties": { - "type": "string" - }, - "type": "object" - }, - { - "type": "null" - } - ], - "default": null, - "title": "Branding" + "additionalProperties": { + "type": "string" + }, + "title": "Branding", + "type": "object" }, "banner_logo": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "default": null, - "title": "Banner Logo" + "default": "", + "title": "Banner Logo", + "type": "string" }, "banner_hero": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "default": null, - "title": "Banner Hero" + "default": "", + "title": "Banner Hero", + "type": "string" }, "tool_prefix": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "default": null, - "title": "Tool Prefix" + "default": "", + "title": "Tool Prefix", + "type": "string" }, "help_header": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "default": null, - "title": "Help Header" + "default": "", + "title": "Help Header", + "type": "string" } }, "title": "SkinPayload", @@ -30402,15 +30358,23 @@ }, "TranscriptMessage": { "additionalProperties": true, - "description": "One stored transcript row as ``session.history`` / resume ``messages`` deliver it.", + "description": "One transcript row as the gateway PROJECTS it for renderers (``session_history._project_history``):\n``text`` (never ``content``), display-only ``timestamp`` / ``display_kind`` / ``display_metadata``, the\ndurable ``row_id`` rewind targets, and for tool rows ``name`` + ``context`` preview + full ``args``.\nAssistant detail sidecars (``reasoning``, \u2026) ride as extra keys.", "properties": { "role": { "title": "Role", "type": "string" }, - "content": { + "text": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], "default": null, - "title": "Content" + "title": "Text" }, "timestamp": { "anyOf": [ @@ -30424,45 +30388,17 @@ "default": null, "title": "Timestamp" }, - "tool_calls": { + "row_id": { "anyOf": [ { - "items": { - "additionalProperties": true, - "type": "object" - }, - "type": "array" + "type": "integer" }, { "type": "null" } ], "default": null, - "title": "Tool Calls" - }, - "tool_call_id": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "default": null, - "title": "Tool Call Id" - }, - "name": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "default": null, - "title": "Name" + "title": "Row Id" }, "display_kind": { "anyOf": [ @@ -30476,17 +30412,52 @@ "default": null, "title": "Display Kind" }, - "row_id": { + "display_metadata": { + "anyOf": [ + {}, + { + "type": "null" + } + ], + "default": null, + "title": "Display Metadata" + }, + "name": { "anyOf": [ { - "type": "integer" + "type": "string" }, { "type": "null" } ], "default": null, - "title": "Row Id" + "title": "Name" + }, + "context": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Context" + }, + "args": { + "anyOf": [ + { + "additionalProperties": true, + "type": "object" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Args" }, "reasoning": { "anyOf": [ diff --git a/apps/shared/src/gateway-events.json b/apps/shared/src/gateway-events.json deleted file mode 100644 index 2337641428..0000000000 --- a/apps/shared/src/gateway-events.json +++ /dev/null @@ -1,86 +0,0 @@ -{ - "events": [ - "agent.terminal.output", - "background.complete", - "billing.step_up.verification", - "bot_relay.outbox.pending", - "browser.controller.cancel", - "browser.controller.command", - "browser.progress", - "btw.complete", - "cron.changed", - "error", - "gateway.ready", - "layout.apply", - "message.complete", - "message.delta", - "message.interim", - "message.reaction", - "message.start", - "moa.aggregating", - "moa.phase", - "moa.progress", - "moa.reference", - "notice", - "notification.clear", - "notification.show", - "pairing.changed", - "pane.reveal", - "pet.changed", - "pet.generate.progress", - "pet.hatch.progress", - "platforms.changed", - "preview.close", - "preview.open", - "preview.restart.complete", - "preview.restart.progress", - "reaction", - "reasoning.available", - "reasoning.delta", - "request.cancel", - "review.summary", - "session.control.update", - "session.info", - "session.reclaimed", - "session.resume_progress", - "session.title", - "session.usage", - "sessions.changed", - "setup.ready", - "skin.changed", - "status.update", - "subagent.complete", - "subagent.progress", - "subagent.spawn_requested", - "subagent.start", - "subagent.thinking", - "subagent.tool", - "terminal.close", - "thinking.delta", - "tip.show", - "todo.updated", - "tool.complete", - "tool.generating", - "tool.output_risk", - "tool.start", - "voice.interrupted", - "voice.status", - "voice.transcript", - "wake.detected" - ], - "server_requests": [ - "approval", - "clarify", - "mcp.setup", - "preview.act", - "preview.read", - "secret", - "sudo", - "terminal.read", - "tour", - "vault.code", - "vault.save_login", - "vault.unlock_prompt", - "window.read" - ] -} diff --git a/apps/shared/src/gateway-events.test.ts b/apps/shared/src/gateway-events.test.ts deleted file mode 100644 index 708f4f5405..0000000000 --- a/apps/shared/src/gateway-events.test.ts +++ /dev/null @@ -1,29 +0,0 @@ -import { describe, expect, it } from 'vitest' - -import { BACKEND_EVENT_NAMES, SERVER_REQUEST_METHODS } from './gateway-events' -import contract from './gateway-events.json' - -/** - * Two-sided contract with `tests/tui_gateway/test_gateway_event_contract.py`: the - * Python side pins the emitter / server-request call sites to `gateway-events.json`; - * this side pins `BACKEND_EVENT_NAMES` (checked against `BackendGatewayEventMap` via - * `satisfies`) and `SERVER_REQUEST_METHODS` (against `ServerRequestMap`) to the same - * JSON. A name added on either side alone goes red here. - */ -describe('gateway-events.json ⇄ BackendGatewayEventMap / ServerRequestMap', () => { - it('lists exactly the backend-emitted notification names', () => { - expect([...BACKEND_EVENT_NAMES]).toEqual(contract.events) - }) - - it('lists exactly the server→client request methods', () => { - expect([...SERVER_REQUEST_METHODS]).toEqual(contract.server_requests) - }) - - it.each([ - ['events', contract.events], - ['server_requests', contract.server_requests] - ])('keeps %s sorted and duplicate-free (stable diffs)', (_label, list) => { - expect(list).toEqual([...list].sort()) - expect(new Set(list).size).toBe(list.length) - }) -}) diff --git a/apps/shared/src/gateway-events.ts b/apps/shared/src/gateway-events.ts index 0ced15188d..ee2c031e35 100644 --- a/apps/shared/src/gateway-events.ts +++ b/apps/shared/src/gateway-events.ts @@ -1,617 +1,24 @@ /** - * Wire types for `tui_gateway` JSON-RPC notifications and the RPC responses the - * TypeScript surfaces (Ink TUI, Desktop, web dashboard) share. + * Wire types for the `tui_gateway` JSON-RPC surface shared by the Ink TUI, Desktop and the + * web dashboard. * - * Every notification arrives as `{jsonrpc: '2.0', method: 'event', params: GatewayEvent}` - * (`tui_gateway/server.py::_event_frame`); server→client REQUESTS (`{id, method, params}`, - * `tui_gateway/server_requests.py`) are typed by `ServerRequestMap` below. `GatewayEventMap` is the single map from - * event `type` to payload shape; `BACKEND_EVENT_NAMES` mirrors the emitter side and is - * pinned to `gateway-events.json` by `gateway-events.test.ts` (vitest) and - * `tests/tui_gateway/test_gateway_event_contract.py` (Python), so a name added on one - * side without the other fails a test instead of drifting silently. + * Every shape here is GENERATED from `tui_gateway/contracts` (Python is the single source): + * `./gateway-contract.generated.ts` carries `RpcMethods` (client→server method → params/result), + * `ServerRequestMap` (server→client request → params/result), `GatewayEventMap` (notification + * type → payload) and every value shape. `scripts/gen_gateway_contracts.py` regenerates it and + * `tests/contracts/test_generated.py` fails when the committed file is stale, so a field the + * backend stops sending fails `tsc` here instead of drifting. * - * Payload interfaces are typed from the Python emitters (file::symbol noted per - * interface). Events whose payload no TS client reads yet are `Record`; - * they still MUST be keys so `on('x', …)` stays exhaustive. + * This module adds only what the wire does not carry: the client-local synthetic events the TUI + * transport publishes into the same handler stream, and the `GatewayEvent` envelope. */ -import type { BillingBlock } from './billing-types.js' -import type { HermesSkin } from './skin.js' +import type { BackendGatewayEventMap } from './gateway-contract.generated.js' -// ── Shared value shapes ────────────────────────────────────────────── - -/** `tui_gateway/server.py::_get_usage` — a session's token/cost counters. */ -export interface Usage { - active_subagents?: number - /** Rolling mean API latency over the last 10 calls (seconds). */ - avg_latency_s?: number - /** Rolling output tokens/sec over the last 10 calls. */ - avg_tps?: number - /** Session prompt-cache hit ratio (cache_read / prompt tokens, %). Omitted (not 0) - * when the provider reports no cache reads. */ - cache_hit_pct?: number - cache_read?: number - cache_write?: number - calls: number - compressions?: number - context_max?: number - context_percent?: number - context_estimated?: boolean - context_source?: string - context_used?: number - cost_status?: string - cost_usd?: number - dev_credits_spent_micros?: number - input: number - output: number - reasoning?: number - total: number -} - -/** Advisory `{layer, code, retryable}` descriptor (`agent/error_surface.py`). */ -export interface ErrorSurface { - code?: string - layer?: string - retryable?: boolean -} - -/** `tui_gateway/tool_progress.py::_normalize_todo_state` — full task snapshot. */ -export interface TodoStatePayload { - revision?: number - todos?: unknown[] -} - -export type SubagentStatus = 'completed' | 'error' | 'failed' | 'interrupted' | 'queued' | 'running' | 'timeout' - -/** `tui_gateway/tool_progress.py::_progress_subagent` — every `subagent.*` frame. */ -export interface SubagentEventPayload { - api_calls?: number - /** The child's own gateway session id — the key a watch window mirrors. */ - child_session_id?: string - /** Batch (delegation) id this subagent belongs to — distinguishes - * interleaved `[n/N]` progress from concurrent or nested fan-outs. */ - delegation_id?: string - depth?: number - duration_seconds?: number - files_read?: string[] - files_written?: string[] - goal: string - input_tokens?: number - model?: string - output_tail?: { is_error?: boolean; preview?: string; tool?: string }[] - output_tokens?: number - parent_id?: null | string - reasoning_tokens?: number - status?: SubagentStatus - subagent_id?: string - summary?: string - task_count?: number - task_index: number - text?: string - tool_count?: number - tool_name?: string - tool_preview?: string - toolsets?: string[] -} - -// ── Event payloads ─────────────────────────────────────────────────── - -/** `tui_gateway/entry.py` (stdio) / `tui_gateway/ws.py` (WebSocket) first frame. */ -export interface GatewayReadyPayload { - /** Backends with the change watcher broadcast `*.changed` events; consumers - * demote their legacy polls to slow backstops. */ - change_events?: boolean - /** WebSocket transport only: the server answers heartbeat pings. */ - heartbeat?: boolean - /** Opaque token for this server process's `seq` numbering; a new epoch means - * replay watermarks must be discarded. */ - replay_epoch?: string - skin?: HermesSkin -} - -/** `tui_gateway/prompt_turn.py::_complete_turn_payload` and - * `tui_gateway/session_auto_continue.py::_emit_terminal_turn_error`. */ -export interface MessageCompletePayload { - /** Structured billing wall when the turn failed with FailoverReason.billing. */ - billing?: BillingBlock - /** `status: "error"` — the failure message (`text` may be streamed output). */ - error?: string - error_surface?: ErrorSurface - failure_reason?: string | null - /** `status: "error"` — `text` is streamed partial output to keep, not the error string. */ - partial?: boolean - reasoning?: string - /** `status: "error"` — the failed turn was retained and replays via `session.resume.inflight`. */ - recoverable?: boolean - rendered?: string - /** The final text was already previewed via `message.interim`; settle, don't duplicate. */ - response_previewed?: boolean - status?: 'complete' | 'error' | 'interrupted' | string - text?: string - usage?: Usage - /** History-commit note (e.g. a mid-turn desync the gateway surfaced instead of dropping). */ - warning?: string -} - -/** `tui_gateway/tool_progress.py::_on_tool_start`. */ -export interface ToolStartPayload { - /** Full tool arguments — the 80-char `context` preview is display-only. */ - args?: Record - /** Verbose mode only: pretty-printed args. */ - args_text?: string - context?: string - name?: string - /** Mirrored child tool rows carry a short preview instead of args. */ - preview?: string - tool_id: string - /** Not on the wire (`_on_tool_start` never sets it): the todo snapshot rides `tool.complete` / - * `todo.updated`. Kept because the TUI handler reads it and its fixtures exercise that path. */ - todos?: unknown[] -} - -/** `tui_gateway/tool_progress.py::_on_tool_complete`. */ -export interface ToolCompletePayload { - args?: Record - duration_s?: number - inline_diff?: string - name?: string - /** Parsed JSON when the tool returned JSON, else the raw string. */ - result?: unknown - /** Verbose mode only. */ - result_text?: string - revision?: number - summary?: string - tool_id: string - todos?: unknown[] -} - -export interface ToolGeneratingPayload { - name?: string -} - -/** `tui_gateway/tool_progress.py::_progress_output_risk`. */ -export interface ToolOutputRiskPayload { - findings?: string[] - name?: string - redacted?: boolean - risk?: string - tool_id?: string -} - -export interface StatusUpdatePayload { - kind?: string - text?: string -} - -export interface NotificationShowPayload { - id?: string - key?: string - kind?: 'sticky' | 'ttl' | string - level?: 'error' | 'info' | 'success' | 'warn' | string - text?: string - ttl_ms?: null | number -} - -export interface NotificationClearPayload { - key?: string -} - -export interface TextPayload { - text?: string -} - -/** `message.delta` / `reasoning.delta` / `reasoning.available` / `thinking.delta`. */ -export interface StreamDeltaPayload { - rendered?: string - text?: string - /** Verbose reasoning mode is on for this session. */ - verbose?: boolean -} - -export interface MessageInterimPayload { - already_streamed?: boolean - text: string -} - -export interface SessionUsagePayload { - usage?: Usage -} - -export interface SessionTitlePayload { - session_id?: string - title?: string -} - -/** `tui_gateway/methods_session.py` resume hydration progress. */ -export interface SessionResumeProgressPayload { - message?: string - message_count?: number - phase?: string - status?: 'complete' | 'failed' | 'loading' | string -} - -/** `tui_gateway/session_lifecycle.py::_announce_session_reclaimed`. */ -export interface SessionReclaimedPayload { - reason?: string - session_id?: string - stored_session_id?: string -} - -export interface SessionControlUpdatePayload { - control?: unknown -} - -export interface ErrorPayload { - message?: string - reason?: string -} - -export interface NoticePayload { - message?: string -} - -export interface ReactionPayload { - kind?: string -} - -export interface BillingStepUpVerificationPayload { - user_code?: string - verification_url: string -} - -export interface VoiceStatusPayload { - state?: 'idle' | 'listening' | 'transcribing' | string -} - -export interface VoiceTranscriptPayload { - no_speech_limit?: boolean - stop_phrase?: boolean - text?: string - typed?: boolean - voice_stopped?: boolean -} - -export interface WakeDetectedPayload { - phrase?: string - profile?: null | string - start_new_session?: boolean -} - -export interface BrowserProgressPayload { - level?: 'error' | 'info' | 'warn' | string - message?: string -} - -export interface MoaReferencePayload { - count?: number - index?: number - label?: string - text?: string -} - -export interface MoaAggregatingPayload { - aggregator?: string -} - -export interface MoaProgressPayload { - label?: string - refs_done?: number - refs_total?: number -} - -export interface MoaPhasePayload { - aggregator?: string - phase?: string - refs_done?: number - refs_total?: number -} - -// ── Server→client requests (`tui_gateway/server_requests.py`) ─────────── -// -// The backend asks the renderer a question with a real JSON-RPC request -// (`{id: 'srq-…', method, params}`) and blocks on the response frame. Every -// entry below is one method: its `params` shape and the `result` the client -// answers with. `request.cancel` (an event) withdraws an open request on -// timeout / interrupt / session close; `open_requests` on `session.resume` / -// `session.events.since` re-delivers unanswered ones after a reconnect. - -/** `request.cancel` payload — the backend withdrew an open server request. */ -export interface RequestCancelPayload { - id: string - method: string - reason: string -} - -export interface ClarifyQuestion { - choices?: null | string[] - multi_select?: boolean - qid: string - question: string -} - -/** `clarify` params. Single question: `question`/`choices`(/`multi_select`); batch: `questions`. - * `answers` rides along only on a reconnect replay (locks the server already accepted). */ -export interface ClarifyRequestParams { - answers?: Record - choices?: null | string[] - multi_select?: boolean - question?: string - questions?: ClarifyQuestion[] -} - -/** `clarify` result. Single: `{answer}` ('' = skip). Batch: the request resolves through - * `clarify.lock` RPCs (the last lock completes it); a response with no `answers` is cancel-all. */ -export interface ClarifyResult { - answer?: string - answers?: Record -} - -/** `approval` params (`tui_gateway/server.py::_approval_request_payload`, command redacted server-side). */ -export interface ApprovalRequestParams { - allow_permanent?: boolean - choices?: string[] - command: string - description: string - request_id: string - smart_denied?: boolean -} - -export interface ApprovalResult { - all?: boolean - choice: 'always' | 'deny' | 'once' | 'session' -} - -/** Every prompt whose answer is one string: `sudo`, `secret`, the vault prompts, the desktop GUI - * bridges (`terminal.read`, `preview.read`, `preview.act`, `window.read`, `tour`) and `mcp.setup`. - * '' means skipped / declined. */ -export interface ValueResult { - value: string -} - -export interface SecretRequestParams { - env_var: string - metadata?: Record - prompt: string -} - -export interface VaultUnlockRequestParams { - backend: string - display_name: string -} - -export interface VaultSaveLoginRequestParams { - origin: string - site: string -} - -export interface VaultCodeRequestParams { - hint?: string - site?: string -} - -export interface McpSetupRequestParams { - action?: string - reason?: string - server?: string -} - -export interface ReadRangeRequestParams { - count?: number - start?: number -} - -/** Server→client request method → `{params, result}`. Every method the backend can ask. */ -export interface ServerRequestMap { - approval: { params: ApprovalRequestParams; result: ApprovalResult } - clarify: { params: ClarifyRequestParams; result: ClarifyResult } - 'mcp.setup': { params: McpSetupRequestParams; result: ValueResult } - 'preview.act': { params: Record; result: ValueResult } - 'preview.read': { params: ReadRangeRequestParams; result: ValueResult } - secret: { params: SecretRequestParams; result: ValueResult } - sudo: { params: Record; result: ValueResult } - 'terminal.read': { params: ReadRangeRequestParams; result: ValueResult } - tour: { params: Record; result: ValueResult } - 'vault.code': { params: VaultCodeRequestParams; result: ValueResult } - 'vault.save_login': { params: VaultSaveLoginRequestParams; result: ValueResult } - 'vault.unlock_prompt': { params: VaultUnlockRequestParams; result: ValueResult } - 'window.read': { params: Record; result: ValueResult } -} - -export type ServerRequestMethod = keyof ServerRequestMap - -/** Pinned to `gateway-events.json`'s `server_requests` list by the two contract tests. Keep sorted. */ -export const SERVER_REQUEST_METHODS = [ - 'approval', - 'clarify', - 'mcp.setup', - 'preview.act', - 'preview.read', - 'secret', - 'sudo', - 'terminal.read', - 'tour', - 'vault.code', - 'vault.save_login', - 'vault.unlock_prompt', - 'window.read' -] as const satisfies readonly ServerRequestMethod[] - -/** Side agents (`tui_gateway/methods_prompt.py::_spawn_side_agent`). */ -export interface SideAgentCompletePayload { - question?: string - task_id: string - text: string -} - -export interface PreviewRestartProgressPayload { - task_id: string - text: string -} - -export interface TerminalOutputPayload { - chunk?: string - process_id?: string -} - -export interface TerminalClosePayload { - process_id?: string -} - -// ── The map ────────────────────────────────────────────────────────── +export * from './gateway-contract.generated.js' /** - * Backend-emitted notification names. Derived from the `tui_gateway` emitter call - * sites and pinned to `gateway-events.json`; keep sorted. Adding a name here without - * the JSON (or vice versa) fails `gateway-events.test.ts`, and a Python emitter that - * names an event missing from the JSON fails `test_gateway_event_contract.py`. - */ -export const BACKEND_EVENT_NAMES = [ - 'agent.terminal.output', - 'background.complete', - 'billing.step_up.verification', - 'bot_relay.outbox.pending', - 'browser.controller.cancel', - 'browser.controller.command', - 'browser.progress', - 'btw.complete', - 'cron.changed', - 'error', - 'gateway.ready', - 'layout.apply', - 'message.complete', - 'message.delta', - 'message.interim', - 'message.reaction', - 'message.start', - 'moa.aggregating', - 'moa.phase', - 'moa.progress', - 'moa.reference', - 'notice', - 'notification.clear', - 'notification.show', - 'pairing.changed', - 'pane.reveal', - 'pet.changed', - 'pet.generate.progress', - 'pet.hatch.progress', - 'platforms.changed', - 'preview.close', - 'preview.open', - 'preview.restart.complete', - 'preview.restart.progress', - 'reaction', - 'reasoning.available', - 'reasoning.delta', - 'request.cancel', - 'review.summary', - 'session.control.update', - 'session.info', - 'session.reclaimed', - 'session.resume_progress', - 'session.title', - 'session.usage', - 'sessions.changed', - 'setup.ready', - 'skin.changed', - 'status.update', - 'subagent.complete', - 'subagent.progress', - 'subagent.spawn_requested', - 'subagent.start', - 'subagent.thinking', - 'subagent.tool', - 'terminal.close', - 'thinking.delta', - 'tip.show', - 'todo.updated', - 'tool.complete', - 'tool.generating', - 'tool.output_risk', - 'tool.start', - 'voice.interrupted', - 'voice.status', - 'voice.transcript', - 'wake.detected' -] as const satisfies readonly (keyof BackendGatewayEventMap)[] - -export type BackendGatewayEventName = (typeof BACKEND_EVENT_NAMES)[number] - -/** Payload per backend-emitted notification `type`. Keys are exactly `BACKEND_EVENT_NAMES`. */ -export interface BackendGatewayEventMap { - 'agent.terminal.output': TerminalOutputPayload - 'background.complete': SideAgentCompletePayload - 'billing.step_up.verification': BillingStepUpVerificationPayload - 'bot_relay.outbox.pending': Record - 'browser.controller.cancel': Record - 'browser.controller.command': Record - 'browser.progress': BrowserProgressPayload - 'btw.complete': SideAgentCompletePayload - 'cron.changed': Record - error: ErrorPayload - 'gateway.ready': GatewayReadyPayload - 'layout.apply': Record - 'message.complete': MessageCompletePayload - 'message.delta': StreamDeltaPayload - 'message.interim': MessageInterimPayload - 'message.reaction': Record - 'message.start': undefined - 'moa.aggregating': MoaAggregatingPayload - 'moa.phase': MoaPhasePayload - 'moa.progress': MoaProgressPayload - 'moa.reference': MoaReferencePayload - notice: NoticePayload - 'notification.clear': NotificationClearPayload - 'notification.show': NotificationShowPayload - 'pairing.changed': Record - 'pane.reveal': Record - 'pet.changed': Record - 'pet.generate.progress': Record - 'pet.hatch.progress': Record - 'platforms.changed': Record - 'preview.close': Record - 'preview.open': Record - 'preview.restart.complete': SideAgentCompletePayload - 'preview.restart.progress': PreviewRestartProgressPayload - reaction: ReactionPayload - 'reasoning.available': StreamDeltaPayload - 'reasoning.delta': StreamDeltaPayload - 'request.cancel': RequestCancelPayload - 'review.summary': TextPayload - 'session.control.update': SessionControlUpdatePayload - /** Surface-specific shape (`tui_gateway/server.py::_session_info`); each client narrows. */ - 'session.info': Record - 'session.reclaimed': SessionReclaimedPayload - 'session.resume_progress': SessionResumeProgressPayload - 'session.title': SessionTitlePayload - 'session.usage': SessionUsagePayload - 'sessions.changed': Record - 'setup.ready': Record - 'skin.changed': HermesSkin - 'status.update': StatusUpdatePayload - 'subagent.complete': SubagentEventPayload - 'subagent.progress': SubagentEventPayload - 'subagent.spawn_requested': SubagentEventPayload - 'subagent.start': SubagentEventPayload - 'subagent.thinking': SubagentEventPayload - 'subagent.tool': SubagentEventPayload - 'terminal.close': TerminalClosePayload - 'thinking.delta': StreamDeltaPayload - 'tip.show': Record - 'todo.updated': TodoStatePayload - 'tool.complete': ToolCompletePayload - 'tool.generating': ToolGeneratingPayload - 'tool.output_risk': ToolOutputRiskPayload - 'tool.start': ToolStartPayload - 'voice.interrupted': Record - 'voice.status': VoiceStatusPayload - 'voice.transcript': VoiceTranscriptPayload - 'wake.detected': WakeDetectedPayload -} - -/** - * Client-local synthetic events. Never emitted by `tui_gateway`; the Ink TUI's - * `gatewayClient` publishes them into the same handler stream to report transport - * state. Excluded from `gateway-events.json` on purpose. + * Client-local synthetic events. Never emitted by `tui_gateway`; the Ink TUI's `gatewayClient` + * publishes them into the same handler stream to report transport state. */ export interface ClientLocalGatewayEventMap { 'dashboard.new_session_requested': { reason?: string } @@ -640,161 +47,6 @@ export interface GatewayEvent { type: K } -// ── RPC responses shared across surfaces ───────────────────────────── - -/** `hermes_cli/inventory.py` one `model.options` provider row (union of every field the - * backend sets; `pricing_pending` / `free_tier_pending` mark the cached-only fail-closed path). */ -export interface ModelOptionProvider { - /** User-defined providers only: every accepted identity for this endpoint - * (bare config key, `custom:`, normalized display name, …). A session's - * `model.options` reports the canonical `custom:` form, so "is this row - * the current provider?" must check membership here, not slug equality. */ - aliases?: string[] - /** OpenAI-compatible endpoint for a user-defined provider. The backend - * exposes this as `api_url`; model assignments send it back as `base_url`. */ - api_url?: string - /** Auth flow for an unconfigured provider: "api_key" can be activated inline - * by pasting `key_env`; anything else (oauth_*, external, aws_sdk, …) needs - * the `hermes model` CLI / onboarding OAuth flow. */ - auth_type?: string - /** True when the provider has usable credentials. False for canonical - * providers surfaced by `include_unconfigured` that the user hasn't set up - * yet — render these with a setup affordance instead of hiding them. */ - authenticated?: boolean - /** Per-model option support, keyed by model id (present when the picker - * requested capabilities). Lets the UI gate fast/reasoning controls. */ - capabilities?: Record - /** Curated shortlist (one flagship per lab) the picker shows by default for - * aggregator providers that serve dozens of models across many labs. */ - featured_models?: string[] - /** Nous only: whether the current account is on the free plan. */ - free_tier?: boolean - /** Nous only, cached-only inventory: entitlement unknown, every model rendered locked. */ - free_tier_pending?: boolean - /** True for the free-tier route's own provider row (no account behind it). - * Never match this row by `name` — the label is copy and can change. */ - free_tier_row?: boolean - is_current?: boolean - /** True for providers defined via the user's `providers:` config block. */ - is_user_defined?: boolean - /** Env var to paste an API key into, for unconfigured `api_key` providers. */ - key_env?: string - models?: string[] - name: string - /** Per-model pricing keyed by model id (present when the picker requested - * pricing and the provider supports live pricing). */ - pricing?: Record - /** Cached-only inventory: pricing not fetched yet. */ - pricing_pending?: boolean - slug: string - source?: string - total_models?: number - /** Nous only: paid models a free-tier user cannot select (shown disabled). */ - unavailable_models?: string[] - warning?: string -} - -export interface ModelPricing { - /** Formatted $/Mtok cached-input price, or null when the model has none. */ - cache: null | string - /** Sale: rounded percent off list when gateway sends pricing.original. */ - discount_percent?: number - /** True when the model costs nothing (free tier eligible). */ - free: boolean - /** Formatted $/Mtok input price, e.g. "$3.00", or "free", or "" if unknown. */ - input: string - /** Formatted $/Mtok output price. */ - output: string - /** Sale: formatted pre-discount input $/Mtok ("was"). */ - was_input?: string - /** Sale: formatted pre-discount output $/Mtok ("was"). */ - was_output?: string -} - -export interface ModelCapabilities { - /** False when the route rejects a reasoning disable ("mandatory" in the - * provider catalog), so the Thinking toggle must not be offered. */ - can_disable_reasoning?: boolean - fast: boolean - reasoning: boolean -} - -export interface ModelOptionsResponse { - model?: string - provider?: string - providers?: ModelOptionProvider[] -} - -/** `tui_gateway/methods_session.py::_session_row_summary` — one `session.list` row. */ -export interface SessionListItem { - id: string - message_count: number - preview: string - /** The runtime id this stored session is currently attached to, when live. */ - resolved_id?: string - source?: string - started_at: number - title: string -} - -export interface SessionListResponse { - sessions?: SessionListItem[] -} - -/** Transcript row as projected by the gateway (`session.resume` / `session.activate`). */ -export interface GatewayTranscriptMessage { - args?: unknown - context?: string - display_kind?: string - display_metadata?: unknown - name?: string - role: 'assistant' | 'system' | 'tool' | 'user' - text?: string -} - -export interface SessionInflightTurn { - assistant?: string - correction_offsets?: number[] - corrections?: string[] - error?: string - error_surface?: ErrorSurface - recoverable?: boolean - status?: string - streaming?: boolean - user?: string -} - -/** `tui_gateway/methods_session.py::_resume_response`. `info` is surface-specific - * (`SessionInfo` in the TUI, `SessionRuntimeInfo` on Desktop); narrow at the call site. */ -export interface SessionResumeResponse, Message = GatewayTranscriptMessage> { - /** Present when the backend found a fresh crash-interrupted turn and scheduled its - * automatic continuation; the turn arrives as a normal message.start stream. */ - auto_continue?: { attempt: number; interrupted_at: number } - /** Deferred hydration: history arrives via `session.resume_progress`. */ - hydrating?: boolean - inflight?: null | SessionInflightTurn - info?: Info - message_count?: number - messages: Message[] - /** `omit_messages` resume: the client still learns the stored size. */ - messages_omitted?: boolean - /** Server→client requests still unanswered for this session (a clarify, sudo prompt, … - * raised while the client was detached); the client re-delivers them to its request - * handlers. `pending_approval` (the approval queue's oldest entry) is the approval twin. */ - open_requests?: OpenServerRequest[] - pending_approval?: ApprovalRequestParams - resumed?: string - running?: boolean - session_id: string - session_key?: string - started_at?: number - status?: string - todo_state?: TodoStatePayload -} - -/** One unanswered server→client request as returned by `open_requests`. */ -export interface OpenServerRequest { - id: string - method: string - params: Record & { session_id?: string } -} +/** Backend-emitted notification names (generated `GATEWAY_EVENT_TYPES`), re-exported under the + * name the consumers already use. */ +export { GATEWAY_EVENT_TYPES as BACKEND_EVENT_NAMES } from './gateway-contract.generated.js' diff --git a/apps/shared/src/index.ts b/apps/shared/src/index.ts index cdc86a5ccf..353b88b120 100644 --- a/apps/shared/src/index.ts +++ b/apps/shared/src/index.ts @@ -8,7 +8,6 @@ export { } from './billing-policy' export type { BillingAutoReload, - BillingBlock, BillingCardInfo, BillingChargeResponse, BillingChargeStatusResponse, @@ -61,58 +60,7 @@ export { } from './data-url-read-max' export { compactNumber } from './format' export { type FuzzyMatch, fuzzyRank, fuzzyScore, fuzzyScoreMulti, type RankedItem } from './fuzzy' -export { - type ApprovalRequestParams, - type ApprovalResult, - BACKEND_EVENT_NAMES, - type BackendGatewayEventMap, - type BackendGatewayEventName, - type ClarifyQuestion, - type ClarifyRequestParams, - type ClarifyResult, - type ClientLocalGatewayEventMap, - type ErrorPayload, - type ErrorSurface, - type GatewayEvent, - type GatewayEventMap, - type GatewayEventName, - type GatewayReadyPayload, - type GatewayTranscriptMessage, - type McpSetupRequestParams, - type MessageCompletePayload, - type MessageInterimPayload, - type ModelCapabilities, - type ModelOptionProvider, - type ModelOptionsResponse, - type ModelPricing, - type NotificationShowPayload, - type OpenServerRequest, - type ReadRangeRequestParams, - type RequestCancelPayload, - type SecretRequestParams, - SERVER_REQUEST_METHODS, - type ServerRequestMap, - type ServerRequestMethod, - type SessionInflightTurn, - type SessionListItem, - type SessionListResponse, - type SessionReclaimedPayload, - type SessionResumeResponse, - type SessionUsagePayload, - type StatusUpdatePayload, - type StreamDeltaPayload, - type SubagentEventPayload, - type SubagentStatus, - type TodoStatePayload, - type ToolCompletePayload, - type ToolStartPayload, - type Usage, - type ValueResult, - type VaultCodeRequestParams, - type VaultSaveLoginRequestParams, - type VaultUnlockRequestParams, - type WakeDetectedPayload -} from './gateway-events' +export * from './gateway-events' export { applyDocumentLocale, type EndonymLocale, diff --git a/tests/tui_gateway/test_gateway_event_contract.py b/tests/tui_gateway/test_gateway_event_contract.py deleted file mode 100644 index ba6ec037be..0000000000 --- a/tests/tui_gateway/test_gateway_event_contract.py +++ /dev/null @@ -1,117 +0,0 @@ -"""Two-sided contract: every notification name ``tui_gateway`` emits, and every -server→client request method it sends, is listed in ``apps/shared/src/gateway-events.json`` -(``events`` / ``server_requests``) and nothing in the JSON is orphaned. - -The TypeScript half (``apps/shared/src/gateway-events.test.ts``) pins the typed -``GatewayEventMap`` and ``ServerRequestMap`` to the same JSON, so a name added on either -side alone goes red somewhere. This file reads only Python sources and the JSON (never -``.ts`` text — see ``tui_gateway/AGENTS.md``). - -Names are collected from the emitter side: literal first arguments to the emit helpers -and the server-request helpers (``server_requests.send`` / ``send_async`` / ``_ask``), -plus the tables that derive names at runtime (the change-watcher table, child delta -mirroring, the subagent relay events from ``tools/delegate_tool*.py``, the ``desktop_ui`` -tool emitters, and the literal ``gateway.ready`` / ``setup.ready`` / browser-controller frames). -""" - -from __future__ import annotations - -import json -import re -from pathlib import Path - -import pytest - -REPO = Path(__file__).resolve().parents[2] -CONTRACT = REPO / "apps" / "shared" / "src" / "gateway-events.json" -GATEWAY_DIR = REPO / "tui_gateway" - -# Every helper whose first positional argument is the wire ``type``. -_EMIT_HELPERS = ("_emit", "_broadcast_global_event", "_voice_emit", "_pet_emit", "_emit_tool_lifecycle") -_LITERAL_EMIT = re.compile(r"\b(?:%s)\(\s*\"([a-z_][a-z0-9_.]*)\"" % "|".join(_EMIT_HELPERS)) -# Server→client requests: ``server_requests.send("x", …)`` / ``send_async`` / the string-answer ``_ask`` and -# ``_read_block`` bridges. -_REQUEST_HELPERS = ("server_requests\\.send", "server_requests\\.send_async", "_ask", "_read_block") -_LITERAL_REQUEST = re.compile(r"\b(?:%s)\(\s*\"([a-z_][a-z0-9_.]*)\"" % "|".join(_REQUEST_HELPERS)) -# ``{"type": "gateway.ready", ...}`` literal frames (entry.py / ws.py) and other -# ``"type": ""`` params written straight into an ``event`` frame. -_LITERAL_FRAME = re.compile(r"\"method\":\s*\"event\".{0,120}?\"type\":\s*\"([a-z_][a-z0-9_.]*)\"", re.S) -_SIDE_AGENT = re.compile(r"_spawn_side_agent\((?:[^()]|\([^()]*\))*?\"([a-z_][a-z0-9_.]*\.complete)\"", re.S) -_SUBAGENT_RELAY = re.compile(r"\"(subagent\.[a-z_]+)\"") -_DESKTOP_UI_EMIT = re.compile(r"desktop_ui\.(?:emit|emit_or_error)\(\s*\"([a-z_][a-z0-9_.]*)\"") -_BROKER_FRAME = re.compile(r"^FRAME_[A-Z_]+ = \"(browser\.controller\.[a-z_]+)\"", re.M) -_SETUP_READY = re.compile(r"^SETUP_READY_EVENT = \"([a-z_.]+)\"", re.M) - - -def _read(path: Path) -> str: - return path.read_text(encoding="utf-8") - - -def emitted_event_names() -> set[str]: - names: set[str] = set() - for src in GATEWAY_DIR.glob("*.py"): - text = _read(src) - names.update(_LITERAL_EMIT.findall(text)) - names.update(_LITERAL_FRAME.findall(text)) - names.update(_SIDE_AGENT.findall(text)) - from tui_gateway.change_watcher import _CHANGE_WATCHES - - names.update(_CHANGE_WATCHES) - from tui_gateway.agent_callbacks import _CHILD_DELTA_EVENTS - - names.update(_CHILD_DELTA_EVENTS.values()) - # ``_progress_subagent`` relays every ``subagent.*`` event verbatim EXCEPT the child's per-token - # ``subagent.text`` (mirrored into the watch window as ``message.delta`` instead); the names live in the relay. - for src in (REPO / "tools").glob("delegate_tool*.py"): - names.update(_SUBAGENT_RELAY.findall(_read(src))) - names.discard("subagent.text") - for src in (REPO / "tools").glob("*.py"): - names.update(_DESKTOP_UI_EMIT.findall(_read(src))) - names.update(_BROKER_FRAME.findall(_read(REPO / "gateway" / "browser_control_broker.py"))) - names.update(_SETUP_READY.findall(_read(REPO / "hermes_cli" / "free_tier_bootstrap.py"))) - # Dispatch-table keys that double as the emitted name (``_PROGRESS_HANDLERS`` re-emits - # ``event_type``) are already literal ``_emit("...")`` calls inside their handlers. - return names - - -def server_request_methods() -> set[str]: - names: set[str] = set() - for src in GATEWAY_DIR.glob("*.py"): - names.update(_LITERAL_REQUEST.findall(_read(src))) - return names - - -@pytest.fixture(scope="module") -def contract() -> list[str]: - return json.loads(_read(CONTRACT))["events"] - - -@pytest.fixture(scope="module") -def request_contract() -> list[str]: - return json.loads(_read(CONTRACT))["server_requests"] - - -def test_contract_is_sorted_and_unique(contract, request_contract): - assert contract == sorted(set(contract)), "gateway-events.json events must be a sorted, duplicate-free list" - assert request_contract == sorted(set(request_contract)), "gateway-events.json server_requests must be sorted" - - -def test_server_request_methods_match_the_contract(request_contract): - sent = server_request_methods() - assert sent == set(request_contract), ( - f"server requests sent {sorted(sent)} vs gateway-events.json server_requests {request_contract}; " - "fix the JSON AND SERVER_REQUEST_METHODS / ServerRequestMap in apps/shared/src/gateway-events.ts") - - -def test_every_emitted_event_is_in_the_contract(contract): - missing = emitted_event_names() - set(contract) - assert not missing, ( - f"tui_gateway emits {sorted(missing)} but apps/shared/src/gateway-events.json does not list them; " - "add the name(s) there AND to BACKEND_EVENT_NAMES / GatewayEventMap in apps/shared/src/gateway-events.ts") - - -def test_contract_has_no_orphan_names(contract): - orphans = set(contract) - emitted_event_names() - assert not orphans, ( - f"apps/shared/src/gateway-events.json lists {sorted(orphans)} but no tui_gateway emitter names them; " - "drop the entry (and its GatewayEventMap key) or wire the emitter") diff --git a/tui_gateway/AGENTS.md b/tui_gateway/AGENTS.md index 6e4224b7ac..1a0bf58cd0 100644 --- a/tui_gateway/AGENTS.md +++ b/tui_gateway/AGENTS.md @@ -29,12 +29,22 @@ the agent thread until the response frame with the same `srq-` id arrives; `c reconnecting client re-renders the still-open questions). Desktop reaches the same server over WebSocket via `apps/shared` (`JsonRpcGatewayClient`, `onRequest`). New RPC = a new `methods_.py` or an entry in an existing topical sibling, registered in the table — no `if method == ...` chain (root shape rules). + +**The wire is declared in Python and generated for TypeScript** (`tui_gateway/contracts/`). Every method +has a `Params` + `Result` model, every server→client request a `Params` + `Result`, every event a +`Payload` — one Pydantic class each, `extra="forbid"` by default (`OpenModel` for producer-owned dicts). +`register_method` refuses an undeclared name at import; the dispatcher rejects unknown param keys +(`4000` + key path) and, under `HERMES_TEST_ISOLATION=1`, raises `ContractViolation` when a handler's +result or an emitted payload does not match its model (production only logs). `apps/shared/src/ +gateway-contract.generated.ts` (`RpcMethods`, `ServerRequestMap`, `BackendGatewayEventMap` + every value +shape) and `gateway-contract.openrpc.json` are rendered by `scripts/gen_gateway_contracts.py`; +`tests/contracts/test_generated.py` fails when they are stale, so the loop is: change the model → +regenerate → `tsc` shows every consumer the field moved. `apps/shared/src/gateway-events.ts` only adds +the client-local synthetic events and the `GatewayEvent` envelope on top. New question for the user = `_ask("", sid, params, timeout)` in the emitter, a handler in `apps/desktop/.../gateway-event/server-requests.ts` and `ui-tui/src/app/createServerRequestHandler.ts`, -and the method in `ServerRequestMap` + `apps/shared/src/gateway-events.json`. -New event = a new key in `apps/shared/src/gateway-events.ts::GatewayEventMap` + `BACKEND_EVENT_NAMES` -AND `apps/shared/src/gateway-events.json`; `tests/tui_gateway/test_gateway_event_contract.py` (emitter -side) and `apps/shared/src/gateway-events.test.ts` (type side) both fail when either drifts. +and a `server_request(...)` in `contracts/server_requests.py`. +New event = `event("", Payload)` in `contracts/events.py`; the emitter is checked against it. ## Key surfaces diff --git a/tui_gateway/contracts/common.py b/tui_gateway/contracts/common.py index 2b3973f726..ea9760c051 100644 --- a/tui_gateway/contracts/common.py +++ b/tui_gateway/contracts/common.py @@ -62,15 +62,15 @@ class SessionLiveInfo(OpenModel): """``tui_gateway/server.py::_session_info`` — the ``session.info`` event and the ``info`` field of ``session.create`` / ``session.resume`` / ``session.activate`` results.""" - model: str = "" + model: str provider: str = "" reasoning_effort: str = "" service_tier: str = "" fast: bool = False yolo: bool = False approval_mode: str = "manual" - tools: dict[str, list[str]] = Field(default_factory=dict) - skills: dict[str, JsonValue] = Field(default_factory=dict) + tools: dict[str, list[str]] + skills: dict[str, list[str]] cwd: str = "" branch: str | None = None project: ProjectRef | None = None @@ -126,16 +126,20 @@ class StoredSessionRow(OpenModel): class TranscriptMessage(OpenModel): - """One stored transcript row as ``session.history`` / resume ``messages`` deliver it.""" + """One transcript row as the gateway PROJECTS it for renderers (``session_history._project_history``): + ``text`` (never ``content``), display-only ``timestamp`` / ``display_kind`` / ``display_metadata``, the + durable ``row_id`` rewind targets, and for tool rows ``name`` + ``context`` preview + full ``args``. + Assistant detail sidecars (``reasoning``, …) ride as extra keys.""" role: str - content: JsonValue = None + text: str | None = None timestamp: float | None = None - tool_calls: list[dict[str, JsonValue]] | None = None - tool_call_id: str | None = None - name: str | None = None - display_kind: str | None = None row_id: int | None = None + display_kind: str | None = None + display_metadata: JsonValue | None = None + name: str | None = None + context: str | None = None + args: dict[str, JsonValue] | None = None reasoning: str | None = None diff --git a/tui_gateway/contracts/events.py b/tui_gateway/contracts/events.py index fca6ba4e47..6904f690da 100644 --- a/tui_gateway/contracts/events.py +++ b/tui_gateway/contracts/events.py @@ -36,15 +36,16 @@ class SkinPayload(OpenPayload): """``tui_gateway/change_watcher.py::resolve_skin`` — the resolved active skin (``HermesSkin``). ``{}`` when the skin engine failed to load. Colour maps are token → colour string.""" - name: str | None = None - colors: dict[str, str] | None = None - light_colors: dict[str, str] | None = None - dark_colors: dict[str, str] | None = None - branding: dict[str, str] | None = None - banner_logo: str | None = None - banner_hero: str | None = None - tool_prefix: str | None = None - help_header: str | None = None + name: str = "" + description: str = "" + colors: dict[str, str] = Field(default_factory=dict) + light_colors: dict[str, str] = Field(default_factory=dict) + dark_colors: dict[str, str] = Field(default_factory=dict) + branding: dict[str, str] = Field(default_factory=dict) + banner_logo: str = "" + banner_hero: str = "" + tool_prefix: str = "" + help_header: str = "" class GatewayReadyPayload(Payload): @@ -154,7 +155,7 @@ class BillingBlock(Payload): provider: str provider_label: str model: str - billing_url: str | None = None + billing_url: str | None is_nous: bool message: str unverified: bool | None = None diff --git a/ui-tui/src/__tests__/activeSessionSwitcher.test.ts b/ui-tui/src/__tests__/activeSessionSwitcher.test.ts index c8e6e5f095..e8eb877a8d 100644 --- a/ui-tui/src/__tests__/activeSessionSwitcher.test.ts +++ b/ui-tui/src/__tests__/activeSessionSwitcher.test.ts @@ -1,4 +1,4 @@ -import type { SessionListItem } from '@hermes/shared/gateway-events' +import type { SessionListRow } from '@hermes/shared/gateway-events' import { describe, expect, it } from 'vitest' import { @@ -192,7 +192,7 @@ describe('unified Sessions overlay helpers', () => { { id: 'a', message_count: 1, preview: '', started_at: 0, title: 'A' }, { id: 'b', message_count: 2, preview: '', started_at: 0, title: 'B' }, { id: 'c', message_count: 3, preview: '', started_at: 0, title: 'C' } - ] satisfies SessionListItem[] + ] satisfies SessionListRow[] const live = [{ id: 'b', status: 'idle' }] satisfies SessionActiveItem[] diff --git a/ui-tui/src/__tests__/createGatewayEventHandler.test.ts b/ui-tui/src/__tests__/createGatewayEventHandler.test.ts index 0577b44848..2ea8cdd6cb 100644 --- a/ui-tui/src/__tests__/createGatewayEventHandler.test.ts +++ b/ui-tui/src/__tests__/createGatewayEventHandler.test.ts @@ -118,7 +118,7 @@ describe('createGatewayEventHandler', () => { const onEvent = createGatewayEventHandler(buildCtx(appended)) onEvent({ payload: {}, type: 'message.start' } as any) - onEvent({ payload: { name: 'todo', todos, tool_id: 'todo-1' }, type: 'tool.start' } as any) + onEvent({ payload: { name: 'todo', todos, tool_id: 'todo-1' }, type: 'tool.complete' } as any) expect(getTurnState().todos).toEqual(todos) onEvent({ payload: { text: 'Started a todo list.' }, type: 'message.complete' } as any) @@ -195,7 +195,7 @@ describe('createGatewayEventHandler', () => { const todos = [{ content: 'Serve tiny latte', id: 'serve', status: 'completed' }] const onEvent = createGatewayEventHandler(buildCtx(appended)) - onEvent({ payload: { name: 'todo', todos, tool_id: 'todo-1' }, type: 'tool.start' } as any) + onEvent({ payload: { name: 'todo', todos, tool_id: 'todo-1' }, type: 'tool.complete' } as any) onEvent({ payload: { text: 'done' }, type: 'message.complete' } as any) expect(getTurnState().todos).toEqual([]) @@ -214,7 +214,7 @@ describe('createGatewayEventHandler', () => { const onEvent = createGatewayEventHandler(buildCtx(appended)) - onEvent({ payload: { name: 'todo', todos, tool_id: 'todo-1' }, type: 'tool.start' } as any) + onEvent({ payload: { name: 'todo', todos, tool_id: 'todo-1' }, type: 'tool.complete' } as any) expect(getTurnState().todos).toEqual(todos) onEvent({ payload: {}, type: 'message.start' } as any) @@ -340,7 +340,7 @@ describe('createGatewayEventHandler', () => { const todos = [{ content: 'Boil water', id: 'boil', status: 'in_progress' }] const onEvent = createGatewayEventHandler(buildCtx(appended)) - onEvent({ payload: { name: 'todo', todos, tool_id: 'todo-1' }, type: 'tool.start' } as any) + onEvent({ payload: { name: 'todo', todos, tool_id: 'todo-1' }, type: 'tool.complete' } as any) expect(getTurnState().todos).toEqual(todos) onEvent({ payload: { name: 'todo', todos: [], tool_id: 'todo-1' }, type: 'tool.complete' } as any) @@ -1480,7 +1480,7 @@ describe('createGatewayEventHandler', () => { todos: [{ content: 'pre-interrupt', id: 'todo-1', status: 'pending' }], tool_id: 't-1' }, - type: 'tool.start' + type: 'tool.complete' } as any) // Pre-interrupt todos should land in turn state. @@ -1494,7 +1494,7 @@ describe('createGatewayEventHandler', () => { }) onEvent({ payload: { text: 'still thinking…' }, type: 'reasoning.delta' } as any) - // Post-interrupt tool.start with a todos payload — must NOT mutate todos. + // Post-interrupt tool.complete with a todos payload — must NOT mutate todos. onEvent({ payload: { context: 'post', @@ -1502,7 +1502,7 @@ describe('createGatewayEventHandler', () => { todos: [{ content: 'late ghost', id: 'todo-ghost', status: 'pending' }], tool_id: 't-2' }, - type: 'tool.start' + type: 'tool.complete' } as any) // Late tool.generating must NOT push a 'drafting …' line into the trail. const trailBefore = getTurnState().turnTrail.length diff --git a/ui-tui/src/app/createGatewayEventHandler.ts b/ui-tui/src/app/createGatewayEventHandler.ts index c13f1af765..2155386ae1 100644 --- a/ui-tui/src/app/createGatewayEventHandler.ts +++ b/ui-tui/src/app/createGatewayEventHandler.ts @@ -3,7 +3,7 @@ import { execFile } from 'child_process' import { forceRedraw, onTerminalBackground, onTerminalForeground } from '@hermes/ink' import { stripAnsi } from '@hermes/shared/ansi' import { relativeLuminance } from '@hermes/shared/color' -import type { SubagentStatus, Usage } from '@hermes/shared/gateway-events' +import type { StreamDeltaPayload, SubagentStatus, Usage } from '@hermes/shared/gateway-events' import { STARTUP_IMAGE, STARTUP_QUERY } from '../config/env.js' import { STREAM_BATCH_MS } from '../config/timing.js' @@ -920,8 +920,8 @@ export function createGatewayEventHandler(ctx: GatewayEventHandlerContext): (ev: } turnController.showNotice({ - id: p.id, - key: p.key, + id: p.id ?? undefined, + key: p.key ?? undefined, kind: p.kind === 'ttl' ? 'ttl' : 'sticky', level: isNoticeLevel(p.level) ? p.level : 'info', text: p.text, @@ -1209,7 +1209,6 @@ export function createGatewayEventHandler(ctx: GatewayEventHandlerContext): (ev: return } - turnController.recordTodos(ev.payload.todos) turnController.recordToolStart( ev.payload.tool_id, ev.payload.name ?? 'tool', @@ -1241,16 +1240,16 @@ export function createGatewayEventHandler(ctx: GatewayEventHandlerContext): (ev: inlineDiffText, ev.payload.tool_id, ev.payload.name, - ev.payload.duration_s, + ev.payload.duration_s ?? undefined, resultText ) } else { turnController.recordToolComplete( ev.payload.tool_id, ev.payload.name, - ev.payload.summary, - ev.payload.duration_s, - ev.payload.todos, + ev.payload.summary ?? undefined, + ev.payload.duration_s ?? undefined, + ev.payload.todos ?? undefined, resultText ) } @@ -1446,7 +1445,7 @@ export function createGatewayEventHandler(ctx: GatewayEventHandlerContext): (ev: } case 'message.delta': - turnController.recordMessageDelta(ev.payload ?? {}) + turnController.recordMessageDelta(ev.payload ?? ({} as StreamDeltaPayload)) return case 'message.interim': { @@ -1481,7 +1480,7 @@ export function createGatewayEventHandler(ctx: GatewayEventHandlerContext): (ev: } if (ev.payload?.usage) { - patchUiState(state => ({ ...state, usage: mergeUsageStable(state.usage, ev.payload!.usage) })) + patchUiState(state => ({ ...state, usage: mergeUsageStable(state.usage, ev.payload!.usage ?? undefined) })) } // Billing wall (out of credits / payment required): open a proper diff --git a/ui-tui/src/app/turnController.ts b/ui-tui/src/app/turnController.ts index afc9f3f6cf..b3ef1a431b 100644 --- a/ui-tui/src/app/turnController.ts +++ b/ui-tui/src/app/turnController.ts @@ -578,7 +578,9 @@ class TurnController { // `display.final_response_markdown: render` because raw ANSI escapes // pass through into the React tree. Prefer raw text and fall back // only when the gateway elected not to send any (#16391). - const rawText = (payload.text ?? payload.rendered ?? this.bufRef).trimStart() + // `text` is `str | JsonValue` on the wire (structured parts stay possible); only a string renders here. + const wireText = typeof payload.text === 'string' ? payload.text : undefined + const rawText = (wireText ?? payload.rendered ?? this.bufRef).trimStart() const split = splitReasoning(rawText) // Only dedupe segments AFTER the interim boundary — interim-sealed // segments are preserved even if the final text includes them. @@ -675,7 +677,7 @@ class TurnController { return { finalMessages, finalText, wasInterrupted } } - recordMessageDelta({ text }: { rendered?: string; text?: string }) { + recordMessageDelta({ text }: { rendered?: string | null; text?: string }) { if (this.interrupted || !text) { return } @@ -1006,12 +1008,12 @@ class TurnController { } const base: SubagentProgress = existing ?? { - delegationId: p.delegation_id, + delegationId: p.delegation_id ?? undefined, depth: p.depth ?? 0, goal: p.goal, id, index: p.task_index, - model: p.model, + model: p.model ?? undefined, notes: [], parentId: p.parent_id ?? null, startedAt: Date.now(), @@ -1020,7 +1022,7 @@ class TurnController { thinking: [], toolCount: p.tool_count ?? 0, tools: [], - toolsets: p.toolsets + toolsets: p.toolsets ?? undefined } // Map snake_case payload keys onto camelCase state. Only overwrite diff --git a/ui-tui/src/app/useSessionLifecycle.ts b/ui-tui/src/app/useSessionLifecycle.ts index 5457fb0226..f14a6c8f30 100644 --- a/ui-tui/src/app/useSessionLifecycle.ts +++ b/ui-tui/src/app/useSessionLifecycle.ts @@ -2,7 +2,7 @@ import { writeFileSync } from 'node:fs' import type { ScrollBoxHandle } from '@hermes/ink' import { evictInkCaches } from '@hermes/ink' -import type { SessionInflightTurn, SessionResumeResponse, Usage } from '@hermes/shared/gateway-events' +import type { InflightTurn, SessionResumeResult, Usage } from '@hermes/shared/gateway-events' import { type RefObject, useCallback, useEffect, useMemo, useRef } from 'react' import { buildSetupRequiredSections, SETUP_REQUIRED_TITLE } from '../content/setup.js' @@ -54,13 +54,13 @@ export const writeActiveSessionFile = (sessionId: null | string, file = process. } } -export const liveSessionInflightMessages = (inflight?: null | SessionInflightTurn): Msg[] => { +export const liveSessionInflightMessages = (inflight?: null | InflightTurn): Msg[] => { const user = String(inflight?.user ?? '').trim() return user ? [{ role: 'user', text: user }] : [] } -export const hydrateLiveSessionInflight = (inflight?: null | SessionInflightTurn) => { +export const hydrateLiveSessionInflight = (inflight?: null | InflightTurn) => { const assistant = String(inflight?.assistant ?? '') if (!assistant && !inflight?.streaming) { @@ -340,9 +340,9 @@ export function useSessionLifecycle(opts: UseSessionLifecycleOptions) { const previousSid = getUiState().sid - gw.request>('session.resume', { cols: colsRef.current, session_id: id }) + gw.request('session.resume', { cols: colsRef.current, session_id: id }) .then(raw => { - const r = asRpcResult>(raw) + const r = asRpcResult(raw) if (!r) { sys('error: invalid response: session.resume') @@ -364,7 +364,7 @@ export function useSessionLifecycle(opts: UseSessionLifecycleOptions) { busy: running, info, sid: r.session_id, - status: statusFromLiveSession(r.status, running), + status: statusFromLiveSession(r.status ?? undefined, running), usage: usageFrom(info) }) hydrateLiveSessionInflight(r.inflight) diff --git a/ui-tui/src/components/activeSessionSwitcher.tsx b/ui-tui/src/components/activeSessionSwitcher.tsx index b4aaa7f0f4..415d5bfe34 100644 --- a/ui-tui/src/components/activeSessionSwitcher.tsx +++ b/ui-tui/src/components/activeSessionSwitcher.tsx @@ -1,5 +1,5 @@ import { Box, Text, useInput, useStdout } from '@hermes/ink' -import type { SessionListItem, SessionListResponse } from '@hermes/shared/gateway-events' +import type { SessionListResult, SessionListRow } from '@hermes/shared/gateway-events' import { useCallback, useEffect, useRef, useState } from 'react' import { sessionScopedModelArg } from '../domain/slash.js' @@ -83,7 +83,7 @@ export const relativeSessionAge = (ts?: number) => { } /** Drop already-live sessions from the resumable history list (dedupe by id). */ -export const resumableHistory = (history: readonly SessionListItem[], live: readonly SessionActiveItem[]) => { +export const resumableHistory = (history: readonly SessionListRow[], live: readonly SessionActiveItem[]) => { const liveIds = new Set(live.map(s => s.id)) return history.filter(h => !liveIds.has(h.id)) @@ -296,7 +296,7 @@ export function ActiveSessionSwitcher({ t }: ActiveSessionSwitcherProps) { const [items, setItems] = useState([]) - const [history, setHistory] = useState([]) + const [history, setHistory] = useState([]) const [err, setErr] = useState('') const [sel, setSel] = useState(0) const [loading, setLoading] = useState(true) @@ -315,12 +315,12 @@ export function ActiveSessionSwitcher({ // re-derives the resumable list from this against the latest live set, so a // session that was hidden while live reappears in history once it closes — // without re-querying the DB. Only refreshed on a full (includeHistory) load. - const rawHistoryRef = useRef([]) + const rawHistoryRef = useRef([]) // Mirror the displayed lists so the async poll can re-anchor the selection to // the *same* row (by session id) after live sessions appear/disappear, rather // than keeping a now-stale flat index. const itemsRef = useRef([]) - const historyDisplayRef = useRef([]) + const historyDisplayRef = useRef([]) const { stdout } = useStdout() // Optional maxWidth lets grid layouts hand the switcher its cell budget. const preferredWidth = Math.max(MIN_WIDTH, Math.min(MAX_WIDTH, (stdout?.columns ?? 80) - 6)) @@ -353,7 +353,7 @@ export function ActiveSessionSwitcher({ gw.request('session.active_list', { current_session_id: currentSessionId }), - includeHistory ? gw.request('session.list', { limit: 200 }) : Promise.resolve(null) + includeHistory ? gw.request('session.list', { limit: 200 }) : Promise.resolve(null) ]) const r = liveRes.status === 'fulfilled' ? asRpcResult(liveRes.value) : null @@ -374,7 +374,7 @@ export function ActiveSessionSwitcher({ if (includeHistory) { if (histRes.status === 'fulfilled') { - const parsedHist = asRpcResult(histRes.value) + const parsedHist = asRpcResult(histRes.value) if (parsedHist) { rawHistoryRef.current = parsedHist.sessions ?? [] diff --git a/ui-tui/src/components/appChrome.tsx b/ui-tui/src/components/appChrome.tsx index 287d359abe..d5ece23473 100644 --- a/ui-tui/src/components/appChrome.tsx +++ b/ui-tui/src/components/appChrome.tsx @@ -510,7 +510,7 @@ export function StatusRule({ onSessionCountClick, t }: StatusRuleProps) { - const pct = usage.context_percent + const pct = usage.context_percent ?? undefined const contextMark = usage.context_estimated ? '~' : '' const barColor = ctxBarColor(pct, t) const segs = statusBarSegments(cols) @@ -526,8 +526,8 @@ export function StatusRule({ ? usage.context_max ? segs.compactCtx ? `${contextMark}${compactNumber(usage.context_used ?? 0)} tok` - : `${contextMark}${compactNumber(usage.context_used ?? 0)}/${compactNumber(usage.context_max)}` - : usage.total > 0 + : `${contextMark}${compactNumber(usage.context_used ?? 0)}/${compactNumber(usage.context_max ?? 0)}` + : (usage.total ?? 0) > 0 ? `${compactNumber(usage.total)} tok` : '' : '' diff --git a/ui-tui/src/components/modelPicker.tsx b/ui-tui/src/components/modelPicker.tsx index d9c629e1ff..3b0f41524c 100644 --- a/ui-tui/src/components/modelPicker.tsx +++ b/ui-tui/src/components/modelPicker.tsx @@ -1,6 +1,6 @@ import { Box, Text, useInput, useStdout } from '@hermes/ink' import { fuzzyRank } from '@hermes/shared/fuzzy' -import type { ModelOptionProvider, ModelOptionsResponse } from '@hermes/shared/gateway-events' +import type { ModelOptionProvider, ModelOptionsResult } from '@hermes/shared/gateway-events' import { modelSearchText } from '@hermes/shared/model-search-text' import { REASONING_EFFORTS } from '@hermes/shared/reasoning-effort' import { useEffect, useMemo, useState } from 'react' @@ -98,7 +98,7 @@ export function ModelPicker({ const width = clampOverlayWidth(preferredWidth, maxWidth) useEffect(() => { - gw.request('model.options', { + gw.request('model.options', { ...(sessionId ? { session_id: sessionId } : {}), ...(initialRefresh ? { refresh: true } : {}), // The TUI picker shows the full provider universe with setup @@ -108,7 +108,7 @@ export function ModelPicker({ include_unconfigured: true }) .then(raw => { - const r = asRpcResult(raw) + const r = asRpcResult(raw) if (!r) { setErr('invalid response: model.options') diff --git a/ui-tui/src/gatewayTypes.ts b/ui-tui/src/gatewayTypes.ts index 7d6efbf7ac..99a23092ef 100644 --- a/ui-tui/src/gatewayTypes.ts +++ b/ui-tui/src/gatewayTypes.ts @@ -2,8 +2,8 @@ import type { UsageModelData } from '@hermes/shared/billing' import type { GatewayEvent, GatewayEventName, - GatewayTranscriptMessage, - SessionInflightTurn, + InflightTurn, + TranscriptMessage, Usage } from '@hermes/shared/gateway-events' import type { HermesSkin } from '@hermes/shared/skin' @@ -201,10 +201,10 @@ export interface SessionActiveListResponse { } export interface SessionActivateResponse { - inflight?: null | SessionInflightTurn + inflight?: null | InflightTurn info?: SessionInfo message_count?: number - messages: GatewayTranscriptMessage[] + messages: TranscriptMessage[] running?: boolean session_id: string session_key?: string @@ -273,7 +273,7 @@ export interface SessionCompressResponse { before_messages?: number before_tokens?: number info?: SessionInfo - messages?: GatewayTranscriptMessage[] + messages?: TranscriptMessage[] removed?: number summary?: { headline?: string diff --git a/ui-tui/src/types.ts b/ui-tui/src/types.ts index 545f15849c..ac0caa5f3f 100644 --- a/ui-tui/src/types.ts +++ b/ui-tui/src/types.ts @@ -1,4 +1,4 @@ -import type { SubagentStatus, Usage } from '@hermes/shared/gateway-events' +import type { ProjectInfo, SessionLiveInfo, SubagentStatus } from '@hermes/shared/gateway-events' export interface ActiveTool { context?: string @@ -182,34 +182,9 @@ export interface McpServerStatus { transport: string } -export interface ProjectInfo { - id: string - name: string - primary_path?: null | string - slug: string -} - -export interface SessionInfo { - cwd?: string - fast?: boolean - install_warning?: string - lazy?: boolean - mcp_servers?: McpServerStatus[] - model: string - profile_name?: string - project?: null | ProjectInfo - reasoning_effort?: string - running?: boolean - release_date?: string - service_tier?: string - skills: Record - system_prompt?: string - tools: Record - update_behind?: number | null - update_command?: string - usage?: Usage - version?: string -} +/** The gateway's `session.info` / resume `info` block — generated from `tui_gateway/contracts`. */ +export type SessionInfo = SessionLiveInfo +export type { ProjectInfo } export interface SudoReq { requestId: string