mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-01 12:02:05 +00:00
v8 made `richlink` outbound-only; inbound rich links now arrive as plain `text`. Remove the `getBalloonBundleId`/`toRichlinkMessage` branches from the iMessage mapper patch and update the fixture, lockfile, and README accordingly.
255 lines
12 KiB
Python
255 lines
12 KiB
Python
"""Regression tests for Hermes' Spectrum mixed text+attachment workaround."""
|
|
from __future__ import annotations
|
|
|
|
import subprocess
|
|
import textwrap
|
|
from pathlib import Path
|
|
|
|
|
|
_PATCHER = Path("plugins/platforms/photon/sidecar/patch-spectrum-mixed-attachments.mjs")
|
|
|
|
|
|
def test_sidecar_applies_spectrum_patch_before_importing_sdk() -> None:
|
|
"""Existing installs should self-heal at runtime, not only during npm postinstall."""
|
|
index = Path("plugins/platforms/photon/sidecar/index.mjs").read_text(encoding="utf-8")
|
|
assert "import { patchSpectrumTs }" in index
|
|
assert "patchSpectrumTs();" in index
|
|
assert index.index("patchSpectrumTs();") < index.index('await import("spectrum-ts")')
|
|
|
|
|
|
def test_sidecar_healthz_reports_stream_health() -> None:
|
|
"""Local process health must include upstream stream health."""
|
|
index = Path("plugins/platforms/photon/sidecar/index.mjs").read_text(encoding="utf-8")
|
|
assert "function streamHealthSnapshot()" in index
|
|
assert 'return ok(res, { stream: streamHealthSnapshot() });' in index
|
|
assert "STREAM_INTERRUPTED_DEGRADE_COUNT" in index
|
|
assert "process.exit(75);" in index
|
|
|
|
|
|
def test_sidecar_intercepts_both_console_channels() -> None:
|
|
"""spectrum-ts routes its stream telemetry through @photon-ai/otel, which
|
|
sends severity >= ERROR to console.error and WARN/INFO to console.log.
|
|
The two lines the health monitor keys off land on *different* channels:
|
|
`log.error("stream persistently failing")` -> console.error, but
|
|
`log.warn("stream interrupted; reconnecting")` -> console.log. Patching
|
|
only console.error would miss every interrupt burst (the primary silent-
|
|
inbound symptom), so both channels must be intercepted.
|
|
"""
|
|
index = Path("plugins/platforms/photon/sidecar/index.mjs").read_text(encoding="utf-8")
|
|
assert "function classifyStreamLog(" in index
|
|
assert "console.error = (...args) =>" in index
|
|
assert "console.log = (...args) =>" in index
|
|
# Both wrappers must feed the shared classifier.
|
|
assert index.count("classifyStreamLog(text)") >= 2
|
|
|
|
|
|
def test_sidecar_labels_catchup_internal_errors_as_upstream_photon() -> None:
|
|
"""Photon cloud stream failures should not look like local auth problems."""
|
|
index = Path("plugins/platforms/photon/sidecar/index.mjs").read_text(encoding="utf-8")
|
|
assert "function inboundStreamErrorMessage" in index
|
|
assert "EventService/CatchUpEvents" in index
|
|
assert "this is upstream of Hermes" in index
|
|
assert "PHOTON_ALLOWED_USERS" in index
|
|
|
|
|
|
def _tabify(src: str) -> str:
|
|
"""Convert the fixture's two-space indentation to the tab indentation that
|
|
spectrum-ts ships in `@spectrum-ts/imessage/dist`, so the patch anchors
|
|
(which match tabs) apply exactly as they do against a real install."""
|
|
out = []
|
|
for line in src.split("\n"):
|
|
stripped = line.lstrip(" ")
|
|
indent = len(line) - len(stripped)
|
|
out.append("\t" * (indent // 2) + " " * (indent % 2) + stripped)
|
|
return "\n".join(out)
|
|
|
|
|
|
# A faithful, *executable* slice of spectrum-ts 8.x's iMessage inbound mapper:
|
|
# the two functions the patch rewrites (`rebuildFromAppleMessage` for
|
|
# `space.getMessage`, `toInboundMessages` for the live stream), plus stubs of
|
|
# the helpers they close over. Mirrors the published shape — tab-indented (via
|
|
# `_tabify`), `const ... = async` declarations, single-line builder calls — so
|
|
# the anchors exercise the real code path, and exporting the two functions lets
|
|
# the test assert runtime behavior rather than only string shape.
|
|
_SPECTRUM_IMESSAGE_FIXTURE = """
|
|
const formatChildId = (partIndex, parentGuid) => `p:${partIndex}/${parentGuid}`;
|
|
const asText = (text) => ({ type: "text", text });
|
|
const asCustom = (message) => ({ type: "custom" });
|
|
const asProviderGroup = (items) => ({ type: "group", items });
|
|
const messageAttachments = (message) => message.content.attachments ?? [];
|
|
const buildMessageBase = (message, chatGuidHint, timestamp, phone) => ({ direction: "inbound", sender: { id: "s" }, space: { id: "sp", type: "dm", phone }, timestamp });
|
|
const buildAttachmentMessage = async (client, base, info, id, partIndex, parentId) => {
|
|
const msg = { ...base, id, content: { type: "attachment", id: info.guid }, partIndex };
|
|
if (parentId !== void 0) msg.parentId = parentId;
|
|
return msg;
|
|
};
|
|
const cacheMessage = (cache, message) => { cache.set(message.id, message); };
|
|
const rebuildFromAppleMessage = async (client, message, phone, chatGuidHint) => {
|
|
const messageGuidStr = message.guid;
|
|
const base = buildMessageBase(message, chatGuidHint, message.dateCreated ?? /* @__PURE__ */ new Date(), phone);
|
|
const attachments = messageAttachments(message);
|
|
if (attachments.length === 1) {
|
|
const info = attachments[0];
|
|
if (!info) throw new Error("Unreachable: attachments.length === 1 but no element");
|
|
return buildAttachmentMessage(client, base, info, messageGuidStr, 0);
|
|
}
|
|
if (attachments.length > 1) {
|
|
const items = [];
|
|
for (let i = 0; i < attachments.length; i++) {
|
|
const info = attachments[i];
|
|
if (!info) continue;
|
|
items.push(await buildAttachmentMessage(client, base, info, formatChildId(i, messageGuidStr), i, messageGuidStr));
|
|
}
|
|
return {
|
|
...base,
|
|
id: messageGuidStr,
|
|
content: asProviderGroup(items)
|
|
};
|
|
}
|
|
const text = message.content.text;
|
|
return {
|
|
...base,
|
|
id: messageGuidStr,
|
|
content: text ? asText(text) : asCustom(message)
|
|
};
|
|
};
|
|
const toInboundMessages = async (client, cache, event, phone) => {
|
|
const base = buildMessageBase(event.message, event.chatGuid, event.occurredAt, phone);
|
|
const messageGuidStr = event.message.guid;
|
|
const attachments = messageAttachments(event.message);
|
|
if (attachments.length === 1) {
|
|
const info = attachments[0];
|
|
if (!info) throw new Error("Unreachable: attachments.length === 1 but no element");
|
|
const msg = await buildAttachmentMessage(client, base, info, messageGuidStr, 0);
|
|
cacheMessage(cache, msg);
|
|
return [msg];
|
|
}
|
|
if (attachments.length > 1) {
|
|
const items = [];
|
|
for (let i = 0; i < attachments.length; i++) {
|
|
const info = attachments[i];
|
|
if (!info) continue;
|
|
items.push(await buildAttachmentMessage(client, base, info, formatChildId(i, messageGuidStr), i, messageGuidStr));
|
|
}
|
|
const parent = {
|
|
...base,
|
|
id: messageGuidStr,
|
|
content: asProviderGroup(items)
|
|
};
|
|
cacheMessage(cache, parent);
|
|
return [parent];
|
|
}
|
|
const text = event.message.content.text;
|
|
const msg = {
|
|
...base,
|
|
id: messageGuidStr,
|
|
content: text ? asText(text) : asCustom(event.message)
|
|
};
|
|
cacheMessage(cache, msg);
|
|
return [msg];
|
|
};
|
|
export { rebuildFromAppleMessage, toInboundMessages };
|
|
"""
|
|
|
|
|
|
def _write_fixture(tmp_path: Path) -> Path:
|
|
dist = tmp_path / "node_modules" / "@spectrum-ts" / "imessage" / "dist"
|
|
dist.mkdir(parents=True)
|
|
chunk = dist / "index.js"
|
|
chunk.write_text(_tabify(_SPECTRUM_IMESSAGE_FIXTURE), encoding="utf-8")
|
|
return chunk
|
|
|
|
|
|
def test_spectrum_patch_rewrites_the_imessage_mapper(tmp_path: Path) -> None:
|
|
"""The dependency patch must apply to the 8.x `@spectrum-ts/imessage` chunk
|
|
and rewrite both inbound mappers to thread text through attachment bubbles."""
|
|
chunk = _write_fixture(tmp_path)
|
|
|
|
result = subprocess.run(
|
|
["node", str(_PATCHER), str(tmp_path)],
|
|
cwd=Path.cwd(),
|
|
text=True,
|
|
capture_output=True,
|
|
check=False,
|
|
)
|
|
assert result.returncode == 0, result.stderr
|
|
|
|
patched = chunk.read_text(encoding="utf-8")
|
|
assert "Preserve mixed text + attachment iMessage payloads" in patched
|
|
# Single-attachment bubbles wrap the text + attachment in a group...
|
|
assert "content: asProviderGroup([textMsg, msg2])" in patched # rebuild
|
|
assert "content: asProviderGroup([textMsg, msg])" in patched # inbound
|
|
# ...multi-attachment bubbles keep the group and shift attachment indices.
|
|
assert "content: asProviderGroup(items)" in patched
|
|
assert "formatChildId(text2 ? i + 1 : i, messageGuidStr)" in patched
|
|
# The text is captured in both mappers before the attachment branches run.
|
|
assert "const text2 = message.content.text;" in patched
|
|
assert "const text2 = event.message.content.text;" in patched
|
|
|
|
# Re-running is a no-op (idempotent self-heal on every sidecar start).
|
|
again = subprocess.run(
|
|
["node", str(_PATCHER), str(tmp_path)],
|
|
cwd=Path.cwd(),
|
|
text=True,
|
|
capture_output=True,
|
|
check=False,
|
|
)
|
|
assert again.returncode == 0, again.stderr
|
|
assert chunk.read_text(encoding="utf-8") == patched
|
|
|
|
|
|
def test_spectrum_patch_preserves_text_at_runtime(tmp_path: Path) -> None:
|
|
"""Execute the patched mappers and assert mixed bubbles become groups whose
|
|
first child is the typed text, while text-free bubbles keep their exact
|
|
original shape (id/partIndex/parentId) so message identity is unchanged."""
|
|
chunk = _write_fixture(tmp_path)
|
|
patch = subprocess.run(
|
|
["node", str(_PATCHER), str(tmp_path)],
|
|
cwd=Path.cwd(),
|
|
text=True,
|
|
capture_output=True,
|
|
check=False,
|
|
)
|
|
assert patch.returncode == 0, patch.stderr
|
|
|
|
harness = textwrap.dedent(
|
|
f"""
|
|
import {{ rebuildFromAppleMessage, toInboundMessages }} from {str(chunk)!r};
|
|
const assert = (c, m) => {{ if (!c) {{ console.error("FAIL: " + m); process.exit(1); }} }};
|
|
|
|
// Mixed text + single attachment -> group [text@0, attachment@1].
|
|
let r = await rebuildFromAppleMessage(null, {{ guid: "G", content: {{ text: "hello", attachments: [{{ guid: "A0" }}] }} }}, "+1");
|
|
assert(r.content.type === "group" && r.id === "G", "single+text -> group parent id=guid");
|
|
assert(r.content.items.length === 2, "two items");
|
|
assert(r.content.items[0].content.type === "text" && r.content.items[0].content.text === "hello" && r.content.items[0].partIndex === 0 && r.content.items[0].id === "p:0/G", "text child @0");
|
|
assert(r.content.items[1].content.type === "attachment" && r.content.items[1].partIndex === 1 && r.content.items[1].id === "p:1/G" && r.content.items[1].parentId === "G", "attachment child @1");
|
|
|
|
// Single attachment, no text -> unchanged bare attachment.
|
|
r = await rebuildFromAppleMessage(null, {{ guid: "G", content: {{ text: "", attachments: [{{ guid: "A0" }}] }} }}, "+1");
|
|
assert(r.content.type === "attachment" && r.id === "G" && r.partIndex === 0 && r.parentId === undefined, "no-text single attachment unchanged");
|
|
|
|
// Multi attachment + text via the live stream -> group [text@0, att@1, att@2].
|
|
let arr = await toInboundMessages(null, new Map(), {{ message: {{ guid: "G2", content: {{ text: "cap", attachments: [{{ guid: "A0" }}, {{ guid: "A1" }}] }} }} }}, "+1");
|
|
assert(arr.length === 1 && arr[0].content.type === "group", "multi+text -> single group");
|
|
let items = arr[0].content.items;
|
|
assert(items.length === 3 && items[0].content.type === "text" && items[0].partIndex === 0, "text first @0");
|
|
assert(items[1].partIndex === 1 && items[1].id === "p:1/G2" && items[2].partIndex === 2 && items[2].id === "p:2/G2", "attachments shifted to @1,@2");
|
|
|
|
// Multi attachment, no text -> unchanged (attachments at @0,@1).
|
|
arr = await toInboundMessages(null, new Map(), {{ message: {{ guid: "G3", content: {{ attachments: [{{ guid: "A0" }}, {{ guid: "A1" }}] }} }} }}, "+1");
|
|
items = arr[0].content.items;
|
|
assert(items.length === 2 && items[0].partIndex === 0 && items[0].id === "p:0/G3" && items[1].partIndex === 1, "no-text multi unchanged");
|
|
|
|
// Text only, no attachments -> plain text (unchanged).
|
|
r = await rebuildFromAppleMessage(null, {{ guid: "G4", content: {{ text: "just text", attachments: [] }} }}, "+1");
|
|
assert(r.content.type === "text" && r.content.text === "just text" && r.id === "G4", "text-only unchanged");
|
|
"""
|
|
)
|
|
run = subprocess.run(
|
|
["node", "--input-type=module", "-e", harness],
|
|
cwd=Path.cwd(),
|
|
text=True,
|
|
capture_output=True,
|
|
check=False,
|
|
)
|
|
assert run.returncode == 0, run.stderr
|