soothe-client-python 0.9.6__tar.gz → 0.9.8__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 (32) hide show
  1. {soothe_client_python-0.9.6 → soothe_client_python-0.9.8}/PKG-INFO +1 -1
  2. {soothe_client_python-0.9.6 → soothe_client_python-0.9.8}/pyproject.toml +6 -0
  3. {soothe_client_python-0.9.6 → soothe_client_python-0.9.8}/src/soothe_client/appkit/classifier.py +16 -12
  4. {soothe_client_python-0.9.6 → soothe_client_python-0.9.8}/src/soothe_client/appkit/daemon_session.py +82 -22
  5. {soothe_client_python-0.9.6 → soothe_client_python-0.9.8}/src/soothe_client/appkit/managed_client.py +20 -14
  6. {soothe_client_python-0.9.6 → soothe_client_python-0.9.8}/src/soothe_client/appkit/turn_runner.py +22 -19
  7. {soothe_client_python-0.9.6 → soothe_client_python-0.9.8}/src/soothe_client/helpers.py +11 -9
  8. soothe_client_python-0.9.8/src/soothe_client/py.typed +0 -0
  9. soothe_client_python-0.9.8/src/soothe_client/stream_terminal.py +113 -0
  10. soothe_client_python-0.9.8/src/soothe_client/turn_boundary.py +54 -0
  11. {soothe_client_python-0.9.6 → soothe_client_python-0.9.8}/src/soothe_client/websocket.py +14 -51
  12. {soothe_client_python-0.9.6 → soothe_client_python-0.9.8}/src/soothe_client/ws_command_client.py +51 -39
  13. {soothe_client_python-0.9.6 → soothe_client_python-0.9.8}/.gitignore +0 -0
  14. {soothe_client_python-0.9.6 → soothe_client_python-0.9.8}/LICENSE +0 -0
  15. {soothe_client_python-0.9.6 → soothe_client_python-0.9.8}/README.md +0 -0
  16. {soothe_client_python-0.9.6 → soothe_client_python-0.9.8}/src/soothe_client/__init__.py +0 -0
  17. {soothe_client_python-0.9.6 → soothe_client_python-0.9.8}/src/soothe_client/appkit/__init__.py +0 -0
  18. {soothe_client_python-0.9.6 → soothe_client_python-0.9.8}/src/soothe_client/appkit/attachments.py +0 -0
  19. {soothe_client_python-0.9.6 → soothe_client_python-0.9.8}/src/soothe_client/appkit/broadcaster.py +0 -0
  20. {soothe_client_python-0.9.6 → soothe_client_python-0.9.8}/src/soothe_client/appkit/chunk_filter.py +0 -0
  21. {soothe_client_python-0.9.6 → soothe_client_python-0.9.8}/src/soothe_client/appkit/events.py +0 -0
  22. {soothe_client_python-0.9.6 → soothe_client_python-0.9.8}/src/soothe_client/appkit/observability.py +0 -0
  23. {soothe_client_python-0.9.6 → soothe_client_python-0.9.8}/src/soothe_client/appkit/pool.py +0 -0
  24. {soothe_client_python-0.9.6 → soothe_client_python-0.9.8}/src/soothe_client/appkit/query_gate.py +0 -0
  25. {soothe_client_python-0.9.6 → soothe_client_python-0.9.8}/src/soothe_client/appkit/session_store.py +0 -0
  26. {soothe_client_python-0.9.6 → soothe_client_python-0.9.8}/src/soothe_client/appkit/thinking_step.py +0 -0
  27. {soothe_client_python-0.9.6 → soothe_client_python-0.9.8}/src/soothe_client/appkit/turn.py +0 -0
  28. {soothe_client_python-0.9.6 → soothe_client_python-0.9.8}/src/soothe_client/errors.py +0 -0
  29. {soothe_client_python-0.9.6 → soothe_client_python-0.9.8}/src/soothe_client/intent_hints.py +0 -0
  30. {soothe_client_python-0.9.6 → soothe_client_python-0.9.8}/src/soothe_client/protocol_params.py +0 -0
  31. {soothe_client_python-0.9.6 → soothe_client_python-0.9.8}/src/soothe_client/schemas.py +0 -0
  32. {soothe_client_python-0.9.6 → soothe_client_python-0.9.8}/src/soothe_client/session.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: soothe-client-python
3
- Version: 0.9.6
3
+ Version: 0.9.8
4
4
  Summary: WebSocket client + appkit for soothe-daemon (Python)
5
5
  Project-URL: Homepage, https://github.com/mirasoth/soothe-client-python
6
6
  Project-URL: Documentation, https://soothe.readthedocs.io
@@ -74,6 +74,12 @@ python_version = "3.12"
74
74
  warn_return_any = true
75
75
  warn_unused_configs = true
76
76
  disallow_untyped_defs = true
77
+ packages = ["soothe_client"]
78
+ mypy_path = "src"
79
+
80
+ [[tool.mypy.overrides]]
81
+ module = ["soothe_sdk", "soothe_sdk.*"]
82
+ ignore_missing_imports = true
77
83
 
78
84
  [tool.pytest.ini_options]
79
85
  asyncio_mode = "auto"
@@ -155,13 +155,12 @@ class EventClassifier:
155
155
  return ChatEventResult(terminal=ChatEventTerminal.CONTINUE)
156
156
 
157
157
  if typ == "error":
158
- err_obj = msg.get("error") if isinstance(msg.get("error"), dict) else {}
159
- code = err_obj.get("code") if isinstance(err_obj.get("code"), int) else -32603
160
- message = (
161
- err_obj.get("message")
162
- if isinstance(err_obj.get("message"), str)
163
- else "daemon error"
164
- )
158
+ raw_err = msg.get("error")
159
+ err_obj: dict[str, Any] = raw_err if isinstance(raw_err, dict) else {}
160
+ raw_code = err_obj.get("code")
161
+ code = raw_code if isinstance(raw_code, int) else -32603
162
+ raw_message = err_obj.get("message")
163
+ message = raw_message if isinstance(raw_message, str) else "daemon error"
165
164
  return self._failed_result(DaemonError(code, message, err_obj.get("data")))
166
165
 
167
166
  if typ == "event":
