mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-29 18:46:59 +00:00
feat(photon): render multiple-choice clarify as a native iMessage poll
The `clarify` tool's multiple-choice prompts flattened to a numbered text
list on Photon/iMessage, even though iMessage has a native poll bubble and
spectrum-ts already exposes it via the `poll()` content builder. Two gaps
caused the flattening:
* Outbound: the sidecar only had `/send` (text); there was no way to send
a poll, so the base adapter's numbered-text fallback was used.
* Inbound: `normalizeContent()` handled only text/attachment/voice, so a
poll vote (`poll_option`) was dropped on the floor ("[Photon content
type not handled: poll_option]") and never resolved the clarify.
Fix, end to end:
* Sidecar: import `poll` from spectrum-ts; add a `/send-poll` route
(`space.send(poll(title, ...options))`); serialize inbound `poll_option`
(the vote: chosen title + selected bool) and `poll` content in
`normalizeContent()`.
* Adapter: override `send_clarify` — for choices, send a native poll via
`_sidecar_send_poll` and call `mark_awaiting_text` so the gateway's
existing pending-clarify text-intercept resolves the answer; open-ended
clarifies keep the plain-text path. Inbound `poll_option` selections are
dispatched as a plain-text MessageEvent carrying the chosen option
(deselections / empty votes are dropped). If the poll send fails (an
older sidecar without `/send-poll`, or a send error) it falls back to the
numbered-text clarify, so nothing regresses on a half-upgraded restart.
No new model tool, no new env var, no core change — the capability lives at
the platform edge. The poll vote reuses the existing clarify text-intercept
resolution path, so no new gateway resolution mechanism is introduced.
Tests: tests/plugins/platforms/photon/test_poll_clarify.py — inbound vote ->
choice text, deselection/empty-vote dropped, send_clarify sends a poll +
enables text-capture, open-ended stays text, and poll-failure falls back to
the text list. Full photon suite green.
Contributed by Vaibhav Sharma (X: @vabbyshabby).
This commit is contained in:
parent
06150f70af
commit
fe95194c59
4 changed files with 379 additions and 0 deletions
2
contributors/emails/vaibhavs362@gmail.com
Normal file
2
contributors/emails/vaibhavs362@gmail.com
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
vaibhavjnf
|
||||
# PR #48194 salvage
|
||||
|
|
@ -1045,6 +1045,37 @@ class PhotonAdapter(BasePlatformAdapter):
|
|||
# caller doesn't pass an explicit message id. Recorded before the
|
||||
# mention gate: a reaction to a non-wake-word group message is valid.
|
||||
self._record_last_inbound(space_id, event.get("messageId"))
|
||||
if ctype == "poll_option":
|
||||
# A native poll vote. A *selection* carries the chosen option text
|
||||
# straight to the agent as if the user had typed it — the gateway's
|
||||
# pending-clarify text-intercept then resolves the open clarify and
|
||||
# unblocks the agent. A *deselection* (selected=false) is dropped:
|
||||
# there's no answer to record, and forwarding "" would mis-resolve.
|
||||
if content.get("selected") is False:
|
||||
logger.debug("[photon] ignoring poll deselection")
|
||||
return
|
||||
choice = (content.get("title") or "").strip()
|
||||
if not choice:
|
||||
logger.debug("[photon] ignoring poll vote with empty title")
|
||||
return
|
||||
source = self.build_source(
|
||||
chat_id=space_id,
|
||||
chat_name=space_id,
|
||||
chat_type=chat_type,
|
||||
user_id=sender_id,
|
||||
user_name=sender_id or None,
|
||||
)
|
||||
await self.handle_message(
|
||||
MessageEvent(
|
||||
text=choice,
|
||||
message_type=MessageType.TEXT,
|
||||
source=source,
|
||||
message_id=event.get("messageId"),
|
||||
raw_message=event,
|
||||
timestamp=timestamp,
|
||||
)
|
||||
)
|
||||
return
|
||||
if ctype == "text":
|
||||
text = content.get("text") or ""
|
||||
mtype = MessageType.TEXT
|
||||
|
|
@ -1454,6 +1485,52 @@ class PhotonAdapter(BasePlatformAdapter):
|
|||
) -> SendResult:
|
||||
return await self._sidecar_send(chat_id, self.format_message(content))
|
||||
|
||||
# -- Clarify (native iMessage poll) ------------------------------------
|
||||
#
|
||||
# iMessage has a native poll bubble; spectrum-ts exposes it via the
|
||||
# `poll()` content builder. A multiple-choice clarify renders as that poll
|
||||
# and the user taps a choice instead of typing a number — the vote streams
|
||||
# back inbound as a `poll_option` event, which `_dispatch_inbound`
|
||||
# translates into a plain-text message carrying the chosen option. We flip
|
||||
# the clarify into text-capture mode (exactly like the base text fallback)
|
||||
# so the gateway's pending-clarify intercept resolves it with that choice.
|
||||
# Open-ended clarifies (no choices) keep the plain-text path.
|
||||
|
||||
async def send_clarify(
|
||||
self,
|
||||
chat_id: str,
|
||||
question: str,
|
||||
choices: Optional[list],
|
||||
clarify_id: str,
|
||||
session_key: str,
|
||||
metadata: Optional[Dict[str, Any]] = None,
|
||||
) -> SendResult:
|
||||
if not choices:
|
||||
# No choices → open-ended. Base behaviour (plain text; the next
|
||||
# message resolves it) is exactly right.
|
||||
return await super().send_clarify(
|
||||
chat_id, question, choices, clarify_id, session_key, metadata
|
||||
)
|
||||
# The poll vote comes back as a normal text message, so enable
|
||||
# text-capture and let the gateway intercept resolve the clarify.
|
||||
from tools.clarify_gateway import mark_awaiting_text
|
||||
|
||||
mark_awaiting_text(clarify_id)
|
||||
result = await self._sidecar_send_poll(chat_id, question, list(choices))
|
||||
if not result.success:
|
||||
# Native poll failed (old sidecar without /send-poll, or a send
|
||||
# error) — fall back to the numbered-text clarify so the user can
|
||||
# still answer. The base impl also calls mark_awaiting_text (a
|
||||
# second call is harmless).
|
||||
logger.warning(
|
||||
"[photon] poll clarify failed (%s); falling back to text list",
|
||||
result.error,
|
||||
)
|
||||
return await super().send_clarify(
|
||||
chat_id, question, choices, clarify_id, session_key, metadata
|
||||
)
|
||||
return result
|
||||
|
||||
# -- Outbound media (parity with the BlueBubbles iMessage channel) -----
|
||||
#
|
||||
# Photon ships outbound attachments via spectrum-ts' `attachment()` /
|
||||
|
|
@ -1933,6 +2010,32 @@ class PhotonAdapter(BasePlatformAdapter):
|
|||
self._record_sent_message(data.get("messageId"))
|
||||
return SendResult(success=True, message_id=data.get("messageId"))
|
||||
|
||||
async def _sidecar_send_poll(
|
||||
self, space_id: str, title: str, options: list,
|
||||
) -> SendResult:
|
||||
"""POST a poll to the sidecar's ``/send-poll`` endpoint.
|
||||
|
||||
Renders a native iMessage poll. ``options`` are choice strings; the
|
||||
sidecar's ``poll()`` builder degrades to a numbered text list on
|
||||
platforms without native polls.
|
||||
"""
|
||||
opts = [str(o).strip() for o in (options or []) if str(o).strip()]
|
||||
if not title or not title.strip():
|
||||
return SendResult(success=False, error="poll title is required")
|
||||
if not opts:
|
||||
return SendResult(success=False, error="poll needs at least one option")
|
||||
body: Dict[str, Any] = {
|
||||
"spaceId": space_id,
|
||||
"title": title.strip()[: self.MAX_MESSAGE_LENGTH],
|
||||
"options": opts,
|
||||
}
|
||||
try:
|
||||
data = await self._sidecar_call("/send-poll", body)
|
||||
except Exception as e:
|
||||
return SendResult(success=False, error=str(e))
|
||||
self._record_sent_message(data.get("messageId"))
|
||||
return SendResult(success=True, message_id=data.get("messageId"))
|
||||
|
||||
async def _sidecar_send_attachment(
|
||||
self,
|
||||
space_id: str,
|
||||
|
|
|
|||
|
|
@ -37,6 +37,10 @@
|
|||
// body: {"spaceId": "...", "text": "...", "effect": "confetti" | ...}
|
||||
// - POST /typing -> {"ok": true}
|
||||
// body: {"spaceId": "...", "state": "start" | "stop"}
|
||||
// - POST /send-poll -> {"ok": true, "messageId": "..."}
|
||||
// body: {"spaceId": "...", "title": "...", "options": ["A", "B", ...]}
|
||||
// Sends a native poll (orange iMessage poll bubble). A tap streams
|
||||
// back inbound as a `poll_option` event ({title, selected}).
|
||||
// - POST /shutdown -> {"ok": true}; then process exits
|
||||
//
|
||||
// On SIGINT/SIGTERM the sidecar calls `app.stop()` (3s graceful) before
|
||||
|
|
@ -253,6 +257,7 @@ try {
|
|||
text: spectrumText,
|
||||
markdown: spectrumMarkdown,
|
||||
typing: spectrumTyping,
|
||||
poll: spectrumPoll,
|
||||
} = await import("spectrum-ts"));
|
||||
({ imessage, effect: imessageEffect } = await import("spectrum-ts/providers/imessage"));
|
||||
} catch (e) {
|
||||
|
|
@ -477,6 +482,29 @@ async function normalizeContent(content) {
|
|||
targetText: reactionTargetText(target),
|
||||
};
|
||||
}
|
||||
// A user tapping a poll choice arrives as `poll_option` carrying the chosen
|
||||
// option title + whether it was selected (true) or deselected (false). This
|
||||
// is how a native iMessage poll's vote streams back — Python turns a
|
||||
// selection into the answer that resolves a pending `clarify`.
|
||||
if (content.type === "poll_option") {
|
||||
return {
|
||||
type: "poll_option",
|
||||
title: content.option?.title ?? content.title ?? "",
|
||||
selected: content.selected !== false,
|
||||
pollTitle: content.poll?.title ?? null,
|
||||
};
|
||||
}
|
||||
// The poll message itself (its creation) — surfaced for completeness so the
|
||||
// agent isn't told "content type not handled" if it sees the echo.
|
||||
if (content.type === "poll") {
|
||||
return {
|
||||
type: "poll",
|
||||
title: content.title ?? "",
|
||||
options: Array.isArray(content.options)
|
||||
? content.options.map((o) => o?.title ?? "")
|
||||
: [],
|
||||
};
|
||||
}
|
||||
return { type: content.type || "unknown" };
|
||||
}
|
||||
|
||||
|
|
@ -830,6 +858,27 @@ const server = http.createServer(async (req, res) => {
|
|||
}
|
||||
return ok(res, { messageId: result?.id || null });
|
||||
}
|
||||
if (req.url === "/send-poll") {
|
||||
const { spaceId, title, options } = body || {};
|
||||
if (!spaceId || typeof title !== "string" || !title) {
|
||||
return badRequest(res, "spaceId and title are required");
|
||||
}
|
||||
if (!Array.isArray(options) || options.length < 1) {
|
||||
return badRequest(res, "options must be a non-empty array");
|
||||
}
|
||||
// spectrum-ts' poll() builder accepts string options; it degrades to a
|
||||
// numbered text list on platforms without native polls (so the gateway
|
||||
// text-intercept still resolves the clarify there).
|
||||
const opts = options
|
||||
.map((o) => (typeof o === "string" ? o : o?.title))
|
||||
.filter((o) => typeof o === "string" && o);
|
||||
if (!opts.length) {
|
||||
return badRequest(res, "options must contain at least one string");
|
||||
}
|
||||
const space = await resolveSpace(spaceId);
|
||||
const result = await space.send(spectrumPoll(title, ...opts));
|
||||
return ok(res, { messageId: result?.id || null });
|
||||
}
|
||||
if (req.url === "/react") {
|
||||
const { spaceId, messageId, emoji } = body || {};
|
||||
if (!spaceId || !messageId || typeof emoji !== "string" || !emoji) {
|
||||
|
|
|
|||
225
tests/plugins/platforms/photon/test_poll_clarify.py
Normal file
225
tests/plugins/platforms/photon/test_poll_clarify.py
Normal file
|
|
@ -0,0 +1,225 @@
|
|||
"""Native-poll clarify tests for PhotonAdapter.
|
||||
|
||||
iMessage has a native poll bubble (spectrum-ts `poll()` builder). A
|
||||
multiple-choice ``clarify`` renders as that poll; the user taps a choice and
|
||||
the vote streams back inbound as a ``poll_option`` event. These tests cover
|
||||
both directions without spawning the Node sidecar or binding ports:
|
||||
|
||||
* outbound — ``send_clarify`` with choices POSTs ``/send-poll`` and flips the
|
||||
clarify into text-capture mode; with no choices it stays plain text;
|
||||
* inbound — a ``poll_option`` selection is dispatched as a plain-text message
|
||||
carrying the chosen option (so the gateway clarify-intercept resolves it),
|
||||
a deselection is dropped, and an empty-title vote is dropped.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
|
||||
import pytest
|
||||
|
||||
from gateway.config import PlatformConfig
|
||||
from gateway.platforms.base import MessageEvent, MessageType, SendResult
|
||||
from plugins.platforms.photon.adapter import PhotonAdapter
|
||||
|
||||
|
||||
def _make_adapter(monkeypatch: pytest.MonkeyPatch) -> PhotonAdapter:
|
||||
monkeypatch.setenv("PHOTON_PROJECT_ID", "test-project-id")
|
||||
monkeypatch.setenv("PHOTON_PROJECT_SECRET", "test-project-secret")
|
||||
cfg = PlatformConfig(enabled=True, token="", extra={})
|
||||
return PhotonAdapter(cfg)
|
||||
|
||||
|
||||
def _capture(
|
||||
adapter: PhotonAdapter, monkeypatch: pytest.MonkeyPatch
|
||||
) -> List[MessageEvent]:
|
||||
captured: List[MessageEvent] = []
|
||||
|
||||
async def fake_handle(event: MessageEvent) -> None:
|
||||
captured.append(event)
|
||||
|
||||
monkeypatch.setattr(adapter, "handle_message", fake_handle)
|
||||
return captured
|
||||
|
||||
|
||||
def _poll_option_event(
|
||||
*, title: str, selected: bool = True, msg_id: str = "spc-msg-vote"
|
||||
) -> Dict[str, Any]:
|
||||
return {
|
||||
"messageId": msg_id,
|
||||
"platform": "iMessage",
|
||||
"space": {"id": "+155****4567", "type": "dm", "phone": "+155****4567"},
|
||||
"sender": {"id": "+155****4567"},
|
||||
"content": {
|
||||
"type": "poll_option",
|
||||
"title": title,
|
||||
"selected": selected,
|
||||
"pollTitle": "Pick one",
|
||||
},
|
||||
"timestamp": "2026-05-14T19:06:32.000Z",
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Inbound: a poll vote becomes the clarify answer.
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_poll_vote_dispatched_as_choice_text(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""A poll selection is forwarded as a plain-text message carrying the
|
||||
chosen option, so the gateway clarify text-intercept can resolve it."""
|
||||
adapter = _make_adapter(monkeypatch)
|
||||
captured = _capture(adapter, monkeypatch)
|
||||
|
||||
await adapter._dispatch_inbound(
|
||||
_poll_option_event(title="Yes — native tappable buttons")
|
||||
)
|
||||
|
||||
assert len(captured) == 1
|
||||
ev = captured[0]
|
||||
assert ev.text == "Yes — native tappable buttons"
|
||||
assert ev.message_type == MessageType.TEXT
|
||||
assert ev.source.chat_id == "+155****4567"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_poll_deselection_is_ignored(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""Un-tapping a choice (selected=false) carries no answer — dropped."""
|
||||
adapter = _make_adapter(monkeypatch)
|
||||
captured = _capture(adapter, monkeypatch)
|
||||
|
||||
await adapter._dispatch_inbound(
|
||||
_poll_option_event(title="Yes", selected=False)
|
||||
)
|
||||
assert captured == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_poll_vote_empty_title_is_ignored(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
adapter = _make_adapter(monkeypatch)
|
||||
captured = _capture(adapter, monkeypatch)
|
||||
|
||||
await adapter._dispatch_inbound(_poll_option_event(title=" "))
|
||||
assert captured == []
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Outbound: send_clarify renders a native poll for choices.
|
||||
|
||||
|
||||
def _stub_sidecar_poll(
|
||||
adapter: PhotonAdapter, monkeypatch: pytest.MonkeyPatch, *, ok: bool = True
|
||||
) -> List[Tuple[str, str, list]]:
|
||||
calls: List[Tuple[str, str, list]] = []
|
||||
|
||||
async def fake_send_poll(space_id: str, title: str, options: list):
|
||||
calls.append((space_id, title, list(options)))
|
||||
return SendResult(
|
||||
success=ok,
|
||||
message_id="spc-msg-poll" if ok else None,
|
||||
error=None if ok else "boom",
|
||||
)
|
||||
|
||||
monkeypatch.setattr(adapter, "_sidecar_send_poll", fake_send_poll)
|
||||
return calls
|
||||
|
||||
|
||||
def _stub_sidecar_text(
|
||||
adapter: PhotonAdapter, monkeypatch: pytest.MonkeyPatch
|
||||
) -> List[Tuple[str, str]]:
|
||||
sends: List[Tuple[str, str]] = []
|
||||
|
||||
async def fake_send(space_id: str, text: str):
|
||||
sends.append((space_id, text))
|
||||
return SendResult(success=True, message_id="spc-msg-text")
|
||||
|
||||
monkeypatch.setattr(adapter, "_sidecar_send", fake_send)
|
||||
return sends
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_send_clarify_with_choices_sends_native_poll(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
adapter = _make_adapter(monkeypatch)
|
||||
poll_calls = _stub_sidecar_poll(adapter, monkeypatch)
|
||||
|
||||
marked: List[str] = []
|
||||
import tools.clarify_gateway as cg
|
||||
|
||||
monkeypatch.setattr(cg, "mark_awaiting_text", lambda cid: marked.append(cid))
|
||||
|
||||
result = await adapter.send_clarify(
|
||||
chat_id="+155****4567",
|
||||
question="Pick one",
|
||||
choices=["A", "B", "C"],
|
||||
clarify_id="clar-1",
|
||||
session_key="sess-1",
|
||||
)
|
||||
|
||||
assert result.success
|
||||
assert len(poll_calls) == 1
|
||||
space_id, title, options = poll_calls[0]
|
||||
assert space_id == "+155****4567"
|
||||
assert title == "Pick one"
|
||||
assert options == ["A", "B", "C"]
|
||||
# The vote returns as text, so text-capture must be enabled.
|
||||
assert marked == ["clar-1"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_send_clarify_open_ended_stays_text(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""No choices → plain text question, never a poll."""
|
||||
adapter = _make_adapter(monkeypatch)
|
||||
poll_calls = _stub_sidecar_poll(adapter, monkeypatch)
|
||||
sends = _stub_sidecar_text(adapter, monkeypatch)
|
||||
|
||||
result = await adapter.send_clarify(
|
||||
chat_id="+155****4567",
|
||||
question="What's your name?",
|
||||
choices=None,
|
||||
clarify_id="clar-2",
|
||||
session_key="sess-2",
|
||||
)
|
||||
|
||||
assert result.success
|
||||
assert poll_calls == [] # no poll for open-ended
|
||||
assert len(sends) == 1
|
||||
assert "What's your name?" in sends[0][1]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_send_clarify_falls_back_to_text_when_poll_fails(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""An old sidecar without /send-poll (or a send error) degrades to the
|
||||
numbered-text clarify so the user can still answer."""
|
||||
adapter = _make_adapter(monkeypatch)
|
||||
poll_calls = _stub_sidecar_poll(adapter, monkeypatch, ok=False)
|
||||
sends = _stub_sidecar_text(adapter, monkeypatch)
|
||||
|
||||
import tools.clarify_gateway as cg
|
||||
|
||||
monkeypatch.setattr(cg, "mark_awaiting_text", lambda cid: None)
|
||||
|
||||
result = await adapter.send_clarify(
|
||||
chat_id="+155****4567",
|
||||
question="Pick one",
|
||||
choices=["A", "B"],
|
||||
clarify_id="clar-3",
|
||||
session_key="sess-3",
|
||||
)
|
||||
|
||||
assert result.success # text fallback succeeded
|
||||
assert len(poll_calls) == 1 # poll was attempted
|
||||
assert len(sends) == 1 # then text list sent
|
||||
body = sends[0][1]
|
||||
assert "Pick one" in body
|
||||
assert "A" in body and "B" in body
|
||||
Loading…
Add table
Add a link
Reference in a new issue