Skip to content

Commit 0e3046f

Browse files
a7vinxclaude
andauthored
feat(protocol)!: align the SDK to the supported protocol scope (#1)
The SDK modelled 33 server-to-client events selected from roughly 60 the backend emits, with nothing marking which of them carry a compatibility guarantee. The supported scope names 19, and the two sets were not in a containment relationship. ## Protocol surface The modelled surface is now the supported scope and nothing else. Four supported events were absent and are added, with models: `session:llm_thinking`, `session:tool_status`, `session:required_action` and `session:restriction`. Between them they carry what a task is doing and why it stopped. The reasoning stream previously ran on `session:work_log`, which is outside the scope and is a distinct event from `session:llm_thinking` rather than a former name for it. Events outside the scope are unmodelled but still delivered, unchanged and in order. `is_supported_event()` distinguishes the two surfaces and `emit_event()` sends an unmodelled event. This follows the scope's own position: tolerating an unsupported event is required, depending on one is not. ## Requirements Three requirements the scope states as MUST were unmet: - **Recovery.** `session:join` now carries `since_revision` `"0"` and ignores the incremental-synchronisation fields. `rebuild()` pages through history until the cursor is exhausted, on every join and every reconnect. A short or empty page does not indicate exhaustion. - **Deduplication.** Events are keyed on the event identifier together with the message type. Identifiers collide across types, so keying on the identifier alone discards valid events. - **Blocking conditions.** `InputState` exposes the reason from `session:input_state`, including the two conditions the scope calls out. Turn control no longer depends on unsupported events. It previously hinged on `session:ask_for_location`, `session:interactive_auth_confirmation`, `session:three_way_call` and `session:reward`. ## Tests Three layers. `tests/protocol/fixtures` holds one envelope per supported event, with provenance recorded — 14 captured from live sessions, 4 derived from the protocol definition for conditions an ordinary session does not reach. `test_contract.py` validates one envelope at a time. `test_flow.py` drives sequences through the real transport and pins each MUST by name, including that an unrecognised event arrives verbatim without disturbing ordering. `tests/integration` was two overlapping copies of one live script, one of which asserted behaviour the SDK had already dropped. It is now a single suite that also serves as the fixture recorder. It places one call to a number in the range NANP reserves for fiction, which is what makes `session:task_ready`, `session:tool_status` and `session:task_finished` observable. It stays out of CI: it requires a token and consumes credits. CI gains a lint step. `ruff` was configured but never invoked. ## Fixed Sessions joined through `join_session()` were never re-joined after a reconnect. Membership was tracked on the fire-and-forget emit path, while joining is a request/response call. ## Breaking `send_auth_confirmation()`, `send_location_response()` and `send_location_selection()` are removed, along with `NotificationEvent`, the `session:reward` and `session:payment` models, the unsupported event constants, the `action` argument on `chat()`, and `request_work_log` on `get_history()`. Each remains reachable through `emit_event()`. `pine-mcp-server` calls the three removed methods and depends on `pine-assistant>=0.3.2`. It requires a corresponding change. Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
1 parent 0fe8331 commit 0e3046f

55 files changed

Lines changed: 2748 additions & 1005 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.github/workflows/ci.yml

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -27,13 +27,18 @@ jobs:
2727
run: pip install build twine
2828

2929
- name: Install package
30-
run: pip install .
30+
run: pip install ".[dev]"
3131

3232
- name: Verify import
3333
run: python -c "from pine_assistant import PineAI, AsyncPineAI, __version__; print(f'pine-assistant {__version__} OK')"
3434

35+
- name: Lint
36+
run: ruff check src tests
37+
38+
# Contract and flow tests run offline against recorded fixtures.
39+
# tests/integration needs a token and spends credits — see its README.
3540
- name: Run tests
36-
run: pip install pytest pytest-asyncio && pytest tests/ -v --ignore=tests/integration
41+
run: pytest tests/ -v --ignore=tests/integration
3742

3843
- name: Build distribution
3944
run: python -m build

CHANGELOG.md

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,70 @@ All notable changes to this project will be documented in this file.
55
The format is based on [Keep a Changelog](https://keepachangelog.com/), and this
66
project adheres to [Semantic Versioning](https://semver.org/).
77

8+
## [0.4.0] - 2026-08-08
9+
10+
Aligned to the supported protocol scope: the subset of the task-session
11+
Socket.IO protocol whose names, payloads, and semantics carry a compatibility
12+
guarantee. What the SDK models is now that subset and nothing else.
13+
14+
### Added
15+
16+
- `is_supported_event()` and `SUPPORTED_EVENTS` — whether an event carries the
17+
guarantee.
18+
- `session:llm_thinking`, `session:tool_status`, `session:required_action` and
19+
`session:restriction`, with models. All four are in the supported scope and
20+
none were modelled before; `session:tool_status` is where an outbound call
21+
reports its number, duration, credits, and textual outcome.
22+
- `AsyncPineAI.rebuild()` — pages through history until the cursor is
23+
exhausted. Recovery is an unconditional rebuild: joining never resumes from a
24+
cursor, and a short or empty page does not mean a range is done.
25+
- `AsyncPineAI.on_reconnect()` — fires after a reconnect has re-joined, so
26+
callers can rebuild. A connection can stay open after delivery has stopped.
27+
- `InputState` with `awaiting_credits` and `needs_phone_verification`. A
28+
blocking condition is read from `session:input_state`, because the events that
29+
elaborate on one are mostly outside the scope.
30+
- `AsyncPineAI.emit_event()` — the escape hatch for sending anything outside the
31+
supported surface.
32+
- Protocol fixtures and contract tests under `tests/protocol`, and
33+
`tests/integration/record_fixtures.py` to record them from a live session.
34+
Re-recording is the only way server drift gets noticed.
35+
36+
### Changed
37+
38+
- `session:join` now carries `since_revision` "0", on first join and on
39+
reconnect. The incremental-synchronization fields in the response are ignored.
40+
- Events are deduplicated on the event identifier together with the message
41+
type. Keying on the identifier alone drops real events, since identifiers
42+
collide across types.
43+
- A turn begins and ends on supported events only. It previously hinged on
44+
`session:ask_for_location`, `session:interactive_auth_confirmation`,
45+
`session:three_way_call` and `session:reward`, none of which are maintained.
46+
47+
### Fixed
48+
49+
- Sessions joined through `join_session()` were never re-joined after a
50+
reconnect. Membership was tracked on the fire-and-forget emit path only, while
51+
joining goes out through the request/response path.
52+
53+
### Removed
54+
55+
Everything below is still emitted by the server and still reaches callers
56+
untouched — the SDK just no longer models it. Send with `emit_event()`.
57+
58+
- `send_auth_confirmation()`, `send_location_response()`,
59+
`send_location_selection()`.
60+
- `NotificationEvent`, the `notification:*` constants, and the `session:reward`
61+
and `session:payment` models.
62+
- The out-of-scope `S2CEvent` and `C2SEvent` members, including
63+
`session:work_log`, `session:work_log_part` and `session:thinking`. The
64+
reasoning stream in scope is `session:llm_thinking`, a different event that
65+
the SDK did not previously carry.
66+
- The `action` argument on `chat()` and `send_message()`, and `request_work_log`
67+
on `get_history()`.
68+
- Wall-clock filtering of events older than the moment a turn began. It
69+
contradicts rebuilding from history, and a clock offset made it drop real
70+
events.
71+
872
## [0.3.3] - 2026-05-23
973

1074
### Fixed

README.md

Lines changed: 149 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -23,62 +23,191 @@ await client.connect()
2323

2424
session = await client.sessions.create()
2525
await client.join_session(session["id"])
26+
await client.rebuild(session["id"]) # load the session's messages
2627

2728
async for event in client.chat(session["id"], "Negotiate my Comcast bill"):
2829
print(event.type, event.data)
2930

3031
await client.disconnect()
3132
```
3233

34+
A client tracks one session. Concurrent sessions need one client each.
35+
3336
## Quick Start (CLI)
3437

3538
```bash
3639
pine auth login # Email verification
3740
pine chat # Interactive REPL
3841
pine send "Negotiate my Comcast bill" # One-shot message
3942
pine sessions list # List sessions
40-
pine task start <session-id> # Start task (Pro)
43+
pine task start <session-id> # Start task
4144
```
4245

43-
## Handling Events
46+
## The supported surface
47+
48+
The SDK models the supported protocol scope: the events whose names,
49+
payloads, and semantics change compatibly or with notice.
50+
51+
**Connection and session**
52+
53+
| Event | What it is for |
54+
|---|---|
55+
| `ready` | Authentication succeeded and the connection is usable. Nothing is sent before it |
56+
| `session:join` | Enter a session and read its current state. Sent both ways under this name |
57+
| `session:history` | Read persisted messages. Also the only recovery mechanism in this scope |
58+
| `session:error` | The only channel for server-reported failures |
59+
60+
**Conversation**
61+
62+
| Event | What it is for |
63+
|---|---|
64+
| `session:message` | Your input. Sent to the server, and returned under the same name in history |
65+
| `session:text` | A complete agent message — the durable record |
66+
| `session:text_part` | Streaming increments of one message, assembled by `message_id` |
67+
| `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 |
68+
| `session:llm_thinking` | Reasoning and tool-call trace. Search has no event of its own — it appears here as a `tool_call` step |
69+
70+
**Session state**
71+
72+
| Event | What it is for |
73+
|---|---|
74+
| `session:state` | Where the task stands in its lifecycle |
75+
| `session:input_state` | Whether input is accepted, and the reason when it is not. This is where a blocked session says why |
76+
| `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 |
77+
| `session:required_action` | Whether the session is waiting on you |
78+
| `session:update_title` | The session title, as the agent revises it |
79+
| `session:restriction` | An account restriction. The only statement that a task will not complete |
80+
81+
**Interaction**
82+
83+
| Event | What it is for |
84+
|---|---|
85+
| `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 |
86+
87+
**Task and result**
88+
89+
| Event | What it is for |
90+
|---|---|
91+
| `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 |
92+
| `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 |
93+
| `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 |
94+
95+
Payloads may gain fields at any time — tolerate fields you do not recognise.
96+
97+
A `tool_call` step in `session:llm_thinking` describes the same operation as the
98+
matching `session:tool_status`. Do not show both.
99+
100+
A turn commonly delivers `session:text_part` alone: the composer reopens once
101+
the agent has finished speaking, and the complete `session:text` is the durable
102+
record, read back from history. Assemble the parts by `message_id` rather than
103+
waiting for the complete message to arrive live.
104+
105+
## Everything else passes through
106+
107+
The server emits many more events. The SDK delivers every one of them unchanged
108+
rather than dropping them, but it models none of them:
109+
110+
```python
111+
from pine_assistant import is_supported_event
112+
113+
async for event in client.chat(session_id, "..."):
114+
if not is_supported_event(event.type):
115+
continue # or handle it yourself, at your own risk
116+
```
117+
118+
An unsupported event may be renamed, have its payload changed, or stop being
119+
emitted, without notice and without a version change. Tolerating one is
120+
required; depending on one is not. To send one, use `client.emit_event(...)`.
121+
122+
Some of them are questions to the user that the SDK has no interface for.
123+
Ignoring one leaves the conversation suspended, and the composer stays open —
124+
show the message text and let the user answer in ordinary conversation. Never
125+
fabricate an answer: the formats have no representation for refusal, and an
126+
empty submission is indistinguishable from empty answers, so the agent may act
127+
on it. Sending nothing is safe.
44128

45-
Pine AI behaves like a human assistant. After you send a message, it sends
46-
acknowledgments, then work logs, then the real response (form, text, or task_ready).
47-
**Don't respond to acknowledgments** — only respond to forms, specific questions,
48-
and task lifecycle events, or you'll create an infinite loop.
129+
## What to respond to
49130

50-
## Continuing Existing Sessions
131+
Pine works the way a person would: a message is acknowledged, then reasoned
132+
about, and only then answered. Acknowledgements and `session:llm_thinking`
133+
arrive before the real response — a form, a text answer, or a task ready to run.
134+
135+
Respond only to what asks you something: `session:form_to_user`, a direct
136+
question, and the task lifecycle. Replying to an acknowledgement starts a loop
137+
in which each side answers the other's filler.
138+
139+
## Continuing an existing session
51140

52141
```python
53-
# List all sessions
54142
result = await client.sessions.list(limit=20)
55143

56-
# Continue an existing session
57144
await client.join_session(existing_session_id)
58-
history = await client.get_history(existing_session_id)
145+
messages = await client.rebuild(existing_session_id)
59146
async for event in client.chat(existing_session_id, "What is the status?"):
60147
...
61148
```
62149

63-
## Attachments
150+
To hand a session back to the user in the web app:
64151

65152
```python
66-
# Upload a document for dispute tasks
67-
attachments = await client.sessions.upload_attachment("bill.pdf")
153+
print(AsyncPineAI.session_url(session_id))
154+
```
155+
156+
## Recovery
157+
158+
State is rebuilt, never resumed. `join_session()` always joins from scratch,
159+
and `rebuild()` pages through history until the cursor is exhausted — a short
160+
or empty page does not mean the range is done.
161+
162+
```python
163+
remove = client.on_reconnect(lambda: asyncio.create_task(reload(session_id)))
68164
```
69165

70-
## Stream Buffering
166+
Rebuild on every join, on every reconnect, and whenever a session you are
167+
tracking has been silent for a while: a connection can stay open after delivery
168+
has stopped.
71169

72-
Text streaming is buffered internally. You receive one merged text event,
73-
not individual chunks. Work log parts are debounced (3s silence).
170+
`rebuild()` returns messages of every type, including unsupported ones.
171+
Filtering them is yours to do.
74172

75-
## Payment
173+
## Blocked sessions
76174

77-
Pro subscription recommended. For non-subscribers:
175+
When the composer is disabled, `session:input_state` carries the reason. Read it
176+
from there rather than inferring it from which events did or did not arrive.
78177

79178
```python
80-
from pine_assistant import AsyncPineAI
81-
print(f"Pay at: {AsyncPineAI.session_url(session_id)}")
179+
from pine_assistant import InputState, S2CEvent
180+
181+
if event.type == S2CEvent.SESSION_INPUT_STATE:
182+
state = InputState.model_validate(event.data)
183+
if state.awaiting_credits:
184+
... # cost is on session:task_ready; retry once the balance is restored
185+
if state.needs_phone_verification:
186+
... # no in-session remedy
187+
```
188+
189+
An expired session has no reason code of its own — it presents only as a
190+
disabled composer. Expiry is the `is_stale` field on the session object, over
191+
REST. On finding one expired, create a new session and reference the old one in
192+
your first message:
193+
194+
```python
195+
new = await client.sessions.create()
196+
client.send_message(new["id"], "...", referenced_sessions=[{"session_id": old_id}])
197+
```
198+
199+
## Before an account is used
200+
201+
Two conditions have no remedy once a session is running:
202+
203+
- **Metered billing.** The account must be billed against a credit balance. On
204+
the alternative path a session halts at a payment step the SDK cannot answer.
205+
- **Phone verification.** Must be completed at provisioning time.
206+
207+
## Attachments
208+
209+
```python
210+
attachments = await client.sessions.upload_attachment("bill.pdf")
82211
```
83212

84213
## License

pyproject.toml

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
44

55
[project]
66
name = "pine-assistant"
7-
version = "0.3.3"
7+
version = "0.4.0"
88
description = "Pine AI SDK — Let Pine AI handle your digital chores. Socket.IO + REST client."
99
readme = "README.md"
1010
license = "MIT"
@@ -55,6 +55,11 @@ line-length = 120
5555
select = ["E", "F", "I", "W", "UP", "B", "SIM"]
5656
ignore = ["E501"]
5757

58+
[tool.ruff.lint.per-file-ignores]
59+
"src/pine_assistant/models/__init__.py" = ["F403"] # deliberate star re-export
60+
"src/pine_assistant/cli/main.py" = ["E402"] # subcommands import after the group exists
61+
"tests/integration/*.py" = ["B017"] # a live server's failure type is not ours to pin
62+
5863
[tool.ruff.lint.isort]
5964
known-first-party = ["pine_assistant"]
6065

src/pine_assistant/__init__.py

Lines changed: 20 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3,25 +3,40 @@
33
44
Let Pine AI handle your digital chores.
55
Socket.IO + REST client for the Pine AI backend.
6+
7+
The SDK models the supported protocol scope. Events outside it are delivered
8+
verbatim but carry no compatibility guarantee: tolerate them, do not depend on
9+
them. `is_supported_event` tells the two apart.
610
"""
711

8-
from pine_assistant.client import PineAI, AsyncPineAI
912
from pine_assistant.auth import Auth
13+
from pine_assistant.chat import ChatEvent
14+
from pine_assistant.client import AsyncPineAI, PineAI
15+
from pine_assistant.errors import AuthError, ConnectionError, PineAIError, SessionError
16+
from pine_assistant.models.events import (
17+
SUPPORTED_EVENTS,
18+
C2SEvent,
19+
S2CEvent,
20+
is_supported_event,
21+
)
22+
from pine_assistant.models.session import InputState, InputStateCode
1023
from pine_assistant.sessions import SessionsAPI
11-
from pine_assistant.errors import PineAIError, AuthError, SessionError, ConnectionError
12-
from pine_assistant.models.events import C2SEvent, S2CEvent, NotificationEvent
1324

14-
__version__ = "0.3.3"
25+
__version__ = "0.4.0"
1526
__all__ = [
1627
"PineAI",
1728
"AsyncPineAI",
1829
"Auth",
1930
"SessionsAPI",
31+
"ChatEvent",
2032
"PineAIError",
2133
"AuthError",
2234
"SessionError",
2335
"ConnectionError",
2436
"C2SEvent",
2537
"S2CEvent",
26-
"NotificationEvent",
38+
"SUPPORTED_EVENTS",
39+
"is_supported_event",
40+
"InputState",
41+
"InputStateCode",
2742
]

src/pine_assistant/auth.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,8 @@
66

77
from typing import Any
88

9-
from pine_assistant.transport.http import HttpClient
109
from pine_assistant.errors import AuthError
10+
from pine_assistant.transport.http import HttpClient
1111

1212

1313
class Auth:
@@ -19,7 +19,7 @@ async def request_code(self, email: str) -> dict[str, Any]:
1919
try:
2020
return await self._http.post("/v2/auth/email/request", {"email": email}, authenticated=False)
2121
except Exception as e:
22-
raise AuthError(f"Failed to request auth code: {e}")
22+
raise AuthError(f"Failed to request auth code: {e}") from e
2323

2424
async def verify_code(self, email: str, code: str, request_token: str) -> dict[str, Any]:
2525
"""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
3232
self._http.set_token(result["access_token"])
3333
return result
3434
except Exception as e:
35-
raise AuthError(f"Failed to verify auth code: {e}")
35+
raise AuthError(f"Failed to verify auth code: {e}") from e

0 commit comments

Comments
 (0)