diff --git a/CHANGELOG.md b/CHANGELOG.md index 6f60783..de02a62 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,18 +15,14 @@ guarantee. What the SDK models is now that subset and nothing else. - `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. +- `session:llm_thinking`, `session:tool_status` and `session:restriction`, with + models. 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 @@ -68,6 +64,14 @@ untouched — the SDK just no longer models it. Send with `emit_event()`. - 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. +- `session:input_state`, `session:required_action` and `session:task_ready`, + with the `InputState`, `InputStateCode`, `RequiredActionData` and + `TaskReadyData` models. The scope no longer covers them. A session stopped on + its credit balance is reported by `session:state`, whose values include + `credits_exhausted` and `task_paused`. +- The turn no longer ends when `session:input_state` reports that input is + accepted. That event is observed to arrive before the agent has said + anything, so ending on it truncates the reply. ## [0.3.3] - 2026-05-23 diff --git a/README.md b/README.md index 39917c6..9987851 100644 --- a/README.md +++ b/README.md @@ -72,9 +72,7 @@ payloads, and semantics change compatibly or with notice. | 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 | @@ -88,7 +86,6 @@ payloads, and semantics change compatibly or with notice. | 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 | @@ -170,26 +167,35 @@ has stopped. `rebuild()` returns messages of every type, including unsupported ones. Filtering them is yours to do. -## Blocked sessions +## When a session cannot proceed -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. +`session:state` reports where the task stands, and several of its values say +that nothing further will arrive until something changes outside the session: ```python -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 +from pine_assistant import S2CEvent + +if event.type == S2CEvent.SESSION_STATE: + state = (event.data or {}).get("content") + if state in ("credits_exhausted", "task_paused"): + ... # waiting on the account, not on the agent + if state in ("task_finished", "task_cancelled"): + ... # the task is over ``` -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: +Two more events state a stop outright: + +```python +if event.type == S2CEvent.SESSION_RESTRICTION: + ... # an account restriction — the task will not complete +if event.type == S2CEvent.SESSION_ERROR: + ... # the only channel for server-reported failures +``` + +An expired session is read over REST, from the `is_stale` field on the session +object — expiry is a property of the session, not one of its states. On finding +one expired, create a new session and reference the old one in your first +message: ```python new = await client.sessions.create() @@ -202,7 +208,8 @@ 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. +- **Phone verification.** Must be completed at provisioning time. It has no + in-session remedy and no in-session signal. ## Attachments diff --git a/src/pine_assistant/__init__.py b/src/pine_assistant/__init__.py index c7a5228..b594372 100644 --- a/src/pine_assistant/__init__.py +++ b/src/pine_assistant/__init__.py @@ -19,7 +19,6 @@ S2CEvent, is_supported_event, ) -from pine_assistant.models.session import InputState, InputStateCode from pine_assistant.sessions import SessionsAPI __version__ = "0.4.0" @@ -37,6 +36,4 @@ "S2CEvent", "SUPPORTED_EVENTS", "is_supported_event", - "InputState", - "InputStateCode", ] diff --git a/src/pine_assistant/chat.py b/src/pine_assistant/chat.py index aeaaf0a..f3ae4cb 100644 --- a/src/pine_assistant/chat.py +++ b/src/pine_assistant/chat.py @@ -11,7 +11,6 @@ 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"} @@ -30,7 +29,6 @@ S2CEvent.SESSION_TEXT_PART, S2CEvent.SESSION_RICH_CONTENT, S2CEvent.SESSION_FORM_TO_USER, - S2CEvent.SESSION_TASK_READY, S2CEvent.SESSION_TASK_FINISHED, S2CEvent.SESSION_TOOL_STATUS, S2CEvent.SESSION_RESTRICTION, @@ -202,10 +200,6 @@ def handler(event: str, raw: dict[str, Any]) -> None: if event in SUBSTANTIVE_EVENTS: received_agent_response = True 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 diff --git a/src/pine_assistant/models/events.py b/src/pine_assistant/models/events.py index db206aa..8c9f16e 100644 --- a/src/pine_assistant/models/events.py +++ b/src/pine_assistant/models/events.py @@ -43,9 +43,7 @@ class S2CEvent(StrEnum): # Session state SESSION_STATE = "session:state" - SESSION_INPUT_STATE = "session:input_state" SESSION_MESSAGE_STATUS = "session:message_status" - SESSION_REQUIRED_ACTION = "session:required_action" SESSION_UPDATE_TITLE = "session:update_title" SESSION_RESTRICTION = "session:restriction" @@ -53,7 +51,6 @@ class S2CEvent(StrEnum): SESSION_FORM_TO_USER = "session:form_to_user" # Task and result - SESSION_TASK_READY = "session:task_ready" SESSION_TASK_FINISHED = "session:task_finished" SESSION_TOOL_STATUS = "session:tool_status" diff --git a/src/pine_assistant/models/session.py b/src/pine_assistant/models/session.py index 08acb37..ce23c81 100644 --- a/src/pine_assistant/models/session.py +++ b/src/pine_assistant/models/session.py @@ -1,27 +1,15 @@ """ -Session models — REST session objects and the `session:input_state` payload. +Session models — the REST session object. """ -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: str | None = None title: str = "" - # 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. + # Expiry is carried here and nowhere on the Socket.IO surface. is_stale: bool | None = None is_processed: bool | None = None state: str = "init" @@ -35,55 +23,3 @@ 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 4f60fbb..b71dcc7 100644 --- a/src/pine_assistant/models/task.py +++ b/src/pine_assistant/models/task.py @@ -1,6 +1,6 @@ """ -Task models — `session:task_ready`, `session:task_finished`, `session:tool_status`, -`session:llm_thinking`, `session:restriction`, `session:required_action`. +Task models — `session:task_finished`, `session:tool_status`, +`session:llm_thinking`, `session:restriction`. """ from typing import Any @@ -8,17 +8,6 @@ from pydantic import BaseModel -class TaskReadyData(BaseModel): - """`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: int | None = None - confirmed: bool = False - - class Achievement(BaseModel): id: str = "" title: str = "" @@ -123,12 +112,6 @@ class RestrictionData(BaseModel): 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.""" diff --git a/tests/integration/test_live.py b/tests/integration/test_live.py index d61d59e..a833ee4 100644 --- a/tests/integration/test_live.py +++ b/tests/integration/test_live.py @@ -13,7 +13,7 @@ import pytest -from pine_assistant import AsyncPineAI, InputState, S2CEvent, is_supported_event +from pine_assistant import AsyncPineAI, S2CEvent, is_supported_event SKIP = not os.environ.get("PINE_INTEGRATION") ACCESS_TOKEN = os.environ.get("PINE_ACCESS_TOKEN", "") @@ -124,19 +124,6 @@ async def test_rebuild_returns_the_conversation(self, session): 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): diff --git a/tests/protocol/fixtures/input_state.json b/tests/protocol/fixtures/input_state.json deleted file mode 100644 index 6a990eb..0000000 --- a/tests/protocol/fixtures/input_state.json +++ /dev/null @@ -1,20 +0,0 @@ -{ - "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/provenance.json b/tests/protocol/fixtures/provenance.json index 1249336..c81946c 100644 --- a/tests/protocol/fixtures/provenance.json +++ b/tests/protocol/fixtures/provenance.json @@ -14,11 +14,6 @@ "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", @@ -39,11 +34,6 @@ "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, @@ -64,11 +54,6 @@ "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", diff --git a/tests/protocol/fixtures/required_action.json b/tests/protocol/fixtures/required_action.json deleted file mode 100644 index d225b2f..0000000 --- a/tests/protocol/fixtures/required_action.json +++ /dev/null @@ -1,21 +0,0 @@ -{ - "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/task_ready.json b/tests/protocol/fixtures/task_ready.json deleted file mode 100644 index b916e30..0000000 --- a/tests/protocol/fixtures/task_ready.json +++ /dev/null @@ -1,23 +0,0 @@ -{ - "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/test_contract.py b/tests/protocol/test_contract.py index 0b4456c..ec02970 100644 --- a/tests/protocol/test_contract.py +++ b/tests/protocol/test_contract.py @@ -6,9 +6,8 @@ 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. +than a terminal state. Assertions here stay on what every instance carries; a +specific condition is constructed by the test that needs it. """ import json @@ -20,15 +19,12 @@ 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 @@ -130,27 +126,6 @@ def test_tool_status_on_a_completed_call_quantifies_it(): 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.""" @@ -191,20 +166,6 @@ def test_restriction_states_the_task_will_not_complete(): 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 diff --git a/tests/test_basics.py b/tests/test_basics.py index 5cf669c..91c13ef 100644 --- a/tests/test_basics.py +++ b/tests/test_basics.py @@ -6,8 +6,6 @@ AuthError, C2SEvent, ConnectionError, - InputState, - InputStateCode, PineAI, PineAIError, S2CEvent, @@ -48,7 +46,7 @@ def test_event_constants(): 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 len(list(S2CEvent)) == 16 assert set(C2SEvent) <= set(SUPPORTED_EVENTS) @@ -59,19 +57,3 @@ def test_is_supported_event_separates_the_two_surfaces(): 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