diff --git a/extensions/diagnostics-otel/src/service.test.ts b/extensions/diagnostics-otel/src/service.test.ts index fb4a3527aa1..04541527e9a 100644 --- a/extensions/diagnostics-otel/src/service.test.ts +++ b/extensions/diagnostics-otel/src/service.test.ts @@ -2175,11 +2175,13 @@ describe("diagnostics-otel service", () => { test("bounds plugin-emitted log attributes and omits source paths", async () => { const service = createDiagnosticsOtelService(); - const ctx = createOtelContext(OTEL_TEST_ENDPOINT, { logs: true }); + const ctx = createOtelContext(OTEL_TEST_ENDPOINT, { logs: true, captureContent: true }); await service.start(ctx); + const boundaryMessage = `${"x".repeat(4095)}🚀tail`; + const boundaryAttribute = `${"y".repeat(4095)}🚀tail`; const attributes = Object.create(null) as Record; - attributes.good = "y".repeat(6000); + attributes.good = boundaryAttribute; attributes["bad key"] = "drop-me"; attributes[PROTO_KEY] = "pollute"; attributes["constructor"] = "pollute"; @@ -2189,7 +2191,7 @@ describe("diagnostics-otel service", () => { emitDiagnosticEvent({ type: "log.record", level: "INFO", - message: "x".repeat(6000), + message: boundaryMessage, attributes, code: { filepath: "/Users/alice/openclaw/src/private.ts", @@ -2204,11 +2206,10 @@ describe("diagnostics-otel service", () => { attributes: Record; body: string; }; - expect(emitCall.body.length).toBeLessThanOrEqual(4200); - expect(String(emitCall.attributes["openclaw.good"])).toMatch(/^y+/); + expect(emitCall.body).toBe(`${"x".repeat(4095)}...(truncated)`); + expect(emitCall.attributes["openclaw.good"]).toBe(`${"y".repeat(4095)}...(truncated)`); expect(emitCall.attributes["code.lineno"]).toBe(42); expect(emitCall.attributes["code.function"]).toBe("handler"); - expect(String(emitCall.attributes["openclaw.good"]).length).toBeLessThanOrEqual(4200); expect(Object.hasOwn(emitCall.attributes, `openclaw.${PROTO_KEY}`)).toBe(false); expect(Object.hasOwn(emitCall.attributes, "openclaw.constructor")).toBe(false); expect(Object.hasOwn(emitCall.attributes, "openclaw.prototype")).toBe(false); diff --git a/extensions/diagnostics-otel/src/service.ts b/extensions/diagnostics-otel/src/service.ts index 34ba60a0890..e12def6baf9 100644 --- a/extensions/diagnostics-otel/src/service.ts +++ b/extensions/diagnostics-otel/src/service.ts @@ -40,6 +40,7 @@ import { waitForDiagnosticEventsDrained } from "openclaw/plugin-sdk/diagnostic-r import { createNodeProxyAgent } from "openclaw/plugin-sdk/fetch-runtime"; import { registerUnhandledRejectionHandler } from "openclaw/plugin-sdk/runtime-env"; import { isRecord } from "openclaw/plugin-sdk/string-coerce-runtime"; +import { truncateUtf16Safe } from "openclaw/plugin-sdk/text-utility-runtime"; import type { DiagnosticEventMetadata, DiagnosticEventPayload, @@ -682,7 +683,7 @@ function addUpstreamRequestIdSpanEvent( } function clampOtelLogText(value: string, maxChars: number): string { - return value.length > maxChars ? `${value.slice(0, maxChars)}...(truncated)` : value; + return value.length > maxChars ? `${truncateUtf16Safe(value, maxChars)}...(truncated)` : value; } function normalizeOtelLogString(value: string, maxChars: number): string { diff --git a/extensions/parallel/src/parallel-search-normalize.ts b/extensions/parallel/src/parallel-search-normalize.ts index 502ccb49d4e..92de1f868e8 100644 --- a/extensions/parallel/src/parallel-search-normalize.ts +++ b/extensions/parallel/src/parallel-search-normalize.ts @@ -8,6 +8,7 @@ import { wrapWebContent, } from "openclaw/plugin-sdk/provider-web-search"; import { normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime"; +import { truncateUtf16Safe } from "openclaw/plugin-sdk/text-utility-runtime"; // Internal-only bounds (the model-facing tool schema declares its own copies). const PARALLEL_MAX_SEARCH_COUNT = 40; @@ -58,7 +59,7 @@ export function normalizeParallelObjective(value: string | undefined): string | } return trimmed.length <= PARALLEL_MAX_OBJECTIVE_CHARS ? trimmed - : trimmed.slice(0, PARALLEL_MAX_OBJECTIVE_CHARS); + : truncateUtf16Safe(trimmed, PARALLEL_MAX_OBJECTIVE_CHARS); } export function normalizeParallelClientModel(value: string | undefined): string | undefined { @@ -68,7 +69,7 @@ export function normalizeParallelClientModel(value: string | undefined): string } return trimmed.length <= PARALLEL_CLIENT_MODEL_MAX_LENGTH ? trimmed - : trimmed.slice(0, PARALLEL_CLIENT_MODEL_MAX_LENGTH); + : truncateUtf16Safe(trimmed, PARALLEL_CLIENT_MODEL_MAX_LENGTH); } // Parallel's API caps each entry at 200 chars and accepts up to 5 queries. We @@ -90,7 +91,7 @@ export function normalizeParallelSearchQueries(value: unknown): string[] { const capped = trimmed.length <= PARALLEL_MAX_SEARCH_QUERY_CHARS ? trimmed - : trimmed.slice(0, PARALLEL_MAX_SEARCH_QUERY_CHARS); + : truncateUtf16Safe(trimmed, PARALLEL_MAX_SEARCH_QUERY_CHARS); if (seen.has(capped)) { continue; } diff --git a/extensions/parallel/src/parallel-web-search-provider.test.ts b/extensions/parallel/src/parallel-web-search-provider.test.ts index 3f19400e102..233495b5ab2 100644 --- a/extensions/parallel/src/parallel-web-search-provider.test.ts +++ b/extensions/parallel/src/parallel-web-search-provider.test.ts @@ -254,6 +254,7 @@ describe("parallel web search provider", () => { expect(testing.normalizeParallelObjective(undefined)).toBeUndefined(); expect(testing.normalizeParallelObjective("")).toBeUndefined(); expect((testing.normalizeParallelObjective("x".repeat(6000)) ?? "").length).toBe(5000); + expect(testing.normalizeParallelObjective(`${"x".repeat(4999)}🚀tail`)).toBe("x".repeat(4999)); }); it("normalizes search_queries: trim, drop blanks, dedupe, cap length, cap count", () => { @@ -270,6 +271,9 @@ describe("parallel web search provider", () => { expect(testing.normalizeParallelSearchQueries(undefined)).toEqual([]); expect(testing.normalizeParallelSearchQueries("openclaw github")).toEqual([]); expect(testing.normalizeParallelSearchQueries(["x".repeat(250)])).toEqual(["x".repeat(200)]); + expect(testing.normalizeParallelSearchQueries([`${"x".repeat(199)}🚀tail`])).toEqual([ + "x".repeat(199), + ]); const six = ["a", "b", "c", "d", "e", "f"]; expect(testing.normalizeParallelSearchQueries(six)).toEqual(["a", "b", "c", "d", "e"]); }); @@ -289,6 +293,7 @@ describe("parallel web search provider", () => { expect(testing.normalizeParallelClientModel(" gpt-5.5 ")).toBe("gpt-5.5"); expect(testing.normalizeParallelClientModel(undefined)).toBeUndefined(); expect((testing.normalizeParallelClientModel("a".repeat(200)) ?? "").length).toBe(100); + expect(testing.normalizeParallelClientModel(`${"m".repeat(99)}🚀tail`)).toBe("m".repeat(99)); }); it("normalizes the Parallel /v1/search response shape", () => { diff --git a/extensions/workboard/src/store.test.ts b/extensions/workboard/src/store.test.ts index ced45e9e983..c0c21809277 100644 --- a/extensions/workboard/src/store.test.ts +++ b/extensions/workboard/src/store.test.ts @@ -1938,6 +1938,24 @@ describe("WorkboardStore", () => { await expect(store.buildWorkerContext(card.id)).resolves.toContain("Failure screenshot"); }); + it("keeps worker-context text bounds UTF-16 safe", async () => { + const store = new WorkboardStore(createMemoryStore()); + const card = await store.create({ + title: "Bound context", + metadata: { + comments: [ + { + id: "comment-1", + body: `${"x".repeat(398)}🚀tail`, + createdAt: 10, + }, + ], + }, + }); + + await expect(store.buildWorkerContext(card.id)).resolves.toContain(`- ${"x".repeat(398)}…`); + }); + it("scopes idempotent creates and stats by board", async () => { const store = new WorkboardStore(createMemoryStore()); const ops = await store.create({ diff --git a/extensions/workboard/src/store.ts b/extensions/workboard/src/store.ts index f4e75de9cba..328f7f0b172 100644 --- a/extensions/workboard/src/store.ts +++ b/extensions/workboard/src/store.ts @@ -5,6 +5,7 @@ import { MAX_DATE_TIMESTAMP_MS, resolveExpiresAtMsFromDurationMs, } from "openclaw/plugin-sdk/number-runtime"; +import { truncateUtf16Safe } from "openclaw/plugin-sdk/text-utility-runtime"; import type { PersistedWorkboardAttachment, PersistedWorkboardBoard, @@ -2039,7 +2040,7 @@ function capText(value: string | undefined, max: number): string | undefined { if (!value) { return undefined; } - return value.length <= max ? value : `${value.slice(0, Math.max(0, max - 1))}…`; + return value.length <= max ? value : `${truncateUtf16Safe(value, Math.max(0, max - 1))}…`; } function cardBoardId(card: WorkboardCard): string { diff --git a/src/agents/agent-hooks/compaction-safeguard.test.ts b/src/agents/agent-hooks/compaction-safeguard.test.ts index 4329f8513b4..356a0430814 100644 --- a/src/agents/agent-hooks/compaction-safeguard.test.ts +++ b/src/agents/agent-hooks/compaction-safeguard.test.ts @@ -392,6 +392,21 @@ describe("compaction-safeguard tool failures", () => { expect(section).toContain("exec (exitCode=2): failed"); }); + it("keeps bounded tool-failure text UTF-16 safe", () => { + const failures = collectToolFailures([ + { + role: "toolResult", + toolCallId: "call-boundary", + toolName: "exec", + isError: true, + content: [{ type: "text", text: `${"x".repeat(236)}🚀tail` }], + timestamp: Date.now(), + }, + ]); + + expect(failures[0]?.summary).toBe(`${"x".repeat(236)}...`); + }); + it("caps the number of failures and adds overflow line", () => { const messages: AgentMessage[] = Array.from({ length: 9 }, (_, idx) => ({ role: "toolResult", @@ -454,6 +469,18 @@ describe("compaction-safeguard summary budgets", () => { expect(capped.endsWith(SUMMARY_TRUNCATED_MARKER)).toBe(true); }); + it("keeps compaction summary prefixes UTF-16 safe", () => { + const prefixBudget = MAX_COMPACTION_SUMMARY_CHARS - SUMMARY_TRUNCATED_MARKER.length; + const oversized = `${"x".repeat(prefixBudget - 1)}🚀${"z".repeat( + SUMMARY_TRUNCATED_MARKER.length + 10, + )}`; + + expect(capCompactionSummary(oversized)).toBe( + `${"x".repeat(prefixBudget - 1)}${SUMMARY_TRUNCATED_MARKER}`, + ); + expect(capCompactionSummary(`${"x".repeat(9)}🚀tail`, 10)).toBe("x".repeat(9)); + }); + it("preserves workspace critical rules suffix when capping", () => { const suffix = "\n\n\n## Session Startup\nRead AGENTS.md\n"; @@ -522,6 +549,10 @@ describe("compaction-safeguard summary budgets", () => { expect(capped).toContain(""); expect(capped).toContain("## Session Startup"); }); + + it("keeps an oversized preserved suffix UTF-16 safe at its leading edge", () => { + expect(capCompactionSummaryPreservingSuffix("body", "A🚀tail", 5)).toBe("tail"); + }); }); describe("computeAdaptiveChunkRatio", () => { @@ -906,6 +937,18 @@ describe("compaction-safeguard recent-turn preservation", () => { expect(section).toContain("[non-text content: image]"); }); + it("keeps bounded preserved-turn text UTF-16 safe", () => { + const section = formatPreservedTurnsSection([ + { + role: "user", + content: `${"x".repeat(599)}🚀tail`, + timestamp: 1, + }, + ]); + + expect(section).toContain(`- User: ${"x".repeat(599)}...`); + }); + it("does not add non-text placeholders for text-only content blocks", () => { const section = formatPreservedTurnsSection([ { @@ -2692,6 +2735,14 @@ describe("readWorkspaceContextForSummary", () => { expect(result).not.toContain("Ignore me"); }); + it("keeps bounded workspace rules UTF-16 safe", async () => { + const heading = "## Session Startup\n"; + const safePrefix = `${heading}${"x".repeat(1_999 - heading.length)}`; + const result = await withWorkspaceSummary(`${safePrefix}🚀tail\n`, ["Session Startup"]); + + expect(result).toContain(`${safePrefix}\n...[truncated]...`); + }); + it("reads workspace context from the configured workspace instead of process cwd", async () => { const processRoot = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-compaction-cwd-")); const workspaceRoot = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-compaction-workspace-")); diff --git a/src/agents/agent-hooks/compaction-safeguard.ts b/src/agents/agent-hooks/compaction-safeguard.ts index 9dbbd7e84fd..7e2cfc2344f 100644 --- a/src/agents/agent-hooks/compaction-safeguard.ts +++ b/src/agents/agent-hooks/compaction-safeguard.ts @@ -1,10 +1,12 @@ /** Extension that safeguards compaction with structured summaries and quality repair. */ + import fs from "node:fs"; import path from "node:path"; +import { sliceUtf16Safe, truncateUtf16Safe } from "@openclaw/normalization-core/utf16-slice"; import { extractSections } from "../../auto-reply/reply/post-compaction-context.js"; +import { isAbortError } from "../../infra/abort-signal.js"; import { openRootFile } from "../../infra/boundary-file-read.js"; import { formatErrorMessage } from "../../infra/errors.js"; -import { isAbortError } from "../../infra/abort-signal.js"; import { createSubsystemLogger } from "../../logging/subsystem.js"; import { getCompactionProvider, @@ -404,7 +406,7 @@ function truncateFailureText(text: string, maxChars: number): string { if (text.length <= maxChars) { return text; } - return `${text.slice(0, Math.max(0, maxChars - 3))}...`; + return `${truncateUtf16Safe(text, Math.max(0, maxChars - 3))}...`; } function formatToolFailureMeta(details: unknown): string | undefined { @@ -568,9 +570,9 @@ function capCompactionSummary(summary: string, maxChars = MAX_COMPACTION_SUMMARY const budget = Math.max(0, maxChars - marker.length); if (budget <= 0) { // Marker cannot fit; keep body prefix instead of a partial marker fragment. - return summary.slice(0, maxChars); + return truncateUtf16Safe(summary, maxChars); } - return `${summary.slice(0, budget)}${marker}`; + return `${truncateUtf16Safe(summary, budget)}${marker}`; } function capCompactionSummaryPreservingSuffix( @@ -586,7 +588,7 @@ function capCompactionSummaryPreservingSuffix( } if (suffix.length >= maxChars) { // Preserve tail (workspace rules, diagnostics) over head (preserved turns). - return suffix.slice(-maxChars); + return sliceUtf16Safe(suffix, -maxChars); } const bodyBudget = Math.max(0, maxChars - suffix.length); const cappedBody = capCompactionSummary(summaryBody, bodyBudget); @@ -790,7 +792,7 @@ function formatContextMessages(messages: AgentMessage[]): string[] { } const trimmed = rendered.length > MAX_RECENT_TURN_TEXT_CHARS - ? `${rendered.slice(0, MAX_RECENT_TURN_TEXT_CHARS)}...` + ? `${truncateUtf16Safe(rendered, MAX_RECENT_TURN_TEXT_CHARS)}...` : rendered; return `- ${roleLabel}: ${trimmed}`; }) @@ -884,7 +886,7 @@ async function readWorkspaceContextForSummary( const combined = sections.join("\n\n"); const safeContent = combined.length > MAX_SUMMARY_CONTEXT_CHARS - ? combined.slice(0, MAX_SUMMARY_CONTEXT_CHARS) + "\n...[truncated]..." + ? `${truncateUtf16Safe(combined, MAX_SUMMARY_CONTEXT_CHARS)}\n...[truncated]...` : combined; return `\n\n\n${safeContent}\n`; diff --git a/src/infra/system-presence.test.ts b/src/infra/system-presence.test.ts index 50824bdbe80..9350ad4b6c5 100644 --- a/src/infra/system-presence.test.ts +++ b/src/infra/system-presence.test.ts @@ -114,6 +114,13 @@ describe("system-presence", () => { expect(entry?.text).toBe("Node: relay-host · mode operator"); }); + it("keeps fallback text keys UTF-16 safe", () => { + const keyPrefix = `presence-${randomUUID()}`.padEnd(63, "x"); + const update = updateSystemPresence({ text: `${keyPrefix}🚀tail` }); + + expect(update.key).toBe(keyPrefix); + }); + it("prunes stale non-self entries after TTL", () => { vi.useFakeTimers(); vi.setSystemTime(Date.now()); diff --git a/src/infra/system-presence.ts b/src/infra/system-presence.ts index 39dea854f3f..25c904134e6 100644 --- a/src/infra/system-presence.ts +++ b/src/infra/system-presence.ts @@ -7,6 +7,7 @@ import { normalizeOptionalLowercaseString, normalizeOptionalString, } from "@openclaw/normalization-core/string-coerce"; +import { truncateUtf16Safe } from "@openclaw/normalization-core/utf16-slice"; import { resolveRuntimeServiceVersion } from "../version.js"; import { pickBestEffortPrimaryLanIPv4 } from "./network-discovery-display.js"; @@ -201,7 +202,7 @@ export function updateSystemPresence(payload: SystemPresencePayload): SystemPres normalizePresenceKey(parsed.instanceId) || normalizePresenceKey(parsed.host) || parsed.ip || - parsed.text.slice(0, 64) || + truncateUtf16Safe(parsed.text, 64) || normalizeLowercaseStringOrEmpty(os.hostname()); const hadExisting = entries.has(key); const existing = entries.get(key) ?? ({} as SystemPresence); diff --git a/ui/src/lib/chat/commands.browser-import.test.ts b/ui/src/lib/chat/commands.browser-import.test.ts index 00387c15356..c008410f00d 100644 --- a/ui/src/lib/chat/commands.browser-import.test.ts +++ b/ui/src/lib/chat/commands.browser-import.test.ts @@ -47,6 +47,7 @@ describe("slash command browser import", () => { ); expect(importDeclarations(commands)).toEqual([ + 'import { truncateUtf16Safe } from "@openclaw/normalization-core/utf16-slice";', 'import type { CommandEntry } from "../../../../packages/gateway-protocol/src/index.js";', 'import { buildBuiltinChatCommands } from "../../../../src/auto-reply/commands-registry.shared.js";', 'import { normalizeLowercaseStringOrEmpty } from "../string-coerce.ts";', diff --git a/ui/src/lib/chat/commands.test.ts b/ui/src/lib/chat/commands.test.ts index fc3ace24d63..07c76f25c14 100644 --- a/ui/src/lib/chat/commands.test.ts +++ b/ui/src/lib/chat/commands.test.ts @@ -234,7 +234,8 @@ describe("parseSlashCommand", () => { it("caps remote command payload size and long metadata before it reaches UI state", () => { const longName = "x".repeat(260); - const longDescription = "d".repeat(2_500); + const longDescription = `${"d".repeat(1_999)}🚀tail`; + const boundaryArgName = `${"n".repeat(199)}🚀tail`; const oversizedCommand = { name: "plugin-0", textAliases: Array.from({ length: 25 }, (_, aliasIndex) => `/plugin-0-${aliasIndex}`), @@ -243,7 +244,7 @@ describe("parseSlashCommand", () => { scope: "both" as const, acceptsArgs: true, args: Array.from({ length: 25 }, (_, argIndex) => ({ - name: `${longName}-${argIndex}`, + name: argIndex === 0 ? boundaryArgName : `${longName}-${argIndex}`, description: longDescription, type: "string" as const, choices: Array.from({ length: 55 }, (_Local, choiceIndex) => ({ @@ -268,8 +269,9 @@ describe("parseSlashCommand", () => { expect(remoteCommands).toHaveLength(500); const first = remoteCommands[0]; expect(first.aliases).toHaveLength(19); - expect(first.description.length).toBeLessThanOrEqual(2_000); + expect(first.description).toBe("d".repeat(1_999)); expect(first.args?.split(" ")).toHaveLength(20); + expect(first.args?.split(" ")[0]).toBe("[" + "n".repeat(199) + "]"); expect(first.argOptions).toHaveLength(50); }); diff --git a/ui/src/lib/chat/commands.ts b/ui/src/lib/chat/commands.ts index a221bd36017..613e5851321 100644 --- a/ui/src/lib/chat/commands.ts +++ b/ui/src/lib/chat/commands.ts @@ -1,4 +1,6 @@ // Control UI chat domain owns pure slash command rules. + +import { truncateUtf16Safe } from "@openclaw/normalization-core/utf16-slice"; import type { CommandEntry } from "../../../../packages/gateway-protocol/src/index.js"; import { buildBuiltinChatCommands } from "../../../../src/auto-reply/commands-registry.shared.js"; import { normalizeLowercaseStringOrEmpty } from "../string-coerce.ts"; @@ -253,7 +255,7 @@ function normalizeSlashIdentifier(raw: string): string | null { function clampText(value: unknown, maxLength: number): string { const text = typeof value === "string" ? value : ""; - return text.length > maxLength ? text.slice(0, maxLength) : text; + return text.length > maxLength ? truncateUtf16Safe(text, maxLength) : text; } function asRecord(value: unknown): Record | null { diff --git a/ui/src/pages/workboard/view.test.ts b/ui/src/pages/workboard/view.test.ts index a5e9af41a12..a464d193cf1 100644 --- a/ui/src/pages/workboard/view.test.ts +++ b/ui/src/pages/workboard/view.test.ts @@ -562,6 +562,55 @@ describe("renderWorkboard", () => { expect(container.textContent).toContain("Needs proof."); }); + it("keeps bounded diagnostic badges UTF-16 safe", () => { + const host = {}; + const state = getWorkboardState(host); + state.loaded = true; + state.cards = [ + { + id: "card-boundary", + title: "Boundary badge", + status: "todo", + priority: "normal", + labels: [], + position: 1000, + createdAt: 1, + updatedAt: 1, + metadata: { + diagnostics: [ + { + kind: "protocol_violation", + severity: "warning", + title: `${"x".repeat(62)}🚀tail`, + detail: "Boundary detail.", + firstSeenAt: 1, + lastSeenAt: 1, + count: 1, + }, + ], + }, + }, + ]; + const container = document.createElement("div"); + + render( + renderWorkboard({ + host, + client: null, + connected: true, + pluginEnabled: true, + agentsList: null, + sessions: [], + onOpenSession: () => undefined, + }), + container, + ); + + expect(container.querySelector(".workboard-card__badge--warning")?.textContent?.trim()).toBe( + `${"x".repeat(62)}…`, + ); + }); + it("renders sub-minute heartbeat ages with the duration count interpolation", () => { vi.useFakeTimers(); vi.setSystemTime(new Date("2026-06-13T12:00:00Z")); diff --git a/ui/src/pages/workboard/view.ts b/ui/src/pages/workboard/view.ts index 830bd2e8620..b98ea61cfed 100644 --- a/ui/src/pages/workboard/view.ts +++ b/ui/src/pages/workboard/view.ts @@ -1,4 +1,6 @@ // Control UI view renders workboard screen content. + +import { truncateUtf16Safe } from "@openclaw/normalization-core/utf16-slice"; import { html, nothing, type TemplateResult } from "lit"; import { ref } from "lit/directives/ref.js"; import type { GatewayBrowserClient } from "../../api/gateway.ts"; @@ -206,7 +208,9 @@ function formatAge(value: number | undefined): string { function truncateBadgeText(value: string, maxLength = 64): string { const trimmed = value.trim(); - return trimmed.length <= maxLength ? trimmed : `${trimmed.slice(0, maxLength - 1)}…`; + return trimmed.length <= maxLength + ? trimmed + : `${truncateUtf16Safe(trimmed, Math.max(0, maxLength - 1))}…`; } function canMutate(props: WorkboardProps): boolean {