soothe-client-python 1.0.8__tar.gz → 1.0.10__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.8 → soothe_client_python-1.0.10}/PKG-INFO +1 -1
  2. {soothe_client_python-1.0.8 → soothe_client_python-1.0.10}/src/soothe_client/appkit/daemon_session.py +64 -20
  3. {soothe_client_python-1.0.8 → soothe_client_python-1.0.10}/src/soothe_client/appkit/turn_boundary.py +58 -10
  4. {soothe_client_python-1.0.8 → soothe_client_python-1.0.10}/src/soothe_client/command_client.py +35 -31
  5. {soothe_client_python-1.0.8 → soothe_client_python-1.0.10}/src/soothe_client/protocol_params.py +11 -0
  6. {soothe_client_python-1.0.8 → soothe_client_python-1.0.10}/src/soothe_client/turn_boundary.py +57 -0
  7. {soothe_client_python-1.0.8 → soothe_client_python-1.0.10}/.gitignore +0 -0
  8. {soothe_client_python-1.0.8 → soothe_client_python-1.0.10}/LICENSE +0 -0
  9. {soothe_client_python-1.0.8 → soothe_client_python-1.0.10}/README.md +0 -0
  10. {soothe_client_python-1.0.8 → soothe_client_python-1.0.10}/pyproject.toml +0 -0
  11. {soothe_client_python-1.0.8 → soothe_client_python-1.0.10}/src/soothe_client/__init__.py +0 -0
  12. {soothe_client_python-1.0.8 → soothe_client_python-1.0.10}/src/soothe_client/appkit/__init__.py +0 -0
  13. {soothe_client_python-1.0.8 → soothe_client_python-1.0.10}/src/soothe_client/appkit/attachments.py +0 -0
  14. {soothe_client_python-1.0.8 → soothe_client_python-1.0.10}/src/soothe_client/appkit/broadcaster.py +0 -0
  15. {soothe_client_python-1.0.8 → soothe_client_python-1.0.10}/src/soothe_client/appkit/card_projection.py +0 -0
  16. {soothe_client_python-1.0.8 → soothe_client_python-1.0.10}/src/soothe_client/appkit/chunk_filter.py +0 -0
  17. {soothe_client_python-1.0.8 → soothe_client_python-1.0.10}/src/soothe_client/appkit/classifier.py +0 -0
  18. {soothe_client_python-1.0.8 → soothe_client_python-1.0.10}/src/soothe_client/appkit/events.py +0 -0
  19. {soothe_client_python-1.0.8 → soothe_client_python-1.0.10}/src/soothe_client/appkit/loop_session_store.py +0 -0
  20. {soothe_client_python-1.0.8 → soothe_client_python-1.0.10}/src/soothe_client/appkit/managed_client.py +0 -0
  21. {soothe_client_python-1.0.8 → soothe_client_python-1.0.10}/src/soothe_client/appkit/observability.py +0 -0
  22. {soothe_client_python-1.0.8 → soothe_client_python-1.0.10}/src/soothe_client/appkit/pool.py +0 -0
  23. {soothe_client_python-1.0.8 → soothe_client_python-1.0.10}/src/soothe_client/appkit/query_gate.py +0 -0
  24. {soothe_client_python-1.0.8 → soothe_client_python-1.0.10}/src/soothe_client/appkit/thinking_step.py +0 -0
  25. {soothe_client_python-1.0.8 → soothe_client_python-1.0.10}/src/soothe_client/appkit/turn.py +0 -0
  26. {soothe_client_python-1.0.8 → soothe_client_python-1.0.10}/src/soothe_client/appkit/turn_runner.py +0 -0
  27. {soothe_client_python-1.0.8 → soothe_client_python-1.0.10}/src/soothe_client/errors.py +0 -0
  28. {soothe_client_python-1.0.8 → soothe_client_python-1.0.10}/src/soothe_client/helpers.py +0 -0
  29. {soothe_client_python-1.0.8 → soothe_client_python-1.0.10}/src/soothe_client/intent_hints.py +0 -0
  30. {soothe_client_python-1.0.8 → soothe_client_python-1.0.10}/src/soothe_client/py.typed +0 -0
  31. {soothe_client_python-1.0.8 → soothe_client_python-1.0.10}/src/soothe_client/schemas.py +0 -0
  32. {soothe_client_python-1.0.8 → soothe_client_python-1.0.10}/src/soothe_client/session.py +0 -0
  33. {soothe_client_python-1.0.8 → soothe_client_python-1.0.10}/src/soothe_client/stream_terminal.py +0 -0
  34. {soothe_client_python-1.0.8 → soothe_client_python-1.0.10}/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.8
