From 770a7a70ba11dd10670143edcc07f5a5b4ad9862 Mon Sep 17 00:00:00 2001 From: Brent Rager Date: Tue, 18 Aug 2026 21:09:26 -0400 Subject: [PATCH] python-server: identity_intake interaction kind + host-effect seam MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Port the identity_intake Rich Interaction kind (structured name/email/phone lead capture) to the Python server, mirroring the Rust reference, and add the kind-routed host-effect seam the choices wave omitted. Why: choices landed the Rich Interactions framework but had no side effect, so the framework never grew the seam a stateful kind needs. identity_intake is that kind — a valid submit must stamp the captured contacts onto the session so the OTP contact seam (and pre-chat/create readers) pick them up. The seam is kind-agnostic (InteractionKind.host_effect defaults to a no-op) and fires on both submit paths — the dispatcher's submit_interaction action AND the conversational- fallback submit_interaction tool — so choices is unaffected and future kinds get the same hook. - identity_intake.py: request_identity_intake { fields, reason } raise tool, validator (required present, email shape, phone -> E.164, per-field errors), conversational fallback directive, capability identity_form, and a host_effect that stamps user_name / contact_email / contact_phone. - interaction.py: host_effect no-op on the base kind; identity_intake registered in the default catalog alongside choices. - session_store: user_name + contact_phone on StoredSession (persisted pre-chat too), attach_session_identity seam (in-memory impl; durable is a host concern), OTP contact seam now offers the captured phone (SMS) as well as email. - Tests: validator units + a park/resume WS integration test asserting the host effect on both the rich and conversational-fallback paths. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01YbN45JeWDbcjvFqGJvmVD3 --- .changeset/python-identity-intake.md | 9 + .../src/smooth_operator_server/__init__.py | 3 + .../src/smooth_operator_server/dispatcher.py | 11 +- .../smooth_operator_server/identity_intake.py | 266 ++++++++++++++++++ .../src/smooth_operator_server/interaction.py | 19 +- .../interaction_tools.py | 17 +- .../smooth_operator_server/session_store.py | 46 ++- .../src/smooth_operator_server/turn_runner.py | 1 + .../server/tests/test_identity_intake_e2e.py | 255 +++++++++++++++++ .../tests/test_identity_intake_validator.py | 159 +++++++++++ .../tests/test_turn_runner_agent_config.py | 2 +- python/server/uv.lock | 2 +- 12 files changed, 778 insertions(+), 12 deletions(-) create mode 100644 .changeset/python-identity-intake.md create mode 100644 python/server/src/smooth_operator_server/identity_intake.py create mode 100644 python/server/tests/test_identity_intake_e2e.py create mode 100644 python/server/tests/test_identity_intake_validator.py diff --git a/.changeset/python-identity-intake.md b/.changeset/python-identity-intake.md new file mode 100644 index 00000000..47f02272 --- /dev/null +++ b/.changeset/python-identity-intake.md @@ -0,0 +1,9 @@ +--- +'@smooai/smooth-operator-server': patch +--- + +Port the `identity_intake` Rich Interaction kind to the Python server, plus the kind-routed host-effect seam the `choices` wave omitted. + +`identity_intake` is structured name/email/phone lead capture — the second interaction kind (after `choices`), mirroring the Rust reference. Its `request_identity_intake` raise tool (`{ fields, reason }`) parks the turn on channels that declare the `identity_form` capability and degrades to a conversational directive on text-only channels; both paths run one server-side validator (required fields present, email shape, phone normalized to E.164, per-field errors) and resume with the same canonical payload. + +New framework piece: a kind-agnostic **host effect** (`InteractionKind.host_effect`, a no-op by default) fires on a valid submit on BOTH paths — the dispatcher's `submit_interaction` action and the conversational-fallback `submit_interaction` tool. `identity_intake` overrides it to stamp the captured contacts onto the session (`user_name` / `contact_email` / `contact_phone` — the same keys the pre-chat create path stashes and the OTP contact seam reads), so a captured contact is immediately OTP-verifiable (email and/or SMS). `choices` is unaffected. Registered in the default interaction catalog alongside `choices`. diff --git a/python/server/src/smooth_operator_server/__init__.py b/python/server/src/smooth_operator_server/__init__.py index 4e7cf3fe..0a51988e 100644 --- a/python/server/src/smooth_operator_server/__init__.py +++ b/python/server/src/smooth_operator_server/__init__.py @@ -28,6 +28,7 @@ from .choices import ChoicesKind, validate_choices from .coding_tools import coding_tools, coding_tools_from_env, resolve_workspace_path from .dispatcher import FrameDispatcher +from .identity_intake import IdentityIntakeKind, validate_intake from .interaction import ( InteractionFieldError, InteractionKind, @@ -78,6 +79,8 @@ "FrameDispatcher", "ChoicesKind", "validate_choices", + "IdentityIntakeKind", + "validate_intake", "InteractionFieldError", "InteractionKind", "InteractionOutcome", diff --git a/python/server/src/smooth_operator_server/dispatcher.py b/python/server/src/smooth_operator_server/dispatcher.py index d7611541..d2db4673 100644 --- a/python/server/src/smooth_operator_server/dispatcher.py +++ b/python/server/src/smooth_operator_server/dispatcher.py @@ -638,7 +638,10 @@ async def _maybe_offer_otp(self, refusal: OtpRefusal, session: Any, request_id: tool = refusal.refused_tool if tool is None or self._otp_service is None: return - contact = OtpContact(email=session.contact_email) + # Both contacts feed the OTP seam: the pre-chat email, plus a phone captured by an + # identity_intake submit (attach_session_identity) — so a captured contact is + # immediately OTP-verifiable over whichever channel it filled (email and/or SMS). + contact = OtpContact(email=session.contact_email, phone=session.contact_phone) if contact.is_empty: return channels = [c.value for c in contact.available_channels()] @@ -850,7 +853,11 @@ async def _handle_submit_interaction(self, frame: dict, request_id: str | None, ) return - # Valid: consume the park and resume the turn with the canonical payload. + # Valid: run the kind's host effect (e.g. identity_intake stamps the captured contacts + # onto the session), THEN consume the park and resume the turn with the canonical payload. + # The effect runs before the resume, mirroring the Rust handle_submit_interaction; it is a + # no-op for kinds without one (choices), so this stays kind-agnostic. + await kind.host_effect(self._store, session_id, canonical or {}) self._interaction_pending.resolve(session_id, InteractionOutcome.submitted(canonical or {})) sink( protocol.immediate_response( diff --git a/python/server/src/smooth_operator_server/identity_intake.py b/python/server/src/smooth_operator_server/identity_intake.py new file mode 100644 index 00000000..2ed8e8b6 --- /dev/null +++ b/python/server/src/smooth_operator_server/identity_intake.py @@ -0,0 +1,266 @@ +"""Identity intake — channel-normalized lead/identity capture: the first (reference) +**Rich Interaction kind** (see :mod:`interaction`, ``docs/Architecture/Rich Interactions.md``, +and the Rust reference ``rust/smooth-operator/src/identity_intake.rs``, mirrored exactly). + +- On a channel that declared the ``identity_form`` capability, the agent's + ``request_identity_intake`` tool parks the turn and the server emits + ``interaction_required { kind: "identity_intake" }``; the client's form resumes with a + ``submit_interaction`` action. +- On a **text-only** channel the same raise degrades to a conversational directive and the + model submits the collected values through the generic ``submit_interaction`` *tool*. + +Both paths validate through :func:`validate_intake` — one implementation, one behavior — +and resume the turn with the same structured payload. On a valid submit the kind's +:meth:`IdentityIntakeKind.host_effect` stamps the captured contacts onto the session +(``userName`` / ``contactEmail`` / ``contactPhone`` — the same keys the pre-chat/create path +stashes and the OTP contact seam reads), so a captured contact is immediately OTP-verifiable. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Any + +from .interaction import InteractionFieldError, InteractionKind, InteractionRequest + +if TYPE_CHECKING: # avoid a runtime import cycle (session_store never imports this module) + from .session_store import SessionStore + +#: The closed set of identity fields intake can collect. +_FIELD_KEYS = ("name", "email", "phone") + + +def normalize_email(raw: str) -> str | None: + """Minimal email-shape validation: exactly one ``@``, non-empty local part, a + dot-containing domain, no whitespace. Returns the trimmed address with a lowercased + domain, or ``None`` when malformed. Mirrors ``identity_intake.rs::normalize_email``. + + ponytail: shape check, not RFC 5322 — deliverability is the host's email service's job, + not the protocol boundary's.""" + s = raw.strip() + if not s or any(c.isspace() for c in s): + return None + local, sep, domain = s.partition("@") + if not sep or not local or "@" in domain: + return None + domain_lc = domain.lower() + parts = domain_lc.split(".") + # Domain needs an interior dot: `a.b`, not `.b`, `a.`, or `ab`. + if len(parts) < 2 or any(p == "" for p in parts): + return None + return f"{local}@{domain_lc}" + + +def normalize_phone_e164(raw: str) -> str | None: + """Normalize a phone number to E.164, or ``None`` when unparseable. Strips common + separators (space, ``-``, ``.``, ``(``, ``)``), then accepts ``+`` + 8–15 digits + (already E.164), or a bare 10-digit / 1-prefixed 11-digit NANP number → ``+1…``. + Mirrors ``identity_intake.rs::normalize_phone_e164``. + + ponytail: NANP default for bare national numbers; swap in a phonenumber library if + non-NANP national formats ever need to parse.""" + s = "".join(c for c in raw.strip() if c not in " -.()") + plus = s.startswith("+") + digits = s[1:] if plus else s + if not digits or not digits.isdigit(): + return None + if plus: + # E.164: country code can't start with 0; total 8–15 digits. + if 8 <= len(digits) <= 15 and not digits.startswith("0"): + return f"+{digits}" + return None + if len(digits) == 10: + return f"+1{digits}" + if len(digits) == 11 and digits.startswith("1"): + return f"+{digits}" + return None + + +def validate_intake( + fields: list[dict[str, Any]], values: dict[str, Any] +) -> tuple[dict[str, Any] | None, list[InteractionFieldError]]: + """Validate raw submitted ``values`` against the requested ``fields``, returning + ``(normalized_values, [])`` or ``(None, errors)`` with EVERY per-field failure (so a + form annotates all of them in one round-trip). Mirrors ``identity_intake.rs::validate_intake``. + + Rules: + - every ``required`` field must be present and non-blank; + - ``name``: non-empty after trim; + - ``email``: ``local@domain.tld`` shape (single ``@``, dot in the domain, no whitespace); + domain lowercased; + - ``phone``: E.164 after stripping separators. + + Fields that were NOT requested but are present are still validated and kept — a visitor + volunteering their phone is a gift, not an error.""" + errors: list[InteractionFieldError] = [] + out: dict[str, Any] = {} + + def _get(key: str) -> str | None: + v = values.get(key) + return v if isinstance(v, str) else None + + # Required-ness: every required requested field must be present + non-blank. + for field in fields: + key = field.get("key") + if field.get("required") and not (_get(key) or "").strip(): + errors.append(InteractionFieldError(key, "this field is required")) + + # Format validation + normalization for whatever was provided. + name = (_get("name") or "").strip() + if name: + out["name"] = name + + email = (_get("email") or "").strip() + if email: + normalized = normalize_email(email) + if normalized is not None: + out["email"] = normalized + else: + errors.append(InteractionFieldError("email", "must be a valid email address")) + + phone = (_get("phone") or "").strip() + if phone: + normalized = normalize_phone_e164(phone) + if normalized is not None: + out["phone"] = normalized + else: + errors.append( + InteractionFieldError( + "phone", "must be a valid phone number (include your country code, e.g. +1 555 123 4567)" + ) + ) + + if errors: + return None, errors + return out, [] + + +def parse_fields(raw: Any) -> list[dict[str, Any]]: + """Parse the raise tool's ``fields`` argument into validated field dicts. Accepts both + the structured form (``[{ "key": "email", "required": true, "label": "Work email" }]``) + and the shorthand the model likes to emit (``["email", "name"]`` — shorthand fields are + ``required: true``). Unknown keys are an error (closed set). Raises :class:`ValueError` + on malformed arguments. Mirrors ``identity_intake.rs::parse_fields``.""" + if not isinstance(raw, list): + raise ValueError("'fields' must be an array") + if not raw: + raise ValueError("'fields' must contain at least one field") + + def _key(s: str) -> str: + if s not in _FIELD_KEYS: + raise ValueError(f"unknown intake field '{s}' (expected name, email, or phone)") + return s + + fields: list[dict[str, Any]] = [] + for item in raw: + if isinstance(item, str): + fields.append({"key": _key(item), "required": True}) + elif isinstance(item, dict): + key = item.get("key") + if not isinstance(key, str): + raise ValueError("each field object needs a string 'key'") + field: dict[str, Any] = {"key": _key(key), "required": bool(item.get("required", True))} + label = item.get("label") + if isinstance(label, str) and label: + field["label"] = label + fields.append(field) + else: + raise ValueError(f"invalid field entry: {item!r}") + return fields + + +class IdentityIntakeKind(InteractionKind): + """The ``identity_intake`` Rich Interaction kind — structured name/email/phone lead + capture (see the module docs and ``spec/interactions/identity-intake.schema.json``).""" + + def kind(self) -> str: + return "identity_intake" + + def capability(self) -> str: + return "identity_form" + + def tool_schema(self) -> dict[str, Any]: + return { + "name": "request_identity_intake", + "description": ( + "Ask the visitor for their contact details (name, email, and/or phone) in a " + "channel-appropriate way. On channels that can render a form the visitor fills a " + "structured form; on text channels you will be told to collect the fields " + "conversationally. Always use this tool instead of free-forming a request for " + "contact details." + ), + "parameters": { + "type": "object", + "properties": { + "fields": { + "type": "array", + "minItems": 1, + "description": ( + "Which fields to collect, in order. Each entry is either a string " + '("name" | "email" | "phone") or an object { key, required?, label? }.' + ), + "items": { + "anyOf": [ + {"type": "string", "enum": list(_FIELD_KEYS)}, + { + "type": "object", + "properties": { + "key": {"type": "string", "enum": list(_FIELD_KEYS)}, + "required": {"type": "boolean"}, + "label": {"type": "string"}, + }, + "required": ["key"], + }, + ] + }, + }, + "reason": { + "type": "string", + "description": 'Why you need these details, phrased for the visitor (e.g. "to send you the quote").', + }, + }, + "required": ["fields", "reason"], + }, + } + + def parse_request(self, args: dict[str, Any]) -> InteractionRequest: + fields = parse_fields(args.get("fields")) + reason = str(args.get("reason") or "").strip() or "to help you better" + return InteractionRequest(kind=self.kind(), spec={"fields": fields}, reason=reason) + + def validate( + self, spec: dict[str, Any] | None, values: dict[str, Any] + ) -> tuple[dict[str, Any] | None, list[InteractionFieldError]]: + # The spec's fields drive required-ness; a None/absent spec (fallback raise from an + # earlier turn) degrades to format-only validation. + fields = (spec or {}).get("fields") or [] + if not isinstance(values, dict): + return None, [InteractionFieldError("values", "invalid values shape: expected an object")] + if not any(isinstance(values.get(k), str) and values.get(k).strip() for k in _FIELD_KEYS): + return None, [InteractionFieldError("values", "provide at least one of name/email/phone, or declined=true")] + return validate_intake(fields, values) + + def fallback_directive(self, spec: dict[str, Any], reason: str) -> str: + field_list = ", ".join(f.get("key", "") for f in spec.get("fields", []) if isinstance(f, dict) and f.get("key")) + return ( + "This visitor's channel cannot display a form. Collect the requested details " + f"({field_list}) conversationally: ask for ONE field at a time, in the order given, " + f"naturally weaving in the reason ({reason}). When you have the values, call the " + '`submit_interaction` tool with kind "identity_intake" and the values — it validates ' + "each field and will tell you if something looks wrong so you can re-ask. If the visitor " + "declines to share, call `submit_interaction` with declined=true and continue helping " + "them without the details." + ) + + async def host_effect(self, store: "SessionStore", session_id: str, values: dict[str, Any]) -> None: + """Stamp the validated identity onto the session (metadata ``userName`` / + ``contactEmail`` / ``contactPhone`` — the same keys the pre-chat/create path stashes and + the OTP contact seam reads), so a captured contact is immediately OTP-verifiable. Only + provided fields are written (an intake that collected just an email never clobbers a known + name). Mirrors the Rust ``attach_interaction_effect`` → ``attach_session_identity``. Durable + participant/CRM attach is a host concern.""" + await store.attach_session_identity( + session_id, + name=values.get("name") if isinstance(values.get("name"), str) else None, + email=values.get("email") if isinstance(values.get("email"), str) else None, + phone=values.get("phone") if isinstance(values.get("phone"), str) else None, + ) diff --git a/python/server/src/smooth_operator_server/interaction.py b/python/server/src/smooth_operator_server/interaction.py index 18255fff..a48c63cc 100644 --- a/python/server/src/smooth_operator_server/interaction.py +++ b/python/server/src/smooth_operator_server/interaction.py @@ -27,7 +27,10 @@ import asyncio from abc import ABC, abstractmethod from dataclasses import dataclass, field -from typing import Any +from typing import TYPE_CHECKING, Any + +if TYPE_CHECKING: + from .session_store import SessionStore #: How long a parked interaction waits for the client's ``submit_interaction`` before the #: raise tool unblocks with a ``no_response`` payload. Matches the Rust server's @@ -143,6 +146,15 @@ def fallback_directive(self, spec: dict[str, Any], reason: str) -> str: the model follows to collect the same information turn by turn, then submit through the ``submit_interaction`` tool.""" + async def host_effect(self, store: "SessionStore", session_id: str, values: dict[str, Any]) -> None: + """The kind-routed **host effect** of an accepted submit — the kind-agnostic seam + the Rust server's ``attach_interaction_effect`` fills. Runs on a valid submit on BOTH + paths (the dispatcher's ``submit_interaction`` action AND the conversational-fallback + ``submit_interaction`` tool) with the canonical validated ``values``. The default is a + **no-op** (``choices`` has no side effect); ``identity_intake`` overrides it to stamp the + captured contacts onto the session. Kinds without an effect leave this untouched.""" + return None + class InteractionRegistry: """The catalog of interaction kinds a server hosts. The default catalog is the @@ -167,10 +179,11 @@ def kinds(self) -> list[InteractionKind]: @classmethod def default(cls) -> InteractionRegistry: - """The reference catalog: ``choices``.""" + """The reference catalog: ``choices`` + ``identity_intake``.""" from .choices import ChoicesKind + from .identity_intake import IdentityIntakeKind - return cls().with_kind(ChoicesKind()) + return cls().with_kind(ChoicesKind()).with_kind(IdentityIntakeKind()) @dataclass diff --git a/python/server/src/smooth_operator_server/interaction_tools.py b/python/server/src/smooth_operator_server/interaction_tools.py index 6211f6a7..d3320df6 100644 --- a/python/server/src/smooth_operator_server/interaction_tools.py +++ b/python/server/src/smooth_operator_server/interaction_tools.py @@ -23,7 +23,7 @@ import asyncio import json import uuid -from typing import Any, Callable +from typing import TYPE_CHECKING, Any, Callable from smooth_operator_core import FunctionTool @@ -36,6 +36,9 @@ PendingInteractions, ) +if TYPE_CHECKING: + from .session_store import SessionStore + #: The sink type the raise tool emits ``interaction_required`` through. Sink = Callable[[dict[str, Any]], None] @@ -94,6 +97,8 @@ async def _run(args: dict[str, Any]) -> str: def _submit_tool( kinds: InteractionRegistry, raised_specs: dict[str, dict[str, Any]], + store: SessionStore, + session_id: str, ) -> FunctionTool: """Build the generic ``submit_interaction`` tool (conversational-fallback submit).""" hosted = [k.kind() for k in kinds.kinds()] @@ -117,6 +122,10 @@ async def _run(args: dict[str, Any]) -> str: raise ValueError( f"validation failed — {detail}. Re-ask the visitor for the corrected value(s) and submit again." ) + # Run the kind's host effect on the conversational path too — the SAME kind-agnostic + # seam the rich path fires (a no-op for choices; identity_intake stamps the session's + # contacts), mirroring the Rust InteractionConfig.attach callback. + await kind.host_effect(store, session_id, canonical or {}) return json.dumps({"status": "submitted", "values": canonical}) return FunctionTool( @@ -147,11 +156,13 @@ def build_interaction_tools( request_id: str, sink: Sink, pending: PendingInteractions, + store: "SessionStore", ) -> list[FunctionTool]: """Build this turn's Rich Interaction tools: one ``request_`` per hosted kind (rich when the kind's capability is declared, else fallback), plus the generic ``submit_interaction`` tool when at least one kind is on the fallback path. Mirrors the - Rust runner's per-turn registration + ``any_fallback`` gate.""" + Rust runner's per-turn registration + ``any_fallback`` gate. ``store`` is threaded through + so a valid conversational-fallback submit can run the kind's host effect on the session.""" raised_specs: dict[str, dict[str, Any]] = {} tools: list[FunctionTool] = [] any_fallback = False @@ -160,5 +171,5 @@ def build_interaction_tools( any_fallback = any_fallback or not rich tools.append(_request_tool(kind, rich, session_id, request_id, sink, pending, raised_specs)) if any_fallback: - tools.append(_submit_tool(kinds, raised_specs)) + tools.append(_submit_tool(kinds, raised_specs, store, session_id)) return tools diff --git a/python/server/src/smooth_operator_server/session_store.py b/python/server/src/smooth_operator_server/session_store.py index 40f058b9..7b972a7f 100644 --- a/python/server/src/smooth_operator_server/session_store.py +++ b/python/server/src/smooth_operator_server/session_store.py @@ -14,7 +14,7 @@ import uuid from abc import ABC, abstractmethod -from dataclasses import dataclass, field +from dataclasses import dataclass, field, replace from datetime import datetime, timezone from enum import Enum from threading import Lock @@ -38,8 +38,16 @@ class StoredSession: #: The caller's email captured at create time, used as the OTP delivery contact #: for the ``end_user`` identity flow (the Python analog of the Rust session's #: ``metadata.contactEmail``). ``None`` when no email was supplied — the server - #: then can't offer OTP for this session. + #: then can't offer OTP for this session. Also (re)written by an ``identity_intake`` + #: submit's host effect (:meth:`SessionStore.attach_session_identity`). contact_email: str | None = None + #: The caller's display name (the Rust session's ``metadata.userName``). Captured at + #: create time from the pre-chat form, and (re)written by an ``identity_intake`` submit. + user_name: str | None = None + #: The caller's phone (the Rust session's ``metadata.contactPhone``), the SMS OTP + #: delivery contact. ``None`` from the pre-chat path (which captures only an email); + #: an ``identity_intake`` submit stamps it, making the session SMS-OTP-verifiable. + contact_phone: str | None = None #: The AUTHENTICATED principal's email that owns this session's conversation — the #: ACL key (th-8fe998). Set from the connection's principal, NEVER from a client #: frame field. ``None`` means "no owner" — an anonymous/emailless principal, or a @@ -201,6 +209,22 @@ async def set_session_authenticated(self, session_id: str, verified: bool) -> No successful ``verify_otp``. A no-op for an unknown session.""" ... + async def attach_session_identity( + self, session_id: str, *, name: str | None = None, email: str | None = None, phone: str | None = None + ) -> None: + """Stamp captured contacts onto the session — the host effect of a valid + ``identity_intake`` submit (the Python analog of the Rust + ``AppState::attach_session_identity``). Writes ``user_name`` / ``contact_email`` / + ``contact_phone`` — the SAME keys the pre-chat create path stashes and the OTP contact + seam (:meth:`OtpService.send_otp` via the dispatcher) reads, so a captured contact is + immediately OTP-verifiable. Only provided (non-``None``) fields are written (an intake + that collected just an email never clobbers a known name); a no-op for an unknown session. + + This **base default is a no-op** — the reference in-memory store overrides it. A durable + (Postgres/Dynamo) store writes to its own metadata; that durable participant/CRM attach is + a host concern, exactly as it is in the Rust reference. th-identity-intake.""" + return None + class InMemorySessionStore(SessionStore): """In-process :class:`SessionStore` — the reference store (the C# analog of @@ -267,6 +291,7 @@ async def create_session( user_participant_id=str(uuid.uuid4()), agent_participant_id=str(uuid.uuid4()), contact_email=(user_email.strip() or None) if isinstance(user_email, str) else None, + user_name=(user_name.strip() or None) if isinstance(user_name, str) else None, owner_email=owner, owner_org=self._orgs.get(conv_id, org_id) if resume else org_id, ) @@ -351,3 +376,20 @@ async def set_session_authenticated(self, session_id: str, verified: bool) -> No self._authenticated[session_id] = True else: self._authenticated.pop(session_id, None) + + async def attach_session_identity( + self, session_id: str, *, name: str | None = None, email: str | None = None, phone: str | None = None + ) -> None: + with self._gate: + session = self._sessions.get(session_id) + if session is None: # no-op for an unknown session (mirrors the Rust map miss). + return + # Only overwrite fields that were provided — an intake that captured just an email + # must never clobber a name known from the pre-chat form. `StoredSession` is frozen, + # so replace it with an updated copy in place. + self._sessions[session_id] = replace( + session, + user_name=name if name is not None else session.user_name, + contact_email=email if email is not None else session.contact_email, + contact_phone=phone if phone is not None else session.contact_phone, + ) diff --git a/python/server/src/smooth_operator_server/turn_runner.py b/python/server/src/smooth_operator_server/turn_runner.py index a87f8a37..11bdddda 100644 --- a/python/server/src/smooth_operator_server/turn_runner.py +++ b/python/server/src/smooth_operator_server/turn_runner.py @@ -360,6 +360,7 @@ async def run( request_id, sink, self._interaction_pending, + self._store, ) ) diff --git a/python/server/tests/test_identity_intake_e2e.py b/python/server/tests/test_identity_intake_e2e.py new file mode 100644 index 00000000..128e1188 --- /dev/null +++ b/python/server/tests/test_identity_intake_e2e.py @@ -0,0 +1,255 @@ +"""Rich Interactions (``identity_intake`` kind) — the raise → park → ``submit_interaction`` +→ resume path end-to-end over the real Python WS server, PLUS the host effect: a valid submit +stamps the captured contacts onto the session (``user_name`` / ``contact_email`` / +``contact_phone`` — the keys the OTP contact seam reads). + +Boots the server with a scripted :class:`~smooth_operator_core.MockLlmProvider` (offline turn) +and drives the full seam over a real ``websockets`` client — the Python analog of the Rust +identity_intake submit tests. Covers the rich path (session declared ``identity_form``, parks ++ resumes + host effect) and the conversational fallback (no capability, generic +``submit_interaction`` tool, SAME host effect). +""" + +from __future__ import annotations + +import json + +import websockets +from smooth_operator_core import MockLlmProvider + +from smooth_operator_server import ServerState, serve +from smooth_operator_server.session_store import InMemorySessionStore + +_FIELDS = [ + {"key": "name", "required": False}, + {"key": "email", "required": True, "label": "Work email"}, + {"key": "phone", "required": False}, +] +_RAISE_ARGS = json.dumps({"fields": _FIELDS, "reason": "to send you the quote"}) +_VALUES = {"name": "Alice Example", "email": "alice@Example.com", "phone": "(555) 123-4567"} +# Canonical (normalized) forms the validator produces + stamps. +_NORM = {"name": "Alice Example", "email": "alice@example.com", "phone": "+15551234567"} + +_SID = "" + + +async def _start(mock: MockLlmProvider) -> tuple: + state = ServerState(store=InMemorySessionStore(), chat_client=mock) + server = await serve(state, "127.0.0.1", 0) + return server, state + + +async def _create_session(ws, supports: list[str] | None = None) -> str: + frame = { + "action": "create_conversation_session", + "requestId": "r-create", + "agentId": "11111111-1111-1111-1111-111111111111", + } + if supports is not None: + frame["supports"] = supports + await ws.send(json.dumps(frame)) + while True: + event = json.loads(await ws.recv()) + if event.get("type") == "immediate_response": + return event["data"]["sessionId"] + + +async def _recv(ws): + while True: + event = json.loads(await ws.recv()) + if event.get("type") not in ("keepalive", "pong"): + return event + + +async def _send_message(ws) -> None: + await ws.send( + json.dumps( + {"action": "send_message", "requestId": "r-msg", "sessionId": _SID, "message": "here are my details"} + ) + ) + + +async def test_rich_path_parks_resumes_and_stamps_contacts() -> None: + mock = MockLlmProvider() + mock.push_tool_call("call-1", "request_identity_intake", _RAISE_ARGS) + mock.push_text("Thanks — I've got your details.") + server, state = await _start(mock) + global _SID + try: + async with websockets.connect(server.ws_url()) as ws: + _SID = await _create_session(ws, supports=["identity_form"]) + + # Before the intake, the session has no captured contact (no pre-chat email/name). + pre = await state.store.get_session(_SID) + assert pre.contact_email is None and pre.contact_phone is None and pre.user_name is None + + await _send_message(ws) + ack = await _recv(ws) + assert ack["type"] == "immediate_response" and ack["status"] == 202 + + # Park: an interaction_required arrives (a toolCall chunk may precede it). + event = await _recv(ws) + if event["type"] == "stream_chunk": + event = await _recv(ws) + assert event["type"] == "interaction_required" + inner = event["data"]["data"] + assert inner["kind"] == "identity_intake" + assert inner["spec"]["fields"][1]["key"] == "email" + assert inner["reason"] == "to send you the quote" + interaction_id = inner["interactionId"] + assert interaction_id + + # Resume: submit the (un-normalized) values. The reader was free to receive this. + await ws.send( + json.dumps( + { + "action": "submit_interaction", + "requestId": "r-msg", + "sessionId": _SID, + "interactionId": interaction_id, + "kind": "identity_intake", + "values": _VALUES, + } + ) + ) + + tool_results: list[dict] = [] + tokens: list[str] = [] + saw_submit_ack = False + while True: + event = await _recv(ws) + etype = event["type"] + if etype == "immediate_response" and event["status"] == 200: + saw_submit_ack = True + # The ack carries the NORMALIZED values. + assert event["data"]["values"] == _NORM + elif etype == "stream_chunk": + tr = event["data"]["state"].get("rawResponse", {}).get("toolResult") + if tr: + tool_results.append(tr) + elif etype == "stream_token": + tokens.append(event["token"]) + elif etype == "eventual_response": + break + + assert saw_submit_ack, "the submit_interaction ack must arrive" + assert "".join(tokens) == "Thanks — I've got your details." + # The raise tool returned the canonical submitted payload to the model. + payload = json.loads(next(tr["result"] for tr in tool_results if tr["name"] == "request_identity_intake")) + assert payload["status"] == "submitted" + assert payload["values"] == _NORM + + # HOST EFFECT: the session now carries the captured, normalized contacts — the same + # keys the OTP contact seam reads (so the session is immediately OTP-verifiable). + after = await state.store.get_session(_SID) + assert after.user_name == "Alice Example" + assert after.contact_email == "alice@example.com" + assert after.contact_phone == "+15551234567" + finally: + await server.shutdown() + + +async def test_invalid_submit_stays_parked_and_does_not_stamp() -> None: + mock = MockLlmProvider() + mock.push_tool_call("call-1", "request_identity_intake", _RAISE_ARGS) + mock.push_text("Got it.") + server, state = await _start(mock) + global _SID + try: + async with websockets.connect(server.ws_url()) as ws: + _SID = await _create_session(ws, supports=["identity_form"]) + await _send_message(ws) + assert (await _recv(ws))["status"] == 202 + event = await _recv(ws) + if event["type"] == "stream_chunk": + event = await _recv(ws) + assert event["type"] == "interaction_required" + interaction_id = event["data"]["data"]["interactionId"] + + # A bad email → interaction_invalid, turn stays parked, nothing stamped. + await ws.send( + json.dumps( + { + "action": "submit_interaction", + "requestId": "r-msg", + "sessionId": _SID, + "interactionId": interaction_id, + "values": {"email": "not-an-email"}, + } + ) + ) + invalid = await _recv(ws) + assert invalid["type"] == "interaction_invalid" + assert invalid["data"]["data"]["errors"][0]["field"] == "email" + # The invalid submit left no contact on the session. + mid = await state.store.get_session(_SID) + assert mid.contact_email is None + + # Corrected submit → resumes and stamps (proves the park survived the invalid attempt). + await ws.send( + json.dumps( + { + "action": "submit_interaction", + "requestId": "r-msg", + "sessionId": _SID, + "interactionId": interaction_id, + "values": {"email": "alice@example.com"}, + } + ) + ) + while True: + event = await _recv(ws) + if event["type"] == "eventual_response": + break + after = await state.store.get_session(_SID) + assert after.contact_email == "alice@example.com" + finally: + await server.shutdown() + + +async def test_fallback_path_submit_tool_also_stamps_contacts() -> None: + mock = MockLlmProvider() + mock.push_tool_call("call-1", "request_identity_intake", _RAISE_ARGS) + mock.push_tool_call( + "call-2", + "submit_interaction", + json.dumps({"kind": "identity_intake", "values": {"email": "bob@Example.com"}}), + ) + mock.push_text("Thanks!") + server, state = await _start(mock) + global _SID + try: + async with websockets.connect(server.ws_url()) as ws: + # No `supports` → text-only channel → identity_intake degrades to the conversational + # fallback (no card, no park); the model submits via the generic tool. + _SID = await _create_session(ws) + await _send_message(ws) + assert (await _recv(ws))["status"] == 202 + + events: list[dict] = [] + tool_results: list[dict] = [] + while True: + event = await _recv(ws) + events.append(event) + if event["type"] == "stream_chunk": + tr = event["data"]["state"].get("rawResponse", {}).get("toolResult") + if tr: + tool_results.append(tr) + if event["type"] == "eventual_response": + break + + # The fallback path NEVER emits interaction_required. + assert all(e["type"] != "interaction_required" for e in events) + raise_result = json.loads( + next(tr["result"] for tr in tool_results if tr["name"] == "request_identity_intake") + ) + assert raise_result["mode"] == "conversational" + submit_result = json.loads(next(tr["result"] for tr in tool_results if tr["name"] == "submit_interaction")) + assert submit_result["status"] == "submitted" + assert submit_result["values"] == {"email": "bob@example.com"} + + # HOST EFFECT fires on the conversational path too. + after = await state.store.get_session(_SID) + assert after.contact_email == "bob@example.com" + finally: + await server.shutdown() diff --git a/python/server/tests/test_identity_intake_validator.py b/python/server/tests/test_identity_intake_validator.py new file mode 100644 index 00000000..5d46bbca --- /dev/null +++ b/python/server/tests/test_identity_intake_validator.py @@ -0,0 +1,159 @@ +"""Unit tests for the ``identity_intake`` Rich Interaction kind's validator + parser. + +Mirrors the Rust reference tests in ``rust/smooth-operator/src/identity_intake.rs``, and +additionally validates the shared conformance fixtures +(``spec/conformance/fixtures.json``) so the Python validator agrees with the golden spec +every language checks against. +""" + +from __future__ import annotations + +import json +from pathlib import Path + +import pytest + +from smooth_operator_server.identity_intake import ( + IdentityIntakeKind, + normalize_email, + normalize_phone_e164, + parse_fields, + validate_intake, +) + +_SPEC = Path(__file__).resolve().parents[3] / "spec" / "conformance" / "fixtures.json" + + +@pytest.fixture(scope="module") +def fixtures() -> dict: + return json.loads(_SPEC.read_text()) + + +def _field(key: str, required: bool) -> dict: + return {"key": key, "required": required} + + +def test_email_shapes() -> None: + assert normalize_email("Alice@Example.COM") == "Alice@example.com", "domain lowercased, local preserved" + for bad in ["", "no-at", "@x.com", "a@b", "a@.com", "a@b.", "a b@c.com", "a@b@c.com"]: + assert normalize_email(bad) is None, f"{bad!r} should be rejected" + + +def test_phone_shapes() -> None: + assert normalize_phone_e164("+1 (555) 123-4567") == "+15551234567" + assert normalize_phone_e164("555.123.4567") == "+15551234567", "bare 10-digit NANP" + assert normalize_phone_e164("1 555 123 4567") == "+15551234567", "1-prefixed 11-digit NANP" + assert normalize_phone_e164("+447911123456") == "+447911123456", "non-NANP with country code" + for bad in ["", "abc", "+0123456789", "12345", "+1234567890123456"]: + assert normalize_phone_e164(bad) is None, f"{bad!r} should be rejected" + + +def test_required_field_missing_is_an_error() -> None: + fields = [_field("email", True), _field("name", False)] + canonical, errors = validate_intake(fields, {}) + assert canonical is None + assert len(errors) == 1 + assert errors[0].field == "email" + + # Blank counts as missing. + canonical2, errors2 = validate_intake(fields, {"email": " "}) + assert canonical2 is None + assert any(e.field == "email" for e in errors2) + + +def test_optional_field_absent_is_fine() -> None: + # name optional and absent, email optional and present → valid, only email kept. + canonical, errors = validate_intake([_field("name", False), _field("email", False)], {"email": "a@b.co"}) + assert errors == [] + assert canonical == {"email": "a@b.co"} + + +def test_valid_submit_normalizes() -> None: + fields = [_field("email", True), _field("phone", False)] + values = {"name": " Alice Example ", "email": "alice@Example.com", "phone": "(555) 123-4567"} + canonical, errors = validate_intake(fields, values) + assert errors == [] + assert canonical == {"name": "Alice Example", "email": "alice@example.com", "phone": "+15551234567"} + + +def test_bad_email_is_a_field_error() -> None: + canonical, errors = validate_intake([_field("email", True)], {"email": "not-an-email"}) + assert canonical is None + assert len(errors) == 1 + assert errors[0].field == "email" + assert "valid email" in errors[0].message + + +def test_all_errors_reported_in_one_pass() -> None: + # missing required name + bad email + bad phone → three field errors, one round-trip. + canonical, errors = validate_intake([_field("name", True)], {"email": "not-an-email", "phone": "nope"}) + assert canonical is None + assert len(errors) == 3, f"{errors!r}" + assert {e.field for e in errors} == {"name", "email", "phone"} + + +def test_volunteered_field_is_kept() -> None: + # Only email requested, but the visitor volunteered a phone — keep it. + canonical, errors = validate_intake([_field("email", True)], {"email": "a@b.co", "phone": "+15551234567"}) + assert errors == [] + assert canonical["phone"] == "+15551234567" + + +def test_parse_fields_accepts_structured_and_shorthand() -> None: + # Structured form. + fields = parse_fields([{"key": "email", "required": True, "label": "Work email"}]) + assert fields == [{"key": "email", "required": True, "label": "Work email"}] + # Shorthand strings → required: True. + fields2 = parse_fields(["name", "phone"]) + assert fields2 == [{"key": "name", "required": True}, {"key": "phone", "required": True}] + # required defaults to True when omitted in the object form. + assert parse_fields([{"key": "phone"}]) == [{"key": "phone", "required": True}] + + +def test_parse_fields_enforces_the_contract() -> None: + with pytest.raises(ValueError): # not an array + parse_fields("email") + with pytest.raises(ValueError): # empty + parse_fields([]) + with pytest.raises(ValueError): # unknown key + parse_fields(["ssn"]) + with pytest.raises(ValueError): # object without a string key + parse_fields([{"required": True}]) + + +def test_kind_wires_the_reference_surface() -> None: + kind = IdentityIntakeKind() + assert kind.kind() == "identity_intake" + assert kind.capability() == "identity_form" + assert kind.tool_schema()["name"] == "request_identity_intake" + + req = kind.parse_request({"fields": ["email", {"key": "phone", "required": False}], "reason": "to send the quote"}) + assert req.kind == "identity_intake" + assert req.reason == "to send the quote" + assert req.spec["fields"][0] == {"key": "email", "required": True} + + canonical, errors = kind.validate(req.spec, {"email": "a@b.co"}) + assert errors == [] + assert canonical == {"email": "a@b.co"} + + # Empty submit (no field carries a value) is rejected with a values-level error. + none_canonical, none_errors = kind.validate(req.spec, {}) + assert none_canonical is None + assert none_errors[0].field == "values" + + directive = kind.fallback_directive(req.spec, "to send the quote") + assert "email, phone" in directive + assert "submit_interaction" in directive + + +def test_shared_fixtures_validate_to_the_canonical_payload(fixtures: dict) -> None: + """The shared ``identity_intake`` fixtures: the golden spec + values must validate to + exactly the golden payload's values (the same golden shapes the Rust/Go/TS/C# engines + check).""" + spec = fixtures["identity_intake_spec"]["instance"] + values = fixtures["identity_intake_values"]["instance"] + expected = fixtures["identity_intake_payload"]["instance"]["values"] + + canonical, errors = IdentityIntakeKind().validate(spec, values) + assert errors == [] + assert canonical == expected diff --git a/python/server/tests/test_turn_runner_agent_config.py b/python/server/tests/test_turn_runner_agent_config.py index c05fcd77..f17737c7 100644 --- a/python/server/tests/test_turn_runner_agent_config.py +++ b/python/server/tests/test_turn_runner_agent_config.py @@ -258,7 +258,7 @@ async def test_tool_config_filters_tools_per_agent() -> None: # Rich Interaction framework tools (request_ + submit_interaction) are always # registered — NOT subject to the agent allow-list, mirroring the Rust runner — so # assert the AGENT tool subset with those filtered out. - _INTERACTION = {"request_choices", "submit_interaction"} + _INTERACTION = {"request_choices", "request_identity_intake", "submit_interaction"} assert [n for n in _sent_tool_names(mock.calls[0]) if n not in _INTERACTION] == ["crm"] assert [n for n in _sent_tool_names(mock.calls[1]) if n not in _INTERACTION] == [ "crm", diff --git a/python/server/uv.lock b/python/server/uv.lock index 7a86a627..634e5b26 100644 --- a/python/server/uv.lock +++ b/python/server/uv.lock @@ -846,7 +846,7 @@ wheels = [ [[package]] name = "smooai-smooth-operator-server" -version = "1.54.1" +version = "1.55.0" source = { editable = "." } dependencies = [ { name = "opentelemetry-api" },