diff --git a/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift b/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift index 072cab76d318..c4a88a6f892e 100644 --- a/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift +++ b/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift @@ -8971,6 +8971,7 @@ public struct SessionWorktreeInfo: Codable, Sendable { public struct SessionsCreateParams: Codable, Sendable { public let key: String? + public let idempotencykey: String? public let agentid: String? public let label: String? public let category: String? @@ -8999,6 +9000,7 @@ public struct SessionsCreateParams: Codable, Sendable { public init( key: String? = nil, + idempotencykey: String? = nil, agentid: String? = nil, label: String? = nil, category: String? = nil, @@ -9026,6 +9028,7 @@ public struct SessionsCreateParams: Codable, Sendable { cwd: String? = nil) { self.key = key + self.idempotencykey = idempotencykey self.agentid = agentid self.label = label self.category = category @@ -9055,6 +9058,7 @@ public struct SessionsCreateParams: Codable, Sendable { private enum CodingKeys: String, CodingKey { case key + case idempotencykey = "idempotencyKey" case agentid = "agentId" case label case category diff --git a/packages/gateway-protocol/src/index.ts b/packages/gateway-protocol/src/index.ts index 6623aef7accf..a551d7a23f10 100644 --- a/packages/gateway-protocol/src/index.ts +++ b/packages/gateway-protocol/src/index.ts @@ -29,6 +29,10 @@ export { export * from "./schema/session-classification.js"; export * from "./schema/sessions-suggestions.js"; export * from "./schema/sessions-delete.js"; +export { + SESSION_CREATE_IDEMPOTENCY_RETENTION_MS, + SESSION_CREATE_RETRY_WINDOW_MS, +} from "./schema/sessions-create.js"; export * from "./schema/projects.js"; export * from "./migration-api.js"; export type * from "./public-session-catalog.js"; diff --git a/packages/gateway-protocol/src/schema/sessions-create.test.ts b/packages/gateway-protocol/src/schema/sessions-create.test.ts index ecc34792dcc9..f6211da3031b 100644 --- a/packages/gateway-protocol/src/schema/sessions-create.test.ts +++ b/packages/gateway-protocol/src/schema/sessions-create.test.ts @@ -1,7 +1,16 @@ import { describe, expect, it } from "vitest"; -import { validateSessionsCreateParams } from "../index.js"; +import { + SESSION_CREATE_IDEMPOTENCY_RETENTION_MS, + SESSION_CREATE_RETRY_WINDOW_MS, + validateSessionsCreateParams, +} from "../index.js"; describe("sessions.create schema", () => { + it("retains successful creates beyond the client's bounded retry window", () => { + expect(SESSION_CREATE_RETRY_WINDOW_MS).toBe(4 * 60_000); + expect(SESSION_CREATE_IDEMPOTENCY_RETENTION_MS).toBeGreaterThan(SESSION_CREATE_RETRY_WINDOW_MS); + }); + it.each(["read-only", "guarded", "workspace", "full"])( "accepts the closed permission mode %s", (permissionMode) => { @@ -24,4 +33,11 @@ describe("sessions.create schema", () => { it("rejects unknown visibility values", () => { expect(validateSessionsCreateParams({ agentId: "main", visibility: "private" })).toBe(false); }); + + it("accepts a nonempty creation idempotency key", () => { + expect(validateSessionsCreateParams({ agentId: "main", idempotencyKey: "start-once" })).toBe( + true, + ); + expect(validateSessionsCreateParams({ agentId: "main", idempotencyKey: "" })).toBe(false); + }); }); diff --git a/packages/gateway-protocol/src/schema/sessions-create.ts b/packages/gateway-protocol/src/schema/sessions-create.ts index 4d5fa0a2b62a..00a13756485e 100644 --- a/packages/gateway-protocol/src/schema/sessions-create.ts +++ b/packages/gateway-protocol/src/schema/sessions-create.ts @@ -5,9 +5,13 @@ import { NonEmptyString, SessionLabelString } from "./primitives.js"; import { SessionPermissionModeSchema } from "./sessions-row.js"; import { SessionVisibilitySchema } from "./sessions-sharing-values.js"; +export const SESSION_CREATE_RETRY_WINDOW_MS = 4 * 60_000; +export const SESSION_CREATE_IDEMPOTENCY_RETENTION_MS = 5 * 60_000; + /** Creates or adopts a session with optional model, thinking, label, and parent linkage. */ export const SessionsCreateParamsSchema = closedObject({ key: Type.Optional(NonEmptyString), + idempotencyKey: Type.Optional(NonEmptyString), agentId: Type.Optional(NonEmptyString), label: Type.Optional(SessionLabelString), category: Type.Optional(SessionLabelString), diff --git a/src/gateway/server-methods/session-create-idempotency.test.ts b/src/gateway/server-methods/session-create-idempotency.test.ts new file mode 100644 index 000000000000..f8a36f874fff --- /dev/null +++ b/src/gateway/server-methods/session-create-idempotency.test.ts @@ -0,0 +1,271 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { SESSION_CREATE_IDEMPOTENCY_RETENTION_MS } from "../../../packages/gateway-protocol/src/index.js"; +import { createDeferredCore } from "../../shared/deferred.js"; +import { DEDUPE_MAX } from "../server-constants.js"; +import { idempotentSessionCreate } from "./session-create-idempotency.js"; +import type { + GatewayRequestContext, + GatewayRequestHandler, + GatewayRequestHandlerOptions, + RespondFn, +} from "./types.js"; + +afterEach(() => { + vi.restoreAllMocks(); +}); + +function createFixture(handler?: GatewayRequestHandler) { + const context = { dedupe: new Map() } as GatewayRequestContext; + const execute = vi.fn( + handler ?? + ((request) => { + request.respond(true, { key: `agent:main:${String(request.params.idempotencyKey)}` }); + }), + ); + const wrapped = idempotentSessionCreate(execute); + const client = { + authenticatedUserId: "owner", + connect: { + minProtocol: 1, + maxProtocol: 1, + client: { id: "test", version: "1", platform: "test", mode: "test" }, + device: { + id: "device", + publicKey: "key", + signature: "signature", + signedAt: 1, + nonce: "nonce", + }, + role: "operator", + scopes: ["operator.admin", "operator.write"], + }, + } satisfies NonNullable; + + function invoke( + params: Record = { agentId: "main", idempotencyKey: "create-once" }, + connection: typeof client = client, + ) { + const respond = vi.fn(); + const request: GatewayRequestHandlerOptions = { + req: { type: "req", id: "1", method: "sessions.create" }, + params, + client: connection, + context, + respond, + isWebchatConnect: () => false, + }; + return { done: Promise.resolve(wrapped(request)), respond }; + } + + return { client, context, execute, invoke }; +} + +describe("sessions.create process-lifetime idempotency", () => { + it("replays reordered requests and added authorization without accepting downgraded scopes", async () => { + const { client, context, execute, invoke } = createFixture(); + const first = invoke({ agentId: "main", idempotencyKey: "create-once", message: "hello" }); + await first.done; + context.dedupe.clear(); + + const added = invoke( + { message: "hello", idempotencyKey: "create-once", agentId: "main" }, + { + ...client, + connect: { + ...client.connect, + scopes: ["operator.write", "operator.read", "operator.admin"], + }, + }, + ); + await added.done; + expect(execute).toHaveBeenCalledOnce(); + expect(added.respond).toHaveBeenCalledWith(true, { key: "agent:main:create-once" }, undefined, { + cached: true, + }); + + const downgraded = invoke( + { agentId: "main", idempotencyKey: "create-once", message: "hello" }, + { ...client, connect: { ...client.connect, scopes: ["operator.write"] } }, + ); + await downgraded.done; + expect(downgraded.respond).toHaveBeenCalledWith( + false, + undefined, + expect.objectContaining({ code: "FORBIDDEN", message: "missing scope: operator.admin" }), + ); + expect(execute).toHaveBeenCalledOnce(); + + const changedRole = invoke( + { agentId: "main", idempotencyKey: "create-once", message: "hello" }, + { ...client, connect: { ...client.connect, role: "node" } }, + ); + await changedRole.done; + expect(changedRole.respond).toHaveBeenCalledWith( + false, + undefined, + expect.objectContaining({ code: "FORBIDDEN" }), + ); + expect(execute).toHaveBeenCalledOnce(); + + const conflict = invoke({ + agentId: "main", + idempotencyKey: "create-once", + message: "different", + }); + await conflict.done; + expect(conflict.respond).toHaveBeenCalledWith( + false, + undefined, + expect.objectContaining({ code: "INVALID_REQUEST" }), + ); + expect(execute).toHaveBeenCalledOnce(); + + const differentDevice = invoke( + { agentId: "main", idempotencyKey: "create-once", message: "hello" }, + { + ...client, + connect: { + ...client.connect, + device: { ...client.connect.device, id: "other-device" }, + }, + }, + ); + await differentDevice.done; + expect(differentDevice.respond).toHaveBeenCalledWith( + true, + { key: "agent:main:create-once" }, + undefined, + { cached: true }, + ); + expect(execute).toHaveBeenCalledOnce(); + }); + + it("rejects idempotent creation without a principal or device namespace", async () => { + const { execute, invoke } = createFixture(); + const anonymous = invoke({ agentId: "main", idempotencyKey: "create-once" }, null as never); + + await anonymous.done; + + expect(anonymous.respond).toHaveBeenCalledWith( + false, + undefined, + expect.objectContaining({ + code: "INVALID_REQUEST", + message: expect.stringContaining("identity"), + }), + ); + expect(execute).not.toHaveBeenCalled(); + }); + + it("pins in-flight request identity beyond retention and independent of global dedupe pruning", async () => { + let now = 1_000; + vi.spyOn(Date, "now").mockImplementation(() => now); + const release = createDeferredCore(); + const { context, execute, invoke } = createFixture(async (request) => { + await release.promise; + request.respond(true, { key: "agent:main:finished" }); + }); + const original = invoke({ agentId: "main", idempotencyKey: "long-create", message: "hello" }); + await vi.waitFor(() => expect(execute).toHaveBeenCalledOnce()); + now += SESSION_CREATE_IDEMPOTENCY_RETENTION_MS + 1; + context.dedupe.clear(); + + const conflicting = invoke({ + agentId: "main", + idempotencyKey: "long-create", + message: "different", + }); + await conflicting.done; + expect(conflicting.respond).toHaveBeenCalledWith( + false, + undefined, + expect.objectContaining({ code: "INVALID_REQUEST" }), + ); + + const joined = invoke({ agentId: "main", idempotencyKey: "long-create", message: "hello" }); + release.resolve(); + await Promise.all([original.done, joined.done]); + expect(execute).toHaveBeenCalledOnce(); + expect(joined.respond).toHaveBeenCalledWith(true, { key: "agent:main:finished" }, undefined, { + cached: true, + }); + }); + + it("expires only settled successful results and immediately releases failed creates", async () => { + let now = 1_000; + vi.spyOn(Date, "now").mockImplementation(() => now); + const { execute, invoke } = createFixture(); + await invoke().done; + now += SESSION_CREATE_IDEMPOTENCY_RETENTION_MS - 1; + await invoke().done; + expect(execute).toHaveBeenCalledOnce(); + now += 1; + await invoke().done; + expect(execute).toHaveBeenCalledTimes(2); + + let fail = true; + const failed = createFixture((request) => { + request.respond(!fail, fail ? undefined : { key: "recovered" }); + fail = false; + }); + await failed.invoke().done; + await failed.invoke().done; + expect(failed.execute).toHaveBeenCalledTimes(2); + }); + + it("enforces capacity per owner without evicting retained successful creations", async () => { + const { client, execute, invoke } = createFixture(); + for (let index = 0; index < DEDUPE_MAX; index += 1) { + await invoke({ agentId: "main", idempotencyKey: `create-${index}` }).done; + } + const overflow = invoke({ agentId: "main", idempotencyKey: "overflow" }); + await overflow.done; + expect(overflow.respond).toHaveBeenCalledWith( + false, + undefined, + expect.objectContaining({ code: "UNAVAILABLE", message: expect.stringContaining("retry") }), + ); + expect(execute).toHaveBeenCalledTimes(DEDUPE_MAX); + + const retained = invoke({ agentId: "main", idempotencyKey: "create-0" }); + await retained.done; + expect(retained.respond).toHaveBeenCalledWith(true, { key: "agent:main:create-0" }, undefined, { + cached: true, + }); + expect(execute).toHaveBeenCalledTimes(DEDUPE_MAX); + + const otherOwner = { ...client, authenticatedUserId: "other-owner" }; + const otherCreation = invoke({ agentId: "main", idempotencyKey: "create-0" }, otherOwner); + await otherCreation.done; + expect(otherCreation.respond).toHaveBeenCalledWith( + true, + { key: "agent:main:create-0" }, + undefined, + undefined, + ); + expect(execute).toHaveBeenCalledTimes(DEDUPE_MAX + 1); + + const otherReplay = invoke({ agentId: "main", idempotencyKey: "create-0" }, otherOwner); + await otherReplay.done; + expect(otherReplay.respond).toHaveBeenCalledWith( + true, + { key: "agent:main:create-0" }, + undefined, + { cached: true }, + ); + expect(execute).toHaveBeenCalledTimes(DEDUPE_MAX + 1); + + for (let index = 1; index < DEDUPE_MAX; index += 1) { + await invoke({ agentId: "main", idempotencyKey: `create-${index}` }, otherOwner).done; + } + const thirdOwner = { ...client, authenticatedUserId: "third-owner" }; + const processOverflow = invoke({ agentId: "main", idempotencyKey: "create-0" }, thirdOwner); + await processOverflow.done; + expect(processOverflow.respond).toHaveBeenCalledWith( + false, + undefined, + expect.objectContaining({ code: "UNAVAILABLE" }), + ); + expect(execute).toHaveBeenCalledTimes(DEDUPE_MAX * 2); + }); +}); diff --git a/src/gateway/server-methods/session-create-idempotency.ts b/src/gateway/server-methods/session-create-idempotency.ts new file mode 100644 index 000000000000..af9356d49682 --- /dev/null +++ b/src/gateway/server-methods/session-create-idempotency.ts @@ -0,0 +1,175 @@ +import { createHash } from "node:crypto"; +import { stableStringify } from "@openclaw/normalization-core"; +import { + ErrorCodes, + SESSION_CREATE_IDEMPOTENCY_RETENTION_MS, + errorShape, + missingScopeErrorShape, +} from "../../../packages/gateway-protocol/src/index.js"; +import { DEDUPE_MAX } from "../server-constants.js"; +import type { GatewayInflightResult } from "./inflight.js"; +import type { GatewayRequestContext, GatewayRequestHandler } from "./types.js"; + +type SessionCreateAuthorization = { role: string | null; scopes: readonly string[] }; +type SessionCreateEntry = { + requestIdentity: string; + authorization: SessionCreateAuthorization; + expiresAt: number; + state: + | { kind: "inflight"; work: Promise } + | { kind: "completed"; result: GatewayInflightResult }; +}; + +const sessionCreatesByContext = new WeakMap< + GatewayRequestContext, + Map> +>(); + +export function idempotentSessionCreate(handler: GatewayRequestHandler): GatewayRequestHandler { + return async (request) => { + const idempotencyKey = request.params.idempotencyKey; + if (typeof idempotencyKey !== "string" || !idempotencyKey) { + await handler(request); + return; + } + const principal = + request.client?.authenticatedUserProfile?.profileId ?? request.client?.authenticatedUserId; + const deviceId = request.client?.connect.device?.id?.trim(); + if (!principal && !deviceId) { + request.respond( + false, + undefined, + errorShape( + ErrorCodes.INVALID_REQUEST, + "idempotent session creation requires an authenticated principal or device identity", + ), + ); + return; + } + const owner = principal ? `principal:${principal}` : `device:${deviceId}`; + let entriesByOwner = sessionCreatesByContext.get(request.context); + if (!entriesByOwner) { + entriesByOwner = new Map(); + sessionCreatesByContext.set(request.context, entriesByOwner); + } + const now = Date.now(); + let retainedEntryCount = 0; + for (const [entryOwner, ownerEntries] of entriesByOwner) { + for (const [key, entry] of ownerEntries) { + if (entry.state.kind === "completed" && entry.expiresAt <= now) { + ownerEntries.delete(key); + } + } + if (ownerEntries.size === 0) { + entriesByOwner.delete(entryOwner); + } else { + retainedEntryCount += ownerEntries.size; + } + } + let entries = entriesByOwner.get(owner); + const requestIdentity = createHash("sha256") + .update(stableStringify(request.params)) + .digest("hex"); + const authorization: SessionCreateAuthorization = { + role: request.client?.connect.role ?? null, + scopes: request.client?.connect.scopes?.toSorted() ?? [], + }; + const existing = entries?.get(idempotencyKey); + if (existing) { + if (existing.requestIdentity !== requestIdentity) { + request.respond( + false, + undefined, + errorShape( + ErrorCodes.INVALID_REQUEST, + "session creation idempotency key was reused with different parameters", + ), + ); + return; + } + if (existing.authorization.role !== authorization.role) { + request.respond( + false, + undefined, + errorShape(ErrorCodes.FORBIDDEN, "session creation authorization changed; start again"), + ); + return; + } + const missingScope = existing.authorization.scopes.find( + (scope) => !authorization.scopes.includes(scope), + ); + if (missingScope) { + request.respond( + false, + undefined, + missingScopeErrorShape({ + missingScope, + requiredScopes: existing.authorization.scopes, + }), + ); + return; + } + const result = + existing.state.kind === "completed" ? existing.state.result : await existing.state.work; + request.respond(result.ok, result.payload, result.error, { + ...result.meta, + cached: true, + }); + return; + } + // Reserve a full owner's capacity for other principals while bounding process-wide state. + if ((entries?.size ?? 0) >= DEDUPE_MAX || retainedEntryCount >= DEDUPE_MAX * 2) { + request.respond( + false, + undefined, + errorShape(ErrorCodes.UNAVAILABLE, "session creation capacity is full; retry later"), + ); + return; + } + if (!entries) { + entries = new Map(); + entriesByOwner.set(owner, entries); + } + const releaseEntry = () => { + entries.delete(idempotencyKey); + if (entries.size === 0) { + entriesByOwner.delete(owner); + } + }; + // The entry is installed before work begins; in-flight identity is never TTL/cap-evictable. + const work = Promise.resolve().then(async (): Promise => { + try { + let result: GatewayInflightResult | undefined; + await handler({ + ...request, + respond: (ok, payload, error, meta) => { + result = { ok, payload, error, meta }; + }, + }); + result ??= { + ok: false, + error: errorShape(ErrorCodes.UNAVAILABLE, "session creation was interrupted"), + }; + if (result.ok) { + entry.expiresAt = Date.now() + SESSION_CREATE_IDEMPOTENCY_RETENTION_MS; + entry.state = { kind: "completed", result }; + } else { + releaseEntry(); + } + return result; + } catch (error) { + releaseEntry(); + throw error; + } + }); + const entry: SessionCreateEntry = { + requestIdentity, + authorization, + expiresAt: now + SESSION_CREATE_IDEMPOTENCY_RETENTION_MS, + state: { kind: "inflight", work }, + }; + entries.set(idempotencyKey, entry); + const result = await work; + request.respond(result.ok, result.payload, result.error, result.meta); + }; +} diff --git a/src/gateway/server-methods/sessions-create.ts b/src/gateway/server-methods/sessions-create.ts index fa2e31568197..ad74f030ad83 100644 --- a/src/gateway/server-methods/sessions-create.ts +++ b/src/gateway/server-methods/sessions-create.ts @@ -43,6 +43,7 @@ import { chatHandlers } from "./chat.js"; import { resolveRegisteredCatalogCreateTarget } from "./session-catalog.js"; import { emitSessionsChanged } from "./session-change-event.js"; import { registerCreatedSessionCategory } from "./session-create-category.js"; +import { idempotentSessionCreate } from "./session-create-idempotency.js"; import { resolveSessionCreateInitialTurn, shouldAttachPendingMessageSeq, @@ -689,3 +690,7 @@ export const sessionCreateHandlers: GatewayRequestHandlers = { } }, }; + +sessionCreateHandlers["sessions.create"] = idempotentSessionCreate( + expectDefined(sessionCreateHandlers["sessions.create"], "sessions.create handler"), +); diff --git a/src/gateway/server.sessions.create.test.ts b/src/gateway/server.sessions.create.test.ts index 525d46c1ff0d..9a9e624ae98a 100644 --- a/src/gateway/server.sessions.create.test.ts +++ b/src/gateway/server.sessions.create.test.ts @@ -4276,6 +4276,121 @@ test("sessions.create adopting an existing key does not restamp node provenance" } }); +test("sessions.create replays an identical creation once and rejects conflicting intent", async () => { + await createSessionStoreDir(); + const { chatHandlers } = await import("./server-methods/chat.js"); + const { sessionCreateHandlers } = await import("./server-methods/sessions-create.js"); + let sharedContext: + | Parameters>[0]["context"] + | undefined; + const chatSend = vi + .spyOn(chatHandlers, "chat.send") + .mockImplementation(async ({ context, respond }) => { + sharedContext ??= context; + respond(true, { runId: "create-once", status: "started" }); + }); + const dedupe = new Map(); + const client = { + connect: { + role: "operator", + scopes: ["operator.write", "operator.admin"], + device: { id: "control-ui-device" }, + }, + authenticatedUserProfile: { profileId: "profile-owner" }, + }; + const params = { + agentId: "main", + idempotencyKey: "create-once", + message: "start this task exactly once", + permissionMode: "full", + }; + const request = async (nextParams = params, nextClient = client) => { + if (!sharedContext) { + return await directSessionReq<{ key: string }>("sessions.create", nextParams, { + client: nextClient as never, + context: { dedupe }, + }); + } + let result: + | { ok: boolean; payload?: { key: string }; error?: { code?: string; message?: string } } + | undefined; + await sessionCreateHandlers["sessions.create"]?.({ + req: {} as never, + params: nextParams, + client: nextClient as never, + context: sharedContext, + isWebchatConnect: () => false, + respond: (ok, payload, error) => { + result = { ok, payload: payload as { key: string } | undefined, error }; + }, + }); + if (!result) { + throw new Error("sessions.create did not respond"); + } + return result; + }; + + try { + const first = await request(); + const replay = await request( + { + message: params.message, + permissionMode: params.permissionMode, + idempotencyKey: params.idempotencyKey, + agentId: params.agentId, + }, + { + ...client, + connect: { + ...client.connect, + scopes: ["operator.admin", "operator.read", "operator.write"], + }, + }, + ); + + expect(first.ok).toBe(true); + expect(replay).toEqual(first); + expect(chatSend).toHaveBeenCalledOnce(); + expect(chatSend.mock.calls[0]?.[0].params).toMatchObject({ + idempotencyKey: expect.any(String), + message: "start this task exactly once", + }); + + const conflict = await request({ ...params, message: "start a different task" }); + expect(conflict).toMatchObject({ + ok: false, + error: { + code: "INVALID_REQUEST", + message: "session creation idempotency key was reused with different parameters", + }, + }); + expect(chatSend).toHaveBeenCalledOnce(); + + const downgraded = await request(params, { + ...client, + connect: { ...client.connect, scopes: ["operator.write"] }, + }); + expect(downgraded).toMatchObject({ + ok: false, + error: { message: "missing scope: operator.admin" }, + }); + expect(chatSend).toHaveBeenCalledOnce(); + + const differentOwner = await request(params, { + ...client, + authenticatedUserProfile: { profileId: "profile-other" }, + }); + expect(differentOwner.ok).toBe(true); + expect(differentOwner.payload?.key).not.toBe(first.payload?.key); + expect(chatSend).toHaveBeenCalledTimes(2); + expect(chatSend.mock.calls[1]?.[0].params.idempotencyKey).not.toBe( + chatSend.mock.calls[0]?.[0].params.idempotencyKey, + ); + } finally { + chatSend.mockRestore(); + } +}); + test("sessions.create scopes the main alias to the requested agent", async () => { const { storePath } = await createSessionStoreDir(); testState.agentsConfig = { list: [{ id: "main", default: true }, { id: "longmemeval" }] }; diff --git a/ui/src/components/settings-ui.ts b/ui/src/components/settings-ui.ts index 007e42557d63..f9f0141ffe9a 100644 --- a/ui/src/components/settings-ui.ts +++ b/ui/src/components/settings-ui.ts @@ -224,6 +224,10 @@ export function renderSettingsToggleRow(props: { `; } +export function renderSettingsDefaultDescription(value: string, overridden: boolean) { + return html`${t(overridden ? "configForm.defaultValue" : "configForm.usingDefault", { value })}`; +} + export function renderSettingsDefaultState(props: { value: string; overridden: boolean; @@ -234,10 +238,7 @@ export function renderSettingsDefaultState(props: { action: TemplateResult | typeof nothing; } { return { - description: html`${t( - props.overridden ? "configForm.defaultValue" : "configForm.usingDefault", - { value: props.value }, - )}`, + description: renderSettingsDefaultDescription(props.value, props.overridden), action: props.overridden ? html`