diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 819aed8..54fe1e2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -27,13 +27,18 @@ jobs: run: pip install build twine - name: Install package - run: pip install . + run: pip install ".[dev]" - name: Verify import run: python -c "from pine_assistant import PineAI, AsyncPineAI, __version__; print(f'pine-assistant {__version__} OK')" + - name: Lint + run: ruff check src tests + + # Contract and flow tests run offline against recorded fixtures. + # tests/integration needs a token and spends credits — see its README. - name: Run tests - run: pip install pytest pytest-asyncio && pytest tests/ -v --ignore=tests/integration + run: pytest tests/ -v --ignore=tests/integration - name: Build distribution run: python -m build diff --git a/CHANGELOG.md b/CHANGELOG.md index 205b620..6f60783 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,70 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/), and this project adheres to [Semantic Versioning](https://semver.org/). +## [0.4.0] - 2026-08-08 + +Aligned to the supported protocol scope: the subset of the task-session +Socket.IO protocol whose names, payloads, and semantics carry a compatibility +guarantee. What the SDK models is now that subset and nothing else. + +### Added + +- `is_supported_event()` and `SUPPORTED_EVENTS` — whether an event carries the + guarantee. +- `session:llm_thinking`, `session:tool_status`, `session:required_action` and + `session:restriction`, with models. All four are in the supported scope and + none were modelled before; `session:tool_status` is where an outbound call + reports its number, duration, credits, and textual outcome. +- `AsyncPineAI.rebuild()` — pages through history until the cursor is + exhausted. Recovery is an unconditional rebuild: joining never resumes from a + cursor, and a short or empty page does not mean a range is done. +- `AsyncPineAI.on_reconnect()` — fires after a reconnect has re-joined, so + callers can rebuild. A connection can stay open after delivery has stopped. +- `InputState` with `awaiting_credits` and `needs_phone_verification`. A + blocking condition is read from `session:input_state`, because the events that + elaborate on one are mostly outside the scope. +- `AsyncPineAI.emit_event()` — the escape hatch for sending anything outside the + supported surface. +- Protocol fixtures and contract tests under `tests/protocol`, and + `tests/integration/record_fixtures.py` to record them from a live session. + Re-recording is the only way server drift gets noticed. + +### Changed + +- `session:join` now carries `since_revision` "0", on first join and on + reconnect. The incremental-synchronization fields in the response are ignored. +- Events are deduplicated on the event identifier together with the message + type. Keying on the identifier alone drops real events, since identifiers + collide across types. +- A turn begins and ends on supported events only. It previously hinged on + `session:ask_for_location`, `session:interactive_auth_confirmation`, + `session:three_way_call` and `session:reward`, none of which are maintained. + +### Fixed + +- Sessions joined through `join_session()` were never re-joined after a + reconnect. Membership was tracked on the fire-and-forget emit path only, while + joining goes out through the request/response path. + +### Removed + +Everything below is still emitted by the server and still reaches callers +untouched — the SDK just no longer models it. Send with `emit_event()`. + +- `send_auth_confirmation()`, `send_location_response()`, + `send_location_selection()`. +- `NotificationEvent`, the `notification:*` constants, and the `session:reward` + and `session:payment` models. +- The out-of-scope `S2CEvent` and `C2SEvent` members, including + `session:work_log`, `session:work_log_part` and `session:thinking`. The + reasoning stream in scope is `session:llm_thinking`, a different event that + the SDK did not previously carry. +- The `action` argument on `chat()` and `send_message()`, and `request_work_log` + on `get_history()`. +- Wall-clock filtering of events older than the moment a turn began. It + contradicts rebuilding from history, and a clock offset made it drop real + events. + ## [0.3.3] - 2026-05-23 ### Fixed diff --git a/README.md b/README.md index 882bd31..39917c6 100644 --- a/README.md +++ b/README.md @@ -23,6 +23,7 @@ await client.connect() session = await client.sessions.create() await client.join_session(session["id"]) +await client.rebuild(session["id"]) # load the session's messages async for event in client.chat(session["id"], "Negotiate my Comcast bill"): print(event.type, event.data) @@ -30,6 +31,8 @@ async for event in client.chat(session["id"], "Negotiate my Comcast bill"): await client.disconnect() ``` +A client tracks one session. Concurrent sessions need one client each. + ## Quick Start (CLI) ```bash @@ -37,48 +40,174 @@ pine auth login # Email verification pine chat # Interactive REPL pine send "Negotiate my Comcast bill" # One-shot message pine sessions list # List sessions -pine task start # Start task (Pro) +pine task start # Start task ``` -## Handling Events +## The supported surface + +The SDK models the supported protocol scope: the events whose names, +payloads, and semantics change compatibly or with notice. + +**Connection and session** + +| Event | What it is for | +|---|---| +| `ready` | Authentication succeeded and the connection is usable. Nothing is sent before it | +| `session:join` | Enter a session and read its current state. Sent both ways under this name | +| `session:history` | Read persisted messages. Also the only recovery mechanism in this scope | +| `session:error` | The only channel for server-reported failures | + +**Conversation** + +| Event | What it is for | +|---|---| +| `session:message` | Your input. Sent to the server, and returned under the same name in history | +| `session:text` | A complete agent message — the durable record | +| `session:text_part` | Streaming increments of one message, assembled by `message_id` | +| `session:rich_content` | A structured document, such as a search report. Its body is **not** repeated in `session:text`; ignore this event and the content is lost | +| `session:llm_thinking` | Reasoning and tool-call trace. Search has no event of its own — it appears here as a `tool_call` step | + +**Session state** + +| Event | What it is for | +|---|---| +| `session:state` | Where the task stands in its lifecycle | +| `session:input_state` | Whether input is accepted, and the reason when it is not. This is where a blocked session says why | +| `session:message_status` | What became of a message you sent — the only way to tell a rejected or rate-limited one from one still being worked on | +| `session:required_action` | Whether the session is waiting on you | +| `session:update_title` | The session title, as the agent revises it | +| `session:restriction` | An account restriction. The only statement that a task will not complete | + +**Interaction** + +| Event | What it is for | +|---|---| +| `session:form_to_user` | Structured data collection — how a task asks for the account details it needs to act. Sent both ways under this name, and the most frequent interaction here | + +**Task and result** + +| Event | What it is for | +|---|---| +| `session:task_ready` | What the task will cost in credits, and whether it is authorised. When the balance covers it the server starts the task itself and this is informational; when it does not, the session waits | +| `session:task_finished` | The result. `completion.result_title`, `result_description` and `outcome_narrative` carry the text; `completion.summary` is quantified, and `brief` is its only prose | +| `session:tool_status` | The record of one asynchronous operation. An outbound call reports here: the number, the duration, the credits, and `summary.text`. It updates in place, reusing its `message_id`, so expect several with the same one | + +Payloads may gain fields at any time — tolerate fields you do not recognise. + +A `tool_call` step in `session:llm_thinking` describes the same operation as the +matching `session:tool_status`. Do not show both. + +A turn commonly delivers `session:text_part` alone: the composer reopens once +the agent has finished speaking, and the complete `session:text` is the durable +record, read back from history. Assemble the parts by `message_id` rather than +waiting for the complete message to arrive live. + +## Everything else passes through + +The server emits many more events. The SDK delivers every one of them unchanged +rather than dropping them, but it models none of them: + +```python +from pine_assistant import is_supported_event + +async for event in client.chat(session_id, "..."): + if not is_supported_event(event.type): + continue # or handle it yourself, at your own risk +``` + +An unsupported event may be renamed, have its payload changed, or stop being +emitted, without notice and without a version change. Tolerating one is +required; depending on one is not. To send one, use `client.emit_event(...)`. + +Some of them are questions to the user that the SDK has no interface for. +Ignoring one leaves the conversation suspended, and the composer stays open — +show the message text and let the user answer in ordinary conversation. Never +fabricate an answer: the formats have no representation for refusal, and an +empty submission is indistinguishable from empty answers, so the agent may act +on it. Sending nothing is safe. -Pine AI behaves like a human assistant. After you send a message, it sends -acknowledgments, then work logs, then the real response (form, text, or task_ready). -**Don't respond to acknowledgments** — only respond to forms, specific questions, -and task lifecycle events, or you'll create an infinite loop. +## What to respond to -## Continuing Existing Sessions +Pine works the way a person would: a message is acknowledged, then reasoned +about, and only then answered. Acknowledgements and `session:llm_thinking` +arrive before the real response — a form, a text answer, or a task ready to run. + +Respond only to what asks you something: `session:form_to_user`, a direct +question, and the task lifecycle. Replying to an acknowledgement starts a loop +in which each side answers the other's filler. + +## Continuing an existing session ```python -# List all sessions result = await client.sessions.list(limit=20) -# Continue an existing session await client.join_session(existing_session_id) -history = await client.get_history(existing_session_id) +messages = await client.rebuild(existing_session_id) async for event in client.chat(existing_session_id, "What is the status?"): ... ``` -## Attachments +To hand a session back to the user in the web app: ```python -# Upload a document for dispute tasks -attachments = await client.sessions.upload_attachment("bill.pdf") +print(AsyncPineAI.session_url(session_id)) +``` + +## Recovery + +State is rebuilt, never resumed. `join_session()` always joins from scratch, +and `rebuild()` pages through history until the cursor is exhausted — a short +or empty page does not mean the range is done. + +```python +remove = client.on_reconnect(lambda: asyncio.create_task(reload(session_id))) ``` -## Stream Buffering +Rebuild on every join, on every reconnect, and whenever a session you are +tracking has been silent for a while: a connection can stay open after delivery +has stopped. -Text streaming is buffered internally. You receive one merged text event, -not individual chunks. Work log parts are debounced (3s silence). +`rebuild()` returns messages of every type, including unsupported ones. +Filtering them is yours to do. -## Payment +## Blocked sessions -Pro subscription recommended. For non-subscribers: +When the composer is disabled, `session:input_state` carries the reason. Read it +from there rather than inferring it from which events did or did not arrive. ```python -from pine_assistant import AsyncPineAI -print(f"Pay at: {AsyncPineAI.session_url(session_id)}") +from pine_assistant import InputState, S2CEvent + +if event.type == S2CEvent.SESSION_INPUT_STATE: + state = InputState.model_validate(event.data) + if state.awaiting_credits: + ... # cost is on session:task_ready; retry once the balance is restored + if state.needs_phone_verification: + ... # no in-session remedy +``` + +An expired session has no reason code of its own — it presents only as a +disabled composer. Expiry is the `is_stale` field on the session object, over +REST. On finding one expired, create a new session and reference the old one in +your first message: + +```python +new = await client.sessions.create() +client.send_message(new["id"], "...", referenced_sessions=[{"session_id": old_id}]) +``` + +## Before an account is used + +Two conditions have no remedy once a session is running: + +- **Metered billing.** The account must be billed against a credit balance. On + the alternative path a session halts at a payment step the SDK cannot answer. +- **Phone verification.** Must be completed at provisioning time. + +## Attachments + +```python +attachments = await client.sessions.upload_attachment("bill.pdf") ``` ## License diff --git a/pyproject.toml b/pyproject.toml index fbc527f..3dacf90 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "pine-assistant" -version = "0.3.3" +version = "0.4.0" description = "Pine AI SDK — Let Pine AI handle your digital chores. Socket.IO + REST client." readme = "README.md" license = "MIT" @@ -55,6 +55,11 @@ line-length = 120 select = ["E", "F", "I", "W", "UP", "B", "SIM"] ignore = ["E501"] +[tool.ruff.lint.per-file-ignores] +"src/pine_assistant/models/__init__.py" = ["F403"] # deliberate star re-export +"src/pine_assistant/cli/main.py" = ["E402"] # subcommands import after the group exists +"tests/integration/*.py" = ["B017"] # a live server's failure type is not ours to pin + [tool.ruff.lint.isort] known-first-party = ["pine_assistant"] diff --git a/src/pine_assistant/__init__.py b/src/pine_assistant/__init__.py index c66b688..c7a5228 100644 --- a/src/pine_assistant/__init__.py +++ b/src/pine_assistant/__init__.py @@ -3,25 +3,40 @@ Let Pine AI handle your digital chores. Socket.IO + REST client for the Pine AI backend. + +The SDK models the supported protocol scope. Events outside it are delivered +verbatim but carry no compatibility guarantee: tolerate them, do not depend on +them. `is_supported_event` tells the two apart. """ -from pine_assistant.client import PineAI, AsyncPineAI from pine_assistant.auth import Auth +from pine_assistant.chat import ChatEvent +from pine_assistant.client import AsyncPineAI, PineAI +from pine_assistant.errors import AuthError, ConnectionError, PineAIError, SessionError +from pine_assistant.models.events import ( + SUPPORTED_EVENTS, + C2SEvent, + S2CEvent, + is_supported_event, +) +from pine_assistant.models.session import InputState, InputStateCode from pine_assistant.sessions import SessionsAPI -from pine_assistant.errors import PineAIError, AuthError, SessionError, ConnectionError -from pine_assistant.models.events import C2SEvent, S2CEvent, NotificationEvent -__version__ = "0.3.3" +__version__ = "0.4.0" __all__ = [ "PineAI", "AsyncPineAI", "Auth", "SessionsAPI", + "ChatEvent", "PineAIError", "AuthError", "SessionError", "ConnectionError", "C2SEvent", "S2CEvent", - "NotificationEvent", + "SUPPORTED_EVENTS", + "is_supported_event", + "InputState", + "InputStateCode", ] diff --git a/src/pine_assistant/auth.py b/src/pine_assistant/auth.py index ea6a5bd..e1e6cbb 100644 --- a/src/pine_assistant/auth.py +++ b/src/pine_assistant/auth.py @@ -6,8 +6,8 @@ from typing import Any -from pine_assistant.transport.http import HttpClient from pine_assistant.errors import AuthError +from pine_assistant.transport.http import HttpClient class Auth: @@ -19,7 +19,7 @@ async def request_code(self, email: str) -> dict[str, Any]: try: return await self._http.post("/v2/auth/email/request", {"email": email}, authenticated=False) except Exception as e: - raise AuthError(f"Failed to request auth code: {e}") + raise AuthError(f"Failed to request auth code: {e}") from e async def verify_code(self, email: str, code: str, request_token: str) -> dict[str, Any]: """Step 2: Verify code and get access token — spec 4.1.2""" @@ -32,4 +32,4 @@ async def verify_code(self, email: str, code: str, request_token: str) -> dict[s self._http.set_token(result["access_token"]) return result except Exception as e: - raise AuthError(f"Failed to verify auth code: {e}") + raise AuthError(f"Failed to verify auth code: {e}") from e diff --git a/src/pine_assistant/chat.py b/src/pine_assistant/chat.py index 9ed6984..aeaaf0a 100644 --- a/src/pine_assistant/chat.py +++ b/src/pine_assistant/chat.py @@ -1,49 +1,99 @@ """ Chat engine — send messages and yield events via async generator. -All events are dispatched immediately as they arrive from the server. +Every event reaches the caller, whether or not the SDK recognises it. Only the +supported surface drives control flow: what terminates a turn, what counts as a +response, and what gets deduplicated are all decided from scope events. """ import asyncio -from datetime import datetime, timedelta, timezone -from typing import Any, AsyncGenerator, Callable, Coroutine, Optional +from collections.abc import AsyncGenerator, Callable, Coroutine +from typing import Any from pine_assistant.models.events import C2SEvent, S2CEvent +from pine_assistant.models.session import ACCEPTING_INPUT from pine_assistant.transport.socketio import SocketIOManager TERMINAL_STATES = {"task_finished", "task_cancelled", "task_stale"} DEFAULT_IDLE_TIMEOUT_S = 120.0 DEFAULT_RESPONSE_IDLE_TIMEOUT_S = 2.0 -SUBSTANTIVE_EVENTS = { - S2CEvent.SESSION_TEXT, S2CEvent.SESSION_TEXT_PART, +# Joining always rebuilds from history rather than resuming a cursor: the +# incremental mechanism is gated and may be unavailable, an unconditional +# rebuild is not. +FULL_REBUILD_REVISION = "0" + +# An agent response, for the purpose of deciding a turn has begun. Scope events +# only — a turn must not hinge on an event we do not maintain. +SUBSTANTIVE_EVENTS = frozenset({ + S2CEvent.SESSION_TEXT, + S2CEvent.SESSION_TEXT_PART, + S2CEvent.SESSION_RICH_CONTENT, S2CEvent.SESSION_FORM_TO_USER, - S2CEvent.SESSION_ASK_FOR_LOCATION, S2CEvent.SESSION_TASK_READY, - S2CEvent.SESSION_TASK_FINISHED, S2CEvent.SESSION_INTERACTIVE_AUTH_CONFIRMATION, - S2CEvent.SESSION_THREE_WAY_CALL, S2CEvent.SESSION_REWARD, -} + S2CEvent.SESSION_TASK_READY, + S2CEvent.SESSION_TASK_FINISHED, + S2CEvent.SESSION_TOOL_STATUS, + S2CEvent.SESSION_RESTRICTION, +}) class ChatEvent: - __slots__ = ("type", "session_id", "message_id", "data", "metadata") + __slots__ = ("type", "session_id", "message_id", "data", "metadata", "event_id") def __init__(self, type: str, session_id: str, data: Any, - message_id: Optional[str] = None, metadata: Optional[dict[str, Any]] = None): + message_id: str | None = None, metadata: dict[str, Any] | None = None, + event_id: str | None = None): self.type = type self.session_id = session_id self.message_id = message_id self.data = data self.metadata = metadata + self.event_id = event_id def __repr__(self) -> str: return f"ChatEvent(type={self.type!r}, session_id={self.session_id!r})" +def event_from_envelope(event_type: str, raw: dict[str, Any], session_id: str) -> ChatEvent: + """Build a ChatEvent from a raw envelope, carrying the payload through as-is.""" + payload = raw.get("payload") or {} + metadata = raw.get("metadata") + return ChatEvent( + type=event_type, + session_id=session_id, + message_id=payload.get("message_id"), + data=payload.get("data"), + metadata=metadata, + event_id=(metadata or {}).get("event_id") if isinstance(metadata, dict) else None, + ) + + +class Deduplicator: + """Suppresses events already seen. + + Keyed on the event identifier together with the message type — never the + identifier alone, which collides across types. An event with no identifier + cannot be keyed and is always passed through. + """ + + def __init__(self) -> None: + self._seen: set[tuple[str, str]] = set() + + def is_duplicate(self, event: ChatEvent) -> bool: + if not event.event_id: + return False + key = (event.event_id, event.type) + if key in self._seen: + return True + self._seen.add(key) + return False + + class ChatEngine: def __init__( self, sio: SocketIOManager, - check_session_state: Optional[Callable[[str], Coroutine[Any, Any, dict[str, Any]]]] = None, + check_session_state: Callable[[str], Coroutine[Any, Any, dict[str, Any]]] | None = None, idle_timeout_s: float = DEFAULT_IDLE_TIMEOUT_S, response_idle_timeout_s: float = DEFAULT_RESPONSE_IDLE_TIMEOUT_S, ): @@ -53,133 +103,113 @@ def __init__( self._response_idle_timeout_s = response_idle_timeout_s async def join_session(self, session_id: str) -> dict[str, Any]: - """Join a session room — spec 5.1.1. - Production handler reads payload.session_id (set by envelope builder). + """Enter a session and retrieve its current state. + + `since_revision` is always "0" and the incremental-synchronization + fields in the response are ignored; callers rebuild from history. """ return await self._sio.emit_and_wait( C2SEvent.SESSION_JOIN, - None, # payload.data is not used for join + {"since_revision": FULL_REBUILD_REVISION}, session_id=session_id, ) def leave_session(self, session_id: str) -> None: - """Leave a session room.""" - self._sio.emit(C2SEvent.SESSION_LEAVE, None, session_id) + """Leave a session room. + + Room management, outside the supported protocol scope; a connection + tracks one session, so disconnecting is the alternative. + """ + self._sio.emit("session:leave", None, session_id) @staticmethod def _build_message_data( content: str, - attachments: Optional[list[dict[str, Any]]] = None, - referenced_sessions: Optional[list[dict[str, str]]] = None, - action: Optional[dict[str, Any]] = None, + attachments: list[dict[str, Any]] | None = None, + referenced_sessions: list[dict[str, str]] | None = None, ) -> dict[str, Any]: - """Build the session:message payload per spec 5.1.1.""" from datetime import datetime - data: dict[str, Any] = { + return { "content": content, "attachments": attachments or [], "referenced_sessions": referenced_sessions or [], "client_now_date": datetime.now().isoformat(), } - if action is not None: - data["action"] = action - return data async def chat( self, session_id: str, content: str, *, - attachments: Optional[list[dict[str, Any]]] = None, - referenced_sessions: Optional[list[dict[str, str]]] = None, - action: Optional[dict[str, Any]] = None, + attachments: list[dict[str, Any]] | None = None, + referenced_sessions: list[dict[str, str]] | None = None, ) -> AsyncGenerator[ChatEvent, None]: - """Send a message and yield events with stream buffering. - Production handler reads payload.data as {content, attachments, ...}. - """ - cutoff = datetime.now(timezone.utc) - timedelta(seconds=5) + """Send a message and yield the events that follow.""" self._sio.emit( C2SEvent.SESSION_MESSAGE, - self._build_message_data(content, attachments, referenced_sessions, action), + self._build_message_data(content, attachments, referenced_sessions), session_id, ) async for event in self._listen(session_id, _skip_state_precheck=True): - if self._is_stale_event(event, cutoff): - continue yield event - @staticmethod - def _is_stale_event(event: "ChatEvent", cutoff: datetime) -> bool: - """Return True if the event's metadata timestamp predates the cutoff.""" - meta = event.metadata - if not isinstance(meta, dict): - return False - ts_str = meta.get("timestamp") - if not ts_str: - return False - try: - event_ts = datetime.fromisoformat(ts_str.replace("Z", "+00:00")) - return event_ts < cutoff - except (ValueError, TypeError): - return False - def send_message( self, session_id: str, content: str, *, - attachments: Optional[list[dict[str, Any]]] = None, - referenced_sessions: Optional[list[dict[str, str]]] = None, - action: Optional[dict[str, Any]] = None, + attachments: list[dict[str, Any]] | None = None, + referenced_sessions: list[dict[str, str]] | None = None, ) -> None: """Fire-and-forget message send (no event listening).""" self._sio.emit( C2SEvent.SESSION_MESSAGE, - self._build_message_data(content, attachments, referenced_sessions, action), + self._build_message_data(content, attachments, referenced_sessions), session_id, ) async def _listen( self, session_id: str, *, _skip_state_precheck: bool = False, ) -> AsyncGenerator[ChatEvent, None]: - """Listen for events — all events dispatched immediately.""" + """Yield events for a session until the turn ends.""" if not _skip_state_precheck and self._check_session_state: try: session = await self._check_session_state(session_id) if session.get("state") in TERMINAL_STATES: - yield ChatEvent(type=S2CEvent.SESSION_STATE, session_id=session_id, data={"content": session["state"]}) + yield ChatEvent(type=S2CEvent.SESSION_STATE, session_id=session_id, + data={"content": session["state"]}) return except Exception: pass # best effort - queue: asyncio.Queue[Optional[ChatEvent]] = asyncio.Queue() + queue: asyncio.Queue[ChatEvent | None] = asyncio.Queue() + dedup = Deduplicator() done = False received_agent_response = False def handler(event: str, raw: dict[str, Any]) -> None: nonlocal done, received_agent_response - payload = raw.get("payload", {}) + payload = raw.get("payload") or {} p_session_id = payload.get("session_id") if p_session_id and p_session_id != session_id: return - queue.put_nowait(ChatEvent( - type=event, session_id=session_id, - message_id=payload.get("message_id"), - data=payload.get("data"), - metadata=raw.get("metadata"), - )) + chat_event = event_from_envelope(event, raw, session_id) + if dedup.is_duplicate(chat_event): + return + queue.put_nowait(chat_event) + if event in SUBSTANTIVE_EVENTS: received_agent_response = True - if event == S2CEvent.SESSION_INPUT_STATE and isinstance(payload.get("data"), dict): - if payload["data"].get("content") == "waiting_input" and received_agent_response: - done = True - queue.put_nowait(None) - if event == S2CEvent.SESSION_STATE and isinstance(payload.get("data"), dict): - state = payload["data"].get("content", "") - if state in TERMINAL_STATES: - done = True - queue.put_nowait(None) + data = payload.get("data") + if (event == S2CEvent.SESSION_INPUT_STATE and isinstance(data, dict) + and data.get("content") == ACCEPTING_INPUT and received_agent_response): + done = True + queue.put_nowait(None) + if (event == S2CEvent.SESSION_STATE and isinstance(data, dict) + and data.get("content", "") in TERMINAL_STATES): + done = True + queue.put_nowait(None) remove_handler = self._sio.add_event_handler(handler) @@ -195,7 +225,8 @@ def handler(event: str, raw: dict[str, Any]) -> None: try: session = await self._check_session_state(session_id) if session.get("state") in TERMINAL_STATES: - yield ChatEvent(type=S2CEvent.SESSION_STATE, session_id=session_id, data={"content": session["state"]}) + yield ChatEvent(type=S2CEvent.SESSION_STATE, session_id=session_id, + data={"content": session["state"]}) break except Exception: pass @@ -211,17 +242,11 @@ def handler(event: str, raw: dict[str, Any]) -> None: remove_handler() def send_form_response(self, session_id: str, message_id: str, form_data: dict[str, Any]) -> None: - """Production handler reads payload.data.content as form key-value pairs.""" - self._sio.emit(C2SEvent.SESSION_FORM_TO_USER, {"content": form_data}, session_id, message_id) - - def send_auth_confirmation(self, session_id: str, message_id: str, data: dict[str, Any]) -> None: - """Production handler reads payload.data.content as confirmation data.""" - self._sio.emit(C2SEvent.SESSION_INTERACTIVE_AUTH_CONFIRMATION, {"content": data}, session_id, message_id) + """Answer a `session:form_to_user` request. - def send_location_response(self, session_id: str, message_id: str, latitude: str, longitude: str) -> None: - """Production handler reads payload.data.content as {latitude, longitude}.""" - self._sio.emit(C2SEvent.SESSION_ASK_FOR_LOCATION, {"content": {"latitude": latitude, "longitude": longitude}}, session_id, message_id) - - def send_location_selection(self, session_id: str, message_id: str, places: list[dict[str, Any]]) -> None: - """Production handler reads payload.data.list as place objects.""" - self._sio.emit(C2SEvent.SESSION_LOCATION_SELECTION, {"list": places}, session_id, message_id) + Never submit values the user did not supply: the format defines no + representation for refusal, an empty submission is indistinguishable + from empty answers, and the agent may act on it. Sending nothing is + safe. + """ + self._sio.emit(C2SEvent.SESSION_FORM_TO_USER, {"content": form_data}, session_id, message_id) diff --git a/src/pine_assistant/cli/auth.py b/src/pine_assistant/cli/auth.py index bf33f91..fcabd0c 100644 --- a/src/pine_assistant/cli/auth.py +++ b/src/pine_assistant/cli/auth.py @@ -1,6 +1,5 @@ """CLI: pine auth login|status|logout""" -from typing import Optional import click from rich.console import Console @@ -32,7 +31,7 @@ def auth(): @auth.command("login") @click.option("--base-url", default=None, help="Pine AI base URL") -def auth_login(base_url: Optional[str]): +def auth_login(base_url: str | None): """Log in with email verification.""" async def _login(): diff --git a/src/pine_assistant/cli/chat.py b/src/pine_assistant/cli/chat.py index 716bfe6..6688fa9 100644 --- a/src/pine_assistant/cli/chat.py +++ b/src/pine_assistant/cli/chat.py @@ -1,7 +1,6 @@ """CLI: pine chat, pine send""" import json -from typing import Optional import click from rich.console import Console @@ -23,7 +22,7 @@ def _run(coro): @click.command("chat") @click.argument("session_id", required=False) -def chat_cmd(session_id: Optional[str]): +def chat_cmd(session_id: str | None): """Interactive chat with Pine AI.""" async def _chat(): @@ -64,7 +63,7 @@ async def _chat(): @click.argument("message") @click.option("-s", "--session", "session_id", default=None) @click.option("--json-output", "--json", is_flag=True) -def send_cmd(message: str, session_id: Optional[str], json_output: bool): +def send_cmd(message: str, session_id: str | None, json_output: bool): """Send a one-shot message.""" async def _send(): diff --git a/src/pine_assistant/cli/main.py b/src/pine_assistant/cli/main.py index 7a10137..7f08fdf 100644 --- a/src/pine_assistant/cli/main.py +++ b/src/pine_assistant/cli/main.py @@ -16,9 +16,10 @@ try: import click from rich.console import Console -except ImportError: - raise SystemExit("CLI requires extras: pip install pine-assistant[cli]") +except ImportError as exc: + raise SystemExit("CLI requires extras: pip install pine-assistant[cli]") from exc +from pine_assistant import __version__ from pine_assistant.client import AsyncPineAI console = Console() @@ -54,7 +55,7 @@ def _run(coro): @click.group() -@click.version_option("0.1.0") +@click.version_option(__version__) def main(): """Pine AI CLI — Let Pine AI handle your digital chores.""" diff --git a/src/pine_assistant/client.py b/src/pine_assistant/client.py index 9fc6a4a..a923f2c 100644 --- a/src/pine_assistant/client.py +++ b/src/pine_assistant/client.py @@ -6,24 +6,28 @@ import logging import os import uuid +from collections.abc import AsyncGenerator, Callable from pathlib import Path -from typing import Any, AsyncGenerator, Generator, Optional +from typing import Any -from pine_assistant.transport.http import HttpClient, DEFAULT_BASE_URL -from pine_assistant.transport.socketio import SocketIOManager from pine_assistant.auth import Auth -from pine_assistant.sessions import SessionsAPI -from pine_assistant.chat import ChatEngine, ChatEvent +from pine_assistant.chat import ChatEngine, ChatEvent, Deduplicator, event_from_envelope from pine_assistant.errors import ConnectionError from pine_assistant.models.events import C2SEvent +from pine_assistant.sessions import SessionsAPI +from pine_assistant.transport.http import DEFAULT_BASE_URL, HttpClient +from pine_assistant.transport.socketio import SocketIOManager DEVICE_ID_FILE = Path.home() / ".pine" / "device_id" DEVICE_ID_ENV = "PINE_DEVICE_ID" +HISTORY_PAGE_SIZE = 30 +HISTORY_MAX_BYTES = 5_242_880 + _logger = logging.getLogger(__name__) -def _get_or_create_device_id(provided: Optional[str] = None) -> str: +def _get_or_create_device_id(provided: str | None = None) -> str: """Resolve a stable device_id. Precedence: explicit argument → PINE_DEVICE_ID env var → ~/.pine/device_id @@ -55,15 +59,18 @@ def _get_or_create_device_id(provided: Optional[str] = None) -> str: class AsyncPineAI: - """Async Pine AI client (primary).""" + """Async Pine AI client (primary). + + A client tracks one session. Concurrent sessions need one client each. + """ def __init__( self, - access_token: Optional[str] = None, - user_id: Optional[str] = None, + access_token: str | None = None, + user_id: str | None = None, base_url: str = DEFAULT_BASE_URL, - device_id: Optional[str] = None, - transports: Optional[list[str]] = None, + device_id: str | None = None, + transports: list[str] | None = None, ready_timeout: float = 15.0, ): self._base_url = base_url @@ -77,14 +84,14 @@ def __init__( self.auth = Auth(self.http) self.sessions = SessionsAPI(self.http) - self._sio: Optional[SocketIOManager] = None - self._chat: Optional[ChatEngine] = None + self._sio: SocketIOManager | None = None + self._chat: ChatEngine | None = None @property def connected(self) -> bool: return self._sio is not None and self._sio.connected - async def connect(self, access_token: Optional[str] = None, user_id: Optional[str] = None) -> None: + async def connect(self, access_token: str | None = None, user_id: str | None = None) -> None: token = access_token or self._access_token uid = user_id or self._user_id if not token or not uid: @@ -109,49 +116,98 @@ async def disconnect(self) -> None: self._chat = None async def join_session(self, session_id: str) -> dict[str, Any]: - """Join a session room — must be called before chatting.""" + """Enter a session — must be called before chatting. + + The join carries `since_revision` "0" and its incremental-sync fields + are ignored. Call `rebuild()` after joining to load the session's + messages; a join alone does not deliver them. + """ self._ensure_connected() return await self._chat.join_session(session_id) # type: ignore[union-attr] def leave_session(self, session_id: str) -> None: - """Leave a session room when done.""" + """Leave a session room. + + Room management, outside the supported protocol scope. + """ self._ensure_connected() self._chat.leave_session(session_id) # type: ignore[union-attr] + def on_reconnect(self, handler: Callable[[], None]) -> Callable[[], None]: + """Register a callback fired after a reconnect re-joins its sessions. + + Rebuild from `rebuild()` when it fires: a connection can stay open after + delivery has stopped, so anything accumulated before it is unreliable. + """ + self._ensure_connected() + return self._sio.add_reconnect_handler(handler) # type: ignore[union-attr] + async def get_history( - self, session_id: str, max_messages: int = 30, order: str = "desc", - from_message_id: Optional[str] = None, request_work_log: bool = False, + self, session_id: str, max_messages: int = HISTORY_PAGE_SIZE, order: str = "desc", + from_message_id: str | None = None, ) -> dict[str, Any]: - """Fetch message history — spec 5.1.1 session:history.""" + """Fetch one page of persisted messages. + + A short or empty page does not mean the range is exhausted — only an + absent `next_message_id` does. Use `rebuild()` unless you are paging + deliberately. + """ self._ensure_connected() return await self._sio.emit_and_wait( # type: ignore[union-attr] C2SEvent.SESSION_HISTORY, { "max_messages": max_messages, - "max_bytes": 5_242_880, + "max_bytes": HISTORY_MAX_BYTES, "order": order, "from_message_id": from_message_id, - "request_work_log": request_work_log, }, session_id=session_id, ) + async def rebuild( + self, session_id: str, *, page_size: int = HISTORY_PAGE_SIZE, max_pages: int = 100, + ) -> list[dict[str, Any]]: + """Rebuild a session's messages from history, unconditionally. + + This is the recovery mechanism: run it on every join and every + reconnect, and whenever a tracked session has been silent for an + extended period. Paging stops only when the cursor is exhausted. + + Messages of every type are returned, including ones outside the + supported scope; filtering is the caller's to do. + """ + messages: list[dict[str, Any]] = [] + cursor: str | None = None + for _ in range(max_pages): + page = await self.get_history(session_id, max_messages=page_size, from_message_id=cursor) + messages.extend(page.get("messages") or []) + cursor = page.get("next_message_id") or None + if not cursor: + return messages + _logger.warning( + "rebuild(%s) stopped at the %d-page limit with the cursor still open; " + "the returned history is incomplete.", session_id, max_pages, + ) + return messages + async def chat( self, session_id: str, content: str, *, - attachments: Optional[list[dict[str, Any]]] = None, - referenced_sessions: Optional[list[dict[str, str]]] = None, - action: Optional[dict[str, Any]] = None, + attachments: list[dict[str, Any]] | None = None, + referenced_sessions: list[dict[str, str]] | None = None, ) -> AsyncGenerator[ChatEvent, None]: - """Send a message and yield buffered events.""" + """Send a message and yield the events that follow. + + Events the SDK does not recognise are yielded unchanged alongside the + rest; ignore what you do not handle. + """ self._ensure_connected() async for event in self._chat.chat( # type: ignore[union-attr] session_id, content, attachments=attachments, referenced_sessions=referenced_sessions, - action=action, ): yield event @@ -160,9 +216,8 @@ def send_message( session_id: str, content: str, *, - attachments: Optional[list[dict[str, Any]]] = None, - referenced_sessions: Optional[list[dict[str, str]]] = None, - action: Optional[dict[str, Any]] = None, + attachments: list[dict[str, Any]] | None = None, + referenced_sessions: list[dict[str, str]] | None = None, ) -> None: """Send a message without waiting for events (fire-and-forget).""" self._ensure_connected() @@ -170,7 +225,6 @@ def send_message( session_id, content, attachments=attachments, referenced_sessions=referenced_sessions, - action=action, ) async def listen(self, session_id: str) -> AsyncGenerator[ChatEvent, None]: @@ -183,24 +237,22 @@ async def subscribe(self, session_id: str) -> AsyncGenerator[ChatEvent, None]: """Persistent event stream for a session — yields events indefinitely. Unlike listen(), this never terminates on terminal states or timeouts. - Designed for bidirectional REPL use where sending and receiving are + Designed for bidirectional use where sending and receiving are concurrent. """ self._ensure_connected() queue: asyncio.Queue[ChatEvent] = asyncio.Queue() + dedup = Deduplicator() def _handler(event_type: str, raw: dict[str, Any]) -> None: - payload = raw.get("payload", {}) + payload = raw.get("payload") or {} p_sid = payload.get("session_id") if p_sid and p_sid != session_id: return - queue.put_nowait(ChatEvent( - type=event_type, - session_id=session_id, - message_id=payload.get("message_id"), - data=payload.get("data"), - metadata=raw.get("metadata"), - )) + event = event_from_envelope(event_type, raw, session_id) + if dedup.is_duplicate(event): + return + queue.put_nowait(event) remove = self._sio.add_event_handler(_handler) # type: ignore[union-attr] try: @@ -224,28 +276,30 @@ async def create_and_chat(self, content: str) -> AsyncGenerator[ChatEvent, None] self.leave_session(sid) def send_form_response(self, session_id: str, message_id: str, form_data: dict[str, Any]) -> None: - """Submit a form response.""" - self._ensure_connected() - self._chat.send_form_response(session_id, message_id, form_data) # type: ignore[union-attr] + """Answer a `session:form_to_user` request. - def send_auth_confirmation(self, session_id: str, message_id: str, data: dict[str, Any]) -> None: - """Submit an interactive auth confirmation (OTP, etc).""" + Submit only values the user supplied. The format has no representation + for refusal and an empty submission is indistinguishable from empty + answers, so a fabricated answer may be acted on. Sending nothing is + safe. + """ self._ensure_connected() - self._chat.send_auth_confirmation(session_id, message_id, data) # type: ignore[union-attr] + self._chat.send_form_response(session_id, message_id, form_data) # type: ignore[union-attr] - def send_location_response(self, session_id: str, message_id: str, latitude: str, longitude: str) -> None: - """Submit a location response.""" - self._ensure_connected() - self._chat.send_location_response(session_id, message_id, latitude, longitude) # type: ignore[union-attr] + def emit_event( + self, event_type: str, data: Any, session_id: str, message_id: str | None = None, + ) -> None: + """Send an arbitrary event, enveloped but otherwise unprocessed. - def send_location_selection(self, session_id: str, message_id: str, places: list[dict[str, Any]]) -> None: - """Submit a location selection.""" + The escape hatch for the unsupported surface. Anything sent through it + may stop working without notice and without a version change. + """ self._ensure_connected() - self._chat.send_location_selection(session_id, message_id, places) # type: ignore[union-attr] + self._sio.emit(event_type, data, session_id, message_id) # type: ignore[union-attr] @staticmethod def session_url(session_id: str) -> str: - """Build the Pine AI web app URL for a session (for payment).""" + """Build the Pine AI web app URL for a session.""" return f"https://www.19pine.ai/app/chat/{session_id}" def _ensure_connected(self) -> None: @@ -290,14 +344,16 @@ def leave_session(self, session_id: str) -> None: def get_history(self, session_id: str, **kwargs: Any) -> dict[str, Any]: return self._run(self._async.get_history(session_id, **kwargs)) + def rebuild(self, session_id: str, **kwargs: Any) -> list[dict[str, Any]]: + return self._run(self._async.rebuild(session_id, **kwargs)) + def chat_sync( self, session_id: str, content: str, *, - attachments: Optional[list[dict[str, Any]]] = None, - referenced_sessions: Optional[list[dict[str, str]]] = None, - action: Optional[dict[str, Any]] = None, + attachments: list[dict[str, Any]] | None = None, + referenced_sessions: list[dict[str, str]] | None = None, ) -> list[ChatEvent]: """Send a message and return all events as a list (blocking).""" async def _collect() -> list[ChatEvent]: @@ -306,7 +362,6 @@ async def _collect() -> list[ChatEvent]: session_id, content, attachments=attachments, referenced_sessions=referenced_sessions, - action=action, ): events.append(event) return events @@ -317,28 +372,22 @@ def send_message( session_id: str, content: str, *, - attachments: Optional[list[dict[str, Any]]] = None, - referenced_sessions: Optional[list[dict[str, str]]] = None, - action: Optional[dict[str, Any]] = None, + attachments: list[dict[str, Any]] | None = None, + referenced_sessions: list[dict[str, str]] | None = None, ) -> None: """Send a message without waiting for events (fire-and-forget).""" self._async.send_message( session_id, content, attachments=attachments, referenced_sessions=referenced_sessions, - action=action, ) def send_form_response(self, session_id: str, message_id: str, form_data: dict[str, Any]) -> None: self._async.send_form_response(session_id, message_id, form_data) - def send_auth_confirmation(self, session_id: str, message_id: str, data: dict[str, Any]) -> None: - self._async.send_auth_confirmation(session_id, message_id, data) - - def send_location_response(self, session_id: str, message_id: str, latitude: str, longitude: str) -> None: - self._async.send_location_response(session_id, message_id, latitude, longitude) - - def send_location_selection(self, session_id: str, message_id: str, places: list[dict[str, Any]]) -> None: - self._async.send_location_selection(session_id, message_id, places) + def emit_event( + self, event_type: str, data: Any, session_id: str, message_id: str | None = None, + ) -> None: + self._async.emit_event(event_type, data, session_id, message_id) session_url = staticmethod(AsyncPineAI.session_url) diff --git a/src/pine_assistant/errors.py b/src/pine_assistant/errors.py index 205b0e5..fca44a1 100644 --- a/src/pine_assistant/errors.py +++ b/src/pine_assistant/errors.py @@ -2,11 +2,11 @@ Pine AI error types — maps spec section 4.2 error codes. """ -from typing import Any, Optional +from typing import Any class PineAIError(Exception): - def __init__(self, code: str, message: str, details: Optional[dict[str, Any]] = None): + def __init__(self, code: str, message: str, details: dict[str, Any] | None = None): super().__init__(message) self.code = code self.details = details @@ -18,7 +18,7 @@ def __init__(self, message: str, code: str = "auth_error"): class SessionError(PineAIError): - def __init__(self, message: str, code: str = "session_error", details: Optional[dict[str, Any]] = None): + def __init__(self, message: str, code: str = "session_error", details: dict[str, Any] | None = None): super().__init__(code, message, details) diff --git a/src/pine_assistant/models/__init__.py b/src/pine_assistant/models/__init__.py index 61bfbe4..99b4656 100644 --- a/src/pine_assistant/models/__init__.py +++ b/src/pine_assistant/models/__init__.py @@ -1,6 +1,5 @@ -from pine_assistant.models.events import * from pine_assistant.models.envelope import * -from pine_assistant.models.session import * +from pine_assistant.models.events import * from pine_assistant.models.form import * -from pine_assistant.models.payment import * +from pine_assistant.models.session import * from pine_assistant.models.task import * diff --git a/src/pine_assistant/models/envelope.py b/src/pine_assistant/models/envelope.py index 3ebdf8f..cde18ca 100644 --- a/src/pine_assistant/models/envelope.py +++ b/src/pine_assistant/models/envelope.py @@ -2,32 +2,33 @@ Master Envelope — spec section 4.1. """ -from typing import Any, Optional +from typing import Any + from pydantic import BaseModel class UserSource(BaseModel): role: str # "user" | "agent" | "system" - user_id: Optional[str] = None - device_id: Optional[str] = None - plat: Optional[str] = None # Platform identifier (production field) - version: Optional[str] = None # App version (production field) + user_id: str | None = None + device_id: str | None = None + plat: str | None = None # Platform identifier (production field) + version: str | None = None # App version (production field) class MessageMetadata(BaseModel): event_id: str - request_id: Optional[str] = None + request_id: str | None = None timestamp: str source: UserSource is_volatile: bool = False class SessionMessagePayload(BaseModel): - session_id: Optional[str] = None - message_id: Optional[str] = None - quoted_message_id: Optional[str] = None - type: Optional[str] = None - data: Optional[Any] = None + session_id: str | None = None + message_id: str | None = None + quoted_message_id: str | None = None + type: str | None = None + data: Any | None = None class MessageEnvelope(BaseModel): diff --git a/src/pine_assistant/models/events.py b/src/pine_assistant/models/events.py index 7f7ecde..db206aa 100644 --- a/src/pine_assistant/models/events.py +++ b/src/pine_assistant/models/events.py @@ -1,5 +1,9 @@ """ -Socket.IO event type constants — derived from pine_backend_api_spec.md sections 5.1 and 5.2. +Socket.IO event types inside the supported protocol scope. + +Only the supported surface is modelled here. The server emits many more events; +they reach callers verbatim through the same stream and carry no compatibility +guarantee — see `pine_assistant.is_supported_event`. """ import sys @@ -14,67 +18,54 @@ class StrEnum(str, Enum): class C2SEvent(StrEnum): - """Client-to-Server events — spec 5.1.1""" + """Client-to-server events inside the supported scope.""" SESSION_JOIN = "session:join" - SESSION_LEAVE = "session:leave" SESSION_HISTORY = "session:history" SESSION_MESSAGE = "session:message" - SESSION_MESSAGE_STATUS = "session:message_status" SESSION_FORM_TO_USER = "session:form_to_user" - SESSION_ASK_FOR_LOCATION = "session:ask_for_location" - SESSION_LOCATION_SELECTION = "session:location_selection" - SESSION_INTERACTIVE_AUTH_CONFIRMATION = "session:interactive_auth_confirmation" - SESSION_UPDATE_PROFILE = "session:update_profile" - SESSION_TYPING_START = "session:typing_start" - SESSION_TYPING_STOP = "session:typing_stop" class S2CEvent(StrEnum): - """Server-to-Client events — spec 5.1.2""" + """Server-to-client events inside the supported scope.""" + + # Connection and session READY = "ready" SESSION_JOIN = "session:join" SESSION_HISTORY = "session:history" - SESSION_MESSAGE_STATUS = "session:message_status" - SESSION_DEBUG = "session:debug" - SESSION_TEXT_PART = "session:text_part" + SESSION_ERROR = "session:error" + + # Conversation + SESSION_MESSAGE = "session:message" SESSION_TEXT = "session:text" + SESSION_TEXT_PART = "session:text_part" SESSION_RICH_CONTENT = "session:rich_content" - SESSION_UPDATE_TITLE = "session:update_title" + SESSION_LLM_THINKING = "session:llm_thinking" + + # Session state SESSION_STATE = "session:state" SESSION_INPUT_STATE = "session:input_state" - SESSION_THINKING = "session:thinking" - SESSION_TASK_PROCESSING = "session:task_processing" - SESSION_WORK_LOG = "session:work_log" - SESSION_WORK_LOG_PART = "session:work_log_part" + SESSION_MESSAGE_STATUS = "session:message_status" + SESSION_REQUIRED_ACTION = "session:required_action" + SESSION_UPDATE_TITLE = "session:update_title" + SESSION_RESTRICTION = "session:restriction" + + # Interaction SESSION_FORM_TO_USER = "session:form_to_user" - SESSION_ASK_FOR_LOCATION = "session:ask_for_location" - SESSION_LOCATION_SELECTION = "session:location_selection" - SESSION_REWARD = "session:reward" - SESSION_PAYMENT = "session:payment" + + # Task and result SESSION_TASK_READY = "session:task_ready" SESSION_TASK_FINISHED = "session:task_finished" - SESSION_INTERACTIVE_AUTH_CONFIRMATION = "session:interactive_auth_confirmation" - SESSION_THREE_WAY_CALL = "session:three_way_call" - SESSION_CONTINUE_IN_NEW_TASK = "session:continue_in_new_task" - SESSION_CARD = "session:card" - SESSION_NEXT_TASKS = "session:next_tasks" - SESSION_ACTION_STATUS = "session:action_status" - SESSION_SOCIAL_SHARING = "session:social_sharing" - SESSION_ERROR = "session:error" - SESSION_RETRY = "session:retry" - SESSION_COMPUTER_USE_INTERVENTION = "session:computer_use_intervention" - SESSION_OUTBOUND_NOTIFICATION = "session:outbound_notification" - - -class NotificationEvent(StrEnum): - """Notification events — spec 5.2.2""" - USER_UPDATED = "notification:user_updated" - SESSION_CREATED = "notification:session_created" - SESSION_DELETED = "notification:session_deleted" - SESSION_UPDATED = "notification:session_updated" - NEW_MESSAGE = "notification:new_message" - CALLER_ID_NUMBER = "notification:caller_id_number" - SUBSCRIPTION_UPDATED = "notification:subscription_updated" - CREDITS_SUFFICIENT = "notification:credits_sufficient" - CREDITS_INSUFFICIENT = "notification:credits_insufficient" - ERROR = "notification:error" + SESSION_TOOL_STATUS = "session:tool_status" + + +SUPPORTED_EVENTS = frozenset(e.value for e in S2CEvent) | frozenset(e.value for e in C2SEvent) + + +def is_supported_event(event_type: str) -> bool: + """Whether an event carries the compatibility guarantee. + + An unsupported event is still delivered — the SDK never drops one — but it + may be renamed, have its payload changed, or cease to be emitted without + notice. Tolerating one is required; depending on one is not. + """ + return event_type in SUPPORTED_EVENTS diff --git a/src/pine_assistant/models/form.py b/src/pine_assistant/models/form.py index 21f15e8..379aeb0 100644 --- a/src/pine_assistant/models/form.py +++ b/src/pine_assistant/models/form.py @@ -1,47 +1,35 @@ """ -Form models — spec 5.1.2 session:form_to_user. +Form models — `session:form_to_user`. """ from __future__ import annotations -from typing import Any, Optional -from pydantic import BaseModel, Field +from typing import Any + +from pydantic import BaseModel class FormField(BaseModel): name: str type: str = "text" - label: Optional[str] = None - placeholder: Optional[str] = None - is_required: Optional[bool] = None - pii_level: Optional[str] = None - prefilled: Optional[str] = None - options: Optional[list[str]] = None + label: str | None = None + description: str | None = None + placeholder: str | None = None + source: str | None = None + is_required: bool | None = None + pii_level: str | None = None + prefilled: str | None = None + options: list[str] | None = None class FormData(BaseModel): fields: list[FormField] = [] - content: Optional[dict[str, Any]] = None + content: dict[str, Any] | None = None is_submitted: bool = False class FormToUserData(BaseModel): - """S2C session:form_to_user payload.data""" - message_to_user: str = "" - form: FormData = FormData() - - -class AskForLocationData(BaseModel): - """S2C session:ask_for_location payload.data""" + """`session:form_to_user` payload — how a task gathers the account details + it needs to act.""" message_to_user: str = "" form: FormData = FormData() - - -class LocationSelectionData(BaseModel): - """S2C session:location_selection payload.data""" - message_to_user: str = "" - locations: list[dict[str, Any]] = Field(default_factory=list, alias="list") - selected: list[dict[str, Any]] = Field(default_factory=list) - limit: int = 0 - - model_config = {"populate_by_name": True} diff --git a/src/pine_assistant/models/payment.py b/src/pine_assistant/models/payment.py deleted file mode 100644 index 550063b..0000000 --- a/src/pine_assistant/models/payment.py +++ /dev/null @@ -1,53 +0,0 @@ -""" -Payment/Reward models — spec 5.1.2 session:reward + session:payment. -""" - -from typing import Any, Optional -from pydantic import BaseModel - - -class RewardData(BaseModel): - """S2C session:reward payload.data""" - payment_id: Optional[str] = None - content: Optional[str] = None - charge_type: str = "" - currency_code: Optional[str] = None - currency_symbol: Optional[str] = None - original_bill_amount: Optional[float] = None - original_bill_amount_usd: Optional[float] = None - estimated_savings: Optional[float] = None - estimated_savings_usd: Optional[float] = None - charge_percentage: Optional[float] = None - charge_percentage_min: Optional[float] = None - charge_percentage_tips: Optional[list[float]] = None - fixed_charge_amount: Optional[float] = None - fixed_charge_amount_min: Optional[float] = None - fixed_charge_amount_tips: Optional[list[float]] = None - status: Optional[str] = None - payment_intent_id: Optional[str] = None - payment_client_secret: Optional[str] = None - coupon_id: Optional[str] = None - coupon_amount: Optional[float] = None - reject_status: Optional[str] = None - - -class PaymentData(BaseModel): - """S2C session:payment payload.data""" - payment_id: Optional[str] = None - message: Optional[str] = None - charge_type: str = "" - currency_code: Optional[str] = None - currency_symbol: Optional[str] = None - original_bill_amount: Optional[float] = None - original_bill_amount_usd: Optional[float] = None - estimated_savings: Optional[float] = None - estimated_savings_usd: Optional[float] = None - actual_savings: Optional[float] = None - actual_savings_usd: Optional[float] = None - actual_payment_amount: Optional[float] = None - service_fee_collected: Optional[float] = None - service_fee_refunded: Optional[float] = None - coupon_amount: Optional[float] = None - status: Optional[str] = None - task_status: Optional[str] = None - cancel_reason: Optional[str] = None diff --git a/src/pine_assistant/models/session.py b/src/pine_assistant/models/session.py index f9f3f33..08acb37 100644 --- a/src/pine_assistant/models/session.py +++ b/src/pine_assistant/models/session.py @@ -1,19 +1,31 @@ """ -Session models — spec section 4.3. +Session models — REST session objects and the `session:input_state` payload. """ -from typing import Any, Optional +import sys + from pydantic import BaseModel +if sys.version_info >= (3, 11): + from enum import StrEnum +else: + from enum import Enum + + class StrEnum(str, Enum): + pass + class SessionInfo(BaseModel): id: str - type: Optional[str] = None + type: str | None = None title: str = "" - is_stale: Optional[bool] = None - is_processed: Optional[bool] = None + # Expiry is carried here and nowhere on the Socket.IO surface: an expired + # session presents only as a disabled composer, with no code that tells it + # apart from other causes. + is_stale: bool | None = None + is_processed: bool | None = None state: str = "init" - version: Optional[str] = None + version: str | None = None created_at: str = "" updated_at: str = "" @@ -23,3 +35,55 @@ class SessionListResponse(BaseModel): total: int limit: int offset: int + + +class InputStateCode(StrEnum): + """Reason codes on `session:input_state`.""" + DEFAULT = "default" + TASK_READY = "task_ready" + TASK_PROCESSING = "task_processing" + PROFILE_UPDATE_REQUIRED = "profile_update_required" + SESSION_SUMMARY = "session_summary" + PHONE_VERIFICATION_REQUIRED = "phone_verification_required" + + +ACCEPTING_INPUT = "waiting_input" + + +class InputState(BaseModel): + """`session:input_state` payload. + + The blocking condition is read from `code`, never inferred from which other + events did or did not arrive — the events that elaborate on a condition are + mostly outside the supported scope. + """ + content: str = "" + detail: str = "" + code: str = "" + + @property + def accepting_input(self) -> bool: + return self.content == ACCEPTING_INPUT + + @property + def blocked(self) -> bool: + return not self.accepting_input + + @property + def awaiting_credits(self) -> bool: + """Blocked on an unconfirmed credit charge. + + The cost is carried by `session:task_ready`. When the balance covers it + the server starts the task itself; when it does not, the session waits + here until the balance is restored. + """ + return self.blocked and self.code == InputStateCode.TASK_READY + + @property + def needs_phone_verification(self) -> bool: + """Blocked on phone verification. + + A provisioning prerequisite: it has no in-session remedy, and the event + that explains it is outside the supported scope. + """ + return self.blocked and self.code == InputStateCode.PHONE_VERIFICATION_REQUIRED diff --git a/src/pine_assistant/models/task.py b/src/pine_assistant/models/task.py index f462b72..4f60fbb 100644 --- a/src/pine_assistant/models/task.py +++ b/src/pine_assistant/models/task.py @@ -1,111 +1,152 @@ """ -Task models — spec 5.1.2 session:work_log, session:task_finished, session:task_ready. +Task models — `session:task_ready`, `session:task_finished`, `session:tool_status`, +`session:llm_thinking`, `session:restriction`, `session:required_action`. """ -from typing import Any, Optional +from typing import Any + from pydantic import BaseModel class TaskReadyData(BaseModel): - """session:task_ready payload""" + """`session:task_ready` payload. + + Informational when the balance covers `required`; when it does not, the + session waits until the balance is restored. + """ required: int = 0 - suggested: Optional[int] = None + suggested: int | None = None confirmed: bool = False -class WorkLogStep(BaseModel): - """Work log step — spec 5.1.2 session:work_log""" - id: str = "" - step_type: str = "" - step_title: str = "" - step_details: Optional[str] = None - status: str = "" - start_time: Optional[int] = None - data: Optional[dict[str, Any]] = None - can_retry: Optional[bool] = None - can_cancel: Optional[bool] = None - is_collapsed: Optional[bool] = None - - -class WorkLogData(BaseModel): - """session:work_log payload.data""" - steps: list[WorkLogStep] = [] - - -class WorkLogPartData(BaseModel): - """session:work_log_part payload.data""" - step_id: str = "" - text_delta: Optional[str] = None - data_delta: Optional[dict[str, Any]] = None - status: Optional[str] = None - - class Achievement(BaseModel): id: str = "" title: str = "" - description: Optional[str] = None - icon_url: Optional[str] = None - rarity: Optional[str] = None - is_new: Optional[bool] = None + description: str | None = None + icon_url: str | None = None + rarity: str | None = None + is_new: bool | None = None class TaskCompletionSummary(BaseModel): - time_saved_minutes: Optional[int] = None - hold_time_avoided_mins: Optional[int] = None - calls_made: Optional[int] = None - call_duration_mins: Optional[int] = None - emails_sent: Optional[int] = None - web_tasks_completed: Optional[int] = None - money_saved: Optional[float] = None - money_saved_currency: Optional[str] = None - credits_invested: Optional[int] = None - achievements: Optional[list[Achievement]] = None + """Quantified outcome. `brief` is its only textual field.""" + brief: str | None = None + time_saved_minutes: int | None = None + hold_time_avoided_mins: int | None = None + calls_made: int | None = None + call_duration_mins: int | None = None + emails_sent: int | None = None + web_tasks_completed: int | None = None + silos_conquered: int | None = None + obstacles_overcome: int | None = None + money_saved: float | None = None + money_saved_currency: str | None = None + credits_invested: int | None = None + achievements: list[Achievement] | None = None class TaskCompletion(BaseModel): + """The task's conclusion. + + The textual result is `result_title`, `result_description` and + `outcome_narrative`; `summary` is quantified. + """ result_title: str = "" - result_description: Optional[str] = None - summary: Optional[TaskCompletionSummary] = None - share_text: Optional[str] = None - engage_enabled: Optional[bool] = None - engage_prompt: Optional[str] = None - engage_status: Optional[str] = None + result_description: str | None = None + outcome_narrative: str | None = None + summary: TaskCompletionSummary | None = None class TaskFinishedData(BaseModel): - """session:task_finished payload.data""" + """`session:task_finished` payload.""" + status: str = "" + completion: TaskCompletion | None = None + + +class ToolStatusSummary(BaseModel): + """Outcome of one tool operation. `text` is its only textual field.""" + text: str | None = None + duration: int | None = None + actions_count: int | None = None + credits_consumed: int | None = None + + +class ToolStatusData(BaseModel): + """`session:tool_status` payload — the record of one asynchronous operation. + + Outbound calls are reported here: `target` is the number called, and the + duration, credits and textual outcome are on `summary`. Live updates reuse + the same `message_id`. + + Distinct from the task-level result. A `tool_call` step in + `session:llm_thinking` describes the same operation and must not be + presented as a second entry. + """ + operation_id: str = "" + tool_name: str | None = None + provider: str | None = None + target: str | None = None + start_time: str | None = None + status: str | None = None + summary: ToolStatusSummary | None = None + + +class ToolCallHistoryEntry(BaseModel): + tool_name: str | None = None + status: str | None = None + title: str | None = None + content: str | None = None + + +class LLMThinkingData(BaseModel): + """`session:llm_thinking` payload — reasoning and tool-call trace. + + Search has no dedicated event; search activity appears here as a `tool_call` + step. + """ + type: str = "" + title: str | None = None + content: str | None = None + tool_name: str | None = None + status: str | None = None + turn_id: str | None = None + thinking_id: str | None = None + final: bool = False + history: list[ToolCallHistoryEntry] | None = None + + +class RestrictionData(BaseModel): + """`session:restriction` payload — the only statement that a task will not + complete.""" + level: str = "" + reason: str | None = None + message: str | None = None + + +class RequiredActionData(BaseModel): + """`session:required_action` payload — whether the session awaits a user + response.""" + is_required_action: bool = False + + +class MessageStatusData(BaseModel): + """`session:message_status` payload — the only means of telling a rejected + or rate-limited message from one still being processed.""" status: str = "" - completion: Optional[TaskCompletion] = None - - -class ThinkingStep(BaseModel): - """session:thinking step""" - kind: str = "" - title: Optional[str] = None - status: Optional[str] = None - content: Optional[str] = None - thinking_data: Optional[dict[str, Any]] = None - - -class InteractiveAuthData(BaseModel): - """session:interactive_auth_confirmation payload.data (S2C)""" - confirmation_id: Optional[str] = None - message_to_user: str = "" - verification_types: Optional[list[str]] = None - verification_guidance: Optional[dict[str, Any]] = None - scheduled_time: Optional[str] = None - scheduled_call_reminder: Optional[bool] = None - user_phone: Optional[str] = None - pine_caller_id: Optional[str] = None - caller_first_name: Optional[str] = None - caller_last_name: Optional[str] = None - expires_at: Optional[str] = None - - -class ThreeWayCallData(BaseModel): - """session:three_way_call payload.data""" - title: Optional[str] = None - content: Optional[str] = None - caller_id_number: Optional[str] = None - caller_first_name: Optional[str] = None - caller_last_name: Optional[str] = None + message_id: str | None = None + request_id: str | None = None + reason: str | None = None + details: dict[str, Any] | None = None + + +class RichContentData(BaseModel): + """`session:rich_content` payload — a structured document. + + Its content is not repeated in `session:text`; ignoring this event loses the + content entirely. + """ + title: str = "" + content: str = "" + subtitle: str | None = None + type: str | None = None + message_to_user: str | None = None diff --git a/src/pine_assistant/sessions.py b/src/pine_assistant/sessions.py index 0ffdf81..c546ed9 100644 --- a/src/pine_assistant/sessions.py +++ b/src/pine_assistant/sessions.py @@ -4,7 +4,7 @@ from __future__ import annotations -from typing import Any, Optional +from typing import Any from pine_assistant.transport.http import HttpClient @@ -13,7 +13,7 @@ class SessionsAPI: def __init__(self, http: HttpClient): self._http = http - async def list(self, state: Optional[str] = None, limit: int = 30, offset: int = 0) -> dict[str, Any]: + async def list(self, state: str | None = None, limit: int = 30, offset: int = 0) -> dict[str, Any]: """List sessions — spec 4.3.1""" params = f"?limit={limit}&offset={offset}" if state: @@ -61,7 +61,7 @@ async def social_share( async def upload_attachment(self, file_path: str) -> list[dict[str, Any]]: """Upload attachment — spec 4.4.1. Multipart form upload.""" - return await self._http.upload(f"/v2/attachments", file_path) + return await self._http.upload("/v2/attachments", file_path) async def delete_attachment(self, attachment_id: str) -> None: """Delete attachment — spec 4.4.2""" diff --git a/src/pine_assistant/transport/envelope.py b/src/pine_assistant/transport/envelope.py index 2943ddc..9496c7e 100644 --- a/src/pine_assistant/transport/envelope.py +++ b/src/pine_assistant/transport/envelope.py @@ -4,7 +4,7 @@ import uuid from datetime import datetime, timezone -from typing import Any, Optional +from typing import Any from pine_assistant.models.envelope import MessageEnvelope, MessageMetadata, SessionMessagePayload, UserSource @@ -14,9 +14,9 @@ def build_envelope( data: Any, user_id: str, device_id: str, - session_id: Optional[str] = None, - message_id: Optional[str] = None, - request_id: Optional[str] = None, + session_id: str | None = None, + message_id: str | None = None, + request_id: str | None = None, is_volatile: bool = False, ) -> dict[str, Any]: """Build a C2S message envelope as a dict ready for Socket.IO emit.""" @@ -39,7 +39,7 @@ def build_envelope( return envelope.model_dump() -def parse_envelope(raw: dict[str, Any]) -> Optional[MessageEnvelope]: +def parse_envelope(raw: dict[str, Any]) -> MessageEnvelope | None: """Parse an S2C message envelope. Returns None if invalid.""" try: return MessageEnvelope.model_validate(raw) diff --git a/src/pine_assistant/transport/http.py b/src/pine_assistant/transport/http.py index c812343..b8ef9eb 100644 --- a/src/pine_assistant/transport/http.py +++ b/src/pine_assistant/transport/http.py @@ -2,7 +2,7 @@ REST HTTP client for Pine AI — spec sections 4.1, 4.3. """ -from typing import Any, Optional +from typing import Any import httpx @@ -12,7 +12,7 @@ class HttpClient: - def __init__(self, base_url: str = DEFAULT_BASE_URL, token: Optional[str] = None): + def __init__(self, base_url: str = DEFAULT_BASE_URL, token: str | None = None): self._base_url = base_url.rstrip("/") self._token = token self._client = httpx.AsyncClient( @@ -43,19 +43,19 @@ async def get(self, path: str, authenticated: bool = True) -> Any: raise PineAIError("http_error", f"HTTP {resp.status_code}: {resp.text[:200]}") return self._unwrap(resp.json()) - async def post(self, path: str, body: Optional[dict[str, Any]] = None, authenticated: bool = True) -> Any: + async def post(self, path: str, body: dict[str, Any] | None = None, authenticated: bool = True) -> Any: resp = await self._client.post(path, json=body, headers=self._auth_headers(authenticated)) if resp.status_code >= 400: raise PineAIError("http_error", f"HTTP {resp.status_code}: {resp.text[:200]}") return self._unwrap(resp.json()) - async def put(self, path: str, body: Optional[dict[str, Any]] = None, authenticated: bool = True) -> Any: + async def put(self, path: str, body: dict[str, Any] | None = None, authenticated: bool = True) -> Any: resp = await self._client.put(path, json=body, headers=self._auth_headers(authenticated)) if resp.status_code >= 400: raise PineAIError("http_error", f"HTTP {resp.status_code}: {resp.text[:200]}") return self._unwrap(resp.json()) - async def delete(self, path: str, params: Optional[dict[str, str]] = None, authenticated: bool = True) -> Any: + async def delete(self, path: str, params: dict[str, str] | None = None, authenticated: bool = True) -> Any: resp = await self._client.delete(path, params=params, headers=self._auth_headers(authenticated)) if resp.status_code >= 400: raise PineAIError("http_error", f"HTTP {resp.status_code}: {resp.text[:200]}") diff --git a/src/pine_assistant/transport/socketio.py b/src/pine_assistant/transport/socketio.py index 65fb74b..5a78b8d 100644 --- a/src/pine_assistant/transport/socketio.py +++ b/src/pine_assistant/transport/socketio.py @@ -6,8 +6,10 @@ """ import asyncio +import contextlib import uuid -from typing import Any, Callable, Optional +from collections.abc import Callable +from typing import Any import socketio @@ -35,8 +37,8 @@ def __init__( base_url: str, token: str, user_id: str, - device_id: Optional[str] = None, - transports: Optional[list[str]] = None, + device_id: str | None = None, + transports: list[str] | None = None, ready_timeout: float = 15.0, ): self._base_url = base_url @@ -45,9 +47,10 @@ def __init__( self._device_id = device_id or str(uuid.uuid4()) self._transports = transports or ["websocket"] self._ready_timeout = ready_timeout - self._sio: Optional[socketio.AsyncClient] = None + self._sio: socketio.AsyncClient | None = None self._connected = False self._event_handlers: list[Callable[[str, dict[str, Any]], None]] = [] + self._reconnect_handlers: list[Callable[[], None]] = [] self._joined_sessions: set[str] = set() @property @@ -62,17 +65,23 @@ def add_event_handler(self, handler: Callable[[str, dict[str, Any]], None]) -> C """Add an event handler. Returns a cleanup function. Supports multiple concurrent handlers.""" self._event_handlers.append(handler) def remove() -> None: - try: + with contextlib.suppress(ValueError): self._event_handlers.remove(handler) - except ValueError: - pass return remove - def on_event(self, handler: Optional[Callable[[str, dict[str, Any]], None]]) -> None: - """Set a single event handler (replaces all). Use add_event_handler() for multi-session.""" - self._event_handlers.clear() - if handler is not None: - self._event_handlers.append(handler) + def add_reconnect_handler(self, handler: Callable[[], None]) -> Callable[[], None]: + """Register a callback fired after a reconnect has re-joined its sessions. + + A reconnect invalidates whatever the caller had accumulated: delivery + may have stopped before the socket noticed, so state has to be rebuilt + rather than resumed. + """ + self._reconnect_handlers.append(handler) + + def remove() -> None: + with contextlib.suppress(ValueError): + self._reconnect_handlers.remove(handler) + return remove async def connect(self) -> None: """Connect to Pine backend, wait for `ready` event — spec 5.1.2.""" @@ -97,9 +106,13 @@ async def on_ready(*_args: Any) -> None: if not ready_event.is_set(): ready_event.set() else: - # Reconnection: re-join all previously joined sessions + # Reconnection: re-join every previously joined session. State + # is rebuilt from history rather than resumed from a cursor, so + # the join carries since_revision "0" here too. for sid in list(self._joined_sessions): - self.emit("session:join", None, sid) + self.emit("session:join", {"since_revision": "0"}, sid) + for on_reconnect in list(self._reconnect_handlers): + on_reconnect() @self._sio.on("*") async def on_any(event: str, data: Any) -> None: @@ -142,14 +155,28 @@ async def disconnect(_reason: str = "") -> None: raise PineConnectionError( f"Socket.IO connected but no 'ready' event after {self._ready_timeout}s. " "This usually means access_token or user_id is invalid/expired — re-run the auth flow." - ) + ) from None + + def _track_membership(self, event_type: str, session_id: str | None) -> None: + """Remember which sessions to re-join after a reconnect. + + Both emit paths run through here: joining goes out via emit_and_wait, + so tracking only on the fire-and-forget path would leave every joined + session unrestored after a drop. + """ + if not session_id: + return + if event_type == "session:join": + self._joined_sessions.add(session_id) + elif event_type == "session:leave": + self._joined_sessions.discard(session_id) def emit( self, event_type: str, data: Any, - session_id: Optional[str] = None, - message_id: Optional[str] = None, + session_id: str | None = None, + message_id: str | None = None, ) -> None: """Emit a typed event with envelope wrapping. @@ -158,11 +185,7 @@ def emit( """ if not self._sio or not self._sio.connected: raise RuntimeError("Socket.IO not connected") - # Track join/leave for reconnection - if event_type == "session:join" and session_id: - self._joined_sessions.add(session_id) - if event_type == "session:leave" and session_id: - self._joined_sessions.discard(session_id) + self._track_membership(event_type, session_id) from pine_assistant.transport.envelope import build_envelope envelope = build_envelope( event_type, data, @@ -195,12 +218,13 @@ async def emit_and_wait( self, event_type: str, data: Any, - session_id: Optional[str] = None, + session_id: str | None = None, timeout: float = 10.0, ) -> dict[str, Any]: """Emit and wait for a response event with matching session_id.""" if not self._sio or not self._sio.connected: raise RuntimeError("Socket.IO not connected") + self._track_membership(event_type, session_id) from pine_assistant.transport.envelope import build_envelope request_id = str(uuid.uuid4()) envelope = build_envelope( @@ -234,7 +258,7 @@ def response_handler(evt: str, raw: dict[str, Any]) -> None: try: await asyncio.wait_for(result_event.wait(), timeout=timeout) except asyncio.TimeoutError: - raise TimeoutError(f"Timeout waiting for {event_type} response") + raise TimeoutError(f"Timeout waiting for {event_type} response") from None finally: remove_handler() diff --git a/tests/integration/record_fixtures.py b/tests/integration/record_fixtures.py new file mode 100644 index 0000000..35003f5 --- /dev/null +++ b/tests/integration/record_fixtures.py @@ -0,0 +1,230 @@ +"""Record protocol fixtures from a live session. + +The fixtures under tests/protocol/fixtures are the baseline the contract tests +run against. Recording them from a running server is what makes them evidence +rather than a restatement of the documentation — and re-recording is how server +drift gets noticed, since nothing else checks that the protocol scope and the +implementation still agree. + + PINE_ACCESS_TOKEN=... PINE_USER_ID=... python -m tests.integration.record_fixtures + +Every envelope seen is written to --raw-dir; only supported events replace a +fixture, and each replacement flips its provenance entry to "recorded". +Unsupported events are reported but never recorded — we do not maintain them. +""" + +from __future__ import annotations + +import argparse +import asyncio +import contextlib +import json +import os +import pathlib +import re +from datetime import datetime, timezone +from typing import Any + +from pine_assistant import AsyncPineAI, S2CEvent, is_supported_event +from tests.protocol.fake import SESSION_ID as PLACEHOLDER_SESSION_ID + +FIXTURES = pathlib.Path(__file__).resolve().parents[1] / "protocol" / "fixtures" +DEFAULT_PROMPT = "Please call +1 415-555-0199 and ask what time Saturday's dinner starts. Make exactly one attempt — if it does not connect, stop and tell me. Do not retry." + +# Values that identify a person or an account never reach a checked-in fixture. +# The phone pattern requires a leading "+" or separators: Pine's identifiers are +# long digit runs, and a looser pattern rewrites them into a fake phone number. +REDACTIONS = ( + (re.compile(r"\+\d[\d\-\s().]{7,}\d"), "+15555550100"), + (re.compile(r"\b\d{3}[-.\s]\d{3}[-.\s]\d{4}\b"), "+15555550100"), + (re.compile(r"[\w.+-]+@[\w-]+\.[\w.]+"), "someone@example.com"), +) + +# Fields whose value is the user's own data. A form carries the account details +# it is asking for — names, addresses, account numbers, PINs — and the server +# builds its placeholders from them, so both sides are replaced wholesale rather +# than pattern-matched. A form's submitted `content` goes too — it is a mapping +# of answers, unlike the `content` string on a text or state event. +# The key stays, the shape stays, the value does not. +# `options` belongs here for a reason that is easy to miss: the server composes +# choice labels in the user's voice, so an option reads "I (Charles) prefer ..." +# and carries their name even though nothing about the key says user data. +USER_DATA_KEYS = frozenset({"prefilled", "placeholder", "options"}) +USER_DATA_PLACEHOLDER = "[redacted]" + +# The account and session a recording ran under are not part of the shape being +# recorded, and a fixture carrying a real session id cannot be replayed into a +# flow test — the client would filter it out as belonging elsewhere. +PLACEHOLDER_USER_ID = "100000000000000001" + +# Identifier fields are never redacted — they are opaque numbers, and rewriting +# one destroys the shape the fixture exists to record. +OPAQUE_KEYS = frozenset({ + "id", "event_id", "message_id", "session_id", "request_id", "operation_id", + "quoted_message_id", "thinking_id", "turn_id", "revision", "next_message_id", + "max_message_revision", "since_revision", "device_id", "user_id", +}) + + +def redact(value: Any) -> Any: + if isinstance(value, str): + for pattern, replacement in REDACTIONS: + value = pattern.sub(replacement, value) + return value + if isinstance(value, dict): + out = {} + for k, v in value.items(): + if k in OPAQUE_KEYS: + out[k] = v + elif k in USER_DATA_KEYS and isinstance(v, str) and v: + out[k] = USER_DATA_PLACEHOLDER + elif k in USER_DATA_KEYS and isinstance(v, list): + out[k] = [USER_DATA_PLACEHOLDER for _ in v] + elif k == "content" and isinstance(v, dict) and v: + out[k] = {key: USER_DATA_PLACEHOLDER for key in v} + else: + out[k] = redact(v) + return out + if isinstance(value, list): + return [redact(v) for v in value] + return value + + +def anonymize(envelope: dict[str, Any]) -> dict[str, Any]: + """Replace the identities the recording ran under. Redaction cannot reach + them: an account id is an opaque number, exempt from pattern matching so it + does not get rewritten into a fake phone number.""" + source = envelope.get("metadata", {}).get("source") + if isinstance(source, dict) and source.get("user_id"): + source["user_id"] = PLACEHOLDER_USER_ID + payload = envelope.get("payload") + if isinstance(payload, dict) and payload.get("session_id"): + payload["session_id"] = PLACEHOLDER_SESSION_ID + return envelope + + +async def record( + prompt: str, raw_dir: pathlib.Path | None, follow_seconds: float = 0.0, +) -> dict[str, dict[str, Any]]: + token = os.environ.get("PINE_ACCESS_TOKEN", "") + user_id = os.environ.get("PINE_USER_ID", "") + if not token or not user_id: + raise SystemExit("PINE_ACCESS_TOKEN and PINE_USER_ID are required.") + + client = AsyncPineAI( + access_token=token, user_id=user_id, + base_url=os.environ.get("PINE_BASE_URL", "https://www.19pine.ai"), + ) + seen: dict[str, dict[str, Any]] = {} + everything: list[dict[str, Any]] = [] + + await client.connect() + session = await client.sessions.create() + sid = session["id"] + + def capture(event_type: str, raw: dict[str, Any]) -> None: + everything.append(raw) + seen[event_type] = raw # a live-updating card is completed by its last envelope + + remove = client._sio.add_event_handler(capture) # type: ignore[union-attr] + try: + await client.join_session(sid) + await client.rebuild(sid) + async for event in client.chat(sid, prompt): + print(f" {event.type}{'' if is_supported_event(event.type) else ' (unsupported)'}") + if follow_seconds: + # A task runs after the turn ends. Its call reports through + # session:tool_status, which no turn-scoped listener would see. + print(f" ... following for {follow_seconds:.0f}s") + before = len(everything) + await asyncio.sleep(follow_seconds) + for env in everything[before:]: + print(f" {env['type']}") + finally: + remove() + client.leave_session(sid) + with contextlib.suppress(Exception): + await client.sessions.delete(sid) + await client.disconnect() + + if raw_dir: + raw_dir.mkdir(parents=True, exist_ok=True) + raw_dir.joinpath("session.jsonl").write_text( + "".join(json.dumps(e) + "\n" for e in everything) + ) + print(f"\n{len(everything)} envelopes -> {raw_dir / 'session.jsonl'}") + + return seen + + +def replay(path: pathlib.Path) -> dict[str, dict[str, Any]]: + """Rebuild fixtures from a captured log. + + A recording costs a live session and its credits. Re-deriving from what was + already captured costs nothing, which matters when the fault is in how a + fixture was written rather than in what the server sent. + """ + seen: dict[str, dict[str, Any]] = {} + for line in path.read_text().splitlines(): + if line.strip(): + envelope = json.loads(line) + seen[envelope["type"]] = envelope + print(f"replayed {path}: {len(seen)} distinct event types") + return seen + + +def write_fixtures(seen: dict[str, dict[str, Any]]) -> tuple[list[str], list[str]]: + provenance = json.loads((FIXTURES / "provenance.json").read_text()) + now = datetime.now(timezone.utc).isoformat(timespec="seconds") + recorded, skipped = [], [] + + for event_type, envelope in sorted(seen.items()): + if event_type == S2CEvent.READY.value or not is_supported_event(event_type): + skipped.append(event_type) + continue + name = event_type.replace("session:", "") + FIXTURES.joinpath(f"{name}.json").write_text( + json.dumps(anonymize(redact(envelope)), indent=2) + "\n" + ) + provenance[event_type] = { + "source": "recorded", "derived_from": None, "recorded_at": now, + } + recorded.append(event_type) + + FIXTURES.joinpath("provenance.json").write_text( + json.dumps(provenance, indent=2, sort_keys=True) + "\n" + ) + return recorded, skipped + + +def main() -> None: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--prompt", default=DEFAULT_PROMPT) + parser.add_argument("--follow", type=float, default=0.0, metavar="SECONDS", + help="keep capturing after the turn ends, for events a " + "running task emits later (session:tool_status)") + parser.add_argument("--from-raw", type=pathlib.Path, default=None, metavar="JSONL", + help="rebuild fixtures from a previously captured log " + "instead of running a session") + parser.add_argument("--raw-dir", type=pathlib.Path, default=None, + help="also dump every envelope seen, for inspection") + args = parser.parse_args() + + seen = (replay(args.from_raw) if args.from_raw + else asyncio.run(record(args.prompt, args.raw_dir, args.follow))) + recorded, skipped = write_fixtures(seen) + + print(f"\nrecorded {len(recorded)}: {', '.join(recorded) or '(none)'}") + print(f"skipped {len(skipped)}: {', '.join(skipped) or '(none)'}") + still_derived = [ + event for event, entry in json.loads((FIXTURES / "provenance.json").read_text()).items() + if entry["source"] == "derived" + ] + if still_derived: + print(f"\nstill derived from the protocol source, never observed: " + f"{', '.join(sorted(still_derived))}") + print("These need a session that reaches the condition they describe.") + + +if __name__ == "__main__": + main() diff --git a/tests/integration/test_live.py b/tests/integration/test_live.py new file mode 100644 index 0000000..d61d59e --- /dev/null +++ b/tests/integration/test_live.py @@ -0,0 +1,191 @@ +"""Live tests against the real Pine AI service. + +Excluded from CI: they need a token and every run spends credits. Their job is +to check the supported surface against a running server, and to be the source +the fixtures are recorded from. + + PINE_INTEGRATION=1 PINE_ACCESS_TOKEN=... PINE_USER_ID=... pytest tests/integration -v +""" + +import asyncio +import contextlib +import os + +import pytest + +from pine_assistant import AsyncPineAI, InputState, S2CEvent, is_supported_event + +SKIP = not os.environ.get("PINE_INTEGRATION") +ACCESS_TOKEN = os.environ.get("PINE_ACCESS_TOKEN", "") +USER_ID = os.environ.get("PINE_USER_ID", "") +BASE_URL = os.environ.get("PINE_BASE_URL", "https://www.19pine.ai") + +pytestmark = pytest.mark.skipif(SKIP, reason="PINE_INTEGRATION not set") + +PROMPT = "Ask what time Saturday's dinner starts." + +# NANP reserves 555-0100 through 555-0199 for fictional use — nothing routes +# there, so the call fails without reaching anyone. +UNROUTABLE_NUMBER = "+1 415-555-0199" +CALL_PROMPT = ( + f"Please call {UNROUTABLE_NUMBER} and ask what time Saturday's dinner starts. " + "Make exactly one attempt — if it does not connect, stop and tell me. Do not retry." +) +CALL_TIMEOUT_S = 300.0 + + +def make_client() -> AsyncPineAI: + return AsyncPineAI(access_token=ACCESS_TOKEN, user_id=USER_ID, base_url=BASE_URL) + + +@pytest.fixture +async def session(): + """A connected client on a fresh session, torn down afterwards.""" + client = make_client() + await client.connect() + created = await client.sessions.create() + sid = created["id"] + await client.join_session(sid) + try: + yield client, sid + finally: + client.leave_session(sid) + with contextlib.suppress(Exception): + await client.sessions.delete(sid) + await client.disconnect() + + +class TestConnection: + async def test_connects_and_receives_ready(self): + client = make_client() + await client.connect() + assert client.connected + await client.disconnect() + + async def test_rejects_an_invalid_token(self): + client = AsyncPineAI(access_token="invalid", user_id=USER_ID, base_url=BASE_URL) + with pytest.raises(Exception): + await client.connect() + + +class TestSessionLifecycle: + async def test_create_list_get_delete(self): + client = make_client() + created = await client.sessions.create() + sid = created["id"] + assert created["state"] == "init" + + listed = await client.sessions.list(limit=50) + assert sid in [s["id"] for s in listed["sessions"]] + + fetched = await client.sessions.get(sid) + assert fetched["id"] == sid + # Expiry lives here and nowhere on the Socket.IO surface. + assert "is_stale" in fetched + + await client.sessions.delete(sid) + + +class TestSupportedSurface: + async def test_a_turn_produces_a_substantive_response(self, session): + """Streamed text, a complete message, a rich document, or a form. + + A turn often ends on streaming increments alone: the composer reopens + once the agent has finished speaking, and the complete `session:text` + is the durable record, read back from history rather than awaited here. + """ + client, sid = session + events = [e async for e in client.chat(sid, PROMPT)] + + types = {e.type for e in events} + assert types & { + S2CEvent.SESSION_TEXT.value, + S2CEvent.SESSION_TEXT_PART.value, + S2CEvent.SESSION_RICH_CONTENT.value, + S2CEvent.SESSION_FORM_TO_USER.value, + }, f"no substantive response; saw {sorted(types)}" + + async def test_unsupported_events_arrive_without_breaking_the_turn(self, session): + """The server keeps emitting outside the scope, and the SDK keeps + handing those events over rather than failing on them.""" + client, sid = session + events = [e async for e in client.chat(sid, PROMPT)] + + unsupported = sorted({e.type for e in events if not is_supported_event(e.type)}) + print(f" unsupported events observed: {unsupported}") + assert events, "the turn produced nothing at all" + + async def test_rebuild_returns_the_conversation(self, session): + client, sid = session + async for _ in client.chat(sid, PROMPT): + pass + + messages = await client.rebuild(sid) + assert isinstance(messages, list) + assert messages, "history came back empty after a turn" + + async def test_input_state_reports_whether_the_composer_is_open(self, session): + client, sid = session + states = [ + InputState.model_validate(e.data) + async for e in client.chat(sid, PROMPT) + if e.type == S2CEvent.SESSION_INPUT_STATE and isinstance(e.data, dict) + ] + assert states, "no session:input_state during a turn" + # Whatever the value, a blocked composer must name its reason. + for state in states: + if state.blocked: + assert state.code or state.detail + + +class TestErrors: + async def test_get_nonexistent_session(self): + client = make_client() + with pytest.raises(Exception): + await client.sessions.get("999999999999") + + +class TestOutboundCall: + """One real call task, placed to a number that cannot connect. + + This is the only test here that starts a task, so it is the only one that + reaches `session:task_ready`, `session:tool_status` and + `session:task_finished` — the events that report what a task did. It costs + credits and takes a few minutes. + """ + + async def test_a_call_that_cannot_connect_reports_through_tool_status(self, session): + client, sid = session + + async for _ in client.chat(sid, CALL_PROMPT): + pass + + # The task runs after the turn ends, so its events arrive outside it. + calls: list[dict] = [] + finished: list[dict] = [] + + async def watch(): + async for event in client.subscribe(sid): + data = event.data if isinstance(event.data, dict) else {} + if event.type == S2CEvent.SESSION_TOOL_STATUS: + calls.append(data) + elif event.type == S2CEvent.SESSION_TASK_FINISHED: + finished.append(data) + return + + # A timeout is not a failure by itself — the assertions below say what + # had to have arrived by then. + with contextlib.suppress(asyncio.TimeoutError): + await asyncio.wait_for(watch(), timeout=CALL_TIMEOUT_S) + + assert calls, f"no session:tool_status within {CALL_TIMEOUT_S:.0f}s" + assert any(c.get("tool_name") == "phone_call" for c in calls) + + last = calls[-1] + assert last.get("status") in ("failed", "completed"), last.get("status") + assert (last.get("summary") or {}).get("text"), "the call reported no outcome" + + assert finished, "the task never reported a result" + completion = finished[-1].get("completion") or {} + assert completion.get("result_title") + assert completion.get("outcome_narrative") diff --git a/tests/integration/test_sdk.py b/tests/integration/test_sdk.py deleted file mode 100644 index 3734813..0000000 --- a/tests/integration/test_sdk.py +++ /dev/null @@ -1,207 +0,0 @@ -""" -Integration tests for Pine AI Python SDK — tests against real Pine AI service. - -Requires environment variables: - PINE_ACCESS_TOKEN — valid access token - PINE_USER_ID — user ID - PINE_BASE_URL — (optional) defaults to https://www.19pine.ai - -Run: PINE_INTEGRATION=1 pytest tests/integration/ -v -""" - -import os -import pytest -import pytest_asyncio - -from pine_assistant import AsyncPineAI, S2CEvent - -SKIP = not os.environ.get("PINE_INTEGRATION") -ACCESS_TOKEN = os.environ.get("PINE_ACCESS_TOKEN", "") -USER_ID = os.environ.get("PINE_USER_ID", "") -BASE_URL = os.environ.get("PINE_BASE_URL", "https://www.19pine.ai") - -pytestmark = pytest.mark.skipif(SKIP, reason="PINE_INTEGRATION not set") - - -def make_client() -> AsyncPineAI: - return AsyncPineAI(access_token=ACCESS_TOKEN, user_id=USER_ID, base_url=BASE_URL) - - -class TestConnectionLifecycle: - """T2: Socket.IO Connection Lifecycle""" - - @pytest.mark.asyncio - async def test_connects_and_receives_ready(self): - client = make_client() - await client.connect() - assert client.connected - await client.disconnect() - - @pytest.mark.asyncio - async def test_rejects_invalid_token(self): - client = AsyncPineAI(access_token="invalid", user_id=USER_ID, base_url=BASE_URL) - with pytest.raises(Exception): - await client.connect() - - -class TestSessionCRUD: - """T3: Session CRUD""" - - @pytest.mark.asyncio - async def test_create_list_get_delete(self): - client = make_client() - - # Create - session = await client.sessions.create() - assert session["id"] - assert session["state"] == "init" - session_id = session["id"] - - # List - result = await client.sessions.list(limit=50) - assert result["total"] > 0 - ids = [s["id"] for s in result["sessions"]] - assert session_id in ids - - # Get - fetched = await client.sessions.get(session_id) - assert fetched["id"] == session_id - - # Delete - await client.sessions.delete(session_id) - - -class TestChatStreaming: - """T4 + T5: Chat with stream buffering""" - - @pytest.mark.asyncio - async def test_chat_returns_merged_text(self): - client = make_client() - await client.connect() - session = await client.sessions.create() - sid = session["id"] - await client.join_session(sid) - - events = [] - async for event in client.chat(sid, "What is Pine AI?"): - events.append(event) - if event.type == S2CEvent.SESSION_TEXT: - break - - # Should have merged text, not individual text_parts - text_events = [e for e in events if e.type == S2CEvent.SESSION_TEXT] - text_part_events = [e for e in events if e.type == S2CEvent.SESSION_TEXT_PART] - assert len(text_events) > 0, "Should receive at least one merged text event" - assert len(text_part_events) == 0, "text_parts should be buffered internally" - - content = text_events[0].data.get("content", "") if isinstance(text_events[0].data, dict) else "" - assert len(content) > 0 - print(f" Response ({len(content)} chars): {content[:200]}...") - - client.leave_session(sid) - try: - await client.sessions.delete(sid) - except Exception: - pass - await client.disconnect() - - -class TestSessionHistory: - """T9: Session History""" - - @pytest.mark.asyncio - async def test_fetch_history(self): - client = make_client() - await client.connect() - session = await client.sessions.create() - sid = session["id"] - await client.join_session(sid) - - # Send a message to create history - async for event in client.chat(sid, "Hello"): - if event.type == S2CEvent.SESSION_TEXT: - break - - # Fetch history - history = await client.get_history(sid, max_messages=10) - assert "messages" in history - assert isinstance(history["messages"], list) - - client.leave_session(sid) - try: - await client.sessions.delete(sid) - except Exception: - pass - await client.disconnect() - - -class TestFormInteraction: - """T6: Form Interaction""" - - @pytest.mark.asyncio - async def test_can_detect_forms(self): - client = make_client() - await client.connect() - session = await client.sessions.create() - sid = session["id"] - await client.join_session(sid) - - events = [] - async for event in client.chat(sid, "Help me negotiate my Comcast internet bill down to $50/month"): - events.append(event) - if event.type == S2CEvent.SESSION_FORM_TO_USER: - data = event.data if isinstance(event.data, dict) else {} - form = data.get("form", {}) - fields = form.get("fields", []) - response = {f.get("name", ""): "test_value" for f in fields if isinstance(f, dict)} - if response: - client.send_form_response(sid, event.message_id or "0", response) - break - if event.type == S2CEvent.SESSION_TEXT: - break - - assert len(events) > 0 - - client.leave_session(sid) - try: - await client.sessions.delete(sid) - except Exception: - pass - await client.disconnect() - - -class TestStateTransitions: - """T7: Session State Transitions""" - - @pytest.mark.asyncio - async def test_init_to_chat_transition(self): - client = make_client() - await client.connect() - session = await client.sessions.create() - sid = session["id"] - assert session["state"] == "init" - - await client.join_session(sid) - async for event in client.chat(sid, "I need help with my phone bill"): - if event.type == S2CEvent.SESSION_TEXT: - break - - updated = await client.sessions.get(sid) - assert updated["state"] in ("chat", "init") - - client.leave_session(sid) - try: - await client.sessions.delete(sid) - except Exception: - pass - await client.disconnect() - - -class TestErrorHandling: - """T10: Error Handling""" - - @pytest.mark.asyncio - async def test_get_nonexistent_session(self): - client = make_client() - with pytest.raises(Exception): - await client.sessions.get("999999999999") diff --git a/tests/protocol/README.md b/tests/protocol/README.md new file mode 100644 index 0000000..3319951 --- /dev/null +++ b/tests/protocol/README.md @@ -0,0 +1,47 @@ +# Protocol fixtures and tests + +These pin the SDK to the supported protocol scope — the subset of the +task-session Socket.IO protocol that carries a compatibility guarantee. + +## Layout + +| Path | What it is | +|---|---| +| `fixtures/*.json` | One envelope per supported event. Not tests — the material the tests run on | +| `fixtures/provenance.json` | Where each fixture came from, and when | +| `test_contract.py` | One envelope at a time: does the SDK read it | +| `test_flow.py` | A sequence of envelopes: does the SDK behave as the scope requires | +| `fake.py` | A scripted stand-in for `socketio.AsyncClient` | + +The two test files fail for different reasons, which is why they are separate: +a contract failure means a payload shape moved, a flow failure means the SDK's +logic is wrong. + +Events reach the flow tests through the real transport — envelope parsing, +`SocketIOManager`, `ChatEngine`. Only the wire is fake. + +## Provenance + +`recorded` fixtures came off a running server. `derived` ones were written from +the protocol source because no ordinary session reaches the condition they +describe — a blocked account, a task waiting on credits. A derived fixture is +weaker evidence: it says the SDK matches what the protocol declares, not what +the server sends. + +Re-record after a backend release: + +```bash +PINE_ACCESS_TOKEN=... PINE_USER_ID=... python -m tests.integration.record_fixtures --raw-dir /tmp/pine-raw +git diff tests/protocol/fixtures +``` + +A non-empty diff is server drift, and re-recording is the only thing that +surfaces it — the scope is a commitment about future behaviour, which no check +in this repository can verify. + +## Fixtures are the supported surface only + +Recording never writes a fixture for an event outside the scope, and +`test_contract.py` fails if one appears. Unsupported events still reach callers +untouched — `test_flow.py` pins that — they are simply not something the SDK +promises anything about. diff --git a/tests/protocol/__init__.py b/tests/protocol/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/protocol/fake.py b/tests/protocol/fake.py new file mode 100644 index 0000000..85faccc --- /dev/null +++ b/tests/protocol/fake.py @@ -0,0 +1,127 @@ +"""A scripted stand-in for socketio.AsyncClient. + +Events travel the real path — envelope parsing, SocketIOManager, ChatEngine — +so what these tests exercise is the SDK, not a mock of it. Only the wire is +fake. +""" + +from __future__ import annotations + +import json +import pathlib +from collections.abc import Callable +from typing import Any + +FIXTURES = pathlib.Path(__file__).parent / "fixtures" +SESSION_ID = "1900000000000000001" + + +def load_fixture(name: str) -> dict[str, Any]: + """Load one recorded envelope by event name (`session:text` or `text`).""" + return json.loads((FIXTURES / f"{name.replace('session:', '')}.json").read_text()) + + +def all_fixtures() -> dict[str, dict[str, Any]]: + """Every fixture, keyed by the event type it carries.""" + out = {} + for path in sorted(FIXTURES.glob("*.json")): + if path.name == "provenance.json": + continue + envelope = json.loads(path.read_text()) + out[envelope["type"]] = envelope + return out + + +def envelope( + event_type: str, + data: Any = None, + *, + session_id: str = SESSION_ID, + message_id: str | None = None, + event_id: str = "00000000-0000-4000-8000-00000000dead", + request_id: str | None = None, + role: str = "agent", +) -> dict[str, Any]: + """Build an arbitrary server envelope — including for events the SDK has + never heard of.""" + return { + "metadata": { + "event_id": event_id, + "request_id": request_id, + "timestamp": "2026-08-08T00:00:00Z", + "source": {"role": role}, + "is_volatile": False, + }, + "type": event_type, + "payload": { + "session_id": session_id, + "message_id": message_id, + "type": event_type, + "data": data, + }, + } + + +class FakeAsyncClient: + """Implements the surface of socketio.AsyncClient that SocketIOManager uses.""" + + def __init__(self) -> None: + self._handlers: dict[str, Any] = {} + self.connected = False + self.emitted: list[tuple[str, dict[str, Any]]] = [] + self.responders: dict[str, Callable[[dict[str, Any]], list[dict[str, Any]]]] = {} + + # -- socketio.AsyncClient surface ------------------------------------- + + def event(self, fn: Any) -> Any: + self._handlers[fn.__name__] = fn + return fn + + def on(self, name: str) -> Any: + def deco(fn: Any) -> Any: + self._handlers[name] = fn + return fn + return deco + + async def connect(self, *_args: Any, **_kwargs: Any) -> None: + self.connected = True + await self.fire_ready() + + async def emit(self, event: str, data: Any = None) -> None: + self.emitted.append((event, data)) + responder = self.responders.get(event) + if responder is not None: + for reply in responder(data): + await self.deliver(reply) + + async def disconnect(self) -> None: + self.connected = False + + # -- test controls ---------------------------------------------------- + + async def fire_ready(self) -> None: + handler = self._handlers.get("ready") + if handler is not None: + await handler() + + async def deliver(self, env: dict[str, Any]) -> None: + """Push one server event down the same path a real one takes.""" + handler = self._handlers.get("*") + if handler is not None: + await handler(env["type"], env) + + def emits_of(self, event_type: str) -> list[dict[str, Any]]: + return [payload for evt, payload in self.emitted if evt == event_type] + + def reply_to(self, event_type: str, data: Any, *, role: str = "system") -> None: + """Answer an emitted request with an envelope echoing its request_id.""" + def responder(request: dict[str, Any]) -> list[dict[str, Any]]: + request_id = (request.get("metadata") or {}).get("request_id") + payload = data(request) if callable(data) else data + return [envelope( + event_type, payload, + request_id=request_id, + event_id=f"reply-{request_id}", + role=role, + )] + self.responders[event_type] = responder diff --git a/tests/protocol/fixtures/error.json b/tests/protocol/fixtures/error.json new file mode 100644 index 0000000..a6834b8 --- /dev/null +++ b/tests/protocol/fixtures/error.json @@ -0,0 +1,21 @@ +{ + "metadata": { + "event_id": "00000000-0000-4000-8000-755020160331", + "request_id": "00000000-0000-4000-8000-000000000001", + "timestamp": "2026-08-08T00:00:00Z", + "source": { + "role": "system" + }, + "is_volatile": false + }, + "type": "session:error", + "payload": { + "session_id": "1900000000000000001", + "message_id": "1900000000000000101", + "type": "session:error", + "data": { + "code": "internal_error", + "message": "Something went wrong." + } + } +} diff --git a/tests/protocol/fixtures/form_to_user.json b/tests/protocol/fixtures/form_to_user.json new file mode 100644 index 0000000..ee045ce --- /dev/null +++ b/tests/protocol/fixtures/form_to_user.json @@ -0,0 +1,198 @@ +{ + "metadata": { + "event_id": "8be552fd-3e06-48ec-95e5-65df7b2bb9fe", + "group_id": "d15ce9f7-f937-4aef-929b-c848a8cc795c", + "is_required_action": true, + "is_volatile": false, + "request_id": "7920bfdc-99e2-4682-a270-2ba0da10a150", + "source": { + "role": "agent", + "user_id": "100000000000000001" + }, + "timestamp": "2026-08-08T13:48:53Z" + }, + "payload": { + "data": { + "form": { + "fields": [ + { + "description": "The full legal name of the account holder on your Xfinity bill.", + "is_required": true, + "name": "Account Holder Full Name", + "pii_level": "L1", + "placeholder": "[redacted]", + "prefilled": "[redacted]", + "source": "knowledge_base", + "type": "text" + }, + { + "description": "Gender of the primary account holder, required by Xfinity customer service representatives for identity authentication during phone calls.", + "is_required": true, + "name": "Legal Gender", + "options": [ + "[redacted]", + "[redacted]", + "[redacted]" + ], + "pii_level": "L1", + "placeholder": "[redacted]", + "prefilled": "[redacted]", + "source": "knowledge_base", + "type": "radio" + }, + { + "description": "The email address associated with your Xfinity account for identity verification.", + "is_required": true, + "name": "Email Address", + "pii_level": "L2", + "placeholder": "[redacted]", + "prefilled": "[redacted]", + "source": "knowledge_base", + "type": "text" + }, + { + "description": "The phone number linked to your Xfinity account.", + "is_required": true, + "name": "Phone Number", + "pii_level": "L2", + "placeholder": "[redacted]", + "prefilled": "[redacted]", + "source": "knowledge_base", + "type": "text" + }, + { + "description": "Complete service address including street, unit, city, state, and ZIP code where your Xfinity service is installed.", + "is_required": true, + "name": "Service Address", + "pii_level": "L3", + "placeholder": "[redacted]", + "prefilled": "[redacted]", + "source": "knowledge_base", + "type": "text" + }, + { + "description": "Your 16-digit Xfinity residential account number (usually starts with 8).", + "is_required": true, + "name": "Xfinity Account Number", + "pii_level": "L2", + "placeholder": "[redacted]", + "prefilled": "[redacted]", + "source": "knowledge_base", + "type": "text" + }, + { + "description": "Your 4-digit Xfinity security PIN or account passcode used for phone authentication.", + "is_required": true, + "name": "Xfinity Account Security PIN", + "pii_level": "L3", + "placeholder": "[redacted]", + "prefilled": "[redacted]", + "source": "knowledge_base", + "type": "text" + }, + { + "description": "The last 4 digits of the payment card on file or Social Security Number for secondary verification.", + "is_required": true, + "name": "Last Four Digits of Payment Card or SSN", + "pii_level": "L2", + "placeholder": "[redacted]", + "prefilled": "[redacted]", + "source": "knowledge_base", + "type": "text" + }, + { + "description": "Your current Xfinity internet plan speed or bundled packages (e.g., Gigabit Extra 1.2 Gbps, Blast 800 Mbps, or Internet + TV bundle).", + "is_required": true, + "name": "Current Internet Plan or Package", + "pii_level": "L1", + "placeholder": "[redacted]", + "prefilled": "[redacted]", + "source": "knowledge_base", + "type": "text" + }, + { + "description": "Sharing a recent PDF bill or screenshot can help identify active line-item fees, equipment rentals, and regional promo codes.", + "is_required": true, + "name": "Bill Sharing Option", + "options": [ + "[redacted]", + "[redacted]" + ], + "pii_level": "L1", + "placeholder": "[redacted]", + "prefilled": "[redacted]", + "source": "agent", + "type": "radio" + }, + { + "description": "Xfinity offers an extra $10/month discount if enrolled in bank account autopay (ACH) versus a credit/debit card.", + "is_required": true, + "name": "Autopay Method Preference", + "options": [ + "[redacted]", + "[redacted]", + "[redacted]" + ], + "pii_level": "L1", + "placeholder": "[redacted]", + "prefilled": "[redacted]", + "source": "knowledge_base", + "type": "radio" + }, + { + "description": "Xfinity rate agreements often come with 1-year or 5-year price guarantee terms.", + "is_required": true, + "name": "Price Guarantee Term Preference", + "options": [ + "[redacted]", + "[redacted]", + "[redacted]" + ], + "pii_level": "L1", + "placeholder": "[redacted]", + "prefilled": "[redacted]", + "source": "knowledge_base", + "type": "radio" + }, + { + "description": "If your current premium tier cannot be reduced to $50/mo, are you open to adjusting your speed tier or removing unneeded add-ons?", + "is_required": true, + "name": "Speed and Package Flexibility", + "options": [ + "[redacted]", + "[redacted]", + "[redacted]" + ], + "pii_level": "L1", + "placeholder": "[redacted]", + "prefilled": "[redacted]", + "source": "agent", + "type": "radio" + }, + { + "description": "If Xfinity retention reps cannot meet $50/mo exactly, select all acceptable backup choices.", + "is_required": true, + "name": "Negotiation Flexibility Alternatives", + "options": [ + "[redacted]", + "[redacted]", + "[redacted]", + "[redacted]" + ], + "pii_level": "L1", + "placeholder": "[redacted]", + "prefilled": "[redacted]", + "source": "agent", + "type": "multiselect" + } + ] + }, + "message_to_user": "I'm on it! I've sent over a quick form to get your Xfinity account details and preferred autopay options so we can start negotiating that bill down to $50." + }, + "message_id": "816306070082297856", + "revision": "3009087", + "session_id": "1900000000000000001", + "type": "session:form_to_user" + }, + "type": "session:form_to_user" +} diff --git a/tests/protocol/fixtures/history.json b/tests/protocol/fixtures/history.json new file mode 100644 index 0000000..590bd28 --- /dev/null +++ b/tests/protocol/fixtures/history.json @@ -0,0 +1,21 @@ +{ + "metadata": { + "event_id": "6dc26984-1ba7-4503-bda8-d68a8d47c8d8", + "request_id": "58f1eabb-2459-46f1-86a2-59de5860103f", + "timestamp": "2026-08-08T13:50:34Z", + "source": { + "role": "system", + "user_id": "100000000000000001" + }, + "is_volatile": false + }, + "type": "session:history", + "payload": { + "session_id": "1900000000000000001", + "type": "session:history", + "data": { + "messages": null, + "order": "DESC" + } + } +} diff --git a/tests/protocol/fixtures/input_state.json b/tests/protocol/fixtures/input_state.json new file mode 100644 index 0000000..6a990eb --- /dev/null +++ b/tests/protocol/fixtures/input_state.json @@ -0,0 +1,20 @@ +{ + "metadata": { + "event_id": "e1f15706-e0ca-40c1-847c-9431ab82636a", + "is_volatile": false, + "source": { + "role": "system" + }, + "timestamp": "2026-08-08T13:53:50Z" + }, + "payload": { + "data": { + "code": "default", + "content": "waiting_input" + }, + "message_id": "816307318147784704", + "session_id": "1900000000000000001", + "type": "session:input_state" + }, + "type": "session:input_state" +} diff --git a/tests/protocol/fixtures/join.json b/tests/protocol/fixtures/join.json new file mode 100644 index 0000000..8142b3b --- /dev/null +++ b/tests/protocol/fixtures/join.json @@ -0,0 +1,27 @@ +{ + "metadata": { + "event_id": "dc632eb5-f344-4c9a-9f08-f9bb0fcdd3f1", + "request_id": "49e9e2c9-787d-4e45-a49d-83da0a852725", + "timestamp": "2026-08-08T13:50:34Z", + "source": { + "role": "system", + "user_id": "100000000000000001" + }, + "is_volatile": false + }, + "type": "session:join", + "payload": { + "session_id": "1900000000000000001", + "type": "session:join", + "data": { + "title": "New Task", + "state": "init", + "input_state": "waiting_input", + "input_state_code": "default", + "is_required_action": false, + "created_at": "2026-08-08T13:50:34Z", + "updated_at": "2026-08-08T13:50:34Z", + "max_message_revision": "0" + } + } +} diff --git a/tests/protocol/fixtures/llm_thinking.json b/tests/protocol/fixtures/llm_thinking.json new file mode 100644 index 0000000..01cab7e --- /dev/null +++ b/tests/protocol/fixtures/llm_thinking.json @@ -0,0 +1,25 @@ +{ + "metadata": { + "event_id": "db97b41d-6d5f-44d7-9c9b-7b5bd66bbe5e", + "group_id": "16148082-0774-4a01-a69d-10e4d427d8aa", + "is_volatile": false, + "source": { + "role": "agent", + "user_id": "100000000000000001" + }, + "timestamp": "2026-08-08T13:54:21Z" + }, + "payload": { + "data": { + "final": true, + "turn_id": "503677ff-3c8c-4c16-8db0-3fa0285a9660", + "type": "turn_end" + }, + "message_id": "816307447667900416", + "revision": "3009458", + "session_id": "1900000000000000001", + "status": "delivered", + "type": "session:llm_thinking" + }, + "type": "session:llm_thinking" +} diff --git a/tests/protocol/fixtures/message.json b/tests/protocol/fixtures/message.json new file mode 100644 index 0000000..5aa4feb --- /dev/null +++ b/tests/protocol/fixtures/message.json @@ -0,0 +1,22 @@ +{ + "metadata": { + "event_id": "00000000-0000-4000-8000-645085987330", + "request_id": "00000000-0000-4000-8000-000000000001", + "timestamp": "2026-08-08T00:00:00Z", + "source": { + "role": "user" + }, + "is_volatile": false + }, + "type": "session:message", + "payload": { + "session_id": "1900000000000000001", + "message_id": "1900000000000000101", + "type": "session:message", + "data": { + "content": "Negotiate my Comcast bill.", + "attachments": [], + "referenced_sessions": [] + } + } +} diff --git a/tests/protocol/fixtures/message_status.json b/tests/protocol/fixtures/message_status.json new file mode 100644 index 0000000..5b23291 --- /dev/null +++ b/tests/protocol/fixtures/message_status.json @@ -0,0 +1,25 @@ +{ + "metadata": { + "event_id": "577b6c2c-b927-4dfa-a348-276eb01e4544", + "group_id": "16148082-0774-4a01-a69d-10e4d427d8aa", + "is_volatile": true, + "request_id": "344ea29a-d16e-4099-845c-6352604e6bfe", + "source": { + "role": "system", + "user_id": "100000000000000001" + }, + "timestamp": "2026-08-08T13:51:00Z" + }, + "payload": { + "data": { + "message_id": "816306493925101568", + "request_id": "16148082-0774-4a01-a69d-10e4d427d8aa", + "revision": "3009104", + "status": "processed" + }, + "message_id": "816306604294029312", + "session_id": "1900000000000000001", + "type": "session:message_status" + }, + "type": "session:message_status" +} diff --git a/tests/protocol/fixtures/provenance.json b/tests/protocol/fixtures/provenance.json new file mode 100644 index 0000000..1249336 --- /dev/null +++ b/tests/protocol/fixtures/provenance.json @@ -0,0 +1,92 @@ +{ + "session:error": { + "derived_from": "the server's protocol definition", + "recorded_at": null, + "source": "derived" + }, + "session:form_to_user": { + "derived_from": null, + "recorded_at": "2026-08-08T13:48:53+00:00", + "source": "recorded" + }, + "session:history": { + "derived_from": null, + "recorded_at": "2026-08-08T13:55:16+00:00", + "source": "recorded" + }, + "session:input_state": { + "derived_from": null, + "recorded_at": "2026-08-08T13:55:16+00:00", + "source": "recorded" + }, + "session:join": { + "derived_from": null, + "recorded_at": "2026-08-08T13:55:16+00:00", + "source": "recorded" + }, + "session:llm_thinking": { + "derived_from": null, + "recorded_at": "2026-08-08T13:55:16+00:00", + "source": "recorded" + }, + "session:message": { + "derived_from": "the server's protocol definition", + "recorded_at": null, + "source": "derived" + }, + "session:message_status": { + "derived_from": null, + "recorded_at": "2026-08-08T13:55:16+00:00", + "source": "recorded" + }, + "session:required_action": { + "derived_from": null, + "recorded_at": "2026-08-08T13:48:53+00:00", + "source": "recorded" + }, + "session:restriction": { + "derived_from": "the server's protocol definition", + "recorded_at": null, + "source": "derived" + }, + "session:rich_content": { + "derived_from": "the server's protocol definition", + "recorded_at": null, + "source": "derived" + }, + "session:state": { + "derived_from": null, + "recorded_at": "2026-08-08T13:55:16+00:00", + "source": "recorded" + }, + "session:task_finished": { + "derived_from": null, + "recorded_at": "2026-08-08T13:55:16+00:00", + "source": "recorded" + }, + "session:task_ready": { + "derived_from": null, + "recorded_at": "2026-08-08T13:55:16+00:00", + "source": "recorded" + }, + "session:text": { + "derived_from": null, + "recorded_at": "2026-08-08T13:55:16+00:00", + "source": "recorded" + }, + "session:text_part": { + "derived_from": null, + "recorded_at": "2026-08-08T13:55:16+00:00", + "source": "recorded" + }, + "session:tool_status": { + "derived_from": null, + "recorded_at": "2026-08-08T13:55:16+00:00", + "source": "recorded" + }, + "session:update_title": { + "derived_from": null, + "recorded_at": "2026-08-08T13:55:16+00:00", + "source": "recorded" + } +} diff --git a/tests/protocol/fixtures/required_action.json b/tests/protocol/fixtures/required_action.json new file mode 100644 index 0000000..d225b2f --- /dev/null +++ b/tests/protocol/fixtures/required_action.json @@ -0,0 +1,21 @@ +{ + "metadata": { + "event_id": "01382d01-c23d-42c7-a420-be074431bb4f", + "group_id": "d15ce9f7-f937-4aef-929b-c848a8cc795c", + "is_volatile": false, + "source": { + "role": "system", + "user_id": "100000000000000001" + }, + "timestamp": "2026-08-08T13:48:53Z" + }, + "payload": { + "data": { + "is_required_action": true + }, + "message_id": "816306070224904192", + "session_id": "1900000000000000001", + "type": "session:required_action" + }, + "type": "session:required_action" +} diff --git a/tests/protocol/fixtures/restriction.json b/tests/protocol/fixtures/restriction.json new file mode 100644 index 0000000..2fa0297 --- /dev/null +++ b/tests/protocol/fixtures/restriction.json @@ -0,0 +1,22 @@ +{ + "metadata": { + "event_id": "00000000-0000-4000-8000-084679774380", + "request_id": "00000000-0000-4000-8000-000000000001", + "timestamp": "2026-08-08T00:00:00Z", + "source": { + "role": "system" + }, + "is_volatile": false + }, + "type": "session:restriction", + "payload": { + "session_id": "1900000000000000001", + "message_id": "1900000000000000101", + "type": "session:restriction", + "data": { + "level": "blocked", + "reason": "account_review", + "message": "This task cannot be completed." + } + } +} diff --git a/tests/protocol/fixtures/rich_content.json b/tests/protocol/fixtures/rich_content.json new file mode 100644 index 0000000..4be3bbd --- /dev/null +++ b/tests/protocol/fixtures/rich_content.json @@ -0,0 +1,24 @@ +{ + "metadata": { + "event_id": "00000000-0000-4000-8000-670639217296", + "request_id": "00000000-0000-4000-8000-000000000001", + "timestamp": "2026-08-08T00:00:00Z", + "source": { + "role": "agent" + }, + "is_volatile": false + }, + "type": "session:rich_content", + "payload": { + "session_id": "1900000000000000001", + "message_id": "1900000000000000101", + "type": "session:rich_content", + "data": { + "title": "Plan comparison", + "subtitle": "3 providers", + "type": "report", + "message_to_user": "Here is what I found.", + "content": "## Findings\n\nDetails." + } + } +} diff --git a/tests/protocol/fixtures/state.json b/tests/protocol/fixtures/state.json new file mode 100644 index 0000000..9a2d2e1 --- /dev/null +++ b/tests/protocol/fixtures/state.json @@ -0,0 +1,18 @@ +{ + "metadata": { + "event_id": "8435e63b-1066-4cd1-a484-355aed82cf73", + "is_volatile": false, + "source": { + "role": "system" + }, + "timestamp": "2026-08-08T13:54:21Z" + }, + "payload": { + "data": { + "content": "task_finished" + }, + "session_id": "1900000000000000001", + "type": "session:state" + }, + "type": "session:state" +} diff --git a/tests/protocol/fixtures/task_finished.json b/tests/protocol/fixtures/task_finished.json new file mode 100644 index 0000000..56e5702 --- /dev/null +++ b/tests/protocol/fixtures/task_finished.json @@ -0,0 +1,49 @@ +{ + "metadata": { + "event_id": "f8389d0c-3a3c-4ee2-a0fa-9bc7e0e8d109", + "group_id": "16148082-0774-4a01-a69d-10e4d427d8aa", + "is_volatile": false, + "source": { + "role": "agent", + "user_id": "100000000000000001" + }, + "timestamp": "2026-08-08T13:54:21Z" + }, + "payload": { + "data": { + "completion": { + "animation_type": "progress", + "engage_prompt": "Would you like to discuss trying the call again or taking a different approach?", + "outcome_narrative": "Pine placed a call to your event contact to ask what time Saturday's dinner starts. Unfortunately, the call failed to connect, and per your strict instruction, Pine did not make any further retry attempts.", + "result_title": "Progress Made", + "share_text": "PineAI helped me make real progress on my issue. Give it a try!", + "summary": { + "achievements": [ + { + "description": "Task completed in under 10 minutes", + "id": "speed_demon", + "is_new": true, + "rarity": "rare", + "title": "Speed Demon" + } + ], + "call_duration_mins": 0, + "calls_made": 1, + "credits_invested": 102, + "emails_sent": 0, + "hold_time_avoided_mins": 0, + "obstacles_overcome": 0, + "silos_conquered": 1, + "time_saved_minutes": 5, + "web_tasks_completed": 0 + } + }, + "status": "failed" + }, + "message_id": "816307447370104832", + "revision": "3009455", + "session_id": "1900000000000000001", + "type": "session:task_finished" + }, + "type": "session:task_finished" +} diff --git a/tests/protocol/fixtures/task_ready.json b/tests/protocol/fixtures/task_ready.json new file mode 100644 index 0000000..b916e30 --- /dev/null +++ b/tests/protocol/fixtures/task_ready.json @@ -0,0 +1,23 @@ +{ + "metadata": { + "event_id": "14c70877-5d29-438d-93e7-163d8040e3b4", + "group_id": "16148082-0774-4a01-a69d-10e4d427d8aa", + "is_volatile": false, + "request_id": "659c6d37-086f-4f81-8606-823432da6da9", + "source": { + "role": "system" + }, + "timestamp": "2026-08-08T13:51:00Z" + }, + "payload": { + "data": { + "confirmed": true, + "required": 30 + }, + "message_id": "816306603308367872", + "revision": "3009101", + "session_id": "1900000000000000001", + "type": "session:task_ready" + }, + "type": "session:task_ready" +} diff --git a/tests/protocol/fixtures/text.json b/tests/protocol/fixtures/text.json new file mode 100644 index 0000000..c5107fa --- /dev/null +++ b/tests/protocol/fixtures/text.json @@ -0,0 +1,23 @@ +{ + "metadata": { + "event_id": "1206769d-e889-4161-b4df-c4f75d6f1414", + "group_id": "16148082-0774-4a01-a69d-10e4d427d8aa", + "is_volatile": false, + "source": { + "role": "agent", + "user_id": "100000000000000001" + }, + "timestamp": "2026-08-08T13:51:26Z" + }, + "payload": { + "data": { + "content": "I've placed the call to ask about Saturday's dinner start time! I'm waiting for the call to finish and will let you know what they say as soon as I have an update. \ud83d\udcde\ud83c\udf7d\ufe0f" + }, + "message_id": "816306711211032576", + "revision": "3009124", + "session_id": "1900000000000000001", + "status": "delivered", + "type": "session:text" + }, + "type": "session:text" +} diff --git a/tests/protocol/fixtures/text_part.json b/tests/protocol/fixtures/text_part.json new file mode 100644 index 0000000..8300590 --- /dev/null +++ b/tests/protocol/fixtures/text_part.json @@ -0,0 +1,22 @@ +{ + "metadata": { + "event_id": "8c7d4b90-236d-46f4-b8e8-f86237a24da1", + "group_id": "16148082-0774-4a01-a69d-10e4d427d8aa", + "is_volatile": false, + "source": { + "role": "agent", + "user_id": "100000000000000001" + }, + "timestamp": "2026-08-08T13:53:54Z" + }, + "payload": { + "data": { + "content": "I tried calling the number, but the call didn't go through successfully and disconnected right away.", + "final": false + }, + "message_id": "816307333704466432", + "session_id": "1900000000000000001", + "type": "session:text_part" + }, + "type": "session:text_part" +} diff --git a/tests/protocol/fixtures/tool_status.json b/tests/protocol/fixtures/tool_status.json new file mode 100644 index 0000000..c9c9d80 --- /dev/null +++ b/tests/protocol/fixtures/tool_status.json @@ -0,0 +1,31 @@ +{ + "metadata": { + "event_id": "45094217-b50a-4e6c-82b4-9b3dd1990590", + "group_id": "16148082-0774-4a01-a69d-10e4d427d8aa", + "is_volatile": false, + "request_id": "e5ea2ed7-0496-4101-a5da-36e6ea259e04", + "source": { + "role": "agent", + "user_id": "100000000000000001" + }, + "timestamp": "2026-08-08T13:51:23Z" + }, + "payload": { + "data": { + "operation_id": "e617a8b3-ec57-4d1f-96f5-f6c1cc914cf8", + "provider": "Dinner Host / Event Contact (+15555550100)", + "start_time": "2026-08-08T13:51:22Z", + "status": "failed", + "summary": { + "text": "The call could not be completed successfully" + }, + "target": "+15555550100", + "tool_name": "phone_call" + }, + "message_id": "816306698405822464", + "revision": "3009123", + "session_id": "1900000000000000001", + "type": "session:tool_status" + }, + "type": "session:tool_status" +} diff --git a/tests/protocol/fixtures/update_title.json b/tests/protocol/fixtures/update_title.json new file mode 100644 index 0000000..7b33d07 --- /dev/null +++ b/tests/protocol/fixtures/update_title.json @@ -0,0 +1,20 @@ +{ + "metadata": { + "event_id": "5a5dbf54-9942-4211-a931-85f387877b82", + "group_id": "16148082-0774-4a01-a69d-10e4d427d8aa", + "is_volatile": false, + "source": { + "role": "agent", + "user_id": "100000000000000001" + }, + "timestamp": "2026-08-08T13:50:37Z" + }, + "payload": { + "data": { + "content": "Call to ask Saturday dinner time" + }, + "session_id": "1900000000000000001", + "type": "session:update_title" + }, + "type": "session:update_title" +} diff --git a/tests/protocol/test_contract.py b/tests/protocol/test_contract.py new file mode 100644 index 0000000..0b4456c --- /dev/null +++ b/tests/protocol/test_contract.py @@ -0,0 +1,264 @@ +"""Contract tests — one envelope at a time, no flow. + +Each supported event has a fixture. These assert the SDK reads it: the envelope +parses, the type survives, and the fields the protocol scope names are +reachable. A failure here means a payload shape moved. + +A fixture records a shape, not a scenario. Whichever instance a recording +happened to catch is the one checked in — `session:state` may hold "chat" rather +than a terminal state, `session:input_state` an open composer rather than a +blocked one. Assertions here stay on what every instance carries; a specific +condition is constructed by the test that needs it. +""" + +import json +import pathlib + +import pytest + +from pine_assistant.chat import event_from_envelope +from pine_assistant.models.envelope import MessageEnvelope +from pine_assistant.models.events import SUPPORTED_EVENTS, S2CEvent +from pine_assistant.models.form import FormToUserData +from pine_assistant.models.session import InputState, InputStateCode +from pine_assistant.models.task import ( + LLMThinkingData, + MessageStatusData, + RequiredActionData, + RestrictionData, + RichContentData, + TaskFinishedData, + TaskReadyData, + ToolStatusData, +) +from pine_assistant.transport.envelope import parse_envelope +from tests.protocol.fake import FIXTURES, all_fixtures, load_fixture + +FIXTURE_EVENTS = sorted(all_fixtures()) + + +def test_every_supported_event_has_a_fixture(): + """`ready` carries no envelope; everything else in scope needs a sample.""" + expected = {e.value for e in S2CEvent} - {S2CEvent.READY.value} + assert set(FIXTURE_EVENTS) == expected + + +def test_fixtures_carry_only_supported_events(): + assert set(FIXTURE_EVENTS) <= SUPPORTED_EVENTS + + +def test_every_fixture_declares_its_provenance(): + """A derived sample and a recorded one are not equally trustworthy, so the + difference is written down rather than remembered.""" + provenance = json.loads((FIXTURES / "provenance.json").read_text()) + assert set(provenance) == set(FIXTURE_EVENTS) + for event, entry in provenance.items(): + assert entry["source"] in ("recorded", "derived"), event + + +@pytest.mark.parametrize("event_type", FIXTURE_EVENTS) +def test_envelope_parses(event_type): + envelope = all_fixtures()[event_type] + parsed = parse_envelope(envelope) + assert isinstance(parsed, MessageEnvelope) + assert parsed.type == event_type + assert parsed.payload.session_id + + +@pytest.mark.parametrize("event_type", FIXTURE_EVENTS) +def test_event_reaches_the_caller_intact(event_type): + envelope = all_fixtures()[event_type] + event = event_from_envelope(event_type, envelope, envelope["payload"]["session_id"]) + assert event.type == event_type + assert event.event_id == envelope["metadata"]["event_id"] + assert event.data == envelope["payload"]["data"] + + +# -- the fields the scope names ------------------------------------------ + + +def test_task_finished_carries_the_textual_conclusion(): + """A finished task always states an outcome. `result_description` and + `summary.brief` are not always written — a task that failed carries the + narrative and nothing else.""" + data = TaskFinishedData.model_validate(load_fixture("task_finished")["payload"]["data"]) + assert data.status + assert data.completion is not None + assert data.completion.result_title + assert data.completion.outcome_narrative + + +def test_task_finished_on_success_quantifies_the_outcome(): + """Constructed: a task that reaches a result is not ours to arrange.""" + data = TaskFinishedData.model_validate({ + "status": "completed", + "completion": { + "result_title": "Bill reduced by $35/month", + "result_description": "A 12-month promotional rate was applied.", + "outcome_narrative": "Pine called, held, and secured a promotional rate.", + "summary": {"brief": "Saved $420 over 12 months.", "money_saved": 420.0, + "calls_made": 1, "credits_invested": 500}, + }, + }) + assert data.completion.result_description + assert data.completion.summary.brief + assert data.completion.summary.credits_invested + + +def test_tool_status_carries_the_outbound_call_record(): + """The number called and the textual outcome. The quantified fields are + written when there is something to count — a call that never connected + reports its outcome and no duration.""" + data = ToolStatusData.model_validate(load_fixture("tool_status")["payload"]["data"]) + assert data.operation_id + assert data.tool_name + assert data.target + assert data.status + assert data.summary is not None + assert data.summary.text + + +def test_tool_status_on_a_completed_call_quantifies_it(): + """Constructed: recording a connected call means calling someone.""" + data = ToolStatusData.model_validate({ + "operation_id": "op-1", "tool_name": "phone_call", "target": "+15555550100", + "status": "completed", + "summary": {"text": "Reached billing.", "duration": 1320, + "actions_count": 3, "credits_consumed": 500}, + }) + assert data.summary.duration + assert data.summary.credits_consumed + + +def test_input_state_reports_the_composer_and_its_reason(): + """Blocked or not, the reason is on the event — never inferred from which + other events arrived.""" + data = InputState.model_validate(load_fixture("input_state")["payload"]["data"]) + assert data.content + assert data.accepting_input is not data.blocked + if data.blocked: + assert data.code or data.detail + + +def test_input_state_codes_name_the_two_conditions_worth_handling(): + """Constructed, not recorded: an ordinary session reaches neither.""" + awaiting = InputState(content="input_disabled", code=InputStateCode.TASK_READY) + assert awaiting.awaiting_credits and not awaiting.needs_phone_verification + + unverified = InputState( + content="input_disabled", code=InputStateCode.PHONE_VERIFICATION_REQUIRED, + ) + assert unverified.needs_phone_verification and not unverified.awaiting_credits + + +def test_llm_thinking_is_typed(): + """A reasoning step always declares its type. Search has no event of its + own — it appears here as a `tool_call` step, whose fields are pinned below.""" + data = LLMThinkingData.model_validate(load_fixture("llm_thinking")["payload"]["data"]) + assert data.type + + +def test_llm_thinking_carries_tool_calls(): + """Constructed: whether a turn produces a tool_call step is not ours to + arrange.""" + data = LLMThinkingData.model_validate({ + "type": "tool_call", "tool_name": "web_search", "status": "completed", + "history": [{"tool_name": "web_search", "status": "completed"}], + }) + assert data.type == "tool_call" + assert data.tool_name + assert data.history + + +def test_rich_content_carries_its_own_body(): + """Not repeated in session:text — ignoring it loses the content.""" + data = RichContentData.model_validate(load_fixture("rich_content")["payload"]["data"]) + assert data.title + assert data.content + + +def test_message_status_carries_a_status_and_its_reason(): + """The only way to tell a rejected or rate-limited message from one still + being processed.""" + data = MessageStatusData.model_validate(load_fixture("message_status")["payload"]["data"]) + assert data.status + assert data.request_id + + +def test_restriction_states_the_task_will_not_complete(): + data = RestrictionData.model_validate(load_fixture("restriction")["payload"]["data"]) + assert data.level + assert data.message + + +def test_required_action_reports_whether_a_response_is_awaited(): + data = RequiredActionData.model_validate(load_fixture("required_action")["payload"]["data"]) + assert data.is_required_action is True + + +def test_task_ready_carries_the_credit_cost(): + """`confirmed` is the authorization state, not a request for one: when the + balance covers `required` the server starts the task itself and this event + is informational. It waits only when the balance does not.""" + data = TaskReadyData.model_validate(load_fixture("task_ready")["payload"]["data"]) + assert data.required > 0 + assert isinstance(data.confirmed, bool) + + +def test_form_to_user_carries_its_fields(): + data = FormToUserData.model_validate(load_fixture("form_to_user")["payload"]["data"]) + assert data.message_to_user + assert data.form.fields + assert data.form.fields[0].name + + +def test_history_page_is_a_message_list(): + """Exhaustion is read from the cursor. An absent cursor is the end — and an + empty page is not, which is why `messages` may be null while more remain.""" + data = load_fixture("history")["payload"]["data"] + assert "messages" in data + + +def test_unknown_payload_fields_are_tolerated(): + """Payloads may gain fields at any time.""" + envelope = load_fixture("text") + envelope["payload"]["data"]["a_field_added_next_week"] = {"nested": True} + envelope["metadata"]["some_new_metadata"] = 1 + assert parse_envelope(envelope) is not None + + +def test_fixture_files_are_stable_json(): + for path in sorted(pathlib.Path(FIXTURES).glob("*.json")): + json.loads(path.read_text()) + + +def test_recording_leaves_identifiers_intact(): + """Redaction rewrites values that identify a person. An identifier is an + opaque number, and rewriting one destroys the shape being recorded.""" + for event_type, envelope in sorted(all_fixtures().items()): + event_id = envelope["metadata"]["event_id"] + assert not event_id.startswith("+"), f"{event_type}: event_id was redacted" + message_id = envelope["payload"].get("message_id") + if message_id: + assert not str(message_id).startswith("+"), f"{event_type}: message_id was redacted" + + +def test_recorded_forms_carry_no_answers(): + """A form is where the user's own data lives — the details it asks for are + prefilled from their profile, the placeholders are built from those values, + and the server writes choice labels in their voice ("I (Name) prefer ..."). + Redaction has to reach all three; grepping for what leaked last time does + not generalise, so this asserts the shape instead. + """ + provenance = json.loads((FIXTURES / "provenance.json").read_text()) + for event_type, envelope in sorted(all_fixtures().items()): + if provenance[event_type]["source"] != "recorded": + continue + fields = (((envelope["payload"].get("data") or {}).get("form") or {}) + .get("fields") or []) + for field in fields: + for key in ("prefilled", "placeholder"): + if field.get(key): + assert field[key] == "[redacted]", f"{event_type}.{field.get('name')}.{key}" + for option in field.get("options") or []: + assert option == "[redacted]", f"{event_type}.{field.get('name')}.options" diff --git a/tests/protocol/test_flow.py b/tests/protocol/test_flow.py new file mode 100644 index 0000000..1cbb7c4 --- /dev/null +++ b/tests/protocol/test_flow.py @@ -0,0 +1,252 @@ +"""Flow tests — the requirements the protocol scope states as MUST. + +Events are pushed through the real transport, so these cover envelope parsing +and SocketIOManager as well as the chat engine. Each test names the requirement +it pins. +""" + +import asyncio +from unittest.mock import patch + +import pytest + +from pine_assistant import AsyncPineAI +from pine_assistant.chat import FULL_REBUILD_REVISION +from tests.protocol.fake import SESSION_ID, FakeAsyncClient, envelope, load_fixture + +OTHER_SESSION = "1900000000000000999" + + +@pytest.fixture +async def client(): + fake = FakeAsyncClient() + with patch("pine_assistant.transport.socketio.socketio.AsyncClient", return_value=fake): + pine = AsyncPineAI(access_token="t", user_id="u", device_id="d") + await pine.connect() + yield pine, fake + await pine.disconnect() + + +def _join_ack(_request): + return load_fixture("join")["payload"]["data"] + + +# -- Recovery is by unconditional rebuild --------------------------------- + + +async def test_join_sends_since_revision_zero(client): + """MUST send session:join with since_revision "0".""" + pine, fake = client + fake.reply_to("session:join", _join_ack) + + await pine.join_session(SESSION_ID) + + joins = fake.emits_of("session:join") + assert len(joins) == 1 + assert joins[0]["payload"]["data"] == {"since_revision": FULL_REBUILD_REVISION} + + +async def test_rebuild_pages_until_the_cursor_is_exhausted(client): + """MUST NOT infer exhaustion from an empty or short page — only the cursor + says a range is done.""" + pine, fake = client + pages = [ + {"messages": [{"id": "1"}, {"id": "2"}], "next_message_id": "2"}, + {"messages": [], "next_message_id": "3"}, # empty, but not the end + {"messages": [{"id": "4"}], "next_message_id": ""}, # short, and the end + ] + calls = {"n": 0} + + def responder(_request): + page = pages[calls["n"]] + calls["n"] += 1 + return page + + fake.reply_to("session:history", responder) + + messages = await pine.rebuild(SESSION_ID, page_size=2) + + assert calls["n"] == 3, "stopped early on an empty or short page" + assert [m["id"] for m in messages] == ["1", "2", "4"] + + +async def test_reconnect_rejoins_with_a_full_rebuild(client): + """MUST rebuild on every reconnect, not resume from a cursor.""" + pine, fake = client + fake.reply_to("session:join", _join_ack) + await pine.join_session(SESSION_ID) + + rebuilt = [] + pine.on_reconnect(lambda: rebuilt.append(True)) + await fake.fire_ready() # the server re-announces readiness after a drop + await asyncio.sleep(0) # the re-join is scheduled, not awaited + + rejoins = fake.emits_of("session:join") + assert len(rejoins) == 2 + assert rejoins[1]["payload"]["data"] == {"since_revision": FULL_REBUILD_REVISION} + assert rebuilt == [True] + + +# -- Unrecognized events are ignored -------------------------------------- + + +async def test_unknown_events_pass_through_unchanged(client): + """MUST ignore unrecognized events without erroring, dropping them, or + disturbing ordering. The SDK delivers them verbatim instead.""" + pine, fake = client + unknown_payload = {"anything": [1, 2, {"deep": True}]} + + def responder(_request): + return [ + load_fixture("text_part"), + envelope("session:an_event_from_the_future", unknown_payload, + event_id="unknown-1"), + load_fixture("text"), + envelope("session:input_state", {"content": "waiting_input"}, + event_id="final-1", role="system"), + ] + + fake.responders["session:message"] = responder + events = [e async for e in pine.chat(SESSION_ID, "hello")] + + types = [e.type for e in events] + assert types[:3] == ["session:text_part", "session:an_event_from_the_future", "session:text"] + unknown = events[1] + assert unknown.data == unknown_payload, "payload was altered on the way out" + assert unknown.event_id == "unknown-1" + + +async def test_events_for_another_session_are_not_delivered(client): + """A client tracks one session.""" + pine, fake = client + + def responder(_request): + return [ + envelope("session:text", {"content": "elsewhere"}, session_id=OTHER_SESSION, + event_id="other-1"), + envelope("session:text", {"content": "here"}, event_id="here-1"), + envelope("session:input_state", {"content": "waiting_input"}, + event_id="final-2", role="system"), + ] + + fake.responders["session:message"] = responder + events = [e async for e in pine.chat(SESSION_ID, "hello")] + + contents = [e.data.get("content") for e in events if e.type == "session:text"] + assert contents == ["here"] + + +# -- Deduplication -------------------------------------------------------- + + +async def test_duplicate_events_are_suppressed(client): + """A rebuild re-delivers what was already seen; the same event must not + surface twice.""" + pine, fake = client + duplicate = envelope("session:text", {"content": "once"}, event_id="dup-1") + + def responder(_request): + return [duplicate, duplicate, + envelope("session:input_state", {"content": "waiting_input"}, + event_id="final-3", role="system")] + + fake.responders["session:message"] = responder + events = [e async for e in pine.chat(SESSION_ID, "hello")] + + assert sum(1 for e in events if e.type == "session:text") == 1 + + +async def test_dedup_keys_on_id_and_type_together(client): + """MUST NOT key on the event identifier alone — identifiers collide across + message types, and keying on one alone silently drops real events.""" + pine, fake = client + shared_id = "shared-event-id" + + def responder(_request): + return [ + envelope("session:text", {"content": "a text"}, event_id=shared_id), + envelope("session:update_title", {"content": "a title"}, event_id=shared_id, + role="system"), + envelope("session:input_state", {"content": "waiting_input"}, + event_id="final-4", role="system"), + ] + + fake.responders["session:message"] = responder + events = [e async for e in pine.chat(SESSION_ID, "hello")] + + types = [e.type for e in events] + assert "session:text" in types + assert "session:update_title" in types + + +# -- Turn termination ----------------------------------------------------- + + +async def test_turn_ends_when_input_reopens_after_a_response(client): + pine, fake = client + + def responder(_request): + return [ + load_fixture("text"), + envelope("session:input_state", {"content": "waiting_input"}, + event_id="final-5", role="system"), + ] + + fake.responders["session:message"] = responder + events = [e async for e in pine.chat(SESSION_ID, "hello")] + + assert events[-1].type == "session:input_state" + + +async def test_turn_ends_on_a_terminal_session_state(client): + pine, fake = client + + def responder(_request): + # Constructed: a recorded session:state holds whichever state the + # recording ended on, which need not be a terminal one. + return [load_fixture("text"), + envelope("session:state", {"content": "task_finished"}, + event_id="terminal-1", role="system")] + + fake.responders["session:message"] = responder + events = [e async for e in pine.chat(SESSION_ID, "hello")] + + assert events[-1].type == "session:state" + assert events[-1].data["content"] == "task_finished" + + +async def test_out_of_scope_events_do_not_end_a_turn(client): + """Control flow must not hinge on an event outside the supported surface.""" + pine, fake = client + + def responder(_request): + return [ + envelope("session:payment", {"status": "pending"}, event_id="oos-1"), + envelope("session:reward", {"charge_type": "percentage"}, event_id="oos-2"), + load_fixture("text"), + envelope("session:input_state", {"content": "waiting_input"}, + event_id="final-6", role="system"), + ] + + fake.responders["session:message"] = responder + events = [e async for e in pine.chat(SESSION_ID, "hello")] + + types = [e.type for e in events] + assert types.index("session:text") < types.index("session:input_state") + assert "session:payment" in types + + +# -- Escape hatch --------------------------------------------------------- + + +async def test_emit_event_sends_anything_enveloped(client): + """The unsupported surface stays reachable, just unmodelled.""" + pine, fake = client + + pine.emit_event("session:location_selection", {"list": [{"id": "p1"}]}, SESSION_ID, "m1") + await asyncio.sleep(0) # emit is scheduled, not awaited + + sent = fake.emits_of("session:location_selection") + assert len(sent) == 1 + assert sent[0]["payload"]["data"] == {"list": [{"id": "p1"}]} + assert sent[0]["payload"]["message_id"] == "m1" diff --git a/tests/run_live.py b/tests/run_live.py deleted file mode 100644 index 70bf074..0000000 --- a/tests/run_live.py +++ /dev/null @@ -1,116 +0,0 @@ -"""Live integration test — Python SDK against real Pine AI API.""" - -import asyncio -import os -import sys - -# Add src to path for development -sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', 'src')) - -from pine_assistant import AsyncPineAI, S2CEvent - -ACCESS_TOKEN = os.environ.get("PINE_ACCESS_TOKEN", "") -USER_ID = os.environ.get("PINE_USER_ID", "") - -passed = 0 -failed = 0 - -def check(condition, msg): - global passed, failed - if condition: - print(f" PASS: {msg}") - passed += 1 - else: - print(f" FAIL: {msg}") - failed += 1 - - -async def main(): - client = AsyncPineAI(access_token=ACCESS_TOKEN, user_id=USER_ID) - - # T2: Connection - print("\n=== T2: Socket.IO Connection ===") - await client.connect() - check(client.connected, "Connected to Pine AI") - - # T3: Session CRUD - print("\n=== T3: Session CRUD ===") - session = await client.sessions.create() - session_id = session["id"] - check(session_id, f"Session created: {session_id}") - check(session["state"] == "init", f"State is init: {session['state']}") - - result = await client.sessions.list(limit=10) - check(result["total"] > 0, f"List sessions: {result['total']} total") - - got = await client.sessions.get(session_id) - check(got["id"] == session_id, f"Get session: {got['id']}") - - # T4: Join + Chat - print("\n=== T4: Join + Chat ===") - join_data = await client.join_session(session_id) - check(join_data is not None, f"Joined session") - - print("\n=== T5: Chat with Stream Buffering ===") - got_text = False - got_form = False - got_text_part = False - events = [] - - async for event in client.chat(session_id, "Help me negotiate my Comcast bill. Account number 12345."): - events.append(event) - detail = "" - if event.type == S2CEvent.SESSION_TEXT: - got_text = True - content = event.data.get("content", "") if isinstance(event.data, dict) else "" - detail = f"({len(content)} chars)" - elif event.type == S2CEvent.SESSION_FORM_TO_USER: - got_form = True - d = event.data if isinstance(event.data, dict) else {} - fields = d.get("form", {}).get("fields", []) - detail = f"({len(fields)} fields)" - msg = d.get("message_to_user", "") - print(f"\n Pine AI form: \"{msg[:200]}\"") - for f in fields: - print(f" - {f.get('name')} ({f.get('type')})") - print() - elif event.type == S2CEvent.SESSION_TEXT_PART: - got_text_part = True - print(f" Event: {event.type} {detail}") - - check(got_text or got_form, "Received substantive response (text or form)") - check(not got_text_part, "No raw text_part leaked") - print(f" Event types: {', '.join(set(e.type for e in events))}") - - # T9: History - print("\n=== T9: History ===") - history = await client.get_history(session_id, max_messages=10) - check("messages" in history, f"History has messages ({len(history.get('messages', []))})") - - # T7: State - print("\n=== T7: State ===") - updated = await client.sessions.get(session_id) - check(updated["state"] in ("chat", "init"), f"State: {updated['state']}") - - # T10: Error - print("\n=== T10: Error ===") - try: - await client.sessions.get("999999999999999") - check(False, "Should have thrown") - except Exception as e: - check(True, f"Non-existent session throws: {str(e)[:80]}") - - # Cleanup - print("\n=== Cleanup ===") - client.leave_session(session_id) - await client.sessions.delete(session_id) - print(f" Session {session_id} deleted") - await client.disconnect() - - print(f"\n{'='*50}") - print(f"Results: {passed} passed, {failed} failed out of {passed + failed}") - print("=" * 50) - sys.exit(1 if failed > 0 else 0) - - -asyncio.run(main()) diff --git a/tests/test_basics.py b/tests/test_basics.py index dfd385c..5cf669c 100644 --- a/tests/test_basics.py +++ b/tests/test_basics.py @@ -1,16 +1,19 @@ """Basic unit tests for pine-assistant package.""" from pine_assistant import ( + SUPPORTED_EVENTS, AsyncPineAI, - PineAI, - PineAIError, AuthError, - SessionError, - ConnectionError, C2SEvent, + ConnectionError, + InputState, + InputStateCode, + PineAI, + PineAIError, S2CEvent, + SessionError, + is_supported_event, ) -from pine_assistant.models.events import NotificationEvent def test_public_exports(): @@ -38,4 +41,37 @@ def test_error_attributes(): def test_event_constants(): assert C2SEvent.SESSION_MESSAGE == "session:message" assert S2CEvent.SESSION_TEXT == "session:text" - assert NotificationEvent.NEW_MESSAGE == "notification:new_message" + assert S2CEvent.SESSION_LLM_THINKING == "session:llm_thinking" + assert S2CEvent.SESSION_TOOL_STATUS == "session:tool_status" + + +def test_only_the_supported_surface_is_modelled(): + """The event constants are the protocol scope, not an inventory of what the + server emits.""" + assert len(list(S2CEvent)) == 19 + assert set(C2SEvent) <= set(SUPPORTED_EVENTS) + + +def test_is_supported_event_separates_the_two_surfaces(): + assert is_supported_event("session:text") + assert is_supported_event("session:llm_thinking") + # Emitted by the server, and reachable — but not maintained. + assert not is_supported_event("session:work_log") + assert not is_supported_event("session:payment") + assert not is_supported_event("session:an_event_from_the_future") + + +def test_input_state_reads_the_blocking_reason(): + accepting = InputState(content="waiting_input") + assert accepting.accepting_input + assert not accepting.blocked + + blocked = InputState(content="input_disabled", code=InputStateCode.TASK_READY) + assert blocked.blocked + assert blocked.awaiting_credits + assert not blocked.needs_phone_verification + + unverified = InputState( + content="input_disabled", code=InputStateCode.PHONE_VERIFICATION_REQUIRED, + ) + assert unverified.needs_phone_verification diff --git a/tests/test_chat_engine.py b/tests/test_chat_engine.py index 1d50ff0..f816d95 100644 --- a/tests/test_chat_engine.py +++ b/tests/test_chat_engine.py @@ -1,194 +1,60 @@ -"""Unit tests for ChatEngine — immediate dispatch, stale-event filtering, state precheck.""" - -import asyncio -from datetime import datetime, timedelta, timezone -from unittest.mock import MagicMock - -import pytest - -from pine_assistant.chat import ChatEngine, ChatEvent -from pine_assistant.models.events import S2CEvent - - -def _make_sio(): - sio = MagicMock() - sio.connected = True - sio.emit = MagicMock() - sio.add_event_handler = MagicMock(return_value=lambda: None) - return sio - - -def _ts_iso(dt: datetime) -> str: - return dt.isoformat().replace("+00:00", "Z") - - -def _inject_events(sio, session_id, events, delay=0.05): - """Replace add_event_handler so it injects events after a short delay.""" - def fake_add_handler(handler): - async def _inject(): - await asyncio.sleep(delay) - for evt_type, raw in events: - handler(evt_type, raw) - asyncio.get_running_loop().create_task(_inject()) - return lambda: None - sio.add_event_handler = fake_add_handler - - -def _raw(event_type, session_id, data, ts=None): - """Build a raw server envelope.""" - meta = {} - if ts: - meta["timestamp"] = ts - return (event_type, { - "payload": {"session_id": session_id, "data": data}, - "metadata": meta, - }) - - -# ── _is_stale_event ────────────────────────────────────────────────────── - -class TestIsStaleEvent: - def test_old_event_is_stale(self): - cutoff = datetime(2026, 2, 21, 10, 0, 0, tzinfo=timezone.utc) - event = ChatEvent( - type=S2CEvent.SESSION_WORK_LOG, session_id="s1", data={}, - metadata={"timestamp": "2026-02-20T09:00:00Z"}, - ) - assert ChatEngine._is_stale_event(event, cutoff) is True - - def test_fresh_event_passes(self): - cutoff = datetime(2026, 2, 21, 10, 0, 0, tzinfo=timezone.utc) - event = ChatEvent( - type=S2CEvent.SESSION_TEXT, session_id="s1", data={}, - metadata={"timestamp": "2026-02-21T10:00:05Z"}, - ) - assert ChatEngine._is_stale_event(event, cutoff) is False - - def test_no_metadata_passes(self): - cutoff = datetime(2026, 2, 21, 10, 0, 0, tzinfo=timezone.utc) - event = ChatEvent(type=S2CEvent.SESSION_WORK_LOG_PART, session_id="s1", data={}) - assert ChatEngine._is_stale_event(event, cutoff) is False - - def test_missing_timestamp_passes(self): - cutoff = datetime(2026, 2, 21, 10, 0, 0, tzinfo=timezone.utc) - event = ChatEvent( - type=S2CEvent.SESSION_WORK_LOG, session_id="s1", data={}, - metadata={"source": {"role": "agent"}}, - ) - assert ChatEngine._is_stale_event(event, cutoff) is False - - def test_malformed_timestamp_passes(self): - cutoff = datetime(2026, 2, 21, 10, 0, 0, tzinfo=timezone.utc) - event = ChatEvent( - type=S2CEvent.SESSION_WORK_LOG, session_id="s1", data={}, - metadata={"timestamp": "not-a-date"}, - ) - assert ChatEngine._is_stale_event(event, cutoff) is False - - -# ── immediate dispatch (no buffering) ───────────────────────────────────── - -class TestImmediateDispatch: - @pytest.mark.asyncio - async def test_text_part_dispatched_immediately(self): - """text_part events are yielded as-is, not buffered.""" - sio = _make_sio() - now = _ts_iso(datetime.now(timezone.utc)) - - _inject_events(sio, "s1", [ - _raw(S2CEvent.SESSION_TEXT_PART, "s1", {"content": "Hello "}, ts=now), - _raw(S2CEvent.SESSION_TEXT_PART, "s1", {"content": "world"}, ts=now), - _raw(S2CEvent.SESSION_STATE, "s1", {"content": "task_finished"}, ts=now), - ]) - - engine = ChatEngine(sio, idle_timeout_s=5.0) - events = [] - async for event in engine._listen("s1", _skip_state_precheck=True): - events.append(event) - - types = [e.type for e in events] - assert types.count(S2CEvent.SESSION_TEXT_PART) == 2 - assert events[0].data["content"] == "Hello " - assert events[1].data["content"] == "world" - - @pytest.mark.asyncio - async def test_work_log_part_dispatched_immediately(self): - """work_log_part events are yielded immediately, not debounced.""" - sio = _make_sio() - now = _ts_iso(datetime.now(timezone.utc)) - - _inject_events(sio, "s1", [ - _raw(S2CEvent.SESSION_WORK_LOG_PART, "s1", - {"step_id": "1", "text_delta": "thinking..."}, ts=now), - _raw(S2CEvent.SESSION_STATE, "s1", {"content": "task_finished"}, ts=now), - ]) - - engine = ChatEngine(sio, idle_timeout_s=5.0) - events = [] - async for event in engine._listen("s1", _skip_state_precheck=True): - events.append(event) - - types = [e.type for e in events] - assert S2CEvent.SESSION_WORK_LOG_PART in types - - -# ── chat() stale filtering ─────────────────────────────────────────────── - -class TestChatStaleFiltering: - @pytest.mark.asyncio - async def test_chat_filters_old_events(self): - sio = _make_sio() - old_ts = _ts_iso(datetime.now(timezone.utc) - timedelta(hours=24)) - fresh_ts = _ts_iso(datetime.now(timezone.utc) + timedelta(seconds=1)) - - _inject_events(sio, "s1", [ - _raw(S2CEvent.SESSION_WORK_LOG, "s1", - {"steps": [{"step_title": "old"}]}, ts=old_ts), - _raw(S2CEvent.SESSION_TEXT_PART, "s1", {"content": "Hi!"}, ts=fresh_ts), - _raw(S2CEvent.SESSION_STATE, "s1", {"content": "task_finished"}, ts=fresh_ts), - ]) - - engine = ChatEngine(sio, idle_timeout_s=5.0) - events = [] - async for event in engine.chat("s1", "test"): - events.append(event) - - types = [e.type for e in events] - assert S2CEvent.SESSION_WORK_LOG not in types - assert S2CEvent.SESSION_TEXT_PART in types - - -# ── _listen with _skip_state_precheck ───────────────────────────────────── - -class TestListenSkipStatePrecheck: - @pytest.mark.asyncio - async def test_listen_without_skip_returns_on_terminal(self): - sio = _make_sio() - - async def _check(_sid): - return {"state": "task_finished"} - - engine = ChatEngine(sio, check_session_state=_check) - events = [e async for e in engine._listen("s1")] - - assert len(events) == 1 - assert events[0].data["content"] == "task_finished" - - @pytest.mark.asyncio - async def test_listen_with_skip_enters_event_loop(self): - sio = _make_sio() - now = _ts_iso(datetime.now(timezone.utc)) - - async def _check(_sid): - return {"state": "task_finished"} - - _inject_events(sio, "s1", [ - _raw(S2CEvent.SESSION_TEXT_PART, "s1", {"content": "response"}, ts=now), - _raw(S2CEvent.SESSION_STATE, "s1", {"content": "task_finished"}, ts=now), - ]) - - engine = ChatEngine(sio, check_session_state=_check, idle_timeout_s=5.0) - events = [e async for e in engine._listen("s1", _skip_state_precheck=True)] - - types = [e.type for e in events] - assert S2CEvent.SESSION_TEXT_PART in types +"""Unit tests for the chat engine's pure parts. + +Turn behaviour is covered in tests/protocol/test_flow.py, where events travel +the real transport instead of being handed straight to a handler. +""" + +from pine_assistant.chat import ( + SUBSTANTIVE_EVENTS, + ChatEvent, + Deduplicator, + event_from_envelope, +) +from pine_assistant.models.events import SUPPORTED_EVENTS + + +def _event(event_id, event_type="session:text"): + return ChatEvent(type=event_type, session_id="s1", data={}, event_id=event_id) + + +class TestDeduplicator: + def test_second_sighting_is_a_duplicate(self): + dedup = Deduplicator() + assert dedup.is_duplicate(_event("e1")) is False + assert dedup.is_duplicate(_event("e1")) is True + + def test_same_id_different_type_is_not_a_duplicate(self): + """Keying on the identifier alone would drop the second event.""" + dedup = Deduplicator() + assert dedup.is_duplicate(_event("e1", "session:text")) is False + assert dedup.is_duplicate(_event("e1", "session:update_title")) is False + + def test_events_without_an_identifier_are_never_suppressed(self): + dedup = Deduplicator() + assert dedup.is_duplicate(_event(None)) is False + assert dedup.is_duplicate(_event(None)) is False + + +class TestEventFromEnvelope: + def test_carries_the_payload_through_untouched(self): + raw = { + "metadata": {"event_id": "e1", "timestamp": "2026-08-08T00:00:00Z"}, + "type": "session:whatever", + "payload": {"session_id": "s1", "message_id": "m1", "data": {"deep": [1, 2]}}, + } + event = event_from_envelope("session:whatever", raw, "s1") + assert event.type == "session:whatever" + assert event.event_id == "e1" + assert event.message_id == "m1" + assert event.data == {"deep": [1, 2]} + assert event.metadata == raw["metadata"] + + def test_tolerates_a_missing_payload_and_metadata(self): + event = event_from_envelope("session:text", {}, "s1") + assert event.data is None + assert event.event_id is None + + +def test_turn_control_uses_only_supported_events(): + """A turn must not begin or end on an event we do not maintain.""" + assert {e.value for e in SUBSTANTIVE_EVENTS} <= SUPPORTED_EVENTS diff --git a/tests/test_device_id.py b/tests/test_device_id.py index 4917763..fd837bf 100644 --- a/tests/test_device_id.py +++ b/tests/test_device_id.py @@ -1,7 +1,5 @@ """Unit tests for device_id resolution — env var, explicit arg, file persistence.""" -from pathlib import Path -from unittest.mock import patch import pine_assistant.client as client_module from pine_assistant.client import _get_or_create_device_id diff --git a/tests/test_socketio_connect.py b/tests/test_socketio_connect.py index a1d295f..968cac3 100644 --- a/tests/test_socketio_connect.py +++ b/tests/test_socketio_connect.py @@ -1,8 +1,7 @@ """Unit tests for SocketIOManager.connect() — connect_error surfacing.""" -import asyncio from typing import Any -from unittest.mock import AsyncMock, MagicMock, patch +from unittest.mock import patch import pytest @@ -74,9 +73,11 @@ async def test_connect_error_payload_surfaces_in_pine_connection_error(): device_id="d1", ready_timeout=0.1, ) - with patch("pine_assistant.transport.socketio.socketio.AsyncClient", return_value=fake): - with pytest.raises(PineConnectionError) as excinfo: - await mgr.connect() + with ( + patch("pine_assistant.transport.socketio.socketio.AsyncClient", return_value=fake), + pytest.raises(PineConnectionError) as excinfo, + ): + await mgr.connect() msg = str(excinfo.value) assert "rejected by server" in msg assert "invalid token" in msg @@ -98,9 +99,11 @@ async def connect_no_event(*_args, **_kwargs): device_id="d", ready_timeout=0.1, ) - with patch("pine_assistant.transport.socketio.socketio.AsyncClient", return_value=fake): - with pytest.raises(PineConnectionError) as excinfo: - await mgr.connect() + with ( + patch("pine_assistant.transport.socketio.socketio.AsyncClient", return_value=fake), + pytest.raises(PineConnectionError) as excinfo, + ): + await mgr.connect() assert "transport closed" in str(excinfo.value) @@ -141,9 +144,11 @@ async def test_silent_stall_raises_diagnostic_pine_error(): device_id="d", ready_timeout=0.1, ) - with patch("pine_assistant.transport.socketio.socketio.AsyncClient", return_value=fake): - with pytest.raises(PineConnectionError) as excinfo: - await mgr.connect() + with ( + patch("pine_assistant.transport.socketio.socketio.AsyncClient", return_value=fake), + pytest.raises(PineConnectionError) as excinfo, + ): + await mgr.connect() msg = str(excinfo.value) assert "no 'ready' event" in msg assert "re-run the auth flow" in msg