soothe-client-python 1.0.7__tar.gz → 1.0.9__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 (34) hide show
  1. {soothe_client_python-1.0.7 → soothe_client_python-1.0.9}/PKG-INFO +1 -1
  2. {soothe_client_python-1.0.7 → soothe_client_python-1.0.9}/src/soothe_client/appkit/daemon_session.py +108 -22
  3. {soothe_client_python-1.0.7 → soothe_client_python-1.0.9}/src/soothe_client/appkit/turn_boundary.py +58 -10
  4. {soothe_client_python-1.0.7 → soothe_client_python-1.0.9}/src/soothe_client/stream_terminal.py +17 -0
  5. {soothe_client_python-1.0.7 → soothe_client_python-1.0.9}/src/soothe_client/turn_boundary.py +57 -0
  6. {soothe_client_python-1.0.7 → soothe_client_python-1.0.9}/.gitignore +0 -0
  7. {soothe_client_python-1.0.7 → soothe_client_python-1.0.9}/LICENSE +0 -0
  8. {soothe_client_python-1.0.7 → soothe_client_python-1.0.9}/README.md +0 -0
  9. {soothe_client_python-1.0.7 → soothe_client_python-1.0.9}/pyproject.toml +0 -0
  10. {soothe_client_python-1.0.7 → soothe_client_python-1.0.9}/src/soothe_client/__init__.py +0 -0
  11. {soothe_client_python-1.0.7 → soothe_client_python-1.0.9}/src/soothe_client/appkit/__init__.py +0 -0
  12. {soothe_client_python-1.0.7 → soothe_client_python-1.0.9}/src/soothe_client/appkit/attachments.py +0 -0
  13. {soothe_client_python-1.0.7 → soothe_client_python-1.0.9}/src/soothe_client/appkit/broadcaster.py +0 -0
  14. {soothe_client_python-1.0.7 → soothe_client_python-1.0.9}/src/soothe_client/appkit/card_projection.py +0 -0
  15. {soothe_client_python-1.0.7 → soothe_client_python-1.0.9}/src/soothe_client/appkit/chunk_filter.py +0 -0
  16. {soothe_client_python-1.0.7 → soothe_client_python-1.0.9}/src/soothe_client/appkit/classifier.py +0 -0
  17. {soothe_client_python-1.0.7 → soothe_client_python-1.0.9}/src/soothe_client/appkit/events.py +0 -0
  18. {soothe_client_python-1.0.7 → soothe_client_python-1.0.9}/src/soothe_client/appkit/loop_session_store.py +0 -0
  19. {soothe_client_python-1.0.7 → soothe_client_python-1.0.9}/src/soothe_client/appkit/managed_client.py +0 -0
  20. {soothe_client_python-1.0.7 → soothe_client_python-1.0.9}/src/soothe_client/appkit/observability.py +0 -0
  21. {soothe_client_python-1.0.7 → soothe_client_python-1.0.9}/src/soothe_client/appkit/pool.py +0 -0
  22. {soothe_client_python-1.0.7 → soothe_client_python-1.0.9}/src/soothe_client/appkit/query_gate.py +0 -0
  23. {soothe_client_python-1.0.7 → soothe_client_python-1.0.9}/src/soothe_client/appkit/thinking_step.py +0 -0
  24. {soothe_client_python-1.0.7 → soothe_client_python-1.0.9}/src/soothe_client/appkit/turn.py +0 -0
  25. {soothe_client_python-1.0.7 → soothe_client_python-1.0.9}/src/soothe_client/appkit/turn_runner.py +0 -0
  26. {soothe_client_python-1.0.7 → soothe_client_python-1.0.9}/src/soothe_client/command_client.py +0 -0
  27. {soothe_client_python-1.0.7 → soothe_client_python-1.0.9}/src/soothe_client/errors.py +0 -0
  28. {soothe_client_python-1.0.7 → soothe_client_python-1.0.9}/src/soothe_client/helpers.py +0 -0
  29. {soothe_client_python-1.0.7 → soothe_client_python-1.0.9}/src/soothe_client/intent_hints.py +0 -0
  30. {soothe_client_python-1.0.7 → soothe_client_python-1.0.9}/src/soothe_client/protocol_params.py +0 -0
  31. {soothe_client_python-1.0.7 → soothe_client_python-1.0.9}/src/soothe_client/py.typed +0 -0
  32. {soothe_client_python-1.0.7 → soothe_client_python-1.0.9}/src/soothe_client/schemas.py +0 -0
  33. {soothe_client_python-1.0.7 → soothe_client_python-1.0.9}/src/soothe_client/session.py +0 -0
  34. {soothe_client_python-1.0.7 → soothe_client_python-1.0.9}/src/soothe_client/websocket.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: soothe-client-python
