pine-assistant 0.2.0__tar.gz → 0.3.1__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {pine_assistant-0.2.0/src/pine_assistant.egg-info → pine_assistant-0.3.1}/PKG-INFO +2 -2
- {pine_assistant-0.2.0 → pine_assistant-0.3.1}/pyproject.toml +2 -2
- {pine_assistant-0.2.0 → pine_assistant-0.3.1}/src/pine_assistant/__init__.py +1 -1
- {pine_assistant-0.2.0 → pine_assistant-0.3.1}/src/pine_assistant/chat.py +44 -93
- {pine_assistant-0.2.0 → pine_assistant-0.3.1}/src/pine_assistant/client.py +33 -0
- {pine_assistant-0.2.0 → pine_assistant-0.3.1/src/pine_assistant.egg-info}/PKG-INFO +2 -2
- {pine_assistant-0.2.0 → pine_assistant-0.3.1}/src/pine_assistant.egg-info/SOURCES.txt +2 -1
- {pine_assistant-0.2.0 → pine_assistant-0.3.1}/src/pine_assistant.egg-info/requires.txt +1 -1
- pine_assistant-0.3.1/tests/test_chat_engine.py +194 -0
- {pine_assistant-0.2.0 → pine_assistant-0.3.1}/LICENSE +0 -0
- {pine_assistant-0.2.0 → pine_assistant-0.3.1}/README.md +0 -0
- {pine_assistant-0.2.0 → pine_assistant-0.3.1}/setup.cfg +0 -0
- {pine_assistant-0.2.0 → pine_assistant-0.3.1}/src/pine_assistant/auth.py +0 -0
- {pine_assistant-0.2.0 → pine_assistant-0.3.1}/src/pine_assistant/cli/__init__.py +0 -0
- {pine_assistant-0.2.0 → pine_assistant-0.3.1}/src/pine_assistant/cli/auth.py +0 -0
- {pine_assistant-0.2.0 → pine_assistant-0.3.1}/src/pine_assistant/cli/chat.py +0 -0
- {pine_assistant-0.2.0 → pine_assistant-0.3.1}/src/pine_assistant/cli/main.py +0 -0
- {pine_assistant-0.2.0 → pine_assistant-0.3.1}/src/pine_assistant/cli/sessions.py +0 -0
- {pine_assistant-0.2.0 → pine_assistant-0.3.1}/src/pine_assistant/cli/tasks.py +0 -0
- {pine_assistant-0.2.0 → pine_assistant-0.3.1}/src/pine_assistant/errors.py +0 -0
- {pine_assistant-0.2.0 → pine_assistant-0.3.1}/src/pine_assistant/models/__init__.py +0 -0
- {pine_assistant-0.2.0 → pine_assistant-0.3.1}/src/pine_assistant/models/envelope.py +0 -0
- {pine_assistant-0.2.0 → pine_assistant-0.3.1}/src/pine_assistant/models/events.py +0 -0
- {pine_assistant-0.2.0 → pine_assistant-0.3.1}/src/pine_assistant/models/form.py +0 -0
- {pine_assistant-0.2.0 → pine_assistant-0.3.1}/src/pine_assistant/models/payment.py +0 -0
- {pine_assistant-0.2.0 → pine_assistant-0.3.1}/src/pine_assistant/models/session.py +0 -0
- {pine_assistant-0.2.0 → pine_assistant-0.3.1}/src/pine_assistant/models/task.py +0 -0
- {pine_assistant-0.2.0 → pine_assistant-0.3.1}/src/pine_assistant/py.typed +0 -0
- {pine_assistant-0.2.0 → pine_assistant-0.3.1}/src/pine_assistant/sessions.py +0 -0
- {pine_assistant-0.2.0 → pine_assistant-0.3.1}/src/pine_assistant/transport/__init__.py +0 -0
- {pine_assistant-0.2.0 → pine_assistant-0.3.1}/src/pine_assistant/transport/envelope.py +0 -0
- {pine_assistant-0.2.0 → pine_assistant-0.3.1}/src/pine_assistant/transport/http.py +0 -0
- {pine_assistant-0.2.0 → pine_assistant-0.3.1}/src/pine_assistant/transport/socketio.py +0 -0
- {pine_assistant-0.2.0 → pine_assistant-0.3.1}/src/pine_assistant.egg-info/dependency_links.txt +0 -0
- {pine_assistant-0.2.0 → pine_assistant-0.3.1}/src/pine_assistant.egg-info/entry_points.txt +0 -0
- {pine_assistant-0.2.0 → pine_assistant-0.3.1}/src/pine_assistant.egg-info/top_level.txt +0 -0
- {pine_assistant-0.2.0 → pine_assistant-0.3.1}/tests/test_basics.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: pine-assistant
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.3.1
|
|
4
4
|
Summary: Pine AI SDK — Let Pine AI handle your digital chores. Socket.IO + REST client.
|
|
5
5
|
Author: Pine AI
|
|
6
6
|
License-Expression: MIT
|
|
@@ -20,7 +20,7 @@ Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
|
20
20
|
Requires-Python: >=3.10
|
|
21
21
|
Description-Content-Type: text/markdown
|
|
22
22
|
License-File: LICENSE
|
|
23
|
-
Requires-Dist: python-socketio
|
|
23
|
+
Requires-Dist: python-socketio>=5.11.0
|
|
24
24
|
Requires-Dist: httpx>=0.27.0
|
|
25
25
|
Requires-Dist: pydantic>=2.0.0
|
|
26
26
|
Provides-Extra: cli
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "pine-assistant"
|
|
7
|
-
version = "0.
|
|
7
|
+
version = "0.3.1"
|
|
8
8
|
description = "Pine AI SDK — Let Pine AI handle your digital chores. Socket.IO + REST client."
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
license = "MIT"
|
|
@@ -23,7 +23,7 @@ classifiers = [
|
|
|
23
23
|
"Topic :: Software Development :: Libraries :: Python Modules",
|
|
24
24
|
]
|
|
25
25
|
dependencies = [
|
|
26
|
-
"python-socketio
|
|
26
|
+
"python-socketio>=5.11.0",
|
|
27
27
|
"httpx>=0.27.0",
|
|
28
28
|
"pydantic>=2.0.0",
|
|
29
29
|
]
|
|
@@ -11,7 +11,7 @@ from pine_assistant.sessions import SessionsAPI
|
|
|
11
11
|
from pine_assistant.errors import PineAIError, AuthError, SessionError, ConnectionError
|
|
12
12
|
from pine_assistant.models.events import C2SEvent, S2CEvent, NotificationEvent
|
|
13
13
|
|
|
14
|
-
__version__ = "0.
|
|
14
|
+
__version__ = "0.3.1"
|
|
15
15
|
__all__ = [
|
|
16
16
|
"PineAI",
|
|
17
17
|
"AsyncPineAI",
|
|
@@ -1,14 +1,11 @@
|
|
|
1
1
|
"""
|
|
2
|
-
Chat engine — send messages and yield
|
|
2
|
+
Chat engine — send messages and yield events via async generator.
|
|
3
3
|
|
|
4
|
-
|
|
5
|
-
- Tier 1: session:text_part buffered until final: true
|
|
6
|
-
- Tier 2: Non-streaming events yield immediately
|
|
7
|
-
- Tier 3: session:work_log_part debounced (3s silence)
|
|
4
|
+
All events are dispatched immediately as they arrive from the server.
|
|
8
5
|
"""
|
|
9
6
|
|
|
10
7
|
import asyncio
|
|
11
|
-
import
|
|
8
|
+
from datetime import datetime, timedelta, timezone
|
|
12
9
|
from typing import Any, AsyncGenerator, Callable, Coroutine, Optional
|
|
13
10
|
|
|
14
11
|
from pine_assistant.models.events import C2SEvent, S2CEvent
|
|
@@ -16,13 +13,11 @@ from pine_assistant.transport.socketio import SocketIOManager
|
|
|
16
13
|
|
|
17
14
|
TERMINAL_STATES = {"task_finished", "task_cancelled", "task_stale"}
|
|
18
15
|
DEFAULT_IDLE_TIMEOUT_S = 120.0
|
|
16
|
+
DEFAULT_RESPONSE_IDLE_TIMEOUT_S = 2.0
|
|
19
17
|
|
|
20
|
-
# Events that are buffered/debounced — NOT dispatched immediately
|
|
21
|
-
BUFFERED_EVENTS = {S2CEvent.SESSION_TEXT_PART, S2CEvent.SESSION_WORK_LOG_PART}
|
|
22
|
-
|
|
23
|
-
# Substantive response events — track for waiting_input termination gating
|
|
24
18
|
SUBSTANTIVE_EVENTS = {
|
|
25
|
-
S2CEvent.SESSION_TEXT, S2CEvent.
|
|
19
|
+
S2CEvent.SESSION_TEXT, S2CEvent.SESSION_TEXT_PART,
|
|
20
|
+
S2CEvent.SESSION_FORM_TO_USER,
|
|
26
21
|
S2CEvent.SESSION_ASK_FOR_LOCATION, S2CEvent.SESSION_TASK_READY,
|
|
27
22
|
S2CEvent.SESSION_TASK_FINISHED, S2CEvent.SESSION_INTERACTIVE_AUTH_CONFIRMATION,
|
|
28
23
|
S2CEvent.SESSION_THREE_WAY_CALL, S2CEvent.SESSION_REWARD,
|
|
@@ -44,33 +39,18 @@ class ChatEvent:
|
|
|
44
39
|
return f"ChatEvent(type={self.type!r}, session_id={self.session_id!r})"
|
|
45
40
|
|
|
46
41
|
|
|
47
|
-
class TextPartBuffer:
|
|
48
|
-
"""Buffer text_part chunks per message_id, flush on final: true."""
|
|
49
|
-
|
|
50
|
-
def __init__(self) -> None:
|
|
51
|
-
self._parts: dict[str, list[str]] = {}
|
|
52
|
-
|
|
53
|
-
def collect(self, message_id: str, content: str, final: bool) -> Optional[str]:
|
|
54
|
-
if message_id not in self._parts:
|
|
55
|
-
self._parts[message_id] = []
|
|
56
|
-
if content:
|
|
57
|
-
self._parts[message_id].append(content)
|
|
58
|
-
if final:
|
|
59
|
-
merged = "".join(self._parts.pop(message_id, []))
|
|
60
|
-
return merged
|
|
61
|
-
return None
|
|
62
|
-
|
|
63
|
-
|
|
64
42
|
class ChatEngine:
|
|
65
43
|
def __init__(
|
|
66
44
|
self,
|
|
67
45
|
sio: SocketIOManager,
|
|
68
46
|
check_session_state: Optional[Callable[[str], Coroutine[Any, Any, dict[str, Any]]]] = None,
|
|
69
47
|
idle_timeout_s: float = DEFAULT_IDLE_TIMEOUT_S,
|
|
48
|
+
response_idle_timeout_s: float = DEFAULT_RESPONSE_IDLE_TIMEOUT_S,
|
|
70
49
|
):
|
|
71
50
|
self._sio = sio
|
|
72
51
|
self._check_session_state = check_session_state
|
|
73
52
|
self._idle_timeout_s = idle_timeout_s
|
|
53
|
+
self._response_idle_timeout_s = response_idle_timeout_s
|
|
74
54
|
|
|
75
55
|
async def join_session(self, session_id: str) -> dict[str, Any]:
|
|
76
56
|
"""Join a session room — spec 5.1.1.
|
|
@@ -117,14 +97,32 @@ class ChatEngine:
|
|
|
117
97
|
"""Send a message and yield events with stream buffering.
|
|
118
98
|
Production handler reads payload.data as {content, attachments, ...}.
|
|
119
99
|
"""
|
|
100
|
+
cutoff = datetime.now(timezone.utc) - timedelta(seconds=5)
|
|
120
101
|
self._sio.emit(
|
|
121
102
|
C2SEvent.SESSION_MESSAGE,
|
|
122
103
|
self._build_message_data(content, attachments, referenced_sessions, action),
|
|
123
104
|
session_id,
|
|
124
105
|
)
|
|
125
|
-
async for event in self._listen(session_id):
|
|
106
|
+
async for event in self._listen(session_id, _skip_state_precheck=True):
|
|
107
|
+
if self._is_stale_event(event, cutoff):
|
|
108
|
+
continue
|
|
126
109
|
yield event
|
|
127
110
|
|
|
111
|
+
@staticmethod
|
|
112
|
+
def _is_stale_event(event: "ChatEvent", cutoff: datetime) -> bool:
|
|
113
|
+
"""Return True if the event's metadata timestamp predates the cutoff."""
|
|
114
|
+
meta = event.metadata
|
|
115
|
+
if not isinstance(meta, dict):
|
|
116
|
+
return False
|
|
117
|
+
ts_str = meta.get("timestamp")
|
|
118
|
+
if not ts_str:
|
|
119
|
+
return False
|
|
120
|
+
try:
|
|
121
|
+
event_ts = datetime.fromisoformat(ts_str.replace("Z", "+00:00"))
|
|
122
|
+
return event_ts < cutoff
|
|
123
|
+
except (ValueError, TypeError):
|
|
124
|
+
return False
|
|
125
|
+
|
|
128
126
|
def send_message(
|
|
129
127
|
self,
|
|
130
128
|
session_id: str,
|
|
@@ -141,10 +139,11 @@ class ChatEngine:
|
|
|
141
139
|
session_id,
|
|
142
140
|
)
|
|
143
141
|
|
|
144
|
-
async def _listen(
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
142
|
+
async def _listen(
|
|
143
|
+
self, session_id: str, *, _skip_state_precheck: bool = False,
|
|
144
|
+
) -> AsyncGenerator[ChatEvent, None]:
|
|
145
|
+
"""Listen for events — all events dispatched immediately."""
|
|
146
|
+
if not _skip_state_precheck and self._check_session_state:
|
|
148
147
|
try:
|
|
149
148
|
session = await self._check_session_state(session_id)
|
|
150
149
|
if session.get("state") in TERMINAL_STATES:
|
|
@@ -153,79 +152,31 @@ class ChatEngine:
|
|
|
153
152
|
except Exception:
|
|
154
153
|
pass # best effort
|
|
155
154
|
|
|
156
|
-
text_buffer = TextPartBuffer()
|
|
157
155
|
queue: asyncio.Queue[Optional[ChatEvent]] = asyncio.Queue()
|
|
158
156
|
done = False
|
|
159
157
|
received_agent_response = False
|
|
160
158
|
|
|
161
|
-
# Work log debounce state
|
|
162
|
-
wl_timers: dict[str, asyncio.TimerHandle] = {}
|
|
163
|
-
wl_buffers: dict[str, dict[str, Any]] = {}
|
|
164
|
-
|
|
165
|
-
def flush_wl(step_id: str) -> None:
|
|
166
|
-
buf = wl_buffers.pop(step_id, None)
|
|
167
|
-
wl_timers.pop(step_id, None)
|
|
168
|
-
if buf:
|
|
169
|
-
queue.put_nowait(ChatEvent(
|
|
170
|
-
type=S2CEvent.SESSION_WORK_LOG_PART,
|
|
171
|
-
session_id=session_id,
|
|
172
|
-
data={"step_id": step_id, "text": buf.get("text", ""), "status": buf.get("status")},
|
|
173
|
-
))
|
|
174
|
-
|
|
175
159
|
def handler(event: str, raw: dict[str, Any]) -> None:
|
|
176
|
-
nonlocal done
|
|
160
|
+
nonlocal done, received_agent_response
|
|
177
161
|
payload = raw.get("payload", {})
|
|
178
162
|
p_session_id = payload.get("session_id")
|
|
179
163
|
if p_session_id and p_session_id != session_id:
|
|
180
|
-
return # Not our session — other handlers will process it
|
|
181
|
-
|
|
182
|
-
message_id = payload.get("message_id")
|
|
183
|
-
data = payload.get("data")
|
|
184
|
-
metadata = raw.get("metadata")
|
|
185
|
-
|
|
186
|
-
# Tier 1: text_part
|
|
187
|
-
if event == S2CEvent.SESSION_TEXT_PART:
|
|
188
|
-
if isinstance(data, dict):
|
|
189
|
-
content = data.get("content", "")
|
|
190
|
-
final = data.get("final", False)
|
|
191
|
-
merged = text_buffer.collect(message_id or "unknown", content, final)
|
|
192
|
-
if merged is not None:
|
|
193
|
-
queue.put_nowait(ChatEvent(
|
|
194
|
-
type=S2CEvent.SESSION_TEXT, session_id=session_id,
|
|
195
|
-
message_id=message_id, data={"content": merged}, metadata=metadata,
|
|
196
|
-
))
|
|
197
|
-
return
|
|
198
|
-
|
|
199
|
-
# Tier 3: work_log_part debounce
|
|
200
|
-
if event == S2CEvent.SESSION_WORK_LOG_PART:
|
|
201
|
-
if isinstance(data, dict):
|
|
202
|
-
step_id = data.get("step_id", "unknown")
|
|
203
|
-
existing = wl_buffers.get(step_id, {"text": ""})
|
|
204
|
-
existing["text"] = existing.get("text", "") + (data.get("text_delta", "") or "")
|
|
205
|
-
if data.get("status"):
|
|
206
|
-
existing["status"] = data["status"]
|
|
207
|
-
wl_buffers[step_id] = existing
|
|
208
|
-
old_timer = wl_timers.pop(step_id, None)
|
|
209
|
-
if old_timer:
|
|
210
|
-
old_timer.cancel()
|
|
211
|
-
loop = asyncio.get_running_loop()
|
|
212
|
-
wl_timers[step_id] = loop.call_later(3.0, flush_wl, step_id)
|
|
213
164
|
return
|
|
214
165
|
|
|
215
|
-
# All other events: dispatch immediately (pass-through for agent)
|
|
216
|
-
nonlocal received_agent_response
|
|
217
166
|
queue.put_nowait(ChatEvent(
|
|
218
167
|
type=event, session_id=session_id,
|
|
219
|
-
message_id=message_id,
|
|
168
|
+
message_id=payload.get("message_id"),
|
|
169
|
+
data=payload.get("data"),
|
|
170
|
+
metadata=raw.get("metadata"),
|
|
220
171
|
))
|
|
221
172
|
if event in SUBSTANTIVE_EVENTS:
|
|
222
173
|
received_agent_response = True
|
|
223
|
-
if event == S2CEvent.SESSION_INPUT_STATE and isinstance(data, dict):
|
|
224
|
-
if data.get("content") == "waiting_input" and received_agent_response:
|
|
174
|
+
if event == S2CEvent.SESSION_INPUT_STATE and isinstance(payload.get("data"), dict):
|
|
175
|
+
if payload["data"].get("content") == "waiting_input" and received_agent_response:
|
|
225
176
|
done = True
|
|
226
177
|
queue.put_nowait(None)
|
|
227
|
-
if event == S2CEvent.SESSION_STATE and isinstance(data, dict):
|
|
228
|
-
state = data.get("content", "")
|
|
178
|
+
if event == S2CEvent.SESSION_STATE and isinstance(payload.get("data"), dict):
|
|
179
|
+
state = payload["data"].get("content", "")
|
|
229
180
|
if state in TERMINAL_STATES:
|
|
230
181
|
done = True
|
|
231
182
|
queue.put_nowait(None)
|
|
@@ -234,10 +185,12 @@ class ChatEngine:
|
|
|
234
185
|
|
|
235
186
|
try:
|
|
236
187
|
while not done:
|
|
188
|
+
timeout = self._response_idle_timeout_s if received_agent_response else self._idle_timeout_s
|
|
237
189
|
try:
|
|
238
|
-
evt = await asyncio.wait_for(queue.get(), timeout=
|
|
190
|
+
evt = await asyncio.wait_for(queue.get(), timeout=timeout)
|
|
239
191
|
except asyncio.TimeoutError:
|
|
240
|
-
|
|
192
|
+
if received_agent_response:
|
|
193
|
+
break
|
|
241
194
|
if self._check_session_state:
|
|
242
195
|
try:
|
|
243
196
|
session = await self._check_session_state(session_id)
|
|
@@ -255,8 +208,6 @@ class ChatEngine:
|
|
|
255
208
|
if evt is not None:
|
|
256
209
|
yield evt
|
|
257
210
|
finally:
|
|
258
|
-
for t in wl_timers.values():
|
|
259
|
-
t.cancel()
|
|
260
211
|
remove_handler()
|
|
261
212
|
|
|
262
213
|
def send_form_response(self, session_id: str, message_id: str, form_data: dict[str, Any]) -> None:
|
|
@@ -158,6 +158,39 @@ class AsyncPineAI:
|
|
|
158
158
|
async for event in self._chat._listen(session_id): # type: ignore[union-attr]
|
|
159
159
|
yield event
|
|
160
160
|
|
|
161
|
+
async def subscribe(self, session_id: str) -> AsyncGenerator[ChatEvent, None]:
|
|
162
|
+
"""Persistent event stream for a session — yields events indefinitely.
|
|
163
|
+
|
|
164
|
+
Unlike listen(), this never terminates on terminal states or timeouts.
|
|
165
|
+
Designed for bidirectional REPL use where sending and receiving are
|
|
166
|
+
concurrent.
|
|
167
|
+
"""
|
|
168
|
+
self._ensure_connected()
|
|
169
|
+
queue: asyncio.Queue[ChatEvent] = asyncio.Queue()
|
|
170
|
+
|
|
171
|
+
def _handler(event_type: str, raw: dict[str, Any]) -> None:
|
|
172
|
+
payload = raw.get("payload", {})
|
|
173
|
+
p_sid = payload.get("session_id")
|
|
174
|
+
if p_sid and p_sid != session_id:
|
|
175
|
+
return
|
|
176
|
+
queue.put_nowait(ChatEvent(
|
|
177
|
+
type=event_type,
|
|
178
|
+
session_id=session_id,
|
|
179
|
+
message_id=payload.get("message_id"),
|
|
180
|
+
data=payload.get("data"),
|
|
181
|
+
metadata=raw.get("metadata"),
|
|
182
|
+
))
|
|
183
|
+
|
|
184
|
+
remove = self._sio.add_event_handler(_handler) # type: ignore[union-attr]
|
|
185
|
+
try:
|
|
186
|
+
while self.connected:
|
|
187
|
+
try:
|
|
188
|
+
yield await asyncio.wait_for(queue.get(), timeout=5.0)
|
|
189
|
+
except asyncio.TimeoutError:
|
|
190
|
+
continue
|
|
191
|
+
finally:
|
|
192
|
+
remove()
|
|
193
|
+
|
|
161
194
|
async def create_and_chat(self, content: str) -> AsyncGenerator[ChatEvent, None]:
|
|
162
195
|
"""Convenience: create session, join, chat, return events."""
|
|
163
196
|
session = await self.sessions.create()
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: pine-assistant
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.3.1
|
|
4
4
|
Summary: Pine AI SDK — Let Pine AI handle your digital chores. Socket.IO + REST client.
|
|
5
5
|
Author: Pine AI
|
|
6
6
|
License-Expression: MIT
|
|
@@ -20,7 +20,7 @@ Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
|
20
20
|
Requires-Python: >=3.10
|
|
21
21
|
Description-Content-Type: text/markdown
|
|
22
22
|
License-File: LICENSE
|
|
23
|
-
Requires-Dist: python-socketio
|
|
23
|
+
Requires-Dist: python-socketio>=5.11.0
|
|
24
24
|
Requires-Dist: httpx>=0.27.0
|
|
25
25
|
Requires-Dist: pydantic>=2.0.0
|
|
26
26
|
Provides-Extra: cli
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
"""Unit tests for ChatEngine — immediate dispatch, stale-event filtering, state precheck."""
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
from datetime import datetime, timedelta, timezone
|
|
5
|
+
from unittest.mock import MagicMock
|
|
6
|
+
|
|
7
|
+
import pytest
|
|
8
|
+
|
|
9
|
+
from pine_assistant.chat import ChatEngine, ChatEvent
|
|
10
|
+
from pine_assistant.models.events import S2CEvent
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def _make_sio():
|
|
14
|
+
sio = MagicMock()
|
|
15
|
+
sio.connected = True
|
|
16
|
+
sio.emit = MagicMock()
|
|
17
|
+
sio.add_event_handler = MagicMock(return_value=lambda: None)
|
|
18
|
+
return sio
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _ts_iso(dt: datetime) -> str:
|
|
22
|
+
return dt.isoformat().replace("+00:00", "Z")
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _inject_events(sio, session_id, events, delay=0.05):
|
|
26
|
+
"""Replace add_event_handler so it injects events after a short delay."""
|
|
27
|
+
def fake_add_handler(handler):
|
|
28
|
+
async def _inject():
|
|
29
|
+
await asyncio.sleep(delay)
|
|
30
|
+
for evt_type, raw in events:
|
|
31
|
+
handler(evt_type, raw)
|
|
32
|
+
asyncio.get_running_loop().create_task(_inject())
|
|
33
|
+
return lambda: None
|
|
34
|
+
sio.add_event_handler = fake_add_handler
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _raw(event_type, session_id, data, ts=None):
|
|
38
|
+
"""Build a raw server envelope."""
|
|
39
|
+
meta = {}
|
|
40
|
+
if ts:
|
|
41
|
+
meta["timestamp"] = ts
|
|
42
|
+
return (event_type, {
|
|
43
|
+
"payload": {"session_id": session_id, "data": data},
|
|
44
|
+
"metadata": meta,
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
# ── _is_stale_event ──────────────────────────────────────────────────────
|
|
49
|
+
|
|
50
|
+
class TestIsStaleEvent:
|
|
51
|
+
def test_old_event_is_stale(self):
|
|
52
|
+
cutoff = datetime(2026, 2, 21, 10, 0, 0, tzinfo=timezone.utc)
|
|
53
|
+
event = ChatEvent(
|
|
54
|
+
type=S2CEvent.SESSION_WORK_LOG, session_id="s1", data={},
|
|
55
|
+
metadata={"timestamp": "2026-02-20T09:00:00Z"},
|
|
56
|
+
)
|
|
57
|
+
assert ChatEngine._is_stale_event(event, cutoff) is True
|
|
58
|
+
|
|
59
|
+
def test_fresh_event_passes(self):
|
|
60
|
+
cutoff = datetime(2026, 2, 21, 10, 0, 0, tzinfo=timezone.utc)
|
|
61
|
+
event = ChatEvent(
|
|
62
|
+
type=S2CEvent.SESSION_TEXT, session_id="s1", data={},
|
|
63
|
+
metadata={"timestamp": "2026-02-21T10:00:05Z"},
|
|
64
|
+
)
|
|
65
|
+
assert ChatEngine._is_stale_event(event, cutoff) is False
|
|
66
|
+
|
|
67
|
+
def test_no_metadata_passes(self):
|
|
68
|
+
cutoff = datetime(2026, 2, 21, 10, 0, 0, tzinfo=timezone.utc)
|
|
69
|
+
event = ChatEvent(type=S2CEvent.SESSION_WORK_LOG_PART, session_id="s1", data={})
|
|
70
|
+
assert ChatEngine._is_stale_event(event, cutoff) is False
|
|
71
|
+
|
|
72
|
+
def test_missing_timestamp_passes(self):
|
|
73
|
+
cutoff = datetime(2026, 2, 21, 10, 0, 0, tzinfo=timezone.utc)
|
|
74
|
+
event = ChatEvent(
|
|
75
|
+
type=S2CEvent.SESSION_WORK_LOG, session_id="s1", data={},
|
|
76
|
+
metadata={"source": {"role": "agent"}},
|
|
77
|
+
)
|
|
78
|
+
assert ChatEngine._is_stale_event(event, cutoff) is False
|
|
79
|
+
|
|
80
|
+
def test_malformed_timestamp_passes(self):
|
|
81
|
+
cutoff = datetime(2026, 2, 21, 10, 0, 0, tzinfo=timezone.utc)
|
|
82
|
+
event = ChatEvent(
|
|
83
|
+
type=S2CEvent.SESSION_WORK_LOG, session_id="s1", data={},
|
|
84
|
+
metadata={"timestamp": "not-a-date"},
|
|
85
|
+
)
|
|
86
|
+
assert ChatEngine._is_stale_event(event, cutoff) is False
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
# ── immediate dispatch (no buffering) ─────────────────────────────────────
|
|
90
|
+
|
|
91
|
+
class TestImmediateDispatch:
|
|
92
|
+
@pytest.mark.asyncio
|
|
93
|
+
async def test_text_part_dispatched_immediately(self):
|
|
94
|
+
"""text_part events are yielded as-is, not buffered."""
|
|
95
|
+
sio = _make_sio()
|
|
96
|
+
now = _ts_iso(datetime.now(timezone.utc))
|
|
97
|
+
|
|
98
|
+
_inject_events(sio, "s1", [
|
|
99
|
+
_raw(S2CEvent.SESSION_TEXT_PART, "s1", {"content": "Hello "}, ts=now),
|
|
100
|
+
_raw(S2CEvent.SESSION_TEXT_PART, "s1", {"content": "world"}, ts=now),
|
|
101
|
+
_raw(S2CEvent.SESSION_STATE, "s1", {"content": "task_finished"}, ts=now),
|
|
102
|
+
])
|
|
103
|
+
|
|
104
|
+
engine = ChatEngine(sio, idle_timeout_s=5.0)
|
|
105
|
+
events = []
|
|
106
|
+
async for event in engine._listen("s1", _skip_state_precheck=True):
|
|
107
|
+
events.append(event)
|
|
108
|
+
|
|
109
|
+
types = [e.type for e in events]
|
|
110
|
+
assert types.count(S2CEvent.SESSION_TEXT_PART) == 2
|
|
111
|
+
assert events[0].data["content"] == "Hello "
|
|
112
|
+
assert events[1].data["content"] == "world"
|
|
113
|
+
|
|
114
|
+
@pytest.mark.asyncio
|
|
115
|
+
async def test_work_log_part_dispatched_immediately(self):
|
|
116
|
+
"""work_log_part events are yielded immediately, not debounced."""
|
|
117
|
+
sio = _make_sio()
|
|
118
|
+
now = _ts_iso(datetime.now(timezone.utc))
|
|
119
|
+
|
|
120
|
+
_inject_events(sio, "s1", [
|
|
121
|
+
_raw(S2CEvent.SESSION_WORK_LOG_PART, "s1",
|
|
122
|
+
{"step_id": "1", "text_delta": "thinking..."}, ts=now),
|
|
123
|
+
_raw(S2CEvent.SESSION_STATE, "s1", {"content": "task_finished"}, ts=now),
|
|
124
|
+
])
|
|
125
|
+
|
|
126
|
+
engine = ChatEngine(sio, idle_timeout_s=5.0)
|
|
127
|
+
events = []
|
|
128
|
+
async for event in engine._listen("s1", _skip_state_precheck=True):
|
|
129
|
+
events.append(event)
|
|
130
|
+
|
|
131
|
+
types = [e.type for e in events]
|
|
132
|
+
assert S2CEvent.SESSION_WORK_LOG_PART in types
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
# ── chat() stale filtering ───────────────────────────────────────────────
|
|
136
|
+
|
|
137
|
+
class TestChatStaleFiltering:
|
|
138
|
+
@pytest.mark.asyncio
|
|
139
|
+
async def test_chat_filters_old_events(self):
|
|
140
|
+
sio = _make_sio()
|
|
141
|
+
old_ts = _ts_iso(datetime.now(timezone.utc) - timedelta(hours=24))
|
|
142
|
+
fresh_ts = _ts_iso(datetime.now(timezone.utc) + timedelta(seconds=1))
|
|
143
|
+
|
|
144
|
+
_inject_events(sio, "s1", [
|
|
145
|
+
_raw(S2CEvent.SESSION_WORK_LOG, "s1",
|
|
146
|
+
{"steps": [{"step_title": "old"}]}, ts=old_ts),
|
|
147
|
+
_raw(S2CEvent.SESSION_TEXT_PART, "s1", {"content": "Hi!"}, ts=fresh_ts),
|
|
148
|
+
_raw(S2CEvent.SESSION_STATE, "s1", {"content": "task_finished"}, ts=fresh_ts),
|
|
149
|
+
])
|
|
150
|
+
|
|
151
|
+
engine = ChatEngine(sio, idle_timeout_s=5.0)
|
|
152
|
+
events = []
|
|
153
|
+
async for event in engine.chat("s1", "test"):
|
|
154
|
+
events.append(event)
|
|
155
|
+
|
|
156
|
+
types = [e.type for e in events]
|
|
157
|
+
assert S2CEvent.SESSION_WORK_LOG not in types
|
|
158
|
+
assert S2CEvent.SESSION_TEXT_PART in types
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
# ── _listen with _skip_state_precheck ─────────────────────────────────────
|
|
162
|
+
|
|
163
|
+
class TestListenSkipStatePrecheck:
|
|
164
|
+
@pytest.mark.asyncio
|
|
165
|
+
async def test_listen_without_skip_returns_on_terminal(self):
|
|
166
|
+
sio = _make_sio()
|
|
167
|
+
|
|
168
|
+
async def _check(_sid):
|
|
169
|
+
return {"state": "task_finished"}
|
|
170
|
+
|
|
171
|
+
engine = ChatEngine(sio, check_session_state=_check)
|
|
172
|
+
events = [e async for e in engine._listen("s1")]
|
|
173
|
+
|
|
174
|
+
assert len(events) == 1
|
|
175
|
+
assert events[0].data["content"] == "task_finished"
|
|
176
|
+
|
|
177
|
+
@pytest.mark.asyncio
|
|
178
|
+
async def test_listen_with_skip_enters_event_loop(self):
|
|
179
|
+
sio = _make_sio()
|
|
180
|
+
now = _ts_iso(datetime.now(timezone.utc))
|
|
181
|
+
|
|
182
|
+
async def _check(_sid):
|
|
183
|
+
return {"state": "task_finished"}
|
|
184
|
+
|
|
185
|
+
_inject_events(sio, "s1", [
|
|
186
|
+
_raw(S2CEvent.SESSION_TEXT_PART, "s1", {"content": "response"}, ts=now),
|
|
187
|
+
_raw(S2CEvent.SESSION_STATE, "s1", {"content": "task_finished"}, ts=now),
|
|
188
|
+
])
|
|
189
|
+
|
|
190
|
+
engine = ChatEngine(sio, check_session_state=_check, idle_timeout_s=5.0)
|
|
191
|
+
events = [e async for e in engine._listen("s1", _skip_state_precheck=True)]
|
|
192
|
+
|
|
193
|
+
types = [e.type for e in events]
|
|
194
|
+
assert S2CEvent.SESSION_TEXT_PART in types
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{pine_assistant-0.2.0 → pine_assistant-0.3.1}/src/pine_assistant.egg-info/dependency_links.txt
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|