3
+ Version: 1.0.10
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
@@ -24,7 +24,14 @@ from soothe_client.stream_terminal import (
24
24
  is_turn_end_custom_data,
25
25
  is_turn_progress_chunk,
26
26
  )
27
- 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
+ )
28
35
  from soothe_client.websocket import WebSocketClient
29
36
 
30
37
  logger = logging.getLogger(__name__)
@@ -368,7 +375,6 @@ class DaemonSession:
368
375
  # Bind turn_id only after this turn's status=running (do not reuse prior).
369
376
  self._expected_turn_id = None
370
377
  expected_turn_id: str | None = None
371
- stream_payload_seen = False
372
378
  turn_progress_seen = False
373
379
  last_end_seq = int(getattr(self, "_last_turn_end_seq", 0) or 0)
374
380
  self._last_turn_end_seq = last_end_seq
@@ -439,23 +445,37 @@ class DaemonSession:
439
445
  if ev_seq is not None and last_end_seq > 0 and ev_seq <= last_end_seq:
440
446
  continue
441
447
 
442
- # Drop mismatched turn_id once bound — except status=running,
443
- # which may rebind to a newer generation (pre-admit early
444
- # 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).
445
452
  ev_turn_id = frame_turn_id(event)
446
- is_running_status = (
447
- event_type == "status" and str(event.get("state") or "") == "running"
448
- )
453
+ status_state = str(event.get("state") or "") if event_type == "status" else ""
454
+ is_running_status = status_state == "running"
455
+ is_terminal_status = status_state in {"idle", "stopped"}
449
456
  if (
450
457
  expected_turn_id
451
- and ev_turn_id
452
- and ev_turn_id != expected_turn_id
453
458
  and event_type in {"event", "status"}
454
459
  and not is_running_status
460
+ and not is_terminal_status
461
+ and not turn_ids_match(expected_turn_id, ev_turn_id)
455
462
  ):
456
463
  logger.debug(
457
- "Ignoring mismatched turn_id frame type=%s got=%s expected=%s",
464
+ "Ignoring unbound/mismatched turn_id frame type=%s got=%s expected=%s",
458
465
  event_type,
466
+ (ev_turn_id or "<absent>")[-24:],
467
+ expected_turn_id[-24:],
468
+ )
469
+ continue
470
+ if (
471
+ expected_turn_id
472
+ and is_terminal_status
473
+ and ev_turn_id
474
+ and not turn_ids_match(expected_turn_id, ev_turn_id)
475
+ ):
476
+ logger.debug(
477
+ "Ignoring mismatched terminal status=%s got=%s expected=%s",
478
+ status_state,
459
479
  ev_turn_id[-24:],
460
480
  expected_turn_id[-24:],
461
481
  )
@@ -478,6 +498,9 @@ class DaemonSession:
478
498
  query_started = True
479
499
  progress_seen = True
480
500
  status_turn = frame_turn_id(event)
501
+ # Bind only from running frames that carry turn_id.
502
+ # Pre-admit early running omits turn_id and must not
503
+ # unlock terminals or poison the expected id.
481
504
  if status_turn:
482
505
  new_gen = parse_turn_generation(status_turn)
483
506
  old_gen = parse_turn_generation(expected_turn_id)
@@ -487,11 +510,13 @@ class DaemonSession:
487
510
  if expected_turn_id and status_turn != expected_turn_id:
488
511
  # Successor turn admitted while this reader
489
512
  # still held a stale/early binding.
490
- stream_payload_seen = False
491
513
  turn_progress_seen = False
492
514
  expected_turn_id = status_turn
493
515
  self._expected_turn_id = status_turn
494
516
  elif query_started and state == "stopped":
517
+ stop_turn = frame_turn_id(event)
518
+ if expected_turn_id and not turn_ids_match(expected_turn_id, stop_turn):
519
+ continue
495
520
  self.last_turn_end_state = state
496
521
  if ev_seq is not None:
497
522
  self._last_turn_end_seq = max(self._last_turn_end_seq, ev_seq)
@@ -501,7 +526,22 @@ class DaemonSession:
501
526
  yield chunk
502
527
  break
