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:
alt-glitch 2026-06-09 08:17:47 +00:00
parent 6a73b09d15
commit e4652b99e2
3 changed files with 228 additions and 62 deletions

View 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)

View file

@ -11,9 +11,16 @@
* User/system rows stay flat `text` (no parts). Carried from Phase 1: streaming
* concat (prefer `payload.text`), skintheme, 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 {

View file

@ -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' })