From 76da34760c3222732db5b0107a82bce3167b0db2 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 17 May 2026 08:02:21 +0100 Subject: [PATCH] fix(mac): speed up config settings --- CHANGELOG.md | 1 + .../OpenClaw/ChannelsStore+Config.swift | 78 ++++ .../Sources/OpenClaw/ChannelsStore.swift | 48 +++ .../Sources/OpenClaw/ConfigSettings.swift | 339 +++++++++++------- .../Sources/OpenClaw/GatewayConnection.swift | 1 + src/config/schema.test.ts | 43 ++- src/config/schema.ts | 51 ++- 7 files changed, 419 insertions(+), 142 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index fb8b8e5f4b7..fa81a21885c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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) diff --git a/apps/macos/Sources/OpenClaw/ChannelsStore+Config.swift b/apps/macos/Sources/OpenClaw/ChannelsStore+Config.swift index 699af36e7dd..5ef2bc8f03f 100644 --- a/apps/macos/Sources/OpenClaw/ChannelsStore+Config.swift +++ b/apps/macos/Sources/OpenClaw/ChannelsStore+Config.swift @@ -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), diff --git a/apps/macos/Sources/OpenClaw/ChannelsStore.swift b/apps/macos/Sources/OpenClaw/ChannelsStore.swift index 439b716b475..2782850b1c7 100644 --- a/apps/macos/Sources/OpenClaw/ChannelsStore.swift +++ b/apps/macos/Sources/OpenClaw/ChannelsStore.swift @@ -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 = [] var configUiHints: [String: ConfigUiHint] = [:] var configSchemaSourceKey: String? var configSchemaLoadingSourceKey: String? diff --git a/apps/macos/Sources/OpenClaw/ConfigSettings.swift b/apps/macos/Sources/OpenClaw/ConfigSettings.swift index 654d01308f5..a310653a474 100644 --- a/apps/macos/Sources/OpenClaw/ConfigSettings.swift +++ b/apps/macos/Sources/OpenClaw/ConfigSettings.swift @@ -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 = [] 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 { diff --git a/apps/macos/Sources/OpenClaw/GatewayConnection.swift b/apps/macos/Sources/OpenClaw/GatewayConnection.swift index e15a4a8e440..a2b87d6af8a 100644 --- a/apps/macos/Sources/OpenClaw/GatewayConnection.swift +++ b/apps/macos/Sources/OpenClaw/GatewayConnection.swift @@ -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" diff --git a/src/config/schema.test.ts b/src/config/schema.test.ts index b8da04872ee..3a498500f96 100644 --- a/src/config/schema.test.ts +++ b/src/config/schema.test.ts @@ -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 }> }; + 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 }> } + >; + }; + } + | 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 } | 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"); diff --git a/src/config/schema.ts b/src/config/schema.ts index fad843894a1..9461c4234a2 100644 --- a/src/config/schema.ts +++ b/src/config/schema.ts @@ -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), }; }