Skip to content

Commit 145d009

Browse files
a7vinxclaude
andauthored
feat(protocol)!: narrow the supported scope to sixteen events (#3)
`session:input_state`, `session:required_action` and `session:task_ready` leave the supported surface. All three continue to be emitted by the server and to reach callers unchanged; the SDK no longer models them and makes no commitment about them. ## Why each one goes `session:input_state` described whether the composer accepts input. It was also the SDK's signal that a turn had ended, and recording a live session showed why that does not hold: the event arrives twice at the very start of a turn, before the agent has said anything. A turn ending on it truncates the reply. `session:required_action` restated in a dedicated event what other events already carry. `session:task_ready` reports what a task will cost. The server computes `confirmed` from the balance and, when it is sufficient, starts the task itself — so the event asks nothing of a client and its absence costs a client nothing. ## What a client keeps A session stopped on its credit balance is still visible: `session:state` is in scope and its values include `credits_exhausted` and `task_paused`. Together with `session:restriction` and `session:error` — and `is_stale` over REST for an expired session — every condition that halts a session remains reportable except one. The exception is an outstanding phone verification, which is a provisioning prerequisite with no in-session remedy and now no in-session signal. The README says so rather than leaving it implicit. `session:task_ready` was also counted towards the SDK's judgement that an agent had responded, which governs how long a turn waits between events. Removing it narrows that set — an accurate narrowing, since a cost estimate is not a reply. Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
1 parent 0e3046f commit 145d009

14 files changed

Lines changed: 45 additions & 276 deletions

File tree

CHANGELOG.md

Lines changed: 11 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -15,18 +15,14 @@ guarantee. What the SDK models is now that subset and nothing else.
1515

1616
- `is_supported_event()` and `SUPPORTED_EVENTS` — whether an event carries the
1717
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.
18+
- `session:llm_thinking`, `session:tool_status` and `session:restriction`, with
19+
models. None were modelled before; `session:tool_status` is where an outbound
20+
call reports its number, duration, credits, and textual outcome.
2221
- `AsyncPineAI.rebuild()` — pages through history until the cursor is
2322
exhausted. Recovery is an unconditional rebuild: joining never resumes from a
2423
cursor, and a short or empty page does not mean a range is done.
2524
- `AsyncPineAI.on_reconnect()` — fires after a reconnect has re-joined, so
2625
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.
3026
- `AsyncPineAI.emit_event()` — the escape hatch for sending anything outside the
3127
supported surface.
3228
- 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()`.
6864
- Wall-clock filtering of events older than the moment a turn began. It
6965
contradicts rebuilding from history, and a clock offset made it drop real
7066
events.
67+
- `session:input_state`, `session:required_action` and `session:task_ready`,
68+
with the `InputState`, `InputStateCode`, `RequiredActionData` and
69+
`TaskReadyData` models. The scope no longer covers them. A session stopped on
70+
its credit balance is reported by `session:state`, whose values include
71+
`credits_exhausted` and `task_paused`.
72+
- The turn no longer ends when `session:input_state` reports that input is
73+
accepted. That event is observed to arrive before the agent has said
74+
anything, so ending on it truncates the reply.
7175

7276
## [0.3.3] - 2026-05-23
7377

README.md

Lines changed: 26 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -72,9 +72,7 @@ payloads, and semantics change compatibly or with notice.
7272
| Event | What it is for |
7373
|---|---|
7474
| `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 |
7675
| `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 |
7876
| `session:update_title` | The session title, as the agent revises it |
7977
| `session:restriction` | An account restriction. The only statement that a task will not complete |
8078

@@ -88,7 +86,6 @@ payloads, and semantics change compatibly or with notice.
8886

8987
| Event | What it is for |
9088
|---|---|
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 |
9289
| `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 |
9390
| `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 |
9491

@@ -170,26 +167,35 @@ has stopped.
170167
`rebuild()` returns messages of every type, including unsupported ones.
171168
Filtering them is yours to do.
172169

173-
## Blocked sessions
170+
## When a session cannot proceed
174171

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.
172+
`session:state` reports where the task stands, and several of its values say
173+
that nothing further will arrive until something changes outside the session:
177174

178175
```python
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
176+
from pine_assistant import S2CEvent
177+
178+
if event.type == S2CEvent.SESSION_STATE:
179+
state = (event.data or {}).get("content")
180+
if state in ("credits_exhausted", "task_paused"):
181+
... # waiting on the account, not on the agent
182+
if state in ("task_finished", "task_cancelled"):
183+
... # the task is over
187184
```
188185

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:
186+
Two more events state a stop outright:
187+
188+
```python
189+
if event.type == S2CEvent.SESSION_RESTRICTION:
190+
... # an account restriction — the task will not complete
191+
if event.type == S2CEvent.SESSION_ERROR:
192+
... # the only channel for server-reported failures
193+
```
194+
195+
An expired session is read over REST, from the `is_stale` field on the session
196+
object — expiry is a property of the session, not one of its states. On finding
197+
one expired, create a new session and reference the old one in your first
198+
message:
193199

194200
```python
195201
new = await client.sessions.create()
@@ -202,7 +208,8 @@ Two conditions have no remedy once a session is running:
202208

203209
- **Metered billing.** The account must be billed against a credit balance. On
204210
the alternative path a session halts at a payment step the SDK cannot answer.
205-
- **Phone verification.** Must be completed at provisioning time.
211+
- **Phone verification.** Must be completed at provisioning time. It has no
212+
in-session remedy and no in-session signal.
206213

207214
## Attachments
208215

src/pine_assistant/__init__.py

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,6 @@
1919
S2CEvent,
2020
is_supported_event,
2121
)
22-
from pine_assistant.models.session import InputState, InputStateCode
2322
from pine_assistant.sessions import SessionsAPI
2423

