Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion console/web/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,7 @@ Then open the printed `Local:` URL (Vite picks the first free port from
Transcripts hydrate from `session::messages` and stream live from
`session::message-added` / `session::message-updated` snapshots —
localStorage keeps only UI affordances (active id, last model).
Double-click a row to rename inline (writes through `session::set_meta`);
Double-click a row to rename inline (writes through `session::set-meta`);
hover to reveal the delete affordance (`session::delete`).
- **Light / dark theme** toggle, persisted under `iii-theme` and applied
pre-paint to avoid a flash.
Expand Down
2 changes: 1 addition & 1 deletion console/web/src/hooks/use-conversations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
* - "new chat" is a LOCAL DRAFT (`draft: true`, id `console-<uuid>`); the
* session is materialised by `ensureSession` on the first send so empty
* chats never litter the store.
* - rename / model / mode changes write through `session::set_meta`. The
* - rename / model / mode changes write through `session::set-meta`. The
* console owns the metadata convention `{ surface, model, mode,
* title_manual }`; metadata replaces WHOLESALE, so the full object is
* always sent.
Expand Down
4 changes: 2 additions & 2 deletions console/web/src/lib/sessions/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,7 @@ export async function setSessionMeta(input: {
metadata?: Record<string, unknown>
}): Promise<{ meta: SessionMeta }> {
const client = await getIiiClient()
return client.call('session::set_meta', input)
return client.call('session::set-meta', input)
}

export async function deleteSession(
Expand All @@ -72,7 +72,7 @@ export async function setSessionStatus(
reason?: string,
): Promise<void> {
const client = await getIiiClient()
await client.call('session::set_status', {
await client.call('session::set-status', {
session_id: sessionId,
status,
...(reason ? { reason } : {}),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,13 @@ import { wt } from '../typography'
import { EnumField } from './EnumField'
import { FieldDispatch, type FieldProps } from './FieldDispatch'
import { errorForField, FieldShell } from './FieldShell'
import { resolveSchema, schemaDefault, schemaTypes } from './ref-resolver'
import { resolveSchema } from './ref-resolver'
import {
isSingleStringEnumVariant,
matchVariantIndex,
variantDefault,
variantLabel,
} from './variant-match'

/**
* `oneOf` / `anyOf` dispatcher.
Expand All @@ -20,9 +26,11 @@ import { resolveSchema, schemaDefault, schemaTypes } from './ref-resolver'
*
* 2. **Heterogeneous variants** — different types or shapes. We render
* a variant `Select` followed by a recursive `FieldDispatch` for the
* chosen sub-schema. Best-effort match-on-load picks the variant
* whose `type` matches the current value; ties fall back to the
* first variant so the operator can always change it.
* chosen sub-schema. Match-on-load prefers a discriminated tag (an
* adjacently/internally tagged enum's single-value `name`/`type`
* property — e.g. an adapter `{ name: "bridge" }`), then falls back to
* structural `type` matching, then to the first variant so the
* operator can always change it. See [`./variant-match`].
*/
export function OneOfField(props: FieldProps) {
const { label, schema, value, onChange, required, rootSchema } = props
Expand Down Expand Up @@ -67,7 +75,7 @@ export function OneOfField(props: FieldProps) {
function handleVariantChange(nextKey: string) {
const nextIdx = Number.parseInt(nextKey, 10)
if (!Number.isFinite(nextIdx)) return
onChange(schemaDefault(variants[nextIdx]))
onChange(variantDefault(variants[nextIdx], rootSchema))
}

return (
Expand Down Expand Up @@ -98,84 +106,3 @@ export function OneOfField(props: FieldProps) {
</div>
)
}

function isSingleStringEnumVariant(variant: JsonSchema): boolean {
if (!Array.isArray(variant.enum) || variant.enum.length !== 1) return false
const types = schemaTypes(variant)
// Accept either explicit `type: string` or an unspecified type with a
// string-typed enum value (some schemars outputs omit `type`).
if (types.length > 0 && !types.includes('string')) return false
return typeof (variant.enum as unknown[])[0] === 'string'
}

function variantLabel(variant: JsonSchema, idx: number): string {
if (typeof variant.title === 'string') return variant.title
if (Array.isArray(variant.enum) && variant.enum.length === 1) {
const single = (variant.enum as unknown[])[0]
if (typeof single === 'string') return single
}
const types = schemaTypes(variant)
if (types.length > 0) return types.join(' | ')
return `variant ${idx + 1}`
}

/**
* Best-effort match of the current value to one of the variant schemas.
* Returns the index of the first matching variant, or 0 when no variant
* fits (so the dispatcher always has something to render).
*/
function matchVariantIndex(
variants: JsonSchema[],
value: JsonValue | undefined,
): number {
for (let i = 0; i < variants.length; i++) {
if (valueMatchesSchema(value, variants[i])) return i
}
return 0
}

function valueMatchesSchema(
value: JsonValue | undefined,
schema: JsonSchema,
): boolean {
if (Array.isArray(schema.enum)) {
return (schema.enum as JsonValue[]).some((v) => deepEqual(v, value))
}
const types = schemaTypes(schema)
if (types.length === 0) return false
const actual = jsonType(value)
return types.includes(actual)
}

function jsonType(value: JsonValue | undefined): string {
if (value === null || value === undefined) return 'null'
if (Array.isArray(value)) return 'array'
if (Number.isInteger(value as number)) return 'integer'
return typeof value
}

function deepEqual(
a: JsonValue | undefined,
b: JsonValue | undefined,
): boolean {
if (a === b) return true
if (a === null || b === null) return false
if (typeof a !== typeof b) return false
if (Array.isArray(a) !== Array.isArray(b)) return false
if (Array.isArray(a) && Array.isArray(b)) {
if (a.length !== b.length) return false
return a.every((x, i) => deepEqual(x, b[i]))
}
if (typeof a === 'object' && typeof b === 'object') {
const ak = Object.keys(a as object)
const bk = Object.keys(b as object)
if (ak.length !== bk.length) return false
return ak.every((k) =>
deepEqual(
(a as Record<string, JsonValue>)[k],
(b as Record<string, JsonValue>)[k],
),
)
}
return false
}
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,7 @@ export const EnumOptional = storyFor('enum-optional')
export const EnumNumeric = storyFor('enum-numeric')
export const OneOfCollapsed = storyFor('oneof-collapsed')
export const OneOfHeterogeneous = storyFor('oneof-hetero')
export const OneOfAdapter = storyFor('adapter-oneof')
export const Nullable = storyFor('nullable')
export const ArrayPrimitives = storyFor('array-primitives')
export const ArrayObjects = storyFor('array-objects')
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
import { describe, expect, it } from 'vitest'
import type { JsonSchema, JsonValue } from '../api'
import {
isSingleStringEnumVariant,
matchVariantIndex,
variantDefault,
variantLabel,
} from './variant-match'

/**
* Mirrors the schemars output for an adjacently tagged enum
* (`#[serde(tag = "name", content = "config")]`), as registered by
* session-manager's `StorageAdapter`. `config` is a `$ref` so the tests
* also exercise root-relative resolution.
*/
const ROOT: JsonSchema = {
type: 'object',
definitions: {
FsBackendConfig: {
type: 'object',
properties: {
data_dir: { type: 'string', default: '~/.iii/data/session-manager' },
},
},
BridgeBackendConfig: {
type: 'object',
properties: {
url: { type: 'string' },
timeout_ms: { type: 'integer', default: 5000 },
},
required: ['url'],
},
},
}

const ADAPTER_VARIANTS: JsonSchema[] = [
{
type: 'object',
properties: {
name: { type: 'string', enum: ['fs'] },
config: { $ref: '#/definitions/FsBackendConfig' },
},
required: ['config', 'name'],
},
{
type: 'object',
properties: {
name: { type: 'string', enum: ['bridge'] },
config: { $ref: '#/definitions/BridgeBackendConfig' },
},
required: ['config', 'name'],
},
]

describe('matchVariantIndex — discriminated (tagged) variants', () => {
it('selects the variant whose tag matches the value', () => {
const fs: JsonValue = { name: 'fs', config: { data_dir: '/tmp' } }
const bridge: JsonValue = { name: 'bridge', config: { url: 'ws://m' } }
expect(matchVariantIndex(ADAPTER_VARIANTS, fs)).toBe(0)
expect(matchVariantIndex(ADAPTER_VARIANTS, bridge)).toBe(1)
})

it('does not collapse two object variants onto the first', () => {
// Both variants are `type: object`; without tag matching a bridge value
// would wrongly resolve to the fs variant (index 0).
const bridge: JsonValue = { name: 'bridge', config: {} }
expect(matchVariantIndex(ADAPTER_VARIANTS, bridge)).toBe(1)
})

it('falls back to the first variant when no tag matches', () => {
expect(matchVariantIndex(ADAPTER_VARIANTS, { name: 'unknown' })).toBe(0)
expect(matchVariantIndex(ADAPTER_VARIANTS, undefined)).toBe(0)
})
})

describe('matchVariantIndex — heterogeneous (structural) variants', () => {
const variants: JsonSchema[] = [
{ type: 'integer' },
{ type: 'string' },
{ type: 'object', properties: { value: { type: 'integer' } } },
]

it('matches by JSON type when there is no tag', () => {
expect(matchVariantIndex(variants, 30)).toBe(0)
expect(matchVariantIndex(variants, 'PT30S')).toBe(1)
expect(matchVariantIndex(variants, { value: 1 })).toBe(2)
})
})

describe('variantDefault', () => {
it('builds a discriminated default with nested config defaults', () => {
expect(variantDefault(ADAPTER_VARIANTS[0], ROOT)).toEqual({
name: 'fs',
config: { data_dir: '~/.iii/data/session-manager' },
})
expect(variantDefault(ADAPTER_VARIANTS[1], ROOT)).toEqual({
name: 'bridge',
config: { url: '', timeout_ms: 5000 },
})
})

it('round-trips: a switched-to variant re-selects itself', () => {
const bridgeDefault = variantDefault(ADAPTER_VARIANTS[1], ROOT)
expect(matchVariantIndex(ADAPTER_VARIANTS, bridgeDefault)).toBe(1)
})

it('defers to the shallow default for primitives', () => {
expect(variantDefault({ type: 'string' }, ROOT)).toBe('')
expect(variantDefault({ type: 'integer', default: 7 }, ROOT)).toBe(7)
})
})

describe('variantLabel', () => {
it('labels tagged object variants by their tag value', () => {
expect(variantLabel(ADAPTER_VARIANTS[0], 0)).toBe('fs')
expect(variantLabel(ADAPTER_VARIANTS[1], 1)).toBe('bridge')
})

it('prefers an explicit title, then enum, then type', () => {
expect(variantLabel({ title: 'seconds', type: 'integer' }, 0)).toBe(
'seconds',
)
expect(variantLabel({ type: 'string', enum: ['otlp'] }, 0)).toBe('otlp')
expect(variantLabel({ type: 'integer' }, 0)).toBe('integer')
})
})

describe('isSingleStringEnumVariant', () => {
it('accepts single string enums and rejects others', () => {
expect(isSingleStringEnumVariant({ type: 'string', enum: ['a'] })).toBe(
true,
)
expect(isSingleStringEnumVariant({ enum: ['a'] })).toBe(true)
expect(
isSingleStringEnumVariant({ type: 'string', enum: ['a', 'b'] }),
).toBe(false)
expect(isSingleStringEnumVariant({ type: 'integer', enum: [1] })).toBe(
false,
)
expect(isSingleStringEnumVariant({ type: 'object' })).toBe(false)
})
})
Loading
Loading