@@ -174,8 +173,10 @@ class EventClassifier:
174
173
  return ChatEventResult(terminal=ChatEventTerminal.CONTINUE)
175
174
 
176
175
  def _classify_next_envelope(self, env: dict[str, Any]) -> ChatEventResult:
177
- payload = env.get("payload") if isinstance(env.get("payload"), dict) else {}
178
- inner_data = payload.get("data") if isinstance(payload.get("data"), dict) else None
176
+ raw_payload = env.get("payload")
177
+ payload: dict[str, Any] = raw_payload if isinstance(raw_payload, dict) else {}
178
+ raw_inner = payload.get("data")
179
+ inner_data: dict[str, Any] | None = raw_inner if isinstance(raw_inner, dict) else None
179
180
  if inner_data is not None:
180
181
  inner_mode = str(inner_data.get("mode") or "")
181
182
  if inner_mode:
@@ -372,8 +373,10 @@ def _first_message_payload(data: Any) -> tuple[str, str, str, bool]:
372
373
  msg_map = data[0]
373
374
  if not isinstance(msg_map, dict):
374
375
  return "", "", "", False
375
- msg_type = msg_map.get("type") if isinstance(msg_map.get("type"), str) else ""
376
- phase = msg_map.get("phase") if isinstance(msg_map.get("phase"), str) else ""
376
+ type_raw = msg_map.get("type")
377
+ msg_type = type_raw if isinstance(type_raw, str) else ""
378
+ phase_raw = msg_map.get("phase")
379
+ phase = phase_raw if isinstance(phase_raw, str) else ""
377
380
  content = _extract_content_from_message(msg_map)
378
381
  return msg_type, content, phase, True
379
382
 
@@ -388,7 +391,8 @@ def _loop_ai_message(data: Any) -> dict[str, str] | None:
388
391
  phase = phase_raw.strip() if isinstance(phase_raw, str) else ""
389
392
  if not phase:
390
393
  return None
391
- msg_type = msg_map.get("type") if isinstance(msg_map.get("type"), str) else ""
394
+ type_raw = msg_map.get("type")
395
+ msg_type = type_raw if isinstance(type_raw, str) else ""
392
396
  content = _extract_content_from_message(msg_map)
393
397
  return {"type": msg_type, "content": content, "phase": phase}
394
398
 
@@ -18,18 +18,18 @@ from soothe_client.appkit.chunk_filter import should_drop_stream_chunk_early
18
18
  from soothe_client.appkit.events import unwrap_next
19
19
  from soothe_client.appkit.observability import TurnEventStats
20
20
  from soothe_client.session import bootstrap_loop_session, connect_websocket_with_retries
21
+ from soothe_client.stream_terminal import (
22
+ STREAM_END,
23
+ is_turn_end_custom_data,
24
+ is_turn_progress_chunk,
25
+ )
26
+ from soothe_client.turn_boundary import frame_seq, frame_turn_id
21
27
  from soothe_client.websocket import WebSocketClient
22
28
 
23
29
  logger = logging.getLogger(__name__)
24
30
 
25
31
  DEFAULT_POST_IDLE_DRAIN_S = 0.5
26
32
  _RPC_HANDSHAKE_TIMEOUT_S = 20.0
27
- _TURN_END_CUSTOM_TYPES = frozenset(
28
- {
29
- "soothe.cognition.strange_loop.completed",
30
- "soothe.stream.end",
31
- }
32
- )
33
33
 
34
34
  EarlyDropFn = Callable[[tuple[Any, ...], str, Any], bool]
35
35
  StatsFactory = Callable[[], Any]
@@ -67,6 +67,9 @@ class DaemonSession:
67
67
  self.last_turn_end_state: str | None = None
68
68
  self.last_turn_cancellation_seen: bool = False
69
69
  self.last_turn_error_message: str | None = None
70
+ # IG-659: last completed turn's seq floor (drop stale prior-turn frames).
71
+ self._last_turn_end_seq: int = 0
72
+ self._expected_turn_id: str | None = None
70
73
 
71
74
  @property
72
75
  def client(self) -> WebSocketClient:
@@ -315,7 +318,13 @@ class DaemonSession:
315
318
  inbound_dropped_baseline = getattr(self._client, "inbound_dropped", 0)
316
319
  query_started = False
317
320
  expected_loop_id = self._loop_id
321
+ # Bind turn_id only after this turn's status=running (do not reuse prior).
322
+ self._expected_turn_id = None
323
+ expected_turn_id: str | None = None
318
324
  stream_payload_seen = False
325
+ turn_progress_seen = False
326
+ last_end_seq = int(getattr(self, "_last_turn_end_seq", 0) or 0)
327
+ self._last_turn_end_seq = last_end_seq
319
328
  self._streaming = True
320
329
  turn_read_started = time.monotonic()
321
330
  first_event_logged = False
@@ -353,9 +362,10 @@ class DaemonSession:
353
362
  if event and not first_event_logged:
354
363
  first_event_logged = True
355
364
  logger.debug(
356
- "First daemon event on turn: type=%s loop_id=%s",
365
+ "First daemon event on turn: type=%s loop_id=%s turn_id=%s",
357
366
  event.get("type"),
358
367
  event.get("loop_id"),
368
+ frame_turn_id(event),
359
369
  )
360
370
  if not event:
361
371
  if query_started and not self._client.is_connection_alive():
@@ -377,6 +387,27 @@ class DaemonSession:
377
387
  ):
378
388
  continue
379
389
 
390
+ # IG-659 phase 5: drop frames at or before prior turn end seq.
391
+ ev_seq = frame_seq(event)
392
+ if ev_seq is not None and last_end_seq > 0 and ev_seq <= last_end_seq:
393
+ continue
394
+
395
+ # IG-659 phases 1–2: drop mismatched turn_id once bound.
396
+ ev_turn_id = frame_turn_id(event)
397
+ if (
398
+ expected_turn_id
399
+ and ev_turn_id
400
+ and ev_turn_id != expected_turn_id
401
+ and event_type in {"event", "status"}
402
+ ):
403
+ logger.debug(
404
+ "Ignoring mismatched turn_id frame type=%s got=%s expected=%s",
405
+ event_type,
406
+ ev_turn_id[-24:],
407
+ expected_turn_id[-24:],
408
+ )
409
+ continue
410
+
380
411
  if event_type == "error":