3
- Version: 1.0.7
3
+ Version: 1.0.9
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
@@ -20,10 +20,18 @@ from soothe_client.appkit.observability import TurnEventStats
20
20
  from soothe_client.session import bootstrap_loop_session, connect_websocket_with_retries
21
21
  from soothe_client.stream_terminal import (
22
22
  STREAM_END,
23
+ is_stream_end_cancel_reason,
23
24
  is_turn_end_custom_data,
24
25
  is_turn_progress_chunk,
25
26
  )
26
- from soothe_client.turn_boundary import frame_seq, frame_turn_id, parse_turn_generation
27
+ from soothe_client.turn_boundary import (
28
+ frame_seq,
29
+ frame_turn_id,
30
+ is_idle_terminal_allowed,
31
+ is_turn_terminal_allowed,
32
+ parse_turn_generation,
33
+ turn_ids_match,
34
+ )
27
35
  from soothe_client.websocket import WebSocketClient
28
36
 
29
37
  logger = logging.getLogger(__name__)
@@ -81,6 +89,7 @@ class DaemonSession:
81
89
  self.last_turn_end_state: str | None = None
82
90
  self.last_turn_cancellation_seen: bool = False
83
91
  self.last_turn_error_message: str | None = None
92
+ self.last_turn_stream_end_reason: str | None = None
84
93
  # Last completed turn's seq floor (drop stale prior-turn frames).
85
94
  # Scoped to the current peer seq namespace: reset on reconnect / loop
86
95
  # change — daemon seq is per-process and restarts after daemon restart.
@@ -359,13 +368,13 @@ class DaemonSession:
359
368
  self.last_turn_end_state = None
360
369
  self.last_turn_cancellation_seen = False
361
370
  self.last_turn_error_message = None
371
+ self.last_turn_stream_end_reason = None
362
372
  inbound_dropped_baseline = getattr(self._client, "inbound_dropped", 0)
363
373
  query_started = False
364
374
  expected_loop_id = self._loop_id
365
375
  # Bind turn_id only after this turn's status=running (do not reuse prior).
366
376
  self._expected_turn_id = None
367
377
  expected_turn_id: str | None = None
368
- stream_payload_seen = False
369
378
  turn_progress_seen = False
370
379
  last_end_seq = int(getattr(self, "_last_turn_end_seq", 0) or 0)
371
380
  self._last_turn_end_seq = last_end_seq
@@ -436,23 +445,40 @@ class DaemonSession:
436
445
  if ev_seq is not None and last_end_seq > 0 and ev_seq <= last_end_seq:
437
446
  continue
438
447
 
439
- # Drop mismatched turn_id once bound — except status=running,
440
- # which may rebind to a newer generation (pre-admit early
441
- # running can briefly carry a stale or empty turn_id).
448
+ # Once bound, drop absent/mismatched turn_id on event/status
449
+ # (except status=running, which may rebind to a newer gen;
450
+ # pre-admit early running can omit turn_id). Idle/stopped
451
+ # fall through to their gates (cancel idle may omit turn_id).
442
452
  ev_turn_id = frame_turn_id(event)
443
- is_running_status = (
444
- event_type == "status" and str(event.get("state") or "") == "running"
453
+ status_state = (
454
+ str(event.get("state") or "") if event_type == "status" else ""
445
455
  )
456
+ is_running_status = status_state == "running"
457
+ is_terminal_status = status_state in {"idle", "stopped"}
446
458
  if (
447
459
  expected_turn_id
448
- and ev_turn_id
449
- and ev_turn_id != expected_turn_id
450
460
  and event_type in {"event", "status"}
451
461
  and not is_running_status
462
+ and not is_terminal_status
463
+ and not turn_ids_match(expected_turn_id, ev_turn_id)
452
464
  ):
453
465
  logger.debug(
454
- "Ignoring mismatched turn_id frame type=%s got=%s expected=%s",
466
+ "Ignoring unbound/mismatched turn_id frame type=%s "
467
+ "got=%s expected=%s",
455
468
  event_type,
469
+ (ev_turn_id or "<absent>")[-24:],
470
+ expected_turn_id[-24:],
471
+ )
472
+ continue
473
+ if (
474
+ expected_turn_id
475
+ and is_terminal_status
476
+ and ev_turn_id
477
+ and not turn_ids_match(expected_turn_id, ev_turn_id)
478
+ ):
479
+ logger.debug(
480
+ "Ignoring mismatched terminal status=%s got=%s expected=%s",
481
+ status_state,
456
482
  ev_turn_id[-24:],
457
483
  expected_turn_id[-24:],
458
484
  )
@@ -475,6 +501,9 @@ class DaemonSession:
475
501
  query_started = True
476
502
  progress_seen = True
477
503
  status_turn = frame_turn_id(event)
504
+ # Bind only from running frames that carry turn_id.
505
+ # Pre-admit early running omits turn_id and must not
506
+ # unlock terminals or poison the expected id.
478
507
  if status_turn:
479
508
  new_gen = parse_turn_generation(status_turn)
480
509
  old_gen = parse_turn_generation(expected_turn_id)
@@ -484,11 +513,15 @@ class DaemonSession:
484
513
  if expected_turn_id and status_turn != expected_turn_id:
485
514
  # Successor turn admitted while this reader
486
515
  # still held a stale/early binding.
487
- stream_payload_seen = False
488
516
  turn_progress_seen = False
489
517
  expected_turn_id = status_turn
490
518
  self._expected_turn_id = status_turn
491
519
  elif query_started and state == "stopped":
520
+ stop_turn = frame_turn_id(event)
521
+ if expected_turn_id and not turn_ids_match(
522
+ expected_turn_id, stop_turn
523
+ ):
524
+ continue
492
525
  self.last_turn_end_state = state
493
526
  if ev_seq is not None:
494
527
  self._last_turn_end_seq = max(self._last_turn_end_seq, ev_seq)
@@ -498,7 +531,22 @@ class DaemonSession:
498
531
  yield chunk
499
532
  break
500
533
  elif query_started and state == "idle":
501
- if not stream_payload_seen and not self.last_turn_cancellation_seen:
534
+ idle_turn = frame_turn_id(event)
535
+ if not is_idle_terminal_allowed(
536
+ expected_turn_id=expected_turn_id,
537
+ frame_turn_id=idle_turn,
538
+ query_started=query_started,
539
+ turn_progress_seen=turn_progress_seen,
540
+ cancellation_seen=self.last_turn_cancellation_seen,
541
+ ):
542
+ logger.debug(
543
+ "Ignoring premature idle "
544
+ "(loop=%s bound=%s idle_turn=%s progress=%s)",
545
+ (expected_loop_id or "?")[:16],
546
+ bool(expected_turn_id),
547
+ (idle_turn or "<absent>")[-24:],
548
+ turn_progress_seen,
549
+ )
502
550
  continue
503
551
  self.last_turn_end_state = state
504
552
  if ev_seq is not None:
@@ -526,38 +574,47 @@ class DaemonSession:
526
574
  self.turn_event_stats.filtered_early += 1
527
575
  continue
528
576
 
529
- # Prefer turn_id match when present; else IG-615 progress gate.
577
+ # Strict turn_id match: absent ids never unlock turn-end.
530
578
  if mode == "custom" and is_turn_end_custom_data(data):
531
579
  data_turn = (
532
580
  frame_turn_id(data if isinstance(data, dict) else None) or ev_turn_id
533
581
  )
534
- turn_ok = (
535
- not expected_turn_id or not data_turn or data_turn == expected_turn_id
582
+ turn_ok = is_turn_terminal_allowed(
583
+ expected_turn_id=expected_turn_id,
584
+ frame_turn_id=data_turn,
585
+ query_started=query_started,
586
+ turn_progress_seen=turn_progress_seen,
536
587
  )
537
- if not turn_ok or not query_started or not turn_progress_seen:
588
+ if not turn_ok:
538
589
  logger.debug(
539
590
  "Ignoring premature turn-end frame %s "
540
- "(loop=%s query_started=%s progress=%s turn_ok=%s)",
591
+ "(loop=%s query_started=%s progress=%s "
592
+ "bound=%s data_turn=%s)",
541
593
  str(data.get("type", "")).strip()
542
594
  if isinstance(data, dict)
543
595
  else "?",
544
596
  (expected_loop_id or "?")[:16],
545
597
  query_started,
546
598
  turn_progress_seen,
547
- turn_ok,
599
+ bool(expected_turn_id),
600
+ (data_turn or "<absent>")[-24:],
548
601
  )
549
602
  continue
550
603
 
551
604
  progress_seen = True
552
- stream_payload_seen = True
553
605
  if is_turn_progress_chunk(mode, data):
554
606
  turn_progress_seen = True
555
607
  yield (namespace, mode, data)
556
608
  if mode == "custom" and is_turn_end_custom_data(data):
557
609
  custom_type = str(data.get("type", "")).strip()
558
- self.last_turn_end_state = (
559
- "stream_end" if custom_type == STREAM_END else "completed"
560
- )
610
+ if custom_type == STREAM_END:
611
+ reason = str(data.get("reason") or "").strip().lower()
612
+ self.last_turn_stream_end_reason = reason or None
613
+ if is_stream_end_cancel_reason(reason):
614
+ self.last_turn_cancellation_seen = True
615
+ self.last_turn_end_state = "stream_end"
616
+ else:
617
+ self.last_turn_end_state = "completed"
561
618
  if ev_seq is not None:
562
619
  self._last_turn_end_seq = max(self._last_turn_end_seq, ev_seq)
563
620
  async for chunk in self._drain_stream_events_after_idle(
@@ -697,6 +754,35 @@ class DaemonSession:
697
754
  values: dict[str, Any] = dict(raw) if isinstance(raw, dict) else {}
698
755
  return SimpleNamespace(values=values)
699
756
 
757
+ async def aupdate_loop_state(
758
+ self,
759
+ loop_id: str,
760
+ values: dict[str, Any],
761
+ *,
762
+ timeout: float = 10.0,
763
+ as_node: str | None = None,
764
+ ) -> dict[str, Any]:
765
+ """Merge partial StrangeLoop state via daemon ``loop_state_update``.
766
+
767
+ Used by the TUI for interrupt cleanup and token-total persistence.
768
+ """
769
+ lid = str(loop_id or "").strip()
770
+ if not lid or not isinstance(values, dict):
771
+ return {}
772
+
773
+ async with self._rpc_lock:
774
+ await self._ensure_rpc_connected()
775
+ try:
776
+ return await self._rpc_client.loop_state_update(
777
+ lid,
778
+ values,
779
+ as_node=as_node,
780
+ timeout=timeout,
781
+ )
782
+ except Exception:
783
+ logger.warning("loop_state_update failed for loop %s", lid[:16], exc_info=True)
784
+ return {}
785
+
700
786
  async def fetch_execution_state(self, loop_id: str) -> SimpleNamespace:
701
787
  """Fetch execution-progress snapshot (plan, step_index, iteration, status).
702
788
 
@@ -14,6 +14,13 @@ from soothe_client.stream_terminal import (
14
14
  is_turn_end_custom_data,
15
15
  is_turn_progress_chunk,
16
16
  )
17
+ from soothe_client.turn_boundary import (
18
+ frame_turn_id,
19
+ is_idle_terminal_allowed,
20
+ is_turn_terminal_allowed,
21
+ parse_turn_generation,
22
+ turn_ids_match,
23
+ )
17
24
 
18
25
  TURN_END_STREAM_END = STREAM_END
19
26
  TURN_END_IDLE = "status.idle"
@@ -25,8 +32,9 @@ class TurnLifecycleGate:
25
32
  """Per-turn progress flags (DaemonSession parity; not shared across chats)."""
26
33
 
27
34
  saw_running: bool = False
28
- saw_stream_payload: bool = False
29
35
  saw_turn_progress: bool = False
36
+ expected_turn_id: str | None = None
37
+ cancellation_seen: bool = False
30
38
 
31
39
  def observe(self, msg: Any) -> None:
32
40
  frame = _normalize_frame(msg)
@@ -36,18 +44,38 @@ class TurnLifecycleGate:
36
44
  if typ == "status":
37
45
  if str(frame.get("state") or "").strip().lower() == "running":
38
46
  self.saw_running = True
47
+ status_turn = frame_turn_id(frame)
48
+ if status_turn:
49
+ new_gen = parse_turn_generation(status_turn)
50
+ old_gen = parse_turn_generation(self.expected_turn_id)
51
+ if self.expected_turn_id is None or (
52
+ new_gen is not None and (old_gen is None or new_gen >= old_gen)
53
+ ):
54
+ if self.expected_turn_id and status_turn != self.expected_turn_id:
55
+ self.saw_turn_progress = False
56
+ self.expected_turn_id = status_turn
39
57
  return
40
58
  if typ == "event":
41
- self.saw_stream_payload = True
42
59
  mode = str(frame.get("mode") or "")
43
60
  if is_turn_progress_chunk(mode, frame.get("data")):
44
61
  self.saw_turn_progress = True
45
62
 
46
- def allow_stream_end(self) -> bool:
47
- return self.saw_running and self.saw_turn_progress
63
+ def allow_stream_end(self, frame_turn: str | None) -> bool:
64
+ return is_turn_terminal_allowed(
65
+ expected_turn_id=self.expected_turn_id,
66
+ frame_turn_id=frame_turn,
67
+ query_started=self.saw_running,
68
+ turn_progress_seen=self.saw_turn_progress,
69
+ )
48
70
 
49
- def allow_idle_complete(self) -> bool:
50
- return self.saw_running and self.saw_stream_payload
71
+ def allow_idle_complete(self, frame_turn: str | None) -> bool:
72
+ return is_idle_terminal_allowed(
73
+ expected_turn_id=self.expected_turn_id,
74
+ frame_turn_id=frame_turn,
75
+ query_started=self.saw_running,
76
+ turn_progress_seen=self.saw_turn_progress,
77
+ cancellation_seen=self.cancellation_seen,
78
+ )
51
79
 
52
80
 
53
81
  @dataclass
@@ -69,16 +97,28 @@ class TurnBoundary:
69
97
  typ = str(frame.get("type") or "")
70
98
  if typ == "status":
71
99
  state = str(frame.get("state") or "").strip().lower()
100
+ frame_turn = frame_turn_id(frame)
72
101
  if state == "stopped" and self.gate.saw_running:
102
+ if self.gate.expected_turn_id and not turn_ids_match(
103
+ self.gate.expected_turn_id, frame_turn
104
+ ):
105
+ return False, ""
73
106
  return self._mark(TURN_END_STOPPED)
74
- if state == "idle" and self.gate.allow_idle_complete():
107
+ if state == "idle" and self.gate.allow_idle_complete(frame_turn):
75
108
  return self._mark(TURN_END_IDLE)
76
109
  return False, ""
77
110
 
78
111
  if typ == "event":
79
112
  mode = str(frame.get("mode") or "")
80
113
  data = frame.get("data")
81
- if mode == "custom" and is_turn_end_custom_data(data) and self.gate.allow_stream_end():
114
+ data_turn = frame_turn_id(data if isinstance(data, dict) else None) or frame_turn_id(
115
+ frame
116
+ )
117
+ if (
118
+ mode == "custom"
119
+ and is_turn_end_custom_data(data)
120
+ and self.gate.allow_stream_end(data_turn)
121
+ ):
82
122
  return self._mark(TURN_END_STREAM_END)
83
123
  return False, ""
84
124
 
@@ -109,19 +149,27 @@ def _normalize_frame(msg: Any) -> dict[str, Any] | None:
109
149
  if isinstance(inner, dict) and inner.get("type") == "status":
110
150
  return inner
111
151
  if isinstance(inner, dict) and inner.get("mode"):
112
- return {
152
+ out = {
113
153
  "type": "event",
114
154
  "mode": inner.get("mode"),
115
155
  "data": inner.get("data"),
116
156
  "namespace": inner.get("namespace") or payload.get("namespace"),
117
157
  }
158
+ tid = inner.get("turn_id") or payload.get("turn_id") or msg.get("turn_id")
159
+ if tid:
160
+ out["turn_id"] = tid
161
+ return out
118
162
  mode = payload.get("mode")
119
163
  if mode:
120
- return {
164
+ out = {
121
165
  "type": "event",
122
166
  "mode": mode,
123
167
  "data": payload.get("data"),
124
168
  "namespace": payload.get("namespace"),
125
169
  }
170
+ tid = payload.get("turn_id") or msg.get("turn_id")
171
+ if tid:
172
+ out["turn_id"] = tid
173
+ return out
126
174
  return None
127
175
  return msg
@@ -24,6 +24,16 @@ from soothe_sdk.core.events import (
24
24
 
25
25
  TURN_END_CUSTOM_TYPES = frozenset({STREAM_END, STRANGE_LOOP_COMPLETED})
26
26
 
27
+ # ``soothe.stream.end`` reasons that mean the turn was cancelled (not a clean end).
28
+ STREAM_END_CANCEL_REASONS = frozenset(
29
+ {
30
+ "cancelled",
31
+ "canceled",
32
+ "user_cancelled",
33
+ "client_disconnect",
34
+ }
35
+ )
36
+
27
37
  # Customs that prove this turn has real work (not mere intake plan.phase).
28
38
  # Intake-only phases must not unlock turn-end — prior-goal stream.end can still
29
39
  # arrive after status=running (loop 3e43).
@@ -64,6 +74,11 @@ def is_turn_end_custom_data(data: Any) -> TypeGuard[dict[str, Any]]:
64
74
  return True
65
75
 
66
76
 
77
+ def is_stream_end_cancel_reason(reason: str | None) -> bool:
78
+ """True when a ``stream.end`` reason is a cancel/disconnect (not clean end)."""
79
+ return str(reason or "").strip().lower() in STREAM_END_CANCEL_REASONS
80
+
81
+
67
82
  def is_turn_progress_chunk(mode: str, data: Any) -> bool:
68
83
  """True when a chunk proves the active turn has non-intake progress.
69
84
 
@@ -113,8 +128,10 @@ def stale_pending_frame_label(event: dict[str, Any]) -> str | None:
113
128
  __all__ = [
114
129
  "STALE_TURN_PENDING_TYPES",
115
130
  "STREAM_END",
131
+ "STREAM_END_CANCEL_REASONS",
116
132
  "STRANGE_LOOP_COMPLETED",
117
133
  "TURN_END_CUSTOM_TYPES",
134
+ "is_stream_end_cancel_reason",
118
135
  "is_turn_end_custom_data",
119
136
  "is_turn_progress_chunk",
120
137
  "stale_pending_frame_label",
@@ -61,9 +61,66 @@ def frame_seq(frame: dict[str, Any] | None) -> int | None:
61
61
  return None
62
62
 
63
63
 
64
+ def turn_ids_match(expected: str | None, candidate: str | None) -> bool:
65
+ """True when both ids are non-empty and equal.
66
+
67
+ Absent ids never match — callers must not treat missing ``turn_id`` as
68
+ compatible with a bound turn (prior-goal terminals / idle can omit or
69
+ carry a stale generation).
70
+ """
71
+ exp = str(expected or "").strip()
72
+ cand = str(candidate or "").strip()
73
+ return bool(exp) and bool(cand) and exp == cand
74
+
75
+
76
+ def is_turn_terminal_allowed(
77
+ *,
78
+ expected_turn_id: str | None,
79
+ frame_turn_id: str | None,
80
+ query_started: bool,
81
+ turn_progress_seen: bool,
82
+ ) -> bool:
83
+ """Gate for turn-scoped ``stream.end`` / ``strange_loop.completed``.
84
+
85
+ Requires an active query, real turn progress, a bound ``expected_turn_id``,
86
+ and an exact ``frame_turn_id`` match. Absent ids are rejected.
87
+ """
88
+ if not query_started or not turn_progress_seen:
89
+ return False
90
+ return turn_ids_match(expected_turn_id, frame_turn_id)
91
+
92
+
93
+ def is_idle_terminal_allowed(
94
+ *,
95
+ expected_turn_id: str | None,
96
+ frame_turn_id: str | None,
97
+ query_started: bool,
98
+ turn_progress_seen: bool,
99
+ cancellation_seen: bool = False,
100
+ ) -> bool:
101
+ """Gate for ``status=idle`` soft-complete.
102
+
103
+ Requires a bound turn. Matching ``turn_id`` needs progress or cancel.
104
+ Absent idle ``turn_id`` is allowed only when cancellation was already seen
105
+ (legacy cancel finalize); mismatched ids are always rejected.
106
+ """
107
+ if not query_started or not str(expected_turn_id or "").strip():
108
+ return False
109
+ cand = str(frame_turn_id or "").strip()
110
+ if cand:
111
+ if not turn_ids_match(expected_turn_id, cand):
112
+ return False
113
+ return bool(turn_progress_seen or cancellation_seen)
114
+ # Absent idle turn_id: never end a healthy turn; cancel path only.
115
+ return bool(cancellation_seen)
116
+
117
+
64
118
  __all__ = [
65
119
  "format_turn_id",
66
120
  "frame_seq",
67
121
  "frame_turn_id",
122
+ "is_idle_terminal_allowed",
123
+ "is_turn_terminal_allowed",
68
124
  "parse_turn_generation",
125
+ "turn_ids_match",
69
126
  ]