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:
@@ -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
|
||||
|
||||
@@ -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
|
||||
})
|
||||
|
||||
|
||||
@@ -102,7 +102,7 @@ export type {
|
||||
SessionInfo,
|
||||
SessionMessage,
|
||||
SessionMessagesResponse,
|
||||
SessionResumeResponse,
|
||||
SessionResumeResult,
|
||||
SessionRuntimeInfo,
|
||||
SessionSearchResponse,
|
||||
SessionSearchResult,
|
||||
|
||||
@@ -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[] {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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) ───────────────────────────────────────
|
||||
|
||||
|
||||
@@ -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": [
|
||||
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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'
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 ?? []
|
||||
|
||||
@@ -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`
|
||||
: ''
|
||||
: ''
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user