You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
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>
await client.rebuild(session["id"]) # load the session's messages
26
27
27
28
asyncfor event in client.chat(session["id"], "Negotiate my Comcast bill"):
28
29
print(event.type, event.data)
29
30
30
31
await client.disconnect()
31
32
```
32
33
34
+
A client tracks one session. Concurrent sessions need one client each.
35
+
33
36
## Quick Start (CLI)
34
37
35
38
```bash
36
39
pine auth login # Email verification
37
40
pine chat # Interactive REPL
38
41
pine send "Negotiate my Comcast bill"# One-shot message
39
42
pine sessions list # List sessions
40
-
pine task start <session-id># Start task (Pro)
43
+
pine task start <session-id># Start task
41
44
```
42
45
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
+
asyncfor event in client.chat(session_id, "..."):
114
+
ifnot 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.
44
128
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
49
130
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
0 commit comments