381
412
  err_obj = event.get("error") or {}
382
413
  err_msg = str(
@@ -393,8 +424,14 @@ class DaemonSession:
393
424
  if state == "running":
394
425
  query_started = True
395
426
  progress_seen = True
427
+ status_turn = frame_turn_id(event)
428
+ if status_turn:
429
+ expected_turn_id = status_turn
430
+ self._expected_turn_id = status_turn
396
431
  elif query_started and state == "stopped":
397
432
  self.last_turn_end_state = state
433
+ if ev_seq is not None:
434
+ self._last_turn_end_seq = max(self._last_turn_end_seq, ev_seq)
398
435
  async for chunk in self._drain_stream_events_after_idle(
399
436
  expected_loop_id=expected_loop_id,
400
437
  ):
@@ -404,6 +441,8 @@ class DaemonSession:
404
441
  if not stream_payload_seen and not self.last_turn_cancellation_seen:
405
442
  continue
406
443
  self.last_turn_end_state = state
444
+ if ev_seq is not None:
445
+ self._last_turn_end_seq = max(self._last_turn_end_seq, ev_seq)
407
446
  async for chunk in self._drain_stream_events_after_idle(
408
447
  expected_loop_id=expected_loop_id,
409
448
  ):
@@ -426,25 +465,46 @@ class DaemonSession:
426
465
  if self._should_drop(namespace, mode, data):
427
466
  self.turn_event_stats.filtered_early += 1
428
467
  continue
468
+
469
+ # Prefer turn_id match when present; else IG-658 progress gate.
470
+ if mode == "custom" and is_turn_end_custom_data(data):
471
+ data_turn = (
472
+ frame_turn_id(data if isinstance(data, dict) else None) or ev_turn_id
473
+ )
474
+ turn_ok = (
475
+ not expected_turn_id or not data_turn or data_turn == expected_turn_id
476
+ )
477
+ if not turn_ok or not query_started or not turn_progress_seen:
478
+ logger.debug(
479
+ "Ignoring premature turn-end frame %s "
480
+ "(loop=%s query_started=%s progress=%s turn_ok=%s)",
481
+ str(data.get("type", "")).strip()
482
+ if isinstance(data, dict)
483
+ else "?",
484
+ (expected_loop_id or "?")[:16],
485
+ query_started,
486
+ turn_progress_seen,
487
+ turn_ok,
488
+ )
489
+ continue
490
+
429
491
  progress_seen = True
430
492
  stream_payload_seen = True
493
+ if is_turn_progress_chunk(mode, data):
494
+ turn_progress_seen = True
431
495
  yield (namespace, mode, data)
432
- if mode == "custom" and isinstance(data, dict):
496
+ if mode == "custom" and is_turn_end_custom_data(data):
433
497
  custom_type = str(data.get("type", "")).strip()
434
- if custom_type in _TURN_END_CUSTOM_TYPES:
435
- # stream.end may be scope-tagged; treat blank / turn as end.
436
- if custom_type == "soothe.stream.end":
437
- scope = str(data.get("scope") or "turn").strip().lower()
438
- if scope not in {"", "turn"}:
439
- continue
440
- self.last_turn_end_state = (
441
- "stream_end" if custom_type == "soothe.stream.end" else "completed"
442
- )
443
- async for chunk in self._drain_stream_events_after_idle(
444
- expected_loop_id=expected_loop_id,
445
- ):
446
- yield chunk
447
- break
498
+ self.last_turn_end_state = (
499
+ "stream_end" if custom_type == STREAM_END else "completed"
500
+ )
501
+ if ev_seq is not None:
502
+ self._last_turn_end_seq = max(self._last_turn_end_seq, ev_seq)
503
+ async for chunk in self._drain_stream_events_after_idle(
504
+ expected_loop_id=expected_loop_id,
505
+ ):
506
+ yield chunk
507
+ break
448
508
  if mode == "updates" and isinstance(data, dict) and "__interrupt__" in data:
449
509
  continue
450
510
  except Exception as exc:
@@ -7,7 +7,7 @@ Wraps ``WebSocketClient`` and upgrades flat appkit payloads
7
7
  from __future__ import annotations
8
8
 
9
9
  from collections.abc import AsyncIterator, Awaitable, Callable
10
- from typing import Any, Protocol, runtime_checkable
10
+ from typing import Any, Protocol, cast, runtime_checkable
11
11
 
12
12
  from soothe_client.errors import DisconnectCause
13
13
  from soothe_client.websocket import WebSocketClient
@@ -60,23 +60,29 @@ def _coerce_appkit_wire_message(msg: dict[str, Any], client: WebSocketClient) ->
60
60
  }
61
61
 
62
62
  if msg_type in _NOTIFICATION_METHODS:
63
- return WireEnvelope(
64
- proto="1",
65
- type=MessageType.NOTIFICATION.value,
66
- method=msg_type,
67
- params=params,
68
- ).to_wire_dict()
63
+ return cast(
64
+ dict[str, Any],
65
+ WireEnvelope(
66
+ proto="1",
67
+ type=MessageType.NOTIFICATION.value,
68
+ method=msg_type,
69
+ params=params,
70
+ ).to_wire_dict(),
71
+ )
69
72
 
70
73
  if msg_type in _REQUEST_METHOD_ALIASES or msg_type:
71
74
  request_method = _REQUEST_METHOD_ALIASES.get(msg_type, msg_type)
72
75
  req_id = str(msg.get("request_id") or msg.get("id") or client._next_request_id())
73
- return WireEnvelope(
74
- proto="1",
75
- type=MessageType.REQUEST.value,
76
- method=request_method,
77
- params=params,
78
- id=req_id,
79
- ).to_wire_dict()
76
+ return cast(
77
+ dict[str, Any],
78
+ WireEnvelope(
79
+ proto="1",
80
+ type=MessageType.REQUEST.value,
81
+ method=request_method,
82
+ params=params,
83
+ id=req_id,
84
+ ).to_wire_dict(),
85
+ )
80
86
 
81
87
  return msg