2524
__version__ = "0.4.0"
@@ -37,6 +36,4 @@
3736
"S2CEvent",
3837
"SUPPORTED_EVENTS",
3938
"is_supported_event",
40-
"InputState",
41-
"InputStateCode",
4239
]

src/pine_assistant/chat.py

Lines changed: 0 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,6 @@
1111
from typing import Any
1212

1313
from pine_assistant.models.events import C2SEvent, S2CEvent
14-
from pine_assistant.models.session import ACCEPTING_INPUT
1514
from pine_assistant.transport.socketio import SocketIOManager
1615

1716
TERMINAL_STATES = {"task_finished", "task_cancelled", "task_stale"}
@@ -30,7 +29,6 @@
3029
S2CEvent.SESSION_TEXT_PART,
3130
S2CEvent.SESSION_RICH_CONTENT,
3231
S2CEvent.SESSION_FORM_TO_USER,
33-
S2CEvent.SESSION_TASK_READY,
3432
S2CEvent.SESSION_TASK_FINISHED,
3533
S2CEvent.SESSION_TOOL_STATUS,
3634
S2CEvent.SESSION_RESTRICTION,
@@ -202,10 +200,6 @@ def handler(event: str, raw: dict[str, Any]) -> None:
202200
if event in SUBSTANTIVE_EVENTS:
203201
received_agent_response = True
204202
data = payload.get("data")
205-
if (event == S2CEvent.SESSION_INPUT_STATE and isinstance(data, dict)
206-
and data.get("content") == ACCEPTING_INPUT and received_agent_response):
207-
done = True
208-
queue.put_nowait(None)
209203
if (event == S2CEvent.SESSION_STATE and isinstance(data, dict)
210204
and data.get("content", "") in TERMINAL_STATES):
211205
done = True

src/pine_assistant/models/events.py

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -43,17 +43,14 @@ class S2CEvent(StrEnum):
4343

4444
# Session state
4545
SESSION_STATE = "session:state"
46-
SESSION_INPUT_STATE = "session:input_state"
4746
SESSION_MESSAGE_STATUS = "session:message_status"
48-
SESSION_REQUIRED_ACTION = "session:required_action"
4947
SESSION_UPDATE_TITLE = "session:update_title"
5048
SESSION_RESTRICTION = "session:restriction"
5149

5250
# Interaction
5351
SESSION_FORM_TO_USER = "session:form_to_user"
5452

5553
# Task and result
56-
SESSION_TASK_READY = "session:task_ready"
5754
SESSION_TASK_FINISHED = "session:task_finished"
5855
SESSION_TOOL_STATUS = "session:tool_status"
5956

Lines changed: 2 additions & 66 deletions
Original file line numberDiff line numberDiff line change
@@ -1,27 +1,15 @@
11
"""
2-
Session models — REST session objects and the `session:input_state` payload.
2+
Session models — the REST session object.
33
"""
44

5-
import sys
6-
75
from pydantic import BaseModel
86

9-
if sys.version_info >= (3, 11):
10-
from enum import StrEnum
11-
else:
12-
from enum import Enum
13-
14-
class StrEnum(str, Enum):
15-
pass
16-
177

188
class SessionInfo(BaseModel):
199
id: str
2010
type: str | None = None
2111
title: str = ""
22-
# Expiry is carried here and nowhere on the Socket.IO surface: an expired
23-
# session presents only as a disabled composer, with no code that tells it
24-
# apart from other causes.
12+
# Expiry is carried here and nowhere on the Socket.IO surface.
2513
is_stale: bool | None = None
2614
is_processed: bool | None = None
2715
state: str = "init"
@@ -35,55 +23,3 @@ class SessionListResponse(BaseModel):
3523
total: int
3624
limit: int
3725
offset: int
38-
39-
40-
class InputStateCode(StrEnum):
41-
"""Reason codes on `session:input_state`."""
42-
DEFAULT = "default"
43-
TASK_READY = "task_ready"
44-
TASK_PROCESSING = "task_processing"
45-
PROFILE_UPDATE_REQUIRED = "profile_update_required"
46-
SESSION_SUMMARY = "session_summary"
47-
PHONE_VERIFICATION_REQUIRED = "phone_verification_required"
48-
49-
50-
ACCEPTING_INPUT = "waiting_input"
51-
52-
53-
class InputState(BaseModel):
54-
"""`session:input_state` payload.
55-
56-
The blocking condition is read from `code`, never inferred from which other
57-
events did or did not arrive — the events that elaborate on a condition are
58-
mostly outside the supported scope.
59-
"""
60-
content: str = ""
61-
detail: str = ""
62-
code: str = ""
63-
64-
@property
65-
def accepting_input(self) -> bool:
66-
return self.content == ACCEPTING_INPUT
67-
68-
@property
69-
def blocked(self) -> bool:
70-
return not self.accepting_input
71-
72-
@property
73-
def awaiting_credits(self) -> bool:
74-
"""Blocked on an unconfirmed credit charge.
75-
76-
The cost is carried by `session:task_ready`. When the balance covers it
77-
the server starts the task itself; when it does not, the session waits
78-
here until the balance is restored.
79-
"""
80-
return self.blocked and self.code == InputStateCode.TASK_READY
81-
82-
@property
83-
def needs_phone_verification(self) -> bool:
84-
"""Blocked on phone verification.
85-
86-
A provisioning prerequisite: it has no in-session remedy, and the event
87-
that explains it is outside the supported scope.
88-
"""
89-
return self.blocked and self.code == InputStateCode.PHONE_VERIFICATION_REQUIRED

src/pine_assistant/models/task.py

Lines changed: 2 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,24 +1,13 @@
11
"""
2-
Task models — `session:task_ready`, `session:task_finished`, `session:tool_status`,
3-
`session:llm_thinking`, `session:restriction`, `session:required_action`.
2+
Task models — `session:task_finished`, `session:tool_status`,
3+
`session:llm_thinking`, `session:restriction`.
44
"""
55

66
from typing import Any
77

88
from pydantic import BaseModel
99

1010

11-
class TaskReadyData(BaseModel):
12-
"""`session:task_ready` payload.
13-
14-
Informational when the balance covers `required`; when it does not, the
15-
session waits until the balance is restored.
16-
"""
17-
required: int = 0
18-
suggested: int | None = None
19-
confirmed: bool = False
20-
21-
2211
class Achievement(BaseModel):
2312
id: str = ""
2413
title: str = ""
@@ -123,12 +112,6 @@ class RestrictionData(BaseModel):
123112
message: str | None = None
124113

125114

126-
class RequiredActionData(BaseModel):
127-
"""`session:required_action` payload — whether the session awaits a user
128-
response."""
129-
is_required_action: bool = False
130-
131-
132115
class MessageStatusData(BaseModel):
133116
"""`session:message_status` payload — the only means of telling a rejected
134117
or rate-limited message from one still being processed."""

tests/integration/test_live.py

Lines changed: 1 addition & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@
1313

1414
import pytest
1515

16-
from pine_assistant import AsyncPineAI, InputState, S2CEvent, is_supported_event
16+
from pine_assistant import AsyncPineAI, S2CEvent, is_supported_event
1717

1818
SKIP = not os.environ.get("PINE_INTEGRATION")
1919
ACCESS_TOKEN = os.environ.get("PINE_ACCESS_TOKEN", "")
@@ -124,19 +124,6 @@ async def test_rebuild_returns_the_conversation(self, session):
124124
assert isinstance(messages, list)
125125
assert messages, "history came back empty after a turn"
126126

127-
async def test_input_state_reports_whether_the_composer_is_open(self, session):
128-
client, sid = session
129-
states = [
130-
InputState.model_validate(e.data)
131-
async for e in client.chat(sid, PROMPT)
132-
if e.type == S2CEvent.SESSION_INPUT_STATE and isinstance(e.data, dict)
133-
]
134-
assert states, "no session:input_state during a turn"
135-
# Whatever the value, a blocked composer must name its reason.
136-
for state in states:
137-
if state.blocked:
138-
assert state.code or state.detail
139-
140127

141128
class TestErrors:
142129
async def test_get_nonexistent_session(self):

tests/protocol/fixtures/input_state.json

Lines changed: 0 additions & 20 deletions
This file was deleted.

tests/protocol/fixtures/provenance.json

Lines changed: 0 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -14,11 +14,6 @@
1414
"recorded_at": "2026-08-08T13:55:16+00:00",
1515
"source": "recorded"
1616
},
17-
"session:input_state": {
18-
"derived_from": null,
19-
"recorded_at": "2026-08-08T13:55:16+00:00",
20-
"source": "recorded"
21-
},
2217
"session:join": {
2318
"derived_from": null,
2419
"recorded_at": "2026-08-08T13:55:16+00:00",
@@ -39,11 +34,6 @@
3934
"recorded_at": "2026-08-08T13:55:16+00:00",
4035
"source": "recorded"
4136
},
42-
"session:required_action": {
43-
"derived_from": null,
44-
"recorded_at": "2026-08-08T13:48:53+00:00",
45-
"source": "recorded"
46-
},
4737
"session:restriction": {
4838
"derived_from": "the server's protocol definition",
4939
"recorded_at": null,
@@ -64,11 +54,6 @@
6454
"recorded_at": "2026-08-08T13:55:16+00:00",
6555
"source": "recorded"
6656
},
67-
"session:task_ready": {
68-
"derived_from": null,
69-
"recorded_at": "2026-08-08T13:55:16+00:00",
70-
"source": "recorded"
71-
},
7257
"session:text": {
7358
"derived_from": null,
7459
"recorded_at": "2026-08-08T13:55:16+00:00",

0 commit comments

Comments
 (0)