pine-assistant 0.2.0__tar.gz → 0.3.0__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.
Files changed (37) hide show
  1. {pine_assistant-0.2.0/src/pine_assistant.egg-info → pine_assistant-0.3.0}/PKG-INFO +1 -1
  2. {pine_assistant-0.2.0 → pine_assistant-0.3.0}/pyproject.toml +1 -1
  3. {pine_assistant-0.2.0 → pine_assistant-0.3.0}/src/pine_assistant/__init__.py +1 -1
  4. {pine_assistant-0.2.0 → pine_assistant-0.3.0}/src/pine_assistant/chat.py +44 -93
  5. {pine_assistant-0.2.0 → pine_assistant-0.3.0}/src/pine_assistant/client.py +33 -0
  6. {pine_assistant-0.2.0 → pine_assistant-0.3.0/src/pine_assistant.egg-info}/PKG-INFO +1 -1
  7. {pine_assistant-0.2.0 → pine_assistant-0.3.0}/src/pine_assistant.egg-info/SOURCES.txt +2 -1
  8. pine_assistant-0.3.0/tests/test_chat_engine.py +194 -0
  9. {pine_assistant-0.2.0 → pine_assistant-0.3.0}/LICENSE +0 -0
  10. {pine_assistant-0.2.0 → pine_assistant-0.3.0}/README.md +0 -0
  11. {pine_assistant-0.2.0 → pine_assistant-0.3.0}/setup.cfg +0 -0
  12. {pine_assistant-0.2.0 → pine_assistant-0.3.0}/src/pine_assistant/auth.py +0 -0
  13. {pine_assistant-0.2.0 → pine_assistant-0.3.0}/src/pine_assistant/cli/__init__.py +0 -0
  14. {pine_assistant-0.2.0 → pine_assistant-0.3.0}/src/pine_assistant/cli/auth.py +0 -0
  15. {pine_assistant-0.2.0 → pine_assistant-0.3.0}/src/pine_assistant/cli/chat.py +0 -0
  16. {pine_assistant-0.2.0 → pine_assistant-0.3.0}/src/pine_assistant/cli/main.py +0 -0
  17. {pine_assistant-0.2.0 → pine_assistant-0.3.0}/src/pine_assistant/cli/sessions.py +0 -0
  18. {pine_assistant-0.2.0 → pine_assistant-0.3.0}/src/pine_assistant/cli/tasks.py +0 -0
  19. {pine_assistant-0.2.0 → pine_assistant-0.3.0}/src/pine_assistant/errors.py +0 -0
  20. {pine_assistant-0.2.0 → pine_assistant-0.3.0}/src/pine_assistant/models/__init__.py +0 -0
  21. {pine_assistant-0.2.0 → pine_assistant-0.3.0}/src/pine_assistant/models/envelope.py +0 -0
  22. {pine_assistant-0.2.0 → pine_assistant-0.3.0}/src/pine_assistant/models/events.py +0 -0
  23. {pine_assistant-0.2.0 → pine_assistant-0.3.0}/src/pine_assistant/models/form.py +0 -0
  24. {pine_assistant-0.2.0 → pine_assistant-0.3.0}/src/pine_assistant/models/payment.py +0 -0
  25. {pine_assistant-0.2.0 → pine_assistant-0.3.0}/src/pine_assistant/models/session.py +0 -0
  26. {pine_assistant-0.2.0 → pine_assistant-0.3.0}/src/pine_assistant/models/task.py +0 -0
  27. {pine_assistant-0.2.0 → pine_assistant-0.3.0}/src/pine_assistant/py.typed +0 -0
  28. {pine_assistant-0.2.0 → pine_assistant-0.3.0}/src/pine_assistant/sessions.py +0 -0
  29. {pine_assistant-0.2.0 → pine_assistant-0.3.0}/src/pine_assistant/transport/__init__.py +0 -0
  30. {pine_assistant-0.2.0 → pine_assistant-0.3.0}/src/pine_assistant/transport/envelope.py +0 -0
  31. {pine_assistant-0.2.0 → pine_assistant-0.3.0}/src/pine_assistant/transport/http.py +0 -0
  32. {pine_assistant-0.2.0 → pine_assistant-0.3.0}/src/pine_assistant/transport/socketio.py +0 -0
  33. {pine_assistant-0.2.0 → pine_assistant-0.3.0}/src/pine_assistant.egg-info/dependency_links.txt +0 -0
  34. {pine_assistant-0.2.0 → pine_assistant-0.3.0}/src/pine_assistant.egg-info/entry_points.txt +0 -0
  35. {pine_assistant-0.2.0 → pine_assistant-0.3.0}/src/pine_assistant.egg-info/requires.txt +0 -0
  36. {pine_assistant-0.2.0 → pine_assistant-0.3.0}/src/pine_assistant.egg-info/top_level.txt +0 -0
  37. {pine_assistant-0.2.0 → pine_assistant-0.3.0}/tests/test_basics.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pine-assistant
3
- Version: 0.2.0
3
+ Version: 0.3.0
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
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "pine-assistant"
7
- version = "0.2.0"
7
+ version = "0.3.0"
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"
@@ -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.2.0"
14
+ __version__ = "0.3.0"
15
15
  __all__ = [
16
16
  "PineAI",
17
17
  "AsyncPineAI",
@@ -1,14 +1,11 @@
1
1
  """
2
- Chat engine — send messages and yield complete events via async generator.
2
+ Chat engine — send messages and yield events via async generator.
3
3
 
4
- Stream buffering:
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 time
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.SESSION_FORM_TO_USER,
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(self, session_id: str) -> AsyncGenerator[ChatEvent, None]:
145
- """Listen for events with stream buffering."""
146
- # Check session state before entering loop — don't hang on completed sessions
147
- if self._check_session_state:
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, data=data, metadata=metadata,
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=self._idle_timeout_s)
190
+ evt = await asyncio.wait_for(queue.get(), timeout=timeout)
239
191
  except asyncio.TimeoutError:
240
- # Idle timeout — check session state via REST
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.2.0
3
+ Version: 0.3.0
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
@@ -31,4 +31,5 @@ src/pine_assistant/transport/__init__.py
31
31
  src/pine_assistant/transport/envelope.py
32
32
  src/pine_assistant/transport/http.py
33
33
  src/pine_assistant/transport/socketio.py
34
- tests/test_basics.py
34
+ tests/test_basics.py
35
+ tests/test_chat_engine.py
@@ -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