82
88
 
@@ -241,23 +241,23 @@ class TurnRunner:
241
241
  input_msg = self._build_input(message, loop_id, atts, opts)
242
242
  try:
243
243
  await conn.client.send_message(input_msg)
244
- except Exception as err:
245
- await self._persist_failed(session_id, loop_id, err)
246
- self._broadcast_error(session_id, err)
244
+ except Exception as send_err:
245
+ await self._persist_failed(session_id, loop_id, send_err)
246
+ self._broadcast_error(session_id, send_err)
247
247
  if self._on_error is not None:
248
- self._on_error(session_id, loop_id, err)
248
+ self._on_error(session_id, loop_id, send_err)
249
249
  raise
250
250
 
251
251
  event_stream = conn.event_stream
252
252
  if event_stream is None:
253
- err = RuntimeError(
253
+ missing = RuntimeError(
254
254
  f"missing event stream for session {session_id} (loop {loop_id})"
255
255
  )
256
- await self._persist_failed(session_id, loop_id, err)
257
- self._broadcast_error(session_id, err)
256
+ await self._persist_failed(session_id, loop_id, missing)
257
+ self._broadcast_error(session_id, missing)
258
258
  if self._on_error is not None:
259
- self._on_error(session_id, loop_id, err)
260
- raise err
259
+ self._on_error(session_id, loop_id, missing)
260
+ raise missing
261
261
 
262
262
  assistant_content = ""
263
263
  started_at = time.time()
@@ -266,9 +266,9 @@ class TurnRunner:
266
266
 
267
267
  while True:
268
268
  if cancel_event is not None and cancel_event.is_set():
269
- err = RuntimeError("aborted")
270
- await self._fail_turn(session_id, loop_id, err)
271
- raise err
269
+ aborted = RuntimeError("aborted")
270
+ await self._fail_turn(session_id, loop_id, aborted)
271
+ raise aborted
272
272
 
273
273
  if query_timed_out.is_set():
274
274
  with contextlib.suppress(Exception):
@@ -298,7 +298,10 @@ class TurnRunner:
298
298
  )
299
299
  return
300
300
 
301
- next_task = asyncio.create_task(agen.__anext__())
301
+ async def _pull_next() -> Any:
302
+ return await agen.__anext__()
303
+
304
+ next_task: asyncio.Task[Any] = asyncio.create_task(_pull_next())
302
305
  waiters: list[asyncio.Task[Any]] = [
303
306
  next_task,
304
307
  asyncio.create_task(query_timed_out.wait()),
@@ -350,9 +353,9 @@ class TurnRunner:
350
353
  return
351
354
 
352
355
  if caller_wait is not None and caller_wait in done:
353
- err = RuntimeError("aborted")
354
- await self._fail_turn(session_id, loop_id, err)
355
- raise err
356
+ aborted = RuntimeError("aborted")
357
+ await self._fail_turn(session_id, loop_id, aborted)
358
+ raise aborted
356
359
 
357
360
  try:
358
361
  msg = next_task.result()
@@ -369,9 +372,9 @@ class TurnRunner:
369
372
  "stream_closed",
370
373
  )
371
374
  return
372
- err = RuntimeError("event stream closed")
373
- await self._fail_turn(session_id, loop_id, err)
374
- raise err from None
375
+ closed = RuntimeError("event stream closed")
376
+ await self._fail_turn(session_id, loop_id, closed)
377
+ raise closed from None
375
378
 
376
379
  arm_idle()
377
380
 
@@ -7,7 +7,7 @@ import contextlib
7
7
  import logging
8
8
  from collections.abc import AsyncIterator
9
9
  from contextlib import asynccontextmanager
10
- from typing import Any
10
+ from typing import Any, cast
11
11
 
12
12
  from soothe_sdk.wire.codec import ProtocolError
13
13
 
