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.
This commit is contained in:
teknium1
2026-09-14 02:02:30 -07:00
committed by Teknium
parent 00d824f655
commit f6306d1920
41 changed files with 286 additions and 1361 deletions
+3 -3
View File
@@ -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<ModelOptionsResponse> {
): Promise<ModelOptionsResult> {
const params = new URLSearchParams()
if (opts?.refresh) {
@@ -48,7 +48,7 @@ export function getGlobalModelOptions(
params.set('explicit_only', '1')
}
return hermesApi<ModelOptionsResponse>({
return hermesApi<ModelOptionsResult>({
...profileScoped(profile),
path: params.size > 0 ? `/api/model/options?${params.toString()}` : '/api/model/options',
timeoutMs: STARTUP_REQUEST_TIMEOUT_MS
+2 -2
View File
@@ -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<ModelOptionsResponse>({
const modelOptionsQuery = useQuery<ModelOptionsResult>({
queryKey: modelOptionsQueryKey(
modelOptionsProfile || activeGatewayProfile,
activeSessionId,
@@ -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<typeof useSessionStateCache>
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<SessionResumeResponse>(owner, requestGateway, 'session.resume', {
requestForSessionProfile<SessionResumeResult>(owner, requestGateway, 'session.resume', {
session_id: storedSessionId,
cols: 96,
omit_messages: true,
@@ -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<ModelOptionsResponse>(modelOptionsQueryKey(profile, sessionId, ownerConnectionId), patch)
queryClient.setQueryData<ModelOptionsResult>(modelOptionsQueryKey(profile, sessionId, ownerConnectionId), patch)
if (includeGlobal) {
queryClient.setQueryData<ModelOptionsResponse>(modelOptionsQueryKey(profile, null, ownerConnectionId), patch)
queryClient.setQueryData<ModelOptionsResult>(modelOptionsQueryKey(profile, null, ownerConnectionId), patch)
}
},
[cacheOwnerConnectionId, cacheProfile, queryClient]
@@ -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<SessionResumeResponse>()
const deferredResume = deferred<SessionResumeResult>()
const requestGatewayMock = vi.fn((method: string, _params?: Record<string, unknown>) => {
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<SessionResumeResponse>()
const activated = deferred<SessionResumeResult>()
const requestGateway = vi.fn((method: string) =>
method === 'session.activate' ? activated.promise : Promise.resolve({})
@@ -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<SessionResumeResponse, 'inflight' | 'queued' | 'session_id'>
liveProjection?: Pick<SessionResumeResult, 'inflight' | 'queued' | 'session_id'>
): 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<SessionResumeResponse, 'inflight' | 'queued' | 'session_id'>
liveProjection?: Pick<SessionResumeResult, 'inflight' | 'queued' | 'session_id'>
): 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<SessionResumeResponse>('session.activate', {
activated = await requestForSession<SessionResumeResult>('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<SessionResumeResponse>('session.resume', {
requestForSession<SessionResumeResult>('session.resume', {
session_id: storedSessionId,
cols: 96,
source: 'desktop',
@@ -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<SessionResumeResponse, 'open_requests'>,
response: Pick<SessionResumeResult, 'open_requests'>,
sessionId: string,
resumeStartedAt: number,
requestIdAtStart?: string
@@ -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', () => {
@@ -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<SessionResumeResponse, 'inflight' | 'queued' | 'session_id'> & {
type LiveSessionProjection = Pick<SessionResumeResult, 'inflight' | 'queued' | 'session_id'> & {
[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<SessionResumeResponse, 'inflight' | 'queued'>
projection: Pick<SessionResumeResult, 'inflight' | 'queued'>
): ChatMessage[] {
const inflightUser = projection.inflight?.user?.replace(/\s+/g, ' ').trim() ?? ''
const inflightAssistant = projection.inflight?.assistant?.replace(/\s+/g, ' ').trim() ?? ''
@@ -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<ModelOptionsResponse> => requestModelOptions({ gateway, profile, request, sessionId })
queryFn: (): Promise<ModelOptionsResult> => requestModelOptions({ gateway, profile, request, sessionId })
})
const loading = modelOptions.isPending && !modelOptions.data
@@ -573,7 +573,7 @@ export function ModelCatalogMenu({
) : null}
</DropdownMenuSubTrigger>
<ModelEditSubmenu
canDisableReasoning={caps?.can_disable_reasoning}
canDisableReasoning={caps?.can_disable_reasoning ?? undefined}
defaultEffort={defaultEffort}
effort={effEffort}
fastControl={fastControl}
@@ -1,4 +1,4 @@
import type { ModelOptionsResponse } from '@hermes/shared'
import type { ModelOptionsResult } from '@hermes/shared'
import { useQueryClient } from '@tanstack/react-query'
import { useState } from 'react'
@@ -50,7 +50,7 @@ export function ModelMenuPanel(props: ModelMenuHostProps) {
// The refreshed catalog is a hint list, never a reason to move the pick:
// a custom slug the row lacks is still what the user selected.
queryClient.setQueryData<ModelOptionsResponse>(queryKey, next)
queryClient.setQueryData<ModelOptionsResult>(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).
@@ -39,7 +39,7 @@ export function ReasoningMenuPanel(props: ModelMenuHostProps) {
return (
<ModelOptionsContent
canDisableReasoning={caps?.can_disable_reasoning}
canDisableReasoning={caps?.can_disable_reasoning ?? undefined}
defaultEffort={defaultEffort}
effort={controller.current.effort}
fastControl={resolveFastControl(
@@ -1,4 +1,4 @@
import { DEFAULT_REASONING_EFFORT, type ModelOptionsResponse } from '@hermes/shared'
import { DEFAULT_REASONING_EFFORT, type ModelOptionsResult } from '@hermes/shared'
import { useStore } from '@nanostores/react'
import { useQuery } from '@tanstack/react-query'
@@ -69,7 +69,7 @@ export function useModelMenuController({
// never repaint that fallback once the catalog resolved.
const modelOptions = useQuery({
queryKey: modelOptionsQueryKey(profile, activeSessionId, ownerConnectionId),
queryFn: (): Promise<ModelOptionsResponse> =>
queryFn: (): Promise<ModelOptionsResult> =>
requestModelOptions({ gateway, profile, request: requestGateway, sessionId: activeSessionId })
})
@@ -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: [
@@ -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<ModelOptionsResponse> => requestModelOptions({ gateway: gw, profile, sessionId }),
queryFn: (): Promise<ModelOptionsResult> => requestModelOptions({ gateway: gw, profile, sessionId }),
enabled: open
})
+1 -1
View File
@@ -102,7 +102,7 @@ export type {
SessionInfo,
SessionMessage,
SessionMessagesResponse,
SessionResumeResponse,
SessionResumeResult,
SessionRuntimeInfo,
SessionSearchResponse,
SessionSearchResult,
+2 -2
View File
@@ -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[] {
+7 -7
View File
@@ -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<ModelOptionsResponse> {
): Promise<ModelOptionsResult> {
const opts = { explicitOnly, ...(refresh ? { refresh: true } : {}) }
const profileKey = (profile ?? '').trim()
@@ -86,7 +86,7 @@ export async function requestModelOptions({
refresh = false,
request,
sessionId
}: ModelOptionsRequest): Promise<ModelOptionsResponse> {
}: ModelOptionsRequest): Promise<ModelOptionsResult> {
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<ModelOptionsResponse>('model.options', params)
gatewayOptions = await dispatch<ModelOptionsResult>('model.options', params)
} catch (error) {
gatewayError = error
}
@@ -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
@@ -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<ModelOptionsResponse>({
return useQuery<ModelOptionsResult>({
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<ModelOptionsResponse>
}) as Promise<ModelOptionsResult>
),
enabled: !orphaned,
staleTime: 120000,
+1 -1
View File
@@ -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. */
+1 -8
View File
@@ -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) ───────────────────────────────────────
+115 -144
View File
@@ -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": [
-86
View File
@@ -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"
]
}
-29
View File
@@ -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)
})
})
+17 -765
View File
@@ -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<string, unknown>`;
* 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<string, unknown>
/** 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<string, unknown>
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<string, string>
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<string, string>
}
/** `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<string, unknown>
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<string, unknown>; result: ValueResult }
'preview.read': { params: ReadRangeRequestParams; result: ValueResult }
secret: { params: SecretRequestParams; result: ValueResult }
sudo: { params: Record<string, never>; result: ValueResult }
'terminal.read': { params: ReadRangeRequestParams; result: ValueResult }
tour: { params: Record<string, unknown>; 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<string, never>; 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<string, unknown>
'browser.controller.cancel': Record<string, unknown>
'browser.controller.command': Record<string, unknown>
'browser.progress': BrowserProgressPayload
'btw.complete': SideAgentCompletePayload
'cron.changed': Record<string, unknown>
error: ErrorPayload
'gateway.ready': GatewayReadyPayload
'layout.apply': Record<string, unknown>
'message.complete': MessageCompletePayload
'message.delta': StreamDeltaPayload
'message.interim': MessageInterimPayload
'message.reaction': Record<string, unknown>
'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<string, unknown>
'pane.reveal': Record<string, unknown>
'pet.changed': Record<string, unknown>
'pet.generate.progress': Record<string, unknown>
'pet.hatch.progress': Record<string, unknown>
'platforms.changed': Record<string, unknown>
'preview.close': Record<string, unknown>
'preview.open': Record<string, unknown>
'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<string, unknown>
'session.reclaimed': SessionReclaimedPayload
'session.resume_progress': SessionResumeProgressPayload
'session.title': SessionTitlePayload
'session.usage': SessionUsagePayload
'sessions.changed': Record<string, unknown>
'setup.ready': Record<string, unknown>
'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<string, unknown>
'todo.updated': TodoStatePayload
'tool.complete': ToolCompletePayload
'tool.generating': ToolGeneratingPayload
'tool.output_risk': ToolOutputRiskPayload
'tool.start': ToolStartPayload
'voice.interrupted': Record<string, unknown>
'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<K extends GatewayEventName = GatewayEventName> {
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:<key>`, normalized display name, …). A session's
* `model.options` reports the canonical `custom:<key>` 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<string, ModelCapabilities>
/** 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<string, ModelPricing>
/** 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<Info = Record<string, unknown>, 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<string, unknown> & { 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'
+1 -53
View File
@@ -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,
@@ -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": "<name>"`` 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")
+14 -4
View File
@@ -29,12 +29,22 @@ the agent thread until the response frame with the same `srq-<n>` 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_<topic>.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("<method>", 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("<type>", Payload)` in `contracts/events.py`; the emitter is checked against it.
## Key surfaces
+13 -9
View File
@@ -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
+11 -10
View File
@@ -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
@@ -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[]
@@ -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
+9 -10
View File
@@ -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
+7 -5
View File
@@ -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
+6 -6
View File
@@ -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<SessionResumeResponse<SessionInfo>>('session.resume', { cols: colsRef.current, session_id: id })
gw.request<SessionResumeResult>('session.resume', { cols: colsRef.current, session_id: id })
.then(raw => {
const r = asRpcResult<SessionResumeResponse<SessionInfo>>(raw)
const r = asRpcResult<SessionResumeResult>(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)
@@ -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<SessionActiveItem[]>([])
const [history, setHistory] = useState<SessionListItem[]>([])
const [history, setHistory] = useState<SessionListRow[]>([])
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<SessionListItem[]>([])
const rawHistoryRef = useRef<SessionListRow[]>([])
// 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<SessionActiveItem[]>([])
const historyDisplayRef = useRef<SessionListItem[]>([])
const historyDisplayRef = useRef<SessionListRow[]>([])
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<SessionActiveListResponse>('session.active_list', {
current_session_id: currentSessionId
}),
includeHistory ? gw.request<SessionListResponse>('session.list', { limit: 200 }) : Promise.resolve(null)
includeHistory ? gw.request<SessionListResult>('session.list', { limit: 200 }) : Promise.resolve(null)
])
const r = liveRes.status === 'fulfilled' ? asRpcResult<SessionActiveListResponse>(liveRes.value) : null
@@ -374,7 +374,7 @@ export function ActiveSessionSwitcher({
if (includeHistory) {
if (histRes.status === 'fulfilled') {
const parsedHist = asRpcResult<SessionListResponse>(histRes.value)
const parsedHist = asRpcResult<SessionListResult>(histRes.value)
if (parsedHist) {
rawHistoryRef.current = parsedHist.sessions ?? []
+3 -3
View File
@@ -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`
: ''
: ''
+3 -3
View File
@@ -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<ModelOptionsResponse>('model.options', {
gw.request<ModelOptionsResult>('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<ModelOptionsResponse>(raw)
const r = asRpcResult<ModelOptionsResult>(raw)
if (!r) {
setErr('invalid response: model.options')
+5 -5
View File
@@ -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
+4 -29
View File
@@ -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<string, string[]>
system_prompt?: string
tools: Record<string, string[]>
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