From d77442228d769ba20d7006bf4befea3a4c2f06d9 Mon Sep 17 00:00:00 2001
From: hhang <17309584214@163.com>
Date: Fri, 6 Nov 2026 14:52:32 +0800
Subject: [PATCH] fix(desktop): align exported XML formatting whitespace
(#9020)
* fix(desktop): align exported XML formatting whitespace
* test(process): align symlink comparison snapshots
* test(boundary): align inherited CLI and MCP fixtures
---------
Co-authored-by: hhang <169027046+xu91102@users.noreply.github.com>
---
docs/desktop-application-state.md | 3 +
src/domain/desktop/appExportPythonXml.ts | 5 +-
.../desktopXmlWhitespacePython.test.ts | 59 +++++++++++++++++++
3 files changed, 66 insertions(+), 1 deletion(-)
create mode 100644 tests/boundary/providers/desktopXmlWhitespacePython.test.ts
diff --git a/docs/desktop-application-state.md b/docs/desktop-application-state.md
index 605d43c728..7ab24697a0 100644
--- a/docs/desktop-application-state.md
+++ b/docs/desktop-application-state.md
@@ -86,6 +86,9 @@ element that holds elements is dropped unless `xml:space="preserve"` applies.
The view keeps up to 5,000 nodes, comments and processing instructions
counted, nested up to 64 levels, and sets `truncated` when it leaves some out.
+Exported XML views use REA's same formatting-whitespace classification, so
+retained XML text and the task checks that address it stay aligned.
+
SQLite databases are read through REA's SQLite worker from a private copy,
with any retained `-wal` or `-journal` file staged beside it; the worker
accepts an inactive rollback journal and refuses a hot one. A version is not
diff --git a/src/domain/desktop/appExportPythonXml.ts b/src/domain/desktop/appExportPythonXml.ts
index fadd5d40d3..30c7571c48 100644
--- a/src/domain/desktop/appExportPythonXml.ts
+++ b/src/domain/desktop/appExportPythonXml.ts
@@ -15,6 +15,9 @@ class _XmlReader:
"""REA's XML view built as expat reads: names as spelled, declarations as attributes, text split where REA's DOM
splits it (an empty CDATA section joins the text around it), and each node counted against REA's limits."""
+ #: Match REA String.trim(); default strip also drops U+0085 and keeps U+FEFF.
+ FORMATTING_SPACE = "\t\n\v\f\r \u00a0\u1680\u2000\u2001\u2002\u2003\u2004\u2005\u2006\u2007\u2008\u2009\u200a\u2028\u2029\u202f\u205f\u3000\ufeff"
+
def __init__(self) -> None:
self.remaining, self.truncated, self.root = MAX_XML_NODES, False, None
#: Per open element: its view or None when left out, whether xml:space="preserve" applies inside it, and
@@ -83,7 +86,7 @@ class _XmlReader:
self._flush()
view, preserve, holds_element = self.open.pop()
if view is not None and holds_element and not preserve:
- view["children"] = [child for child in view["children"] if not isinstance(child, str) or child.strip()]
+ view["children"] = [child for child in view["children"] if not isinstance(child, str) or child.strip(self.FORMATTING_SPACE)]
if not self.open:
self.root = view
diff --git a/tests/boundary/providers/desktopXmlWhitespacePython.test.ts b/tests/boundary/providers/desktopXmlWhitespacePython.test.ts
new file mode 100644
index 0000000000..6ea9560acd
--- /dev/null
+++ b/tests/boundary/providers/desktopXmlWhitespacePython.test.ts
@@ -0,0 +1,59 @@
+import { execFileSync } from "node:child_process";
+import { expect, it } from "vitest";
+import {
+ PYTHON_HELPERS,
+ PYTHON_IMPORTS,
+} from "../../../src/domain/desktop/appExportPython.js";
+import { xmlView } from "../../../src/domain/desktop/stateFormats.js";
+
+/** Execute the unmodified generated XML reader with its standard-library imports. */
+const pythonViews = (texts: readonly string[]) => {
+ const imports = PYTHON_IMPORTS.split("\n")
+ .filter((line) => !line.includes("agentenv_protocol"))
+ .join("\n");
+ return JSON.parse(
+ execFileSync(
+ "python3",
+ [
+ "-I",
+ "-B",
+ "-c",
+ `${imports}${PYTHON_HELPERS}\nprint(json.dumps([_xml_view(text) for text in json.load(sys.stdin)]))`,
+ ],
+ { input: JSON.stringify(texts), encoding: "utf8" },
+ ),
+ );
+};
+
+it.each(["\u0085", "\ufeff"])(
+ "matches REA's formatting decision for %j between elements",
+ (text) => {
+ const xml = `${text}`;
+ expect(pythonViews([xml])).toEqual([xmlView(xml)]);
+ },
+);
+
+it("matches the ECMAScript whitespace set for XML-valid characters and preserves other text", () => {
+ const characters =
+ "\t\n\r \u00a0\u1680\u2000\u2001\u2002\u2003\u2004\u2005\u2006\u2007\u2008\u2009\u200a\u2028\u2029\u202f\u205f\u3000\ufeff\u0085\u180e\u200b";
+ const texts = [...characters].map((text) => `${text}`);
+ expect(pythonViews(texts)).toEqual(texts.map(xmlView));
+});
+
+it("keeps leaf text and xml:space-preserved text, with the original parsing refusals", () => {
+ const texts = [
+ "\ufeff",
+ "\u0085",
+ '\ufeff',
+ '\u0085',
+ ];
+ const actual = pythonViews(texts) as { root: { children: unknown[] } }[];
+ const expected = texts.map(xmlView) as { root: { children: unknown[] } }[];
+ expect(actual.map((view) => view.root.children)).toEqual(
+ expected.map((view) => view.root.children),
+ );
+ expect(pythonViews(["", ""])).toEqual([
+ null,
+ null,
+ ]);
+});