503
528
  elif query_started and state == "idle":
504
- if not stream_payload_seen and not self.last_turn_cancellation_seen:
529
+ idle_turn = frame_turn_id(event)
530
+ if not is_idle_terminal_allowed(
531
+ expected_turn_id=expected_turn_id,
532
+ frame_turn_id=idle_turn,
533
+ query_started=query_started,
534
+ turn_progress_seen=turn_progress_seen,
535
+ cancellation_seen=self.last_turn_cancellation_seen,
536
+ ):
537
+ logger.debug(
538
+ "Ignoring premature idle "
539
+ "(loop=%s bound=%s idle_turn=%s progress=%s)",
540
+ (expected_loop_id or "?")[:16],
541
+ bool(expected_turn_id),
542
+ (idle_turn or "<absent>")[-24:],
543
+ turn_progress_seen,
544
+ )
505
545
  continue
506
546
  self.last_turn_end_state = state
507
547
  if ev_seq is not None:
@@ -529,30 +569,34 @@ class DaemonSession:
529
569
  self.turn_event_stats.filtered_early += 1
530
570
  continue
531
571
 
532
- # Prefer turn_id match when present; else IG-615 progress gate.
572
+ # Strict turn_id match: absent ids never unlock turn-end.
533
573
  if mode == "custom" and is_turn_end_custom_data(data):
534
574
  data_turn = (
535
575
  frame_turn_id(data if isinstance(data, dict) else None) or ev_turn_id
536
576
  )