@@ -310,20 +310,20 @@ async def is_daemon_live(
310
310
 
311
311
  # Standard liveness check without waiting
312
312
  for attempt in range(attempts):
313
- client: WebSocketClient | None = None
313
+ probe: WebSocketClient | None = None
314
314
  try:
315
- client = WebSocketClient(url=ws_url)
316
- await client.connect()
317
- status = await check_daemon_status(client, timeout=timeout)
315
+ probe = WebSocketClient(url=ws_url)
316
+ await probe.connect()
317
+ status = await check_daemon_status(probe, timeout=timeout)
318
318
  return _daemon_status_indicates_live(status)
319
319
  except Exception as exc:
320
320
  last_error = exc
321
321
  if attempt < attempts - 1:
322
322
  await asyncio.sleep(delay_s)
323
323
  finally:
324
- if client is not None:
324
+ if probe is not None:
325
325
  with contextlib.suppress(Exception):
326
- await client.close()
326
+ await probe.close()
327
327
 
328
328
  if last_error is not None:
329
329
  logger.debug("Daemon health check failed for %s: %s", ws_url, last_error)
@@ -383,7 +383,8 @@ async def fetch_skills_catalog(client: WebSocketClient, timeout: float = 15.0) -
383
383
  ConnectionError: If daemon not reachable
384
384
  """
385
385
  response = await client.request("skills_list", {}, timeout=timeout)
386
- return response.get("skills", [])
386
+ skills = response.get("skills", [])
387
+ return cast(list[dict[Any, Any]], skills if isinstance(skills, list) else [])
387
388
 
388
389
 
389
390
  async def fetch_config_section(client: WebSocketClient, section: str, timeout: float = 5.0) -> dict:
@@ -405,7 +406,8 @@ async def fetch_config_section(client: WebSocketClient, section: str, timeout: f
405
406
  """
406
407
  await _ensure_handshake(client, timeout=timeout)
407
408
  response = await client.request("config_get", {"section": section}, timeout=timeout)
408
- return response.get(section, {})
409
+ section_data = response.get(section, {})
410
+ return cast(dict[Any, Any], section_data if isinstance(section_data, dict) else {})
409
411
 
410
412
 
411
413
  async def fetch_loop_history(
File without changes
@@ -0,0 +1,113 @@
1
+ """Shared stream/turn terminal frame helpers for WebSocket and DaemonSession.
2
+
3
+ Keeps peel-at-turn-start and turn-end detection on one vocabulary so leftover
4
+ prior-goal terminals cannot blank the next TUI query.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from typing import Any, TypeGuard
10
+
11
+ from soothe_sdk.core.events import (
12
+ PLAN_CREATED,
13
+ STRANGE_LOOP_COMPLETED,
14
+ STRANGE_LOOP_STEP_COMPLETED,
15
+ STRANGE_LOOP_STEP_QUEUED,
16
+ STRANGE_LOOP_STEP_STARTED,
17
+ STREAM_END,
18
+ )
19
+
20
+ TURN_END_CUSTOM_TYPES = frozenset({STREAM_END, STRANGE_LOOP_COMPLETED})
21
+
22
+ # Customs that prove this turn has real work (not mere intake plan.phase).
23
+ # Intake-only phases must not unlock turn-end — prior-goal stream.end can still
24
+ # arrive after status=running (loop 3e43).
25
+ _TURN_PROGRESS_CUSTOM_TYPES = frozenset(
26
+ {
27
+ PLAN_CREATED,
28
+ STRANGE_LOOP_STEP_STARTED,
29
+ STRANGE_LOOP_STEP_QUEUED,
30
+ STRANGE_LOOP_STEP_COMPLETED,
31
+ }
32
+ )
33
+
34
+ # Handshake / card-replay / subscription leftovers safe to drop at turn start.
35
+ STALE_TURN_PENDING_TYPES = frozenset(
36
+ {
37
+ "connection_ack",
38
+ "card.replay_begin",
39
+ "card.replay_end",
40
+ "card.created",
41
+ "complete",
42
+ }
43
+ )
44
+
45
+
46
+ def is_turn_end_custom_data(data: Any) -> TypeGuard[dict[str, Any]]:
47
+ """True when ``data`` is a turn-scoped terminal custom payload."""
48
+ if not isinstance(data, dict):
49
+ return False
50
+ custom_type = str(data.get("type", "")).strip()
51
+ if custom_type not in TURN_END_CUSTOM_TYPES:
52
+ return False
53
+ if custom_type == STREAM_END:
54
+ scope = str(data.get("scope") or "turn").strip().lower()
55
+ return scope in {"", "turn"}
56
+ return True
57
+
58
+
59
+ def is_turn_progress_chunk(mode: str, data: Any) -> bool:
60
+ """True when a chunk proves the active turn has non-intake progress.
61
+
62
+ Used so late prior-goal ``stream.end`` cannot close a turn that has only
63
+ seen intake lifecycle (e.g. plan.phase "Interpreting goal").
64
+ """
65
+ if mode in {"messages", "updates"}:
66
+ return True
67
+ if mode != "custom" or not isinstance(data, dict):
68
+ return False
69
+ if is_turn_end_custom_data(data):
70
+ return False
71
+ custom_type = str(data.get("type", "")).strip()
72
+ if custom_type in _TURN_PROGRESS_CUSTOM_TYPES:
73
+ return True
74
+ if custom_type.startswith("soothe.cognition.strange_loop.step"):
75
+ return True
76
+ return False
77
+
78
+
79
+ def stale_pending_frame_label(event: dict[str, Any]) -> str | None:
80
+ """Return a peel label when ``event`` is safe to drop at turn start."""
81
+ event_type = str(event.get("type") or "")
82
+ if event_type in STALE_TURN_PENDING_TYPES:
83
+ return event_type
84
+
85
+ if event_type == "next":
86
+ payload = event.get("payload")
87
+ if not isinstance(payload, dict):
88
+ return None
89
+ stale_mode = str(payload.get("mode") or "")
90
+ if stale_mode in STALE_TURN_PENDING_TYPES:
91
+ return stale_mode
92
+ inner = payload.get("data")
93
+ if isinstance(inner, dict):
94
+ return stale_pending_frame_label(inner)
95
+ return None
96
+
97
+ if event_type == "event":
98
+ mode = str(event.get("mode") or "")
99
+ data = event.get("data")
100
+ if mode == "custom" and is_turn_end_custom_data(data):
101
+ return str(data.get("type") or "").strip()
102
+ return None
103
+
104
+
105
+ __all__ = [
106
+ "STALE_TURN_PENDING_TYPES",
107
+ "STREAM_END",
108
+ "STRANGE_LOOP_COMPLETED",
109
+ "TURN_END_CUSTOM_TYPES",
110
+ "is_turn_end_custom_data",
111
+ "is_turn_progress_chunk",
112
+ "stale_pending_frame_label",
113
+ ]
@@ -0,0 +1,54 @@
1
+ """Turn / stream boundary helpers (IG-659).
2
+
3
+ ``turn_id`` correlates a user turn across status, events, and terminals.
4
+ ``seq`` is a daemon-assigned monotonic counter per loop for drop-stale filtering.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from typing import Any
10
+
11
+
12
+ def format_turn_id(loop_id: str, generation: int) -> str:
13
+ """Return wire ``turn_id`` for ``loop_id`` + admit generation."""
14
+ lid = str(loop_id or "").strip()
15
+ gen = int(generation)
16
+ if not lid or gen <= 0:
17
+ return ""
18
+ return f"{lid}:{gen}"
19
+
20
+
21
+ def frame_turn_id(frame: dict[str, Any] | None) -> str | None:
22
+ """Return ``turn_id`` from a status/event frame or nested custom data."""
23
+ if not isinstance(frame, dict):
24
+ return None
25
+ tid = frame.get("turn_id")
26
+ if isinstance(tid, str) and tid.strip():
27
+ return tid.strip()
28
+ data = frame.get("data")
29
+ if isinstance(data, dict):
30
+ inner = data.get("turn_id")
31
+ if isinstance(inner, str) and inner.strip():
32
+ return inner.strip()
33
+ return None
34
+
35
+
36
+ def frame_seq(frame: dict[str, Any] | None) -> int | None:
37
+ """Return non-negative ``seq`` from a wire frame, or None."""
38
+ if not isinstance(frame, dict):
39
+ return None
40
+ raw = frame.get("seq")
41
+ if isinstance(raw, bool):
42
+ return None
43
+ if isinstance(raw, int) and raw >= 0:
44
+ return raw
45
+ if isinstance(raw, float) and raw >= 0 and raw == int(raw):
46
+ return int(raw)
47
+ return None
48
+
49
+
50
+ __all__ = [
51
+ "format_turn_id",
52
+ "frame_seq",
53
+ "frame_turn_id",
54
+ ]
@@ -9,7 +9,7 @@ import time
9
9
  import uuid
10
10
  from collections import deque
11
11
  from collections.abc import AsyncGenerator, Callable
12
- from typing import Any
12
+ from typing import Any, cast
13
13
 
14
14
  import websockets.asyncio.client
15
15
  import websockets.exceptions
@@ -18,6 +18,7 @@ from soothe_sdk.wire.protocol import decode_websocket_text, encode_websocket_tex
18
18
 
19
19
  from soothe_client.errors import DisconnectCause, ReconnectError, StaleLoopError
20
20
  from soothe_client.intent_hints import validate_loop_input_intent_hint
21
+ from soothe_client.stream_terminal import TURN_END_CUSTOM_TYPES, stale_pending_frame_label
21
22
 
22
23
  logger = logging.getLogger(__name__)
23
24
 
@@ -115,9 +116,7 @@ def _inbound_frame_drop_priority(event: dict[str, Any] | None) -> int:
115
116
  data = event.get("data")
116
117
  if isinstance(data, dict):
117
118
  custom_type = data.get("type", "")
118
- if custom_type == "soothe.stream.end":
119
- return _DROP_PRIORITY_CRITICAL
120
- if custom_type == "soothe.cognition.strange_loop.completed":
119
+ if custom_type in TURN_END_CUSTOM_TYPES:
121
120
  return _DROP_PRIORITY_CRITICAL
122
121
  # Cognition events (step started/completed) - prefer keep
123
122
  if custom_type.startswith("soothe.cognition."):
@@ -181,11 +180,7 @@ def _inbound_needs_delivery_ack(event: dict[str, Any]) -> bool:
181
180
  body = data[0]
182
181
  return isinstance(body, dict) and is_stream_terminal_wire_dict(body)
183
182
  if inner_mode == "custom" and isinstance(data, dict):
184
- ctype = data.get("type", "")
185
- return ctype in (
186
- "soothe.cognition.strange_loop.completed",
187
- "soothe.stream.end",
188
- )
183
+ return data.get("type", "") in TURN_END_CUSTOM_TYPES
189
184
  if event.get("type") == "event":
190
185
  mode = event.get("mode", "")
191
186
  data = event.get("data")
@@ -193,11 +188,7 @@ def _inbound_needs_delivery_ack(event: dict[str, Any]) -> bool:
193
188
  body = data[0]
194
189
  return isinstance(body, dict) and is_stream_terminal_wire_dict(body)
195
190
  if mode == "custom" and isinstance(data, dict):
196
- ctype = data.get("type", "")
197
- return ctype in (
198
- "soothe.cognition.strange_loop.completed",
199
- "soothe.stream.end",
200
- )
191
+ return data.get("type", "") in TURN_END_CUSTOM_TYPES
201
192
  return False
202
193
 
203
194
 
@@ -931,7 +922,7 @@ class WebSocketClient:
931
922
  from soothe_sdk.wire.codec import MessageType
932
923
 
933
924
  if self._pending_events:
934
- event = self._pending_events.popleft()
925
+ event: dict[str, Any] | None = self._pending_events.popleft()
935
926
  else:
936
927
  event = await self._read_inbound_event()
937
928
 
@@ -1552,31 +1543,14 @@ class WebSocketClient:
1552
1543
  """
1553
1544
  self._pending_events.clear()
1554
1545
 
1555
- # Handshake / RPC responses that must not count as turn progress (TUI stall detection).
1556
- # ``card.replay_*`` / ``card.created`` are emitted by the daemon during
1557
- # ``loop_subscribe`` for non-TUI clients. The TUI consumes its
1558
- # cards via the synchronous ``loop_cards_fetch`` RPC so these frames are
1559
- # peeled silently here. Under protocol-1, RPC responses arrive as
1560
- # ``type:"response"`` correlated by ``id`` (peeled by the reader loop), so
1561
- # the legacy ``*_response`` type entries are gone. Under protocol-1 (
1562
- # §9.3) card replay frames arrive wrapped in ``next`` envelopes with
1563
- # ``payload.mode`` set to the originating frame type; the peel logic below
1564
- # inspects both the raw ``type`` and the wrapped ``payload.mode``.
1565
- _STALE_TURN_PENDING_TYPES = frozenset(
1566
- {
1567
- "connection_ack",
1568
- "card.replay_begin",
1569
- "card.replay_end",
1570
- "card.created",
1571
- }
1572
- )
1573
-
1574
1546
  def peel_stale_pending_control_events(self) -> list[str]:
1575
- """Remove stale handshake/RPC frames left in ``_pending_events`` before a turn.
1547
+ """Remove stale handshake/terminal frames left in ``_pending_events`` before a turn.
1576
1548
 
1577
1549
  ``request`` queues unrelated inbound frames while waiting for a matching
1578
1550
  ``id``. If a handshake/control frame remains at turn start, the TUI can
1579
- mistake it for live progress and never log a stalled stream.
1551
+ mistake it for live progress and never log a stalled stream. Leftover
1552
+ prior-goal terminal frames must also be peeled so they cannot end the
1553
+ next turn before ``status=running``.
1580
1554
 
1581
1555
  Returns:
1582
1556
  List of removed frame types (in order).
@@ -1588,20 +1562,9 @@ class WebSocketClient:
1588
1562
  removed: list[str] = []
1589
1563
  while self._pending_events:
1590
1564
  event = self._pending_events.popleft()
1591
- event_type = str(event.get("type") or "")
1592
- # Protocol-1 wraps card replay frames in ``next`` envelopes; peel
1593
- # them by inspecting ``payload.mode`` so they don't masquerade as
1594
- # live progress at turn start.
1595
- stale_mode = ""
1596
- if event_type == "next":
1597
- payload = event.get("payload")
1598
- if isinstance(payload, dict):
1599
- stale_mode = str(payload.get("mode") or "")
1600
- if event_type in self._STALE_TURN_PENDING_TYPES:
1601
- removed.append(event_type)
1602
- continue
1603
- if stale_mode and stale_mode in self._STALE_TURN_PENDING_TYPES:
1604
- removed.append(stale_mode)
1565
+ label = stale_pending_frame_label(event)
1566
+ if label is not None:
1567
+ removed.append(label)
1605
1568
  continue
1606
1569
  kept.append(event)
1607
1570
  self._pending_events = kept
@@ -1617,7 +1580,7 @@ class WebSocketClient:
1617
1580
  message = await self._ws.recv()
1618
1581
  if isinstance(message, bytes):
1619
1582
  message = message.decode("utf-8")
1620
- return decode_websocket_text(message)
1583
+ return cast(dict[str, Any] | None, decode_websocket_text(message))
1621
1584
  except websockets.exceptions.ConnectionClosed:
1622
1585
  return None
1623
1586
  except Exception:
@@ -9,7 +9,7 @@ from __future__ import annotations
9
9
 
10
10
  import asyncio
11
11
  import logging
12
- from typing import Any
12
+ from typing import Any, cast
13
13
  from uuid import uuid4
14
14
 
15
15
  from soothe_sdk.wire.codec import (
@@ -241,7 +241,10 @@ class WsCommandClient:
241
241
  if response.get("id") != req_id:
242
242
  # Not our response; keep waiting for the match.
243
243
  continue
244
- return response.get("result") or {}
244
+ result = response.get("result") or {}
245
+ if not isinstance(result, dict):
246
+ raise RuntimeError(f"Unexpected result payload: {result!r}")
247
+ return cast(dict[str, Any], result)
245
248
 
246
249
  # Other message types (next/complete/etc.) are unexpected
247
250
  # for a blocking request; keep reading.
@@ -266,7 +269,7 @@ class WsCommandClient:
266
269
  self, description: str, *, priority: int = 50, workspace: str | None = None
267
270
  ) -> dict[str, Any]:
268
271
  """Submit a new autopilot task."""
269
- payload = {"description": description, "priority": priority}
272
+ payload: dict[str, Any] = {"description": description, "priority": priority}
270
273
  if workspace:
271
274
  payload["workspace"] = workspace
272
275
  return await self._send_command("autopilot_submit", payload)
@@ -334,7 +337,7 @@ class WsCommandClient:
334
337
  Returns:
335
338
  Dict with job_id and status.
336
339
  """
337
- payload = {"goal": goal}
340
+ payload: dict[str, Any] = {"goal": goal}
338
341
  if workspace:
339
342
  payload["workspace"] = workspace
340
343
  if autonomous:
@@ -413,7 +416,7 @@ class WsCommandClient:
413
416
  Returns:
414
417
  Dict with job_id, goal_id, absorbed.
415
418
  """
416
- payload = {"job_id": job_id, "content": content}
419
+ payload: dict[str, Any] = {"job_id": job_id, "content": content}
417
420
  if goal_id:
418
421
  payload["goal_id"] = goal_id
419
422
  return await self._send_command("job_guidance", payload)
@@ -422,7 +425,7 @@ class WsCommandClient:
422
425
 
423
426
  async def cron_add(self, text: str, *, priority: int | None = None) -> dict[str, Any]:
424
427
  """Submit a natural-language scheduled job."""
425
- payload = {"text": text}
428
+ payload: dict[str, Any] = {"text": text}
426
429
  if priority is not None:
427
430
  payload["priority"] = priority
428
431
  result = await self._send_command("cron_add", payload)
@@ -441,7 +444,7 @@ class WsCommandClient:
441
444
  Returns:
442
445
  Dict with jobs list.
443
446
  """
444
- payload = {}
447
+ payload: dict[str, Any] = {}
445
448
  if status:
446
449
  payload["status"] = status
447
450
  return await self._send_command("cron_list", payload)
@@ -492,55 +495,61 @@ class SyncWsCommandClient:
492
495
 
493
496
  def autopilot_status(self) -> dict[str, Any]:
494
497
  """Get autopilot status (sync)."""
495
- return self._run_async(self._client.autopilot_status())
498
+ return cast(dict[str, Any], self._run_async(self._client.autopilot_status()))
496
499
 
497
500
  def autopilot_submit(
498
501
  self, description: str, *, priority: int = 50, workspace: str | None = None
499
502
  ) -> dict[str, Any]:
500
503
  """Submit a new autopilot task (sync)."""
501
- return self._run_async(
502
- self._client.autopilot_submit(description, priority=priority, workspace=workspace)
504
+ return cast(
505
+ dict[str, Any],
506
+ self._run_async(
507
+ self._client.autopilot_submit(description, priority=priority, workspace=workspace)
508
+ ),
503
509
  )
504
510
 
505
511
  def autopilot_list_goals(self) -> dict[str, Any]:
506
512
  """List all goals (sync)."""
507
- return self._run_async(self._client.autopilot_list_goals())
513
+ return cast(dict[str, Any], self._run_async(self._client.autopilot_list_goals()))
508
514
 
509
515
  def autopilot_get_goal(self, goal_id: str) -> dict[str, Any]:
510
516
  """Get goal details (sync)."""
511
- return self._run_async(self._client.autopilot_get_goal(goal_id))
517
+ return cast(dict[str, Any], self._run_async(self._client.autopilot_get_goal(goal_id)))
512
518
 
513
519
  def autopilot_cancel_goal(self, goal_id: str) -> dict[str, Any]:
514
520
  """Cancel a goal (sync)."""
515
- return self._run_async(self._client.autopilot_cancel_goal(goal_id))
521
+ return cast(dict[str, Any], self._run_async(self._client.autopilot_cancel_goal(goal_id)))
516
522
 
517
523
  def autopilot_wake(self) -> dict[str, Any]:
518
524
  """Exit dreaming mode (sync)."""
519
- return self._run_async(self._client.autopilot_wake())
525
+ return cast(dict[str, Any], self._run_async(self._client.autopilot_wake()))
520
526
 
521
527
  def autopilot_dream(self) -> dict[str, Any]:
522
528
  """Force dreaming mode (sync)."""
523
- return self._run_async(self._client.autopilot_dream())
529
+ return cast(dict[str, Any], self._run_async(self._client.autopilot_dream()))
524
530
 
525
531
  def autopilot_resume(self, goal_id: str) -> dict[str, Any]:
526
532
  """Resume a suspended/blocked goal (sync)."""
527
- return self._run_async(self._client.autopilot_resume(goal_id))
533
+ return cast(dict[str, Any], self._run_async(self._client.autopilot_resume(goal_id)))
528
534
 
529
535
  def autopilot_list_jobs(self) -> dict[str, Any]:
530
536
  """List root goals (jobs) only (sync)."""
531
- return self._run_async(self._client.autopilot_list_jobs())
537
+ return cast(dict[str, Any], self._run_async(self._client.autopilot_list_jobs()))
532
538
 
533
539
  def autopilot_get_job(self, job_id: str) -> dict[str, Any]:
534
540
  """Get job status with DAG snapshot (sync)."""
535
- return self._run_async(self._client.autopilot_get_job(job_id))
541
+ return cast(dict[str, Any], self._run_async(self._client.autopilot_get_job(job_id)))
536
542
 
537
543
  def job_pause(self, job_id: str) -> dict[str, Any]:
538
544
  """Pause a running autopilot job (sync)."""
539
- return self._run_async(self._client.job_pause(job_id))
545
+ return cast(dict[str, Any], self._run_async(self._client.job_pause(job_id)))
540
546
 
541
547
  def job_guidance(self, job_id: str, text: str, *, goal_id: str | None = None) -> dict[str, Any]:
542
548
  """Send guidance to an autopilot job or specific goal (sync)."""
543
- return self._run_async(self._client.job_guidance(job_id, text, goal_id=goal_id))
549
+ return cast(
550
+ dict[str, Any],
551
+ self._run_async(self._client.job_guidance(job_id, text, goal_id=goal_id)),
552
+ )
544
553
 
545
554
  def job_create(
546
555
  self,
@@ -552,59 +561,62 @@ class SyncWsCommandClient:
552
561
  guidance: str | None = None,
553
562
  ) -> dict[str, Any]:
554
563
  """Create a new autopilot job."""
555
- return self._run_async(
556
- self._client.job_create(
557
- goal,
558
- workspace=workspace,
559
- autonomous=autonomous,
560
- max_iterations=max_iterations,
561
- guidance=guidance,
562
- )
564
+ return cast(
565
+ dict[str, Any],
566
+ self._run_async(
567
+ self._client.job_create(
568
+ goal,
569
+ workspace=workspace,
570
+ autonomous=autonomous,
571
+ max_iterations=max_iterations,
572
+ guidance=guidance,
573
+ )
574
+ ),
563
575
  )
564
576
 
565
577
  def job_status(self, job_id: str) -> dict[str, Any]:
566
578
  """Get job status with goal counts and workers."""
567
- return self._run_async(self._client.job_status(job_id))
579
+ return cast(dict[str, Any], self._run_async(self._client.job_status(job_id)))
568
580
 
569
581
  def job_resume(self, job_id: str) -> dict[str, Any]:
570
582
  """Resume a paused autopilot job."""
571
- return self._run_async(self._client.job_resume(job_id))
583
+ return cast(dict[str, Any], self._run_async(self._client.job_resume(job_id)))
572
584
 
573
585
  def job_cancel(self, job_id: str) -> dict[str, Any]:
574
586
  """Cancel an autopilot job."""
575
- return self._run_async(self._client.job_cancel(job_id))
587
+ return cast(dict[str, Any], self._run_async(self._client.job_cancel(job_id)))
576
588
 
577
589
  def job_dag(self, job_id: str) -> dict[str, Any]:
578
590
  """Get job DAG snapshot for visualization."""
579
- return self._run_async(self._client.job_dag(job_id))
591
+ return cast(dict[str, Any], self._run_async(self._client.job_dag(job_id)))
580
592
 
581
593
  def autopilot_subscribe(self) -> dict[str, Any]:
582
594
  """Subscribe to autopilot worker events (sync)."""
583
- return self._run_async(self._client.autopilot_subscribe())
595
+ return cast(dict[str, Any], self._run_async(self._client.autopilot_subscribe()))
584
596
 
585
597
  def autopilot_unsubscribe(self) -> dict[str, Any]:
586
598
  """Unsubscribe from autopilot worker events (sync)."""
587
- return self._run_async(self._client.autopilot_unsubscribe())
599
+ return cast(dict[str, Any], self._run_async(self._client.autopilot_unsubscribe()))
588
600
 
589
601
  def cron_add(self, text: str, *, priority: int | None = None) -> dict[str, Any]:
590
602
  """Submit a natural-language scheduled job (sync)."""
591
- return self._run_async(self._client.cron_add(text, priority=priority))
603
+ return cast(dict[str, Any], self._run_async(self._client.cron_add(text, priority=priority)))
592
604
 
593
605
  def cron_list(self, *, status: str | None = None) -> dict[str, Any]:
594
606
  """List scheduled jobs (sync)."""
595
- return self._run_async(self._client.cron_list(status=status))
607
+ return cast(dict[str, Any], self._run_async(self._client.cron_list(status=status)))
596
608
 
597
609
  def cron_show(self, job_id: str) -> dict[str, Any]:
598
610
  """Get job details (sync)."""
599
- return self._run_async(self._client.cron_show(job_id))
611
+ return cast(dict[str, Any], self._run_async(self._client.cron_show(job_id)))
600
612
 
601
613
  def cron_cancel(self, job_id: str) -> dict[str, Any]:
602
614
  """Cancel a scheduled job (sync)."""
603
- return self._run_async(self._client.cron_cancel(job_id))
615
+ return cast(dict[str, Any], self._run_async(self._client.cron_cancel(job_id)))
604
616
 
605
617
  def memory_stats(self, mode: str = "daemon") -> dict[str, Any]:
606
618
  """Query daemon memory profiling stats (sync)."""
607
- return self._run_async(self._client.memory_stats(mode))
619
+ return cast(dict[str, Any], self._run_async(self._client.memory_stats(mode)))
608
620
 
609
621
 
610
622
  def ws_command_client_from_config(cfg: Any) -> SyncWsCommandClient: