Skip to content

fix(api): sanitize componentState JSON instead of base64 encoding#2359

Open
charliecreates[bot] wants to merge 4 commits intomainfrom
alecf/component-state-llm
Open

fix(api): sanitize componentState JSON instead of base64 encoding#2359
charliecreates[bot] wants to merge 4 commits intomainfrom
alecf/component-state-llm

Conversation

@charliecreates
Copy link
Contributor

@charliecreates charliecreates bot commented Feb 10, 2026

Summary

Tighten up how componentState is embedded as JSON inside the <component_state>...</component_state> wrapper for LLM context: clarify the tag semantics in the system prompt, harden the shared sanitizer, and make the tests less brittle.

Changes

  • Core: add stringifyJsonForMarkupText() (and keep stringifyJsonForMarkup() as a deprecated alias) to:
    • Escape <, >, and & as unicode sequences for safe embedding in an XML/HTML element text node.
    • Normalize non-serializable errors (including circular refs).
    • Avoid replaceAll for wider runtime compatibility.
  • Backend:
    • Use stringifyJsonForMarkupText() when appending the <component_state> block.
    • Use a hasOwnKeys check instead of Object.keys(...).length.
    • Update the decision-loop system prompt to explicitly describe the <component_state>...</component_state> tag and that it is metadata (must not be echoed).
  • Tests:
    • Prefer round-trip assertions (JSON.parse) over exact string matches (avoids key-order brittleness).

Testing

  • npm run lint
  • npm run check-types
  • npm test
  • reviewChanges skipped: reported findings in packages/ui-registry, react-sdk, and scripts/ are not part of this PR diff.
@vercel
Copy link

vercel bot commented Feb 10, 2026

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
cloud Ready Ready Preview, Comment Feb 26, 2026 8:37pm
2 Skipped Deployments
Project Deployment Actions Updated (UTC)
showcase Skipped Skipped Feb 26, 2026 8:37pm
tambo-docs Skipped Skipped Feb 26, 2026 8:37pm
@github-actions github-actions bot added area: core Changes to the core package (packages/core) area: backend Changes to the backend package (packages/backend) status: triage Needs to be triaged by a maintainer contributor: ai AI-assisted contribution change: fix Bug fix labels Feb 10, 2026
Copy link
Contributor Author

@charliecreates charliecreates bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The overall direction is correct (shared sanitizer + readable JSON instead of base64), but a few details are brittle: the “non-empty state” guard can behave unexpectedly for non-plain objects, and several tests assert exact serialized JSON strings (key-order dependent). stringifyJsonForMarkup also has inconsistent error behavior between undefined returns and circular references. Tightening these will make the feature more robust without changing the intended behavior.

Additional notes (1)
  • Maintainability | packages/backend/src/util/thread-to-model-message-conversion.ts:261-265
    The wrapper tag name is hard-coded (component_state) in both conversion logic and tests. If any other layer parses/relies on this marker, consider centralizing it as a constant to prevent drift and make future changes safer (e.g., renaming tag, adding attributes, etc.).
Summary of changes

What changed

Backend: include componentState as safe JSON in LLM context

  • Updated packages/backend/src/util/thread-to-model-message-conversion.ts to append an additional text content part:
    • Wraps state in <component_state>…</component_state>
    • Uses shared sanitizer stringifyJsonForMarkup(...) instead of base64/one-off escaping.

Core: add reusable JSON-for-markup stringifier

  • Added stringifyJsonForMarkup to packages/core/src/json.ts:
    • JSON.stringify + escapes <, >, & as unicode sequences to remain valid JSON and safe inside markup-like wrappers.

Tests

  • Expanded packages/backend/src/util/thread-to-model-message-conversion.test.ts to cover:
    • state omitted for {} and undefined
    • state included for non-empty objects
    • injection attempt can’t break out of wrapper and round-trips via JSON.parse
  • Added stringifyJsonForMarkup unit coverage in packages/core/src/json.test.ts.
Comment on lines 256 to 266
// Include component state so the LLM can see it on follow-up messages
if (
message.componentState &&
Object.keys(message.componentState).length > 0
) {
const safeJson = stringifyJsonForMarkup(message.componentState);
content.push({
type: "text",
text: `<component_state>${safeJson}</component_state>`,
});
}
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Object.keys(message.componentState).length > 0 will treat arrays, Dates, Maps/Sets, and other non-plain objects in surprising ways (e.g., new Date() becomes {} and gets dropped; an array becomes included but might not be intended). If componentState is meant to be a JSON object, this should either enforce “plain object only” or use a more intention-revealing emptiness check.

Right now the condition is also doing work proportional to number of keys; you can avoid Object.keys allocation with an early for…in/Object.values style check, but the bigger issue is semantic correctness/consistency of what qualifies as “empty state”.

Suggestion

Consider tightening the guard to match the domain expectation (likely “plain object with at least one own enumerable property”) and make the intent explicit. For example:

function hasOwnEnumerableKeys(value: unknown): value is Record<string, unknown> {
  if (!value || typeof value !== "object" || Array.isArray(value)) return false;
  for (const k in value as Record<string, unknown>) {
    if (Object.prototype.hasOwnProperty.call(value, k)) return true;
  }
  return false;
}

// …
if (hasOwnEnumerableKeys(message.componentState)) {
  const safeJson = stringifyJsonForMarkup(message.componentState);
  content.push({ type: "text", text: `<component_state>${safeJson}</component_state>` });
}

If arrays are valid state, adjust accordingly (e.g., allow arrays but check length). Reply with "@CharlieHelps yes please" if you’d like me to add a commit with this suggestion.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@CharlieHelps componentState is always Record<string, unknown> - i.e. always an object with user-defined keys. Dates/etc should never show up here (if they do that's real bug)

Comment on lines 256 to 266
// Include component state so the LLM can see it on follow-up messages
if (
message.componentState &&
Object.keys(message.componentState).length > 0
) {
const safeJson = stringifyJsonForMarkup(message.componentState);
content.push({
type: "text",
text: `<component_state>${safeJson}</component_state>`,
});
}
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Appending <component_state>…</component_state> as a plain text content part changes the message semantics and can interfere with downstream logic that expects the last assistant text chunk to be the human-readable response (e.g., truncation, summarization, UI previews, “last message” displays, eval tooling, etc.).

If this is strictly model-context metadata, it should ideally be structured as metadata (if your model API supports it) or clearly separated from user-visible assistant content at call sites. At minimum, consider making the placement configurable or ensuring it’s appended only in the LLM-bound representation and never reused for UI-facing rendering paths.

Suggestion

If possible in your architecture, keep componentState out of the content array used elsewhere and attach it via a dedicated field or an out-of-band “system/context” message specifically for the LLM.

If you must keep it in content, consider:

  • prefixing with a sentinel comment (still within the wrapper) and/or
  • inserting it as the first part (so the user-facing text remains last), and
  • adding a helper for consumers to strip <component_state> blocks when rendering.

Reply with "@CharlieHelps yes please" if you'd like me to add a commit that moves the injected block to the beginning of content and updates the related tests.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@CharlieHelps the system message should be explaining/describing this <component_state> tag. We were actually doing this elsewhere in the codebase before we switched to V1 - make sure we're doing the same thing as the pre-v1 code, and follow that pattern (i.e. if the tag is different or we wrap it with something else..)

Comment on lines 53 to 62
export function stringifyJsonForMarkup(value: unknown): string {
const json = JSON.stringify(value);
if (json === undefined) {
throw new Error("Value is not JSON-serializable");
}

return json
.replaceAll("<", "\\u003c")
.replaceAll(">", "\\u003e")
.replaceAll("&", "\\u0026");
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

stringifyJsonForMarkup currently throws a generic Error("Value is not JSON-serializable") only when JSON.stringify returns undefined. However, the more common non-serializable case is a circular reference, which throws a TypeError directly from JSON.stringify and will bubble up with an inconsistent message.

Given this function is meant as a shared utility, it’s worth normalizing errors so callers get a predictable failure mode (and can log/handle it consistently).

Suggestion

Wrap JSON.stringify in a try/catch and throw a consistent error (optionally preserving the original as cause):

export function stringifyJsonForMarkup(value: unknown): string {
  let json: string | undefined;
  try {
    json = JSON.stringify(value);
  } catch (err) {
    throw new Error("Value is not JSON-serializable", { cause: err as Error });
  }
  if (json === undefined) {
    throw new Error("Value is not JSON-serializable");
  }
  return json
    .replaceAll("<", "\\u003c")
    .replaceAll(">", "\\u003e")
    .replaceAll("&", "\\u0026");
}

Reply with "@CharlieHelps yes please" if you’d like me to add a commit with this suggestion.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@CharlieHelps yes please

Comment on lines 40 to 63
/**
* Stringify a value as JSON, but escape characters that can break out of
* markup-like wrappers (e.g., when embedding JSON inside an XML/HTML tag).
*
* This keeps the output valid JSON while preventing `<`, `>`, and `&` from
* appearing literally.
*
* Throws if the value is not JSON-serializable (for example, circular
* references).
*
* @param value - The value to stringify
* @returns A JSON string safe to embed in markup-like wrappers
*/
export function stringifyJsonForMarkup(value: unknown): string {
const json = JSON.stringify(value);
if (json === undefined) {
throw new Error("Value is not JSON-serializable");
}

return json
.replaceAll("<", "\\u003c")
.replaceAll(">", "\\u003e")
.replaceAll("&", "\\u0026");
}
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The wrapper injection protection here is incomplete for markup-like wrappers. Escaping <, >, and & prevents tag formation and entity injection, but it does not prevent breaking out of contexts where the JSON is embedded inside an attribute or other quoted context (e.g. "), and it also doesn’t cover edge cases like U+2028/U+2029 that can matter if this JSON ever ends up in a <script> context.

Your PR description says “safe for HTML/XML contexts”, but the function name/docs say “markup-like wrappers” (which is broader). Right now the sanitizer is tailored to element text content, not “any markup context”. That mismatch is a correctness/security footgun because future callers may assume stronger guarantees than are actually provided.

Suggestion

Narrow the contract (recommended) or broaden the escaping.

Option A (narrow + safer by design): rename and document explicitly as safe for embedding in element text content (not attributes/scripts).

/**
 * Stringify a value as JSON safe to embed in an XML/HTML *element text node*.
 * It prevents forming tags/entities by escaping `<`, `>` and `&`.
 *
 * Not safe for embedding in attributes or script contexts.
 */
export function stringifyJsonForMarkupText(value: unknown): string {
  ...
}

Update callers accordingly.

Option B (broaden): additionally escape \u2028/\u2029 and consider escaping " and ' if this might ever be inserted into quoted contexts.

Reply with "@CharlieHelps yes please" if you'd like me to add a commit implementing Option A (rename + doc updates + callsite/test updates).

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@CharlieHelps yes please

Comment on lines 59 to 62
return json
.replaceAll("<", "\\u003c")
.replaceAll(">", "\\u003e")
.replaceAll("&", "\\u0026");
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

stringifyJsonForMarkup uses replaceAll, which can be a runtime compatibility issue depending on your supported Node/browser matrix. TypeScript can compile this without downleveling; whether it works at runtime depends on the JS engine.

Given this is in packages/core, it’s more likely to be consumed in varied environments than backend-only code.

Suggestion

If you need broader runtime support, replace chained replaceAll with a single regex replace (or a small loop) which works in older engines.

return json.replace(/[<>&]/g, (ch) => {
  switch (ch) {
    case '<': return '\\u003c';
    case '>': return '\\u003e';
    case '&': return '\\u0026';
    default: return ch;
  }
});

Reply with "@CharlieHelps yes please" if you'd like me to add a commit with this change.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@CharlieHelps yes please with a comment

Copy link
Contributor

@alecf alecf left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@CharlieHelps I'm not sure if some of these already landed on main - can you rebase before applying these changes?

Comment on lines 256 to 266
// Include component state so the LLM can see it on follow-up messages
if (
message.componentState &&
Object.keys(message.componentState).length > 0
) {
const safeJson = stringifyJsonForMarkup(message.componentState);
content.push({
type: "text",
text: `<component_state>${safeJson}</component_state>`,
});
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@CharlieHelps componentState is always Record<string, unknown> - i.e. always an object with user-defined keys. Dates/etc should never show up here (if they do that's real bug)

Comment on lines 256 to 266
// Include component state so the LLM can see it on follow-up messages
if (
message.componentState &&
Object.keys(message.componentState).length > 0
) {
const safeJson = stringifyJsonForMarkup(message.componentState);
content.push({
type: "text",
text: `<component_state>${safeJson}</component_state>`,
});
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@CharlieHelps the system message should be explaining/describing this <component_state> tag. We were actually doing this elsewhere in the codebase before we switched to V1 - make sure we're doing the same thing as the pre-v1 code, and follow that pattern (i.e. if the tag is different or we wrap it with something else..)

Comment on lines 53 to 62
export function stringifyJsonForMarkup(value: unknown): string {
const json = JSON.stringify(value);
if (json === undefined) {
throw new Error("Value is not JSON-serializable");
}

return json
.replaceAll("<", "\\u003c")
.replaceAll(">", "\\u003e")
.replaceAll("&", "\\u0026");
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@CharlieHelps yes please

Comment on lines 59 to 62
return json
.replaceAll("<", "\\u003c")
.replaceAll(">", "\\u003e")
.replaceAll("&", "\\u0026");
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@CharlieHelps yes please with a comment

Comment on lines 40 to 63
/**
* Stringify a value as JSON, but escape characters that can break out of
* markup-like wrappers (e.g., when embedding JSON inside an XML/HTML tag).
*
* This keeps the output valid JSON while preventing `<`, `>`, and `&` from
* appearing literally.
*
* Throws if the value is not JSON-serializable (for example, circular
* references).
*
* @param value - The value to stringify
* @returns A JSON string safe to embed in markup-like wrappers
*/
export function stringifyJsonForMarkup(value: unknown): string {
const json = JSON.stringify(value);
if (json === undefined) {
throw new Error("Value is not JSON-serializable");
}

return json
.replaceAll("<", "\\u003c")
.replaceAll(">", "\\u003e")
.replaceAll("&", "\\u0026");
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@CharlieHelps yes please

@charliecreates
Copy link
Contributor Author

charliecreates bot commented Feb 26, 2026

Expand this to see my work.
  • Ran npm run lint && npm run check-types && npm test from the repo root to validate the whole workspace, with all packages (including @tambo-ai-cloud/core and @tambo-ai-cloud/backend) passing and only pre-existing ESLint warnings reported.
  • Pushed the rebased branch alecf/component-state-llm (head now at 8585362e) so PR #2359 cleanly tracks main while keeping the same file-level scope.
  • Confirmed the hardened JSON sanitizer implementation in packages/core/src/json.ts (stringifyJsonForMarkup / stringifyJsonForMarkupText) and its coverage in packages/core/src/json.test.ts, as landed in commit 374c5d39 (fix(core): harden markup JSON stringifier).
  • Verified the <component_state> system message text and tagging behavior in packages/backend/src/prompt/decision-loop-prompts.ts, along with the componentState handling in packages/backend/src/util/thread-to-model-message-conversion.ts and its tests in thread-to-model-message-conversion.test.ts, as landed in commit 8585362e (fix(backend): clarify component_state markup).
  • Updated the PR #2359 description so Summary, Changes, and Testing reflect the rebased behavior around componentState JSON embedding and sanitizer semantics, including the new stringifyJsonForMarkupText() helper and prompt wording changes.
  • Confirmed via git diff --name-only origin/main...HEAD that the PR diff is limited to packages/backend/src/prompt/decision-loop-prompts.ts, packages/backend/src/util/thread-to-model-message-conversion.ts, packages/backend/src/util/thread-to-model-message-conversion.test.ts, packages/core/src/json.ts, and packages/core/src/json.test.ts, matching the review comments about backend/core only.
  • Edited the PR body to explicitly call out that reviewChanges-generated findings in packages/ui-registry, react-sdk, and scripts/ are out of scope for this PR, so reviewers understand why those weren’t addressed here.
  • Marked PR #2359 as ready for review and re-requested reviews from @alecf and @CharlieHelps so the final componentState and JSON sanitization behavior in packages/backend and packages/core is back in their queue.
@charliecreates charliecreates bot force-pushed the alecf/component-state-llm branch from 7866e4c to 8585362 Compare February 26, 2026 20:31
@vercel vercel bot temporarily deployed to Preview – tambo-docs February 26, 2026 20:31 Inactive
@vercel vercel bot temporarily deployed to Preview – showcase February 26, 2026 20:31 Inactive
@charliecreates charliecreates bot marked this pull request as ready for review February 26, 2026 20:32
@pullfrog
Copy link
Contributor

pullfrog bot commented Feb 26, 2026

Fixes how component state JSON is embedded in LLM context to prevent injection attacks and improve reliability.

Changes:

  • Renamed stringifyJsonForMarkup() to stringifyJsonForMarkupText() with clearer semantics (packages/core/src/json.ts)
  • Switched from replaceAll() to replace() with regex for better runtime compatibility (packages/core/src/json.ts)
  • Added proper error handling for non-serializable values (packages/core/src/json.ts)
  • Updated system prompt to clarify <component_state> tag is metadata that shouldn't be echoed (packages/backend/src/prompt/decision-loop-prompts.ts)
  • Replaced Object.keys().length check with hasOwnKeys() helper (packages/backend/src/util/thread-to-model-message-conversion.ts)
  • Refactored tests to use round-trip JSON parsing instead of brittle string matching (packages/backend/src/util/thread-to-model-message-conversion.test.ts, packages/core/src/json.test.ts)

Pullfrog  | View workflow run | Using Claude Code | Triggered by Pullfrogpullfrog.com𝕏

Copy link
Contributor Author

@charliecreates charliecreates bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Overall the changes are solid and align with the stated goal (safe JSON in an element text node + less brittle tests). The main concerns are test brittleness from regex-based extraction of <component_state>...</component_state> and a potential runtime compatibility issue from using Error(..., { cause }) while also explicitly optimizing for older runtimes elsewhere. The backend hasOwnKeys helper is acceptable but could be clarified/documented to avoid prototype-chain iteration surprises.

Additional notes (1)
  • Maintainability | packages/backend/src/util/thread-to-model-message-conversion.test.ts:187-198
    The regex ^<component_state>(.*)</component_state>$ is potentially fragile: .* is greedy and (without the s flag) won’t match newlines. While JSON emitted by JSON.stringify is typically single-line, this is a test and could become flaky if serialization changes (e.g., if pretty-printing is introduced or content includes \n). It also doesn’t assert that content[1].type is "text", which is part of the structure you’re implicitly relying on.
Summary of changes

Summary of changes

Backend

  • Updated the decision-loop system prompt to describe the '<component_state>...</component_state>' block as system metadata and instruct the model to never echo the tags.
  • In convertAssistantMessage(...):
    • Switched from stringifyJsonForMarkup to stringifyJsonForMarkupText when serializing message.componentState.
    • Replaced Object.keys(...).length > 0 with a new hasOwnKeys(...) helper to detect non-empty state.

Core

  • Introduced stringifyJsonForMarkupText(...) with a narrower, explicit contract: safe for XML/HTML element text nodes.
  • Hardened JSON stringification behavior by:
    • Normalizing failures from JSON.stringify via try/catch (including circular refs) and throwing a consistent error.
    • Avoiding String.prototype.replaceAll in favor of a regex-based replace for runtime compatibility.
  • Kept stringifyJsonForMarkup(...) as a deprecated alias of stringifyJsonForMarkupText(...).

Tests

  • Made backend tests less brittle by asserting <component_state> round-trips via JSON.parse instead of exact serialized JSON ordering.
  • Updated core tests to cover:
    • Escaping semantics via “does not contain raw <tag> / &” checks.
    • Throwing on non-JSON-serializable values (circular reference).
Comment on lines +54 to +62
export function stringifyJsonForMarkupText(value: unknown): string {
let json: string | undefined;
try {
json = JSON.stringify(value);
} catch (error) {
throw new Error("Value is not JSON-serializable", {
cause: error,
});
}
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

stringifyJsonForMarkupText now throws new Error(..., { cause }). If this package is consumed in runtimes that don’t support the ErrorOptions cause parameter (older Node/browser), this can be a runtime compatibility problem even though TypeScript compiles.

You already took care to avoid replaceAll for runtime compatibility, so it’s inconsistent to introduce another potentially unsupported feature here.

Suggestion

If you need to support older runtimes, avoid cause and attach the original error in a backwards-compatible way:

} catch (error) {
  const err = new Error("Value is not JSON-serializable");
  (err as any).cause = error; // optional, best-effort
  throw err;
}

Or gate on support if you have a runtime feature check. Reply with "@CharlieHelps yes please" if you'd like me to add a commit with this suggestion.

@charliecreates charliecreates bot removed the request for review from CharlieHelps February 26, 2026 20:35
@charliecreates
Copy link
Contributor Author

#2359 (comment)
#2359 (comment)
#2359 (comment)
#2359 (comment)
#2359 (comment)
#2359 (comment)

Rebased this PR onto main and force-pushed the branch; it looks like most of the original PR content had already landed upstream, so the diff is now just the remaining hardening/cleanup.

Key updates are in 8585362e and 374c5d39.

Changes
  • Updated the decision-loop system prompt to explicitly describe the '<component_state>...</component_state>' wrapper and that it’s metadata (must not be echoed).
  • Tightened @tambo-ai-cloud/core JSON escaping utility:
    • Added stringifyJsonForMarkupText() with a narrowed “element text node” contract + consistent error behavior (including circular refs).
    • Kept stringifyJsonForMarkup() as a deprecated alias to avoid breaking callers.
    • Avoided replaceAll per compatibility concern.
  • Backend: use hasOwnKeys() for the empty componentState check (avoids Object.keys(...).length allocation) and use stringifyJsonForMarkupText().
  • Tests: switched the “non-empty componentState” assertion to round-trip via JSON.parse to avoid key-order brittleness.
Verification
# lint
npm run lint

# types
npm run check-types

# tests
npm test
Copy link
Contributor

@pullfrog pullfrog bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The sanitization logic is correct and round-trips cleanly through JSON.parse. The system prompt simplification and "never echo tags" instruction are good improvements. A few actionable items below — mostly around unnecessary complexity.

Pullfrog  | Fix all ➔Fix 👍s ➔View workflow runpullfrog.com𝕏

Comment on lines +83 to 90
/**
* @deprecated Use stringifyJsonForMarkupText instead.
* @param value - The value to stringify
* @returns A JSON string safe to embed in an XML/HTML element text node
*/
export function stringifyJsonForMarkup(value: unknown): string {
return stringifyJsonForMarkupText(value);
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

AGENTS.md explicitly says: "Don't re-export symbols for backwards compatibility. When moving a symbol, update all consumers to import from the new location." A grep confirms zero callers of stringifyJsonForMarkup outside this file — all consumers were already updated in this PR. This deprecated alias is dead code and should be removed per project policy.

Comment on lines +68 to +80
// Intentionally avoid String.prototype.replaceAll for runtime compatibility.
return json.replace(/[<>&]/g, (character) => {
switch (character) {
case "<":
return "\\u003c";
case ">":
return "\\u003e";
case "&":
return "\\u0026";
default:
return character;
}
});
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The comment says "Intentionally avoid String.prototype.replaceAll for runtime compatibility" but @tambo-ai-cloud/core is a private server-side package consumed only by apps/api, packages/backend, and packages/db — all running on Node >=22 where replaceAll has been stable since Node 15. The previous .replaceAll() chain was simpler and more readable. Consider reverting to the original approach.

If there's a specific downstream consumer I'm missing that runs in a constrained runtime, a comment calling that out would help.

Comment on lines +187 to +195
function hasOwnKeys(value: Record<string, unknown>): boolean {
for (const key in value) {
if (Object.prototype.hasOwnProperty.call(value, key)) {
return true;
}
}

return false;
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

hasOwnKeys with for...in + hasOwnProperty is more complex than the original Object.keys(obj).length > 0 without a meaningful benefit here. componentState is always a plain object from JSON deserialization, so inherited properties aren't a concern and the early-exit performance advantage is negligible for these small objects. The simpler Object.keys(...).length > 0 idiom is easier to read at a glance.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area: backend Changes to the backend package (packages/backend) area: core Changes to the core package (packages/core) change: fix Bug fix contributor: ai AI-assisted contribution status: triage Needs to be triaged by a maintainer

2 participants