537
- turn_ok = (
538
- not expected_turn_id or not data_turn or data_turn == expected_turn_id
577
+ turn_ok = is_turn_terminal_allowed(
578
+ expected_turn_id=expected_turn_id,
579
+ frame_turn_id=data_turn,
580
+ query_started=query_started,
581
+ turn_progress_seen=turn_progress_seen,
539
582
  )
540
- if not turn_ok or not query_started or not turn_progress_seen:
583
+ if not turn_ok:
541
584
  logger.debug(
542
585
  "Ignoring premature turn-end frame %s "
543
- "(loop=%s query_started=%s progress=%s turn_ok=%s)",
586
+ "(loop=%s query_started=%s progress=%s "
587
+ "bound=%s data_turn=%s)",
544
588
  str(data.get("type", "")).strip()
545
589
  if isinstance(data, dict)
546
590
  else "?",
547
591
  (expected_loop_id or "?")[:16],
548
592
  query_started,
549
593
  turn_progress_seen,
550
- turn_ok,
594
+ bool(expected_turn_id),
595
+ (data_turn or "<absent>")[-24:],
551
596
  )
552
597
  continue
553
598
 
554
599
  progress_seen = True
555
- stream_payload_seen = True
556
600
  if is_turn_progress_chunk(mode, data):
557
601
  turn_progress_seen = True
558
602
  yield (namespace, mode, data)
@@ -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
@@ -132,34 +132,6 @@ async def _perform_handshake(ws: Any, *, timeout: float) -> None:
132
132
  raise RuntimeError(f"Daemon state is {state}")
133
133
 
134
134
 
135
- # Command message types
136
- _AUTOPilot_COMMANDS = {
137
- "status": "autopilot_status",
138
- "submit": "autopilot_submit",
139
- "list_goals": "autopilot_list_goals",
140
- "get_goal": "autopilot_get_goal",
141
- "cancel_goal": "autopilot_cancel_goal",
142
- "cancel_all": "autopilot_cancel_all",
143
- "wake": "autopilot_wake",
144
- "dream": "autopilot_dream",
145
- "resume": "autopilot_resume",
146
- "list_jobs": "autopilot_list_jobs",
147
- "get_job": "autopilot_get_job",
148
- }
149
-
150
- _CRON_COMMANDS = {
151
- "add": "cron_add",
152
- "list": "cron_list",
153
- "list_jobs": "cron_list",
154
- "show": "cron_show",
155
- "cancel": "cron_cancel",
156
- }
157
-
158
- _MEMORY_COMMANDS = {
159
- "stats": "memory_stats",
160
- }
161
-
162
-
163
135
  class AsyncCommandClient:
164
136
  """Async client for one-shot daemon RPCs (jobs, autopilot, cron).
165
137
 
@@ -270,12 +242,19 @@ class AsyncCommandClient:
270
242
  return await self._send_command("autopilot_status")
271
243
 
272
244
  async def autopilot_submit(
273
- self, description: str, *, priority: int = 50, workspace: str | None = None
245
+ self,
246
+ description: str,
247
+ *,
248
+ priority: int = 50,
249
+ workspace: str | None = None,
250
+ rail_id: str | None = None,
274
251
  ) -> dict[str, Any]:
275
252
  """Submit a new autopilot goal (returns ``goal_id``)."""
276
253
  payload: dict[str, Any] = {"description": description, "priority": priority}
277
254
  if workspace:
278
255
  payload["workspace"] = workspace
256
+ if rail_id:
257
+ payload["rail_id"] = rail_id
279
258
  return await self._send_command("autopilot_submit", payload)
280
259
 
281
260
  async def autopilot_list_goals(self) -> dict[str, Any]:
@@ -318,6 +297,14 @@ class AsyncCommandClient:
318
297
  """Get a root job with DAG snapshot. Prefer ``job_status`` / ``job_dag``."""
319
298
  return await self._send_command("autopilot_get_job", {"job_id": job_id})
320
299
 
300
+ async def autopilot_top(self, *, include_terminal: bool = False) -> dict[str, Any]:
301
+ """Jobs → goals → loops snapshot for CLI ``top`` (IG-679 / IG-688).
302
+
303
+ Args:
304
+ include_terminal: When true, include terminal goals and jobs.
305
+ """
306
+ return await self._send_command("autopilot_top", {"include_terminal": include_terminal})
307
+
321
308
  async def autopilot_subscribe(self) -> dict[str, Any]:
322
309
  """Subscribe this connection to autopilot worker events."""
323
310
  return await self._send_command("autopilot_subscribe")
@@ -442,13 +429,23 @@ class CommandClient:
442
429
  return cast(dict[str, Any], self._run_async(self._client.autopilot_status()))
443
430
 
444
431
  def autopilot_submit(
445
- self, description: str, *, priority: int = 50, workspace: str | None = None
432
+ self,
433
+ description: str,
434
+ *,
435
+ priority: int = 50,
436
+ workspace: str | None = None,
437
+ rail_id: str | None = None,
446
438
  ) -> dict[str, Any]:
447
439
  """Submit a new autopilot goal."""
448
440
  return cast(
449
441
  dict[str, Any],
450
442
  self._run_async(
451
- self._client.autopilot_submit(description, priority=priority, workspace=workspace)
443
+ self._client.autopilot_submit(
444
+ description,
445
+ priority=priority,
446
+ workspace=workspace,
447
+ rail_id=rail_id,
448
+ )
452
449
  ),
453
450
  )
454
451
 
@@ -488,6 +485,13 @@ class CommandClient:
488
485
  """Get a root job with DAG snapshot."""
489
486
  return cast(dict[str, Any], self._run_async(self._client.autopilot_get_job(job_id)))
490
487
 
488
+ def autopilot_top(self, *, include_terminal: bool = False) -> dict[str, Any]:
489
+ """Jobs → goals → loops snapshot for CLI ``top``."""
490
+ return cast(
491
+ dict[str, Any],
492
+ self._run_async(self._client.autopilot_top(include_terminal=include_terminal)),
493
+ )
494
+
491
495
  def autopilot_subscribe(self) -> dict[str, Any]:
492
496
  """Subscribe to autopilot worker events."""
493
497
  return cast(dict[str, Any], self._run_async(self._client.autopilot_subscribe()))
@@ -38,6 +38,7 @@ __all__ = [
38
38
  "AutopilotResumeParams",
39
39
  "AutopilotListJobsParams",
40
40
  "AutopilotGetJobParams",
41
+ "AutopilotTopParams",
41
42
  # Job RPC params
42
43
  "JobCreateParams",
43
44
  "JobStatusParams",
@@ -401,6 +402,16 @@ class AutopilotGetJobParams(ParamsBase):
401
402
  job_id: str = Field(..., min_length=1)
402
403
 
403
404
 
405
+ class AutopilotTopParams(ParamsBase):
406
+ """Params for ``method=autopilot_top`` (IG-679 / IG-688).
407
+
408
+ Attributes:
409
+ include_terminal: Include terminal goals/jobs when true (default false).
410
+ """
411
+
412
+ include_terminal: bool = False
413
+
414
+
404
415
  # ---------------------------------------------------------------------------
405
416
  # Job RPC params
406
417
  # ---------------------------------------------------------------------------
@@ -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
  ]