fix(mac): speed up config settings

This commit is contained in:
Peter Steinberger
2026-05-17 08:02:21 +01:00
parent c30c8cb471
commit 76da34760c
7 changed files with 419 additions and 142 deletions

View File

@@ -24,6 +24,7 @@ Docs: https://docs.openclaw.ai
- Mac app: make Channels settings open faster by deferring config-schema work, avoiding startup channel probes, caching decoded channel status rows, and showing only compact quick settings instead of the full generated channel schema.
- Control UI: include the Control UI and Gateway protocol versions in protocol-mismatch errors so stale app/dashboard pairings identify which side needs rebuilding or restarting.
- Gateway/protocol: restore Gateway WS protocol v4 and keep `message.action` room-event metadata on the existing `inboundTurnKind` wire field while preserving internal inbound-event classification.
- Mac app: make Config settings open from shallow schema lookups and load selected paths on demand instead of fetching and rendering the full generated config schema up front.
- Codex: sanitize inline image payloads before Codex app-server and OpenAI Responses replay, and clear poisoned Codex thread bindings after invalid image errors. Fixes #82878.
- Providers/GitHub Copilot: request identity-encoded Copilot API responses across token exchange, catalog, model calls, usage, and embeddings so compressed Business-account error payloads no longer reach JSON parsers as gzip bytes. Fixes #82871. Thanks @tonyfe01.
- Telegram: preserve replied-to bot messages, captions, and media metadata in group reply chains so follow-up replies understand what the user is reacting to. (#82863)

View File

@@ -37,6 +37,38 @@ extension ChannelsStore {
}
}
@discardableResult
func loadConfigSchemaLookup(path: String, force: Bool = false) async -> ConfigSchemaLookupNode? {
let sourceKey = self.currentConfigCacheSourceKey()
self.resetConfigSchemaCacheIfSourceChanged(sourceKey)
let normalizedPath = Self.normalizeConfigLookupPath(path)
if !force, let cached = self.configLookupNode(path: normalizedPath) {
return cached
}
if self.configLookupLoadingPaths.contains(normalizedPath) {
return self.configLookupNode(path: normalizedPath)
}
self.configLookupLoadingPaths.insert(normalizedPath)
defer { self.configLookupLoadingPaths.remove(normalizedPath) }
do {
let res: ConfigSchemaLookupResult = try await GatewayConnection.shared.requestDecoded(
method: .configSchemaLookup,
params: ["path": AnyCodable(normalizedPath)],
timeoutMs: 5000)
guard let node = self.makeConfigLookupNode(res) else {
self.configStatus = "Config schema lookup returned an unsupported payload."
return nil
}
self.applyConfigLookupNode(node, sourceKey: sourceKey)
return node
} catch {
self.configStatus = error.localizedDescription
return nil
}
}
func loadConfig(force: Bool = true) async {
let sourceKey = self.currentConfigCacheSourceKey()
self.resetConfigCacheIfSourceChanged(sourceKey)
@@ -100,6 +132,44 @@ extension ChannelsStore {
self.configSchemaSourceKey = sourceKey
}
func configLookupNode(path: String) -> ConfigSchemaLookupNode? {
let normalizedPath = Self.normalizeConfigLookupPath(path)
if normalizedPath == "." {
return self.configLookupRoot
}
return self.configLookupCache[normalizedPath]
}
func makeConfigLookupNode(_ res: ConfigSchemaLookupResult) -> ConfigSchemaLookupNode? {
let schemaValue = res.schema.foundationValue
guard let schema = ConfigSchemaNode(raw: schemaValue) else { return nil }
let hint = res.hint.map { ConfigUiHint(raw: $0.mapValues(\.foundationValue)) }
let children = res.children.compactMap(ConfigSchemaLookupChild.init(raw:))
return ConfigSchemaLookupNode(
path: Self.normalizeConfigLookupPath(res.path),
schema: schema,
hint: hint,
hintPath: res.hintpath,
children: children)
}
func applyConfigLookupNode(_ node: ConfigSchemaLookupNode, sourceKey: String) {
guard self.configSchemaSourceKey == sourceKey else { return }
if node.path == "." {
self.configLookupRoot = node
} else {
self.configLookupCache[node.path] = node
}
if let hint = node.hint {
self.configUiHints[node.path] = hint
}
for child in node.children {
if let hint = child.hint {
self.configUiHints[child.path] = hint
}
}
}
private func applyUIConfig(_ snap: ConfigSnapshot) {
let ui = snap.config?["ui"]?.dictionaryValue
let rawSeam = ui?["seamColor"]?.stringValue?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
@@ -154,6 +224,9 @@ extension ChannelsStore {
}
guard cachedSourceKey != sourceKey else { return }
self.configSchema = nil
self.configLookupRoot = nil
self.configLookupCache.removeAll(keepingCapacity: true)
self.configLookupLoadingPaths.removeAll(keepingCapacity: true)
self.configUiHints = [:]
self.configSchemaSourceKey = sourceKey
}
@@ -204,6 +277,11 @@ extension ChannelsStore {
].joined(separator: "|")
}
static func normalizeConfigLookupPath(_ path: String) -> String {
let trimmed = path.trimmingCharacters(in: .whitespacesAndNewlines)
return trimmed.isEmpty ? "." : trimmed
}
private static func configFingerprint(_ value: Any?) -> String {
guard let value else { return "nil" }
if JSONSerialization.isValidJSONObject(value),

View File

@@ -219,6 +219,51 @@ struct ConfigSnapshot: Codable {
let issues: [Issue]?
}
struct ConfigSchemaLookupChild: Identifiable {
let key: String
let path: String
let typeLabel: String?
let required: Bool
let hasChildren: Bool
let hint: ConfigUiHint?
let hintPath: String?
var id: String { self.path }
init?(raw: [String: AnyCodable]) {
guard let key = raw["key"]?.stringValue,
let path = raw["path"]?.stringValue
else {
return nil
}
self.key = key
self.path = path
if let type = raw["type"]?.stringValue {
self.typeLabel = type
} else if let types = raw["type"]?.arrayValue {
self.typeLabel = types.compactMap(\.stringValue).joined(separator: " / ")
} else {
self.typeLabel = nil
}
self.required = raw["required"]?.boolValue ?? false
self.hasChildren = raw["hasChildren"]?.boolValue ?? false
if let hint = raw["hint"]?.dictionaryValue {
self.hint = ConfigUiHint(raw: hint.mapValues(\.foundationValue))
} else {
self.hint = nil
}
self.hintPath = raw["hintPath"]?.stringValue
}
}
struct ConfigSchemaLookupNode {
let path: String
let schema: ConfigSchemaNode
let hint: ConfigUiHint?
let hintPath: String?
let children: [ConfigSchemaLookupChild]
}
@MainActor
@Observable
final class ChannelsStore {
@@ -243,6 +288,9 @@ final class ChannelsStore {
var isSavingConfig = false
var configSchemaLoading = false
var configSchema: ConfigSchemaNode?
var configLookupRoot: ConfigSchemaLookupNode?
var configLookupCache: [String: ConfigSchemaLookupNode] = [:]
var configLookupLoadingPaths: Set<String> = []
var configUiHints: [String: ConfigUiHint] = [:]
var configSchemaSourceKey: String?
var configSchemaLoadingSourceKey: String?

View File

@@ -6,8 +6,8 @@ struct ConfigSettings: View {
private let isNixMode = ProcessInfo.processInfo.isNixMode
@Bindable var store: ChannelsStore
@State private var hasLoaded = false
@State private var activeSectionKey: String?
@State private var activeSubsection: SubsectionSelection?
@State private var activePath: String?
@State private var failedLookupPaths: Set<String> = []
init(store: ChannelsStore = .shared) {
self.store = store
@@ -23,30 +23,32 @@ struct ConfigSettings: View {
guard !self.hasLoaded else { return }
guard !self.isPreview else { return }
self.hasLoaded = true
await self.store.loadConfigSchema()
await self.store.loadConfig(force: false)
Task { await self.store.loadConfig(force: false) }
_ = await self.store.loadConfigSchemaLookup(path: ".")
self.ensureSelection()
}
.task(id: self.activePath) {
guard let activePath = self.activePath else { return }
await self.loadPath(activePath)
}
.onAppear { self.ensureSelection() }
.onChange(of: self.store.configSchemaLoading) { _, loading in
if !loading { self.ensureSelection() }
.onChange(of: self.store.configLookupRoot?.path) { _, _ in
self.failedLookupPaths.removeAll()
self.ensureSelection()
}
}
}
extension ConfigSettings {
private enum SubsectionSelection: Hashable {
case all
case key(String)
}
private struct ConfigSection: Identifiable {
let key: String
let label: String
let help: String?
let node: ConfigSchemaNode
let path: String
let hasChildren: Bool
var id: String {
self.key
self.path
}
}
@@ -54,21 +56,22 @@ extension ConfigSettings {
let key: String
let label: String
let help: String?
let node: ConfigSchemaNode
let path: ConfigPath
let path: String
let hasChildren: Bool
var id: String {
self.key
self.path
}
}
private var sections: [ConfigSection] {
guard let schema = self.store.configSchema else { return [] }
return self.resolveSections(schema)
guard let root = self.store.configLookupRoot else { return [] }
return self.resolveSections(root.children)
}
private var activeSection: ConfigSection? {
self.sections.first { $0.key == self.activeSectionKey }
guard let activePath = self.activePath else { return nil }
return self.sections.first { activePath == $0.path || activePath.hasPrefix("\($0.path).") }
}
private var sidebar: some View {
@@ -91,16 +94,16 @@ extension ConfigSettings {
private var detail: some View {
VStack(alignment: .leading, spacing: 16) {
if self.store.configSchemaLoading {
if self.store.configLookupRoot == nil,
!self.hasLoaded || self.store.configLookupLoadingPaths.contains(".")
{
ProgressView().controlSize(.small)
} else if let section = self.activeSection {
self.sectionDetail(section)
} else if self.store.configSchema != nil {
} else if self.store.configLookupRoot != nil {
self.emptyDetail
} else {
Text("Schema unavailable.")
.font(.caption)
.foregroundStyle(.secondary)
self.schemaUnavailableDetail
}
}
.frame(minWidth: 460, maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
@@ -117,6 +120,18 @@ extension ConfigSettings {
.padding(.vertical, 18)
}
private var schemaUnavailableDetail: some View {
VStack(alignment: .leading, spacing: 8) {
self.header
Text(self.store.configStatus ?? "Schema unavailable.")
.font(.callout)
.foregroundStyle(.secondary)
self.actionRow
}
.padding(.horizontal, 24)
.padding(.vertical, 18)
}
private func sectionDetail(_ section: ConfigSection) -> some View {
ScrollView(.vertical) {
VStack(alignment: .leading, spacing: 16) {
@@ -176,13 +191,13 @@ extension ConfigSettings {
Button(self.store.isSavingConfig ? "Saving…" : "Save") {
Task { await self.store.saveConfigDraft() }
}
.disabled(self.isNixMode || self.store.isSavingConfig || !self.store.configDirty)
.disabled(self.isNixMode || self.store.isSavingConfig || !self.store.configLoaded || !self.store.configDirty)
}
.buttonStyle(.bordered)
}
private func sidebarSection(_ section: ConfigSection) -> some View {
let isExpanded = self.activeSectionKey == section.key
let isExpanded = self.activePath == section.path || self.activePath?.hasPrefix("\(section.path).") == true
let subsections = isExpanded ? self.resolveSubsections(for: section) : []
return VStack(alignment: .leading, spacing: 2) {
@@ -200,7 +215,7 @@ extension ConfigSettings {
.padding(.vertical, 5)
.padding(.horizontal, 8)
.frame(maxWidth: .infinity, alignment: .leading)
.background(isExpanded && subsections.isEmpty
.background(self.activePath == section.path
? Color.accentColor.opacity(0.18)
: Color.clear)
.clipShape(RoundedRectangle(cornerRadius: 8, style: .continuous))
@@ -211,9 +226,8 @@ extension ConfigSettings {
if isExpanded, !subsections.isEmpty {
VStack(alignment: .leading, spacing: 1) {
self.sidebarSubRow(title: "All", key: nil, sectionKey: section.key)
ForEach(subsections) { sub in
self.sidebarSubRow(title: sub.label, key: sub.key, sectionKey: section.key)
self.sidebarSubRow(sub)
}
}
.padding(.leading, 20)
@@ -223,21 +237,12 @@ extension ConfigSettings {
.animation(.easeInOut(duration: 0.18), value: isExpanded)
}
private func sidebarSubRow(title: String, key: String?, sectionKey: String) -> some View {
let isSelected: Bool = {
guard self.activeSectionKey == sectionKey else { return false }
if let key { return self.activeSubsection == .key(key) }
return self.activeSubsection == .all
}()
private func sidebarSubRow(_ subsection: ConfigSubsection) -> some View {
let isSelected = self.activePath == subsection.path
return Button {
if let key {
self.activeSubsection = .key(key)
} else {
self.activeSubsection = .all
}
self.selectPath(subsection.path)
} label: {
Text(title)
Text(subsection.label)
.font(.callout)
.lineLimit(1)
.padding(.vertical, 4)
@@ -252,123 +257,191 @@ extension ConfigSettings {
}
private func sectionForm(_ section: ConfigSection) -> some View {
let subsection = self.activeSubsection
let defaultPath: ConfigPath = [.key(section.key)]
let subsections = self.resolveSubsections(for: section)
let resolved: (ConfigSchemaNode, ConfigPath) = {
if case let .key(key) = subsection,
let match = subsections.first(where: { $0.key == key })
{
return (match.node, match.path)
let path = self.activePath ?? section.path
if self.store.configLookupLoadingPaths.contains(path) {
return AnyView(ProgressView().controlSize(.small))
}
guard let node = self.store.configLookupNode(path: path) else {
if self.failedLookupPaths.contains(path) {
return AnyView(self.lookupUnavailable(path: path))
}
return (self.resolvedSchemaNode(section.node), defaultPath)
}()
return ConfigSchemaForm(store: self.store, schema: resolved.0, path: resolved.1)
.disabled(self.isNixMode)
return AnyView(ProgressView().controlSize(.small))
}
if !node.children.isEmpty, !Self.shouldRenderFormEditor(for: node.schema) {
return AnyView(self.lookupChildrenList(node))
}
guard self.store.configLoaded else {
return AnyView(
HStack(spacing: 8) {
ProgressView().controlSize(.small)
Text("Loading current values…")
.font(.caption)
.foregroundStyle(.secondary)
})
}
guard let configPath = Self.configPath(from: node.path) else {
return AnyView(
Text("Wildcard config entries are edited from their concrete key.")
.font(.caption)
.foregroundStyle(.secondary))
}
return AnyView(
ConfigSchemaForm(store: self.store, schema: node.schema, path: configPath)
.disabled(self.isNixMode))
}
private func ensureSelection() {
guard let schema = self.store.configSchema else { return }
let sections = self.resolveSections(schema)
let sections = self.sections
guard !sections.isEmpty else { return }
let active = sections.first { $0.key == self.activeSectionKey } ?? sections[0]
if self.activeSectionKey != active.key {
self.activeSectionKey = active.key
}
self.ensureSubsection(for: active)
}
private func ensureSubsection(for section: ConfigSection) {
let subsections = self.resolveSubsections(for: section)
guard !subsections.isEmpty else {
self.activeSubsection = nil
if let activePath = self.activePath,
sections.contains(where: { activePath == $0.path || activePath.hasPrefix("\($0.path).") })
{
return
}
switch self.activeSubsection {
case .all:
return
case let .key(key):
if subsections.contains(where: { $0.key == key }) { return }
case .none:
break
}
if let first = subsections.first {
self.activeSubsection = .key(first.key)
}
self.selectSection(sections[0])
}
private func selectSection(_ section: ConfigSection) {
guard self.activeSectionKey != section.key else { return }
self.activeSectionKey = section.key
let subsections = self.resolveSubsections(for: section)
if let first = subsections.first {
self.activeSubsection = .key(first.key)
} else {
self.activeSubsection = nil
self.activePath = section.path
}
private func selectPath(_ path: String) {
self.activePath = path
}
private func lookupUnavailable(path: String) -> some View {
VStack(alignment: .leading, spacing: 8) {
Text(self.store.configStatus ?? "Schema unavailable.")
.font(.callout)
.foregroundStyle(.secondary)
Button("Retry") {
self.failedLookupPaths.remove(path)
Task { await self.loadPath(path) }
}
.buttonStyle(.bordered)
}
}
private func resolveSections(_ root: ConfigSchemaNode) -> [ConfigSection] {
let node = self.resolvedSchemaNode(root)
let hints = self.store.configUiHints
let keys = node.properties.keys.sorted { lhs, rhs in
let orderA = hintForPath([.key(lhs)], hints: hints)?.order ?? 0
let orderB = hintForPath([.key(rhs)], hints: hints)?.order ?? 0
if orderA != orderB { return orderA < orderB }
return lhs < rhs
@ViewBuilder
private func lookupChildrenList(_ node: ConfigSchemaLookupNode) -> some View {
VStack(alignment: .leading, spacing: 8) {
ForEach(node.children) { child in
Button {
self.selectPath(child.path)
} label: {
HStack(spacing: 8) {
VStack(alignment: .leading, spacing: 2) {
Text(self.label(for: child))
.font(.callout.weight(.semibold))
if let help = child.hint?.help {
Text(help)
.font(.caption)
.foregroundStyle(.secondary)
.lineLimit(2)
} else if let type = child.typeLabel {
Text(type)
.font(.caption)
.foregroundStyle(.secondary)
}
}
Spacer()
if child.required {
Text("Required")
.font(.caption)
.foregroundStyle(.secondary)
}
Image(systemName: child.hasChildren ? "chevron.right" : "slider.horizontal.3")
.font(.caption.weight(.semibold))
.foregroundStyle(.tertiary)
}
.padding(.vertical, 8)
.padding(.horizontal, 10)
.background(Color.primary.opacity(0.04))
.clipShape(RoundedRectangle(cornerRadius: 8, style: .continuous))
.contentShape(Rectangle())
}
.buttonStyle(.plain)
}
}
}
return keys.compactMap { key in
guard let child = node.properties[key] else { return nil }
let path: ConfigPath = [.key(key)]
let hint = hintForPath(path, hints: hints)
let label = hint?.label
?? child.title
?? self.humanize(key)
let help = hint?.help ?? child.description
return ConfigSection(key: key, label: label, help: help, node: child)
}
private func resolveSections(_ children: [ConfigSchemaLookupChild]) -> [ConfigSection] {
children
.sorted(by: self.sortLookupChildren)
.map { child in
ConfigSection(
key: child.key,
label: self.label(for: child),
help: child.hint?.help,
path: child.path,
hasChildren: child.hasChildren)
}
}
private func resolveSubsections(for section: ConfigSection) -> [ConfigSubsection] {
let node = self.resolvedSchemaNode(section.node)
guard node.schemaType == "object" else { return [] }
let hints = self.store.configUiHints
let keys = node.properties.keys.sorted { lhs, rhs in
let orderA = hintForPath([.key(section.key), .key(lhs)], hints: hints)?.order ?? 0
let orderB = hintForPath([.key(section.key), .key(rhs)], hints: hints)?.order ?? 0
if orderA != orderB { return orderA < orderB }
return lhs < rhs
guard let node = self.store.configLookupNode(path: section.path) else {
return []
}
return node.children
.sorted(by: self.sortLookupChildren)
.map { child in
ConfigSubsection(
key: child.key,
label: self.label(for: child),
help: child.hint?.help,
path: child.path,
hasChildren: child.hasChildren)
}
}
return keys.compactMap { key in
guard let child = node.properties[key] else { return nil }
let path: ConfigPath = [.key(section.key), .key(key)]
let hint = hintForPath(path, hints: hints)
let label = hint?.label
?? child.title
?? self.humanize(key)
let help = hint?.help ?? child.description
return ConfigSubsection(
key: key,
label: label,
help: help,
node: child,
path: path)
private func loadPath(_ path: String) async {
guard self.store.configLookupNode(path: path) == nil else {
self.failedLookupPaths.remove(path)
return
}
guard !self.store.configLookupLoadingPaths.contains(path) else { return }
if await self.store.loadConfigSchemaLookup(path: path) == nil {
self.failedLookupPaths.insert(path)
} else {
self.failedLookupPaths.remove(path)
}
}
private func resolvedSchemaNode(_ node: ConfigSchemaNode) -> ConfigSchemaNode {
let variants = node.anyOf.isEmpty ? node.oneOf : node.anyOf
if !variants.isEmpty {
let nonNull = variants.filter { !$0.isNullSchema }
if nonNull.count == 1, let only = nonNull.first { return only }
private func label(for child: ConfigSchemaLookupChild) -> String {
child.hint?.label
?? self.humanize(child.key)
}
private func sortLookupChildren(_ lhs: ConfigSchemaLookupChild, _ rhs: ConfigSchemaLookupChild) -> Bool {
let orderA = lhs.hint?.order ?? 0
let orderB = rhs.hint?.order ?? 0
if orderA != orderB { return orderA < orderB }
return lhs.key < rhs.key
}
private static func configPath(from lookupPath: String) -> ConfigPath? {
guard lookupPath != "." else { return [] }
let normalized = lookupPath
.replacingOccurrences(of: "[", with: ".")
.replacingOccurrences(of: "]", with: "")
let parts = normalized
.split(separator: ".")
.map(String.init)
.filter { !$0.isEmpty }
guard !parts.contains("*") else { return nil }
return parts.map { part in
if let index = Int(part) {
return .index(index)
}
return .key(part)
}
return node
}
private static func shouldRenderFormEditor(for schema: ConfigSchemaNode) -> Bool {
if schema.schemaType == "array" { return true }
return schema.additionalProperties != nil
}
private func humanize(_ key: String) -> String {

View File

@@ -64,6 +64,7 @@ actor GatewayConnection {
case configSet = "config.set"
case configPatch = "config.patch"
case configSchema = "config.schema"
case configSchemaLookup = "config.schema.lookup"
case wizardStart = "wizard.start"
case wizardNext = "wizard.next"
case wizardCancel = "wizard.cancel"

View File

@@ -646,19 +646,54 @@ describe("config schema", () => {
expect(schema?.properties).toBeUndefined();
});
it("returns a shallow lookup schema without nested composition keywords", () => {
it("looks up root config schema children without returning the full schema tree", () => {
const lookup = lookupConfigSchema(baseSchema, ".");
expect(lookup?.path).toBe(".");
expect(lookup?.children.map((child) => child.key)).toContain("gateway");
expect(lookup?.children.find((child) => child.key === "gateway")?.path).toBe("gateway");
const schema = lookup?.schema as { properties?: unknown } | undefined;
expect(schema?.properties).toBeUndefined();
});
it("returns a shallow lookup schema with top-level composition for editing", () => {
const lookup = lookupConfigSchema(baseSchema, "agents.list.0.runtime");
expect(lookup?.path).toBe("agents.list.0.runtime");
expect(lookup?.hintPath).toBe("agents.list[].runtime");
// The shallow lookup schema carries field docs, but should not expose
// nested composition keywords (allOf, oneOf, etc.).
expect(lookup?.schema).not.toHaveProperty("allOf");
expect(lookup?.schema).not.toHaveProperty("oneOf");
expect(lookup?.schema).not.toHaveProperty("anyOf");
const schema = lookup?.schema as { anyOf?: Array<{ properties?: Record<string, unknown> }> };
expect(schema.anyOf?.some((variant) => variant.properties?.type)).toBe(true);
expect(lookup?.schema).toHaveProperty("title", "Agent Runtime");
expect(lookup?.schema).toHaveProperty("description");
});
it("keeps scoped collection item schemas for form editing", () => {
const lookup = lookupConfigSchema(baseSchema, "agents.list");
expect(lookup?.schema).toHaveProperty("items");
const schema = lookup?.schema as
| {
items?: {
properties?: Record<
string,
{ anyOf?: Array<{ properties?: Record<string, unknown> }> }
>;
};
}
| undefined;
expect(schema?.items?.properties).toHaveProperty("runtime");
const runtimeVariants = schema?.items?.properties?.runtime?.anyOf ?? [];
expect(runtimeVariants.length).toBeGreaterThan(0);
expect(runtimeVariants.some((variant) => variant.properties?.type)).toBe(true);
});
it("keeps scoped map properties for form editing", () => {
const lookup = lookupConfigSchema(baseSchema, "env");
expect(lookup?.children.map((child) => child.key)).toEqual(["shellEnv", "vars", "*"]);
const schema = lookup?.schema as { properties?: Record<string, unknown> } | undefined;
expect(schema?.properties).toHaveProperty("shellEnv");
expect(schema?.properties).toHaveProperty("vars");
});
it("matches wildcard ui hints for concrete lookup paths", () => {
const lookup = lookupConfigSchema(baseSchema, "agents.list.0.identity.avatar");
expect(lookup?.path).toBe("agents.list.0.identity.avatar");

View File

@@ -25,6 +25,9 @@ type JsonSchemaObject = JsonSchemaNode & {
required?: string[];
additionalProperties?: JsonSchemaObject | boolean;
items?: JsonSchemaObject | JsonSchemaObject[];
anyOf?: JsonSchemaObject[];
oneOf?: JsonSchemaObject[];
allOf?: JsonSchemaObject[];
};
const asJsonSchemaObject = (value: unknown): JsonSchemaObject | null =>
@@ -62,6 +65,8 @@ const LOOKUP_SCHEMA_BOOLEAN_KEYS = new Set([
"writeOnly",
]);
const MAX_LOOKUP_PATH_SEGMENTS = 32;
const LOOKUP_SCHEMA_COMPOSITION_KEYS = ["anyOf", "oneOf", "allOf"] as const;
const LOOKUP_SCHEMA_NESTED_FORM_DEPTH = 4;
function isObjectSchema(schema: JsonSchemaObject): boolean {
const type = schema.type;
@@ -655,7 +660,7 @@ function resolveLookupChildSchema(
return null;
}
function stripSchemaForLookup(schema: JsonSchemaObject): JsonSchemaNode {
function stripSchemaForLookup(schema: JsonSchemaObject, nestedFormDepth = 0): JsonSchemaNode {
const next: JsonSchemaNode = {};
for (const [key, value] of Object.entries(schema)) {
@@ -703,6 +708,41 @@ function stripSchemaForLookup(schema: JsonSchemaObject): JsonSchemaNode {
}
}
if (
schema.properties &&
((nestedFormDepth > 0 && nestedFormDepth <= LOOKUP_SCHEMA_NESTED_FORM_DEPTH) ||
(schema.additionalProperties && typeof schema.additionalProperties === "object"))
) {
next.properties = Object.fromEntries(
Object.entries(schema.properties).map(([key, child]) => [
key,
stripSchemaForLookup(child, nestedFormDepth + 1),
]),
);
}
if (schema.additionalProperties && typeof schema.additionalProperties === "object") {
next.additionalProperties = stripSchemaForLookup(
schema.additionalProperties,
nestedFormDepth + 1,
);
}
if (Array.isArray(schema.items)) {
next.items = schema.items.map((item) => stripSchemaForLookup(item, nestedFormDepth + 1));
} else if (schema.items && typeof schema.items === "object") {
next.items = stripSchemaForLookup(schema.items, nestedFormDepth + 1);
}
if (nestedFormDepth <= LOOKUP_SCHEMA_NESTED_FORM_DEPTH) {
for (const key of LOOKUP_SCHEMA_COMPOSITION_KEYS) {
const variants = schema[key];
if (!Array.isArray(variants)) {
continue;
}
next[key] = variants
.filter((variant) => variant && typeof variant === "object")
.map((variant) => stripSchemaForLookup(variant, nestedFormDepth + 1));
}
}
return next;
}
@@ -749,12 +789,13 @@ export function lookupConfigSchema(
response: ConfigSchemaResponse,
path: string,
): ConfigSchemaLookupResult | null {
const wantsRoot = path.trim() === ".";
const normalizedPath = normalizeLookupPath(path);
if (!normalizedPath) {
if (!normalizedPath && !wantsRoot) {
return null;
}
const parts = splitLookupPath(normalizedPath);
if (parts.length === 0 || parts.length > MAX_LOOKUP_PATH_SEGMENTS) {
if ((!wantsRoot && parts.length === 0) || parts.length > MAX_LOOKUP_PATH_SEGMENTS) {
return null;
}
@@ -772,10 +813,10 @@ export function lookupConfigSchema(
const resolvedHint = resolveUiHintMatch(response.uiHints, normalizedPath);
return {
path: normalizedPath,
path: wantsRoot ? "." : normalizedPath,
schema: stripSchemaForLookup(current),
hint: resolvedHint?.hint,
hintPath: resolvedHint?.path,
children: buildLookupChildren(current, normalizedPath, response.uiHints),
children: buildLookupChildren(current, wantsRoot ? "" : normalizedPath, response.uiHints),
};
}