mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-09 13:21:42 +00:00
opentui(ts): decode SessionInfo + Catalog via Schema (drop the as-casts)
Replace the two ad-hoc as-cast loose readers in src/logic/store.ts with effect Schema decode-at-boundary. New src/boundary/schema/SessionInfo.ts defines SessionInfoPatchSchema + CatalogSchema (decodeUnknownOption), mirroring GatewayEvent.ts. readInfoPatch + setCatalog now decode once and build the typed patch/Catalog from the result (Option.none → empty patch / catalog unset, never crashes). Wire field names verified against tui_gateway/server.py. Removed the now-dead readOptBool helper. Tests extended for nested-usage vs top-level context fallback, malformed/partial payloads, and a garbage catalog.
This commit is contained in:
parent
6a73b09d15
commit
e4652b99e2
3 changed files with 228 additions and 62 deletions
99
ui-tui-opentui-v2/src/boundary/schema/SessionInfo.ts
Normal file
99
ui-tui-opentui-v2/src/boundary/schema/SessionInfo.ts
Normal file
|
|
@ -0,0 +1,99 @@
|
|||
/**
|
||||
* SessionInfo + Catalog decoders — the decode-at-boundary idiom (spec v4 §3.3),
|
||||
* mirroring GatewayEvent.ts. These two payloads are UNTRUSTED loose JSON from the
|
||||
* Python `tui_gateway` (`session.info` event / `session.create`/`resume` result
|
||||
* `info`, and the `startup.catalog` RPC result), so they are decoded ONCE with an
|
||||
* Effect Schema instead of hand-rolled `as`-cast readers.
|
||||
*
|
||||
* Decode with `Schema.decodeUnknownOption`: a malformed/partial payload yields
|
||||
* `Option.none` and the caller falls back to an empty patch / leaves the catalog
|
||||
* unset — a stray shape never crashes the reducer.
|
||||
*
|
||||
* Wire field names are verified against `tui_gateway/server.py`:
|
||||
* - session.info → `_session_info()` (server.py:~1830): top-level `model`,
|
||||
* `reasoning_effort`, `fast`, `cwd`, `branch`, `running`, plus a nested
|
||||
* `usage` (`_get_usage()`, server.py:~1698) carrying `context_used`,
|
||||
* `context_max`, `context_percent`, `compressions` (context_* only present
|
||||
* when the compressor knows a context length).
|
||||
* - startup.catalog → `@method("startup.catalog")` (server.py:~8521):
|
||||
* `{ tools:{total, toolsets:[{name,count,enabled,tools}]},
|
||||
* skills:{total, categories:[{name,count}]}, mcp:{servers:[]} }`.
|
||||
*
|
||||
* These schemas are used PURELY as decoders; they do NOT Effect-ify the store's
|
||||
* reactivity or control flow (Solid stays the runtime — spec v4 §1).
|
||||
*/
|
||||
import { Schema } from 'effect'
|
||||
|
||||
const Str = Schema.String
|
||||
const Num = Schema.Number
|
||||
const Bool = Schema.Boolean
|
||||
const opt = Schema.optionalKey
|
||||
|
||||
// ── session.info / session.create.info ────────────────────────────────
|
||||
// Context/usage numbers arrive nested under `usage`; the same names may also
|
||||
// appear at the top level depending on the RPC vs event path (the reader prefers
|
||||
// `usage.context_*`, then the top-level fallback). All keys are optional — a
|
||||
// `session.info` patch only carries the fields that actually changed.
|
||||
const UsageSchema = Schema.Struct({
|
||||
context_used: opt(Num),
|
||||
context_max: opt(Num),
|
||||
context_percent: opt(Num),
|
||||
compressions: opt(Num)
|
||||
})
|
||||
|
||||
export const SessionInfoPatchSchema = Schema.Struct({
|
||||
model: opt(Str),
|
||||
reasoning_effort: opt(Str),
|
||||
fast: opt(Bool),
|
||||
cwd: opt(Str),
|
||||
branch: opt(Str),
|
||||
running: opt(Bool),
|
||||
// top-level context fallback (used when there's no nested `usage`)
|
||||
context_used: opt(Num),
|
||||
context_max: opt(Num),
|
||||
context_percent: opt(Num),
|
||||
compressions: opt(Num),
|
||||
usage: opt(UsageSchema)
|
||||
})
|
||||
export type SessionInfoPatchDecoded = typeof SessionInfoPatchSchema.Type
|
||||
|
||||
/** Decode a loose session.info payload → `Option<SessionInfoPatchDecoded>`. */
|
||||
export const decodeSessionInfoPatch = Schema.decodeUnknownOption(SessionInfoPatchSchema)
|
||||
|
||||
// ── startup.catalog ───────────────────────────────────────────────────
|
||||
// Mirrors the `Catalog` interface in store.ts. `enabled` defaults to true at the
|
||||
// reader (an absent flag means on), so it stays optional here.
|
||||
const ToolsetSchema = Schema.Struct({
|
||||
name: opt(Str),
|
||||
count: opt(Num),
|
||||
enabled: opt(Bool),
|
||||
tools: opt(Schema.Array(Schema.Unknown))
|
||||
})
|
||||
const CategorySchema = Schema.Struct({
|
||||
name: opt(Str),
|
||||
count: opt(Num)
|
||||
})
|
||||
|
||||
export const CatalogSchema = Schema.Struct({
|
||||
tools: opt(
|
||||
Schema.Struct({
|
||||
total: opt(Num),
|
||||
toolsets: opt(Schema.Array(ToolsetSchema))
|
||||
})
|
||||
),
|
||||
skills: opt(
|
||||
Schema.Struct({
|
||||
total: opt(Num),
|
||||
categories: opt(Schema.Array(CategorySchema))
|
||||
})
|
||||
),
|
||||
mcp: opt(
|
||||
Schema.Struct({
|
||||
servers: opt(Schema.Array(Schema.Unknown))
|
||||
})
|
||||
)
|
||||
})
|
||||
export type CatalogDecoded = typeof CatalogSchema.Type
|
||||
|
||||
/** Decode a loose startup.catalog result → `Option<CatalogDecoded>`. */
|
||||
export const decodeCatalog = Schema.decodeUnknownOption(CatalogSchema)
|
||||
|
|
@ -11,9 +11,16 @@
|
|||
* User/system rows stay flat `text` (no parts). Carried from Phase 1: streaming
|
||||
* concat (prefer `payload.text`), skin→theme, LRU dedup, hydrate-while-buffering.
|
||||
*/
|
||||
import { Option } from 'effect'
|
||||
import { createStore, produce } from 'solid-js/store'
|
||||
|
||||
import type { GatewayEvent, GatewaySkinDecoded } from '../boundary/schema/GatewayEvent.ts'
|
||||
import {
|
||||
decodeCatalog,
|
||||
decodeSessionInfoPatch,
|
||||
type CatalogDecoded,
|
||||
type SessionInfoPatchDecoded
|
||||
} from '../boundary/schema/SessionInfo.ts'
|
||||
import { stripAnsi, stripOmittedNote, stripToolEnvelope } from './toolOutput.ts'
|
||||
import { DEFAULT_THEME, type Theme, themeFromSkin } from './theme.ts'
|
||||
|
||||
|
|
@ -205,45 +212,72 @@ function readOptNum(payload: { readonly [k: string]: unknown }, key: string): nu
|
|||
return typeof v === 'number' ? v : undefined
|
||||
}
|
||||
|
||||
/** Read a boolean field (undefined when absent). */
|
||||
function readOptBool(payload: { readonly [k: string]: unknown }, key: string): boolean | undefined {
|
||||
const v = payload[key]
|
||||
return typeof v === 'boolean' ? v : undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* Fold a `session.info` / `session.create.info` / `session.usage` payload into a
|
||||
* partial SessionInfo, reading context/usage fields from either a nested `usage`
|
||||
* object or the top level (the gateway shapes vary by RPC vs event — §gateway map).
|
||||
* Fold a `session.info` / `session.create.info` payload into a partial SessionInfo.
|
||||
* The loose wire JSON is decoded ONCE via `SessionInfoPatchSchema` (decode-at-
|
||||
* boundary); context/usage numbers are read from the nested `usage` object first,
|
||||
* falling back to the top level (the gateway shapes vary by RPC vs event). A
|
||||
* malformed payload decodes to `Option.none` → an empty patch (never crashes).
|
||||
* Only present fields are included so a partial patch can't clobber prior chrome.
|
||||
*/
|
||||
function readInfoPatch(payload: { readonly [k: string]: unknown }): Partial<SessionInfo> {
|
||||
const usageRaw = payload['usage']
|
||||
const usage: { readonly [k: string]: unknown } =
|
||||
usageRaw && typeof usageRaw === 'object' ? (usageRaw as { readonly [k: string]: unknown }) : {}
|
||||
const decoded = decodeSessionInfoPatch(payload)
|
||||
if (Option.isNone(decoded)) return {}
|
||||
return infoPatchFrom(decoded.value)
|
||||
}
|
||||
|
||||
/** Build the SessionInfo patch from a decoded session.info payload. */
|
||||
function infoPatchFrom(d: SessionInfoPatchDecoded): Partial<SessionInfo> {
|
||||
const patch: Partial<SessionInfo> = {}
|
||||
const model = readStr(payload, 'model')
|
||||
if (model) patch.model = model
|
||||
const effort = readStr(payload, 'reasoning_effort')
|
||||
if (effort) patch.effort = effort
|
||||
const fast = readOptBool(payload, 'fast')
|
||||
if (fast !== undefined) patch.fast = fast
|
||||
const cwd = readStr(payload, 'cwd')
|
||||
if (cwd) patch.cwd = cwd
|
||||
const branch = readStr(payload, 'branch')
|
||||
if (branch) patch.branch = branch
|
||||
const running = readOptBool(payload, 'running')
|
||||
if (running !== undefined) patch.running = running
|
||||
const used = readOptNum(usage, 'context_used') ?? readOptNum(payload, 'context_used')
|
||||
if (d.model) patch.model = d.model
|
||||
if (d.reasoning_effort) patch.effort = d.reasoning_effort
|
||||
if (d.fast !== undefined) patch.fast = d.fast
|
||||
if (d.cwd) patch.cwd = d.cwd
|
||||
if (d.branch) patch.branch = d.branch
|
||||
if (d.running !== undefined) patch.running = d.running
|
||||
// prefer the nested usage.context_* numbers, else the top-level fallback.
|
||||
const used = d.usage?.context_used ?? d.context_used
|
||||
if (used !== undefined) patch.contextUsed = used
|
||||
const max = readOptNum(usage, 'context_max') ?? readOptNum(payload, 'context_max')
|
||||
const max = d.usage?.context_max ?? d.context_max
|
||||
if (max !== undefined) patch.contextMax = max
|
||||
const pct = readOptNum(usage, 'context_percent') ?? readOptNum(payload, 'context_percent')
|
||||
const pct = d.usage?.context_percent ?? d.context_percent
|
||||
if (pct !== undefined) patch.contextPercent = pct
|
||||
const comp = readOptNum(usage, 'compressions') ?? readOptNum(payload, 'compressions')
|
||||
const comp = d.usage?.compressions ?? d.compressions
|
||||
if (comp !== undefined) patch.compressions = comp
|
||||
return patch
|
||||
}
|
||||
|
||||
/** Keep only the string elements of a decoded (unknown-element) array. */
|
||||
function onlyStrings(items: ReadonlyArray<unknown> | undefined): string[] {
|
||||
return (items ?? []).filter((s): s is string => typeof s === 'string')
|
||||
}
|
||||
|
||||
/** Build the typed Catalog from a decoded startup.catalog result (item 9). An
|
||||
* absent `enabled` flag means on; nameless toolsets/categories are dropped and
|
||||
* non-string tool/server names are filtered (defensive — wire arrays are loose). */
|
||||
function catalogFrom(d: CatalogDecoded): Catalog {
|
||||
return {
|
||||
mcp: { servers: onlyStrings(d.mcp?.servers) },
|
||||
skills: {
|
||||
total: d.skills?.total ?? 0,
|
||||
categories: (d.skills?.categories ?? [])
|
||||
.map(c => ({ count: c.count ?? 0, name: c.name ?? '' }))
|
||||
.filter(c => c.name)
|
||||
},
|
||||
tools: {
|
||||
total: d.tools?.total ?? 0,
|
||||
toolsets: (d.tools?.toolsets ?? [])
|
||||
.map(t => ({
|
||||
count: t.count ?? 0,
|
||||
enabled: t.enabled !== false,
|
||||
name: t.name ?? '',
|
||||
tools: onlyStrings(t.tools)
|
||||
}))
|
||||
.filter(t => t.name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** The subagent status implied by an event type (an explicit payload `status` wins). */
|
||||
function subagentStatusFor(type: string): string {
|
||||
if (type === 'subagent.complete') return 'complete'
|
||||
|
|
@ -760,41 +794,15 @@ export function createSessionStore() {
|
|||
commitSnapshot(loadSnapshot())
|
||||
}
|
||||
|
||||
/** Map the loose `startup.catalog` response into the typed Catalog (item 9). */
|
||||
/**
|
||||
* Map the loose `startup.catalog` response into the typed Catalog (item 9).
|
||||
* Decoded ONCE via `CatalogSchema` (decode-at-boundary); garbage decodes to
|
||||
* `Option.none` → the catalog is left unset rather than crashing the panel.
|
||||
*/
|
||||
function setCatalog(raw: unknown): void {
|
||||
if (!raw || typeof raw !== 'object') return
|
||||
const root = raw as { readonly [k: string]: unknown }
|
||||
const obj = (v: unknown): { readonly [k: string]: unknown } =>
|
||||
v && typeof v === 'object' ? (v as { readonly [k: string]: unknown }) : {}
|
||||
const num = (v: unknown): number => (typeof v === 'number' ? v : 0)
|
||||
const list = (v: unknown): unknown[] => (Array.isArray(v) ? v : [])
|
||||
const strs = (v: unknown): string[] => list(v).filter((s): s is string => typeof s === 'string')
|
||||
const pair = (v: unknown) => {
|
||||
const o = obj(v)
|
||||
return { count: num(o.count), name: readStr(o, 'name') ?? '' }
|
||||
}
|
||||
const toolset = (v: unknown) => {
|
||||
const o = obj(v)
|
||||
return { count: num(o.count), enabled: o.enabled !== false, name: readStr(o, 'name') ?? '', tools: strs(o.tools) }
|
||||
}
|
||||
const tools = obj(root.tools)
|
||||
const skills = obj(root.skills)
|
||||
const mcp = obj(root.mcp)
|
||||
setState('catalog', {
|
||||
mcp: { servers: strs(mcp.servers) },
|
||||
skills: {
|
||||
categories: list(skills.categories)
|
||||
.map(pair)
|
||||
.filter(p => p.name),
|
||||
total: num(skills.total)
|
||||
},
|
||||
tools: {
|
||||
toolsets: list(tools.toolsets)
|
||||
.map(toolset)
|
||||
.filter(p => p.name),
|
||||
total: num(tools.total)
|
||||
}
|
||||
})
|
||||
const decoded = decodeCatalog(raw)
|
||||
if (Option.isNone(decoded)) return
|
||||
setState('catalog', catalogFrom(decoded.value))
|
||||
}
|
||||
|
||||
function setSessionId(sid: string | undefined): void {
|
||||
|
|
|
|||
|
|
@ -177,6 +177,27 @@ describe('session store — ordered parts (Phase 2b)', () => {
|
|||
expect(c.mcp.servers).toEqual(['railway', 'beeper']) // non-string dropped
|
||||
})
|
||||
|
||||
test('setCatalog leaves the catalog unset on garbage / non-object input (decode → none)', () => {
|
||||
const store = createSessionStore()
|
||||
expect(store.state.catalog).toBeUndefined()
|
||||
store.setCatalog('not an object')
|
||||
expect(store.state.catalog).toBeUndefined()
|
||||
store.setCatalog(null)
|
||||
expect(store.state.catalog).toBeUndefined()
|
||||
store.setCatalog(42)
|
||||
expect(store.state.catalog).toBeUndefined()
|
||||
})
|
||||
|
||||
test('setCatalog accepts a sparse but well-shaped catalog (absent sections default empty)', () => {
|
||||
const store = createSessionStore()
|
||||
store.setCatalog({ tools: { total: 3, toolsets: [{ name: 'core', count: 3, tools: ['a'] }] } })
|
||||
const c = store.state.catalog!
|
||||
expect(c.tools.total).toBe(3)
|
||||
expect(c.tools.toolsets).toEqual([{ name: 'core', count: 3, enabled: true, tools: ['a'] }]) // enabled defaults on
|
||||
expect(c.skills).toEqual({ total: 0, categories: [] }) // absent section → empty
|
||||
expect(c.mcp.servers).toEqual([])
|
||||
})
|
||||
|
||||
test('reasoning.delta accumulates into a reasoning part', () => {
|
||||
const store = createSessionStore()
|
||||
store.apply({ type: 'message.start' })
|
||||
|
|
@ -304,6 +325,44 @@ describe('session store — session chrome / status bar (item 14)', () => {
|
|||
expect(info.contextMax).toBe(200000)
|
||||
})
|
||||
|
||||
test('session.info reads context from TOP-LEVEL fields when there is no nested usage', () => {
|
||||
const store = createSessionStore()
|
||||
store.apply({
|
||||
type: 'session.info',
|
||||
payload: { model: 'gpt-5.4', context_used: 1000, context_max: 8000, context_percent: 13, compressions: 2 }
|
||||
})
|
||||
const info = store.state.info
|
||||
expect(info.model).toBe('gpt-5.4')
|
||||
expect(info.contextUsed).toBe(1000)
|
||||
expect(info.contextMax).toBe(8000)
|
||||
expect(info.contextPercent).toBe(13)
|
||||
expect(info.compressions).toBe(2)
|
||||
})
|
||||
|
||||
test('session.info prefers nested usage.context_* over the top-level fallback', () => {
|
||||
const store = createSessionStore()
|
||||
store.apply({
|
||||
type: 'session.info',
|
||||
payload: { context_percent: 5, usage: { context_percent: 88 } }
|
||||
})
|
||||
expect(store.state.info.contextPercent).toBe(88) // nested wins
|
||||
})
|
||||
|
||||
test('session.info with a malformed payload does NOT crash and leaves chrome untouched (decode → none)', () => {
|
||||
const store = createSessionStore()
|
||||
store.applyInfo({ model: 'opus', cwd: '/p' })
|
||||
// a wrong-typed field (model: number) fails the schema → empty patch, prior info survives
|
||||
store.apply({ type: 'session.info', payload: { model: 123, usage: 'nope' } })
|
||||
expect(store.state.info).toMatchObject({ model: 'opus', cwd: '/p' })
|
||||
})
|
||||
|
||||
test('session.info with a partial payload only patches the present fields', () => {
|
||||
const store = createSessionStore()
|
||||
store.applyInfo({ model: 'opus', branch: 'main', running: true })
|
||||
store.apply({ type: 'session.info', payload: { branch: 'dev' } }) // only branch present
|
||||
expect(store.state.info).toMatchObject({ model: 'opus', branch: 'dev', running: true })
|
||||
})
|
||||
|
||||
test('message.start sets running, message.complete clears it + refreshes usage', () => {
|
||||
const store = createSessionStore()
|
||||
store.apply({ type: 'message.start' })
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue