diff --git a/ui-tui-opentui-v2/src/boundary/schema/SessionInfo.ts b/ui-tui-opentui-v2/src/boundary/schema/SessionInfo.ts new file mode 100644 index 00000000000..d67c16d13a9 --- /dev/null +++ b/ui-tui-opentui-v2/src/boundary/schema/SessionInfo.ts @@ -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`. */ +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`. */ +export const decodeCatalog = Schema.decodeUnknownOption(CatalogSchema) diff --git a/ui-tui-opentui-v2/src/logic/store.ts b/ui-tui-opentui-v2/src/logic/store.ts index 66db44b3192..c17939e12ce 100644 --- a/ui-tui-opentui-v2/src/logic/store.ts +++ b/ui-tui-opentui-v2/src/logic/store.ts @@ -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 { - 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 { const patch: Partial = {} - 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 | 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 { diff --git a/ui-tui-opentui-v2/src/test/store.test.ts b/ui-tui-opentui-v2/src/test/store.test.ts index 46f6851daae..c6aeb7aec87 100644 --- a/ui-tui-opentui-v2/src/test/store.test.ts +++ b/ui-tui-opentui-v2/src/test/store.test.ts @@ -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' })