agentproc 0.7.0__tar.gz → 0.8.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 (93) hide show
  1. {agentproc-0.7.0 → agentproc-0.8.0}/PKG-INFO +1 -1
  2. {agentproc-0.7.0 → agentproc-0.8.0}/pyproject.toml +1 -1
  3. {agentproc-0.7.0 → agentproc-0.8.0}/src/agentproc/__init__.py +58 -0
  4. {agentproc-0.7.0 → agentproc-0.8.0}/src/agentproc/runner.py +58 -33
  5. {agentproc-0.7.0 → agentproc-0.8.0}/src/agentproc.egg-info/PKG-INFO +1 -1
  6. {agentproc-0.7.0 → agentproc-0.8.0}/src/agentproc.egg-info/SOURCES.txt +1 -0
  7. agentproc-0.8.0/tests/test_hub_bridge_conformance.py +144 -0
  8. {agentproc-0.7.0 → agentproc-0.8.0}/tests/test_runner.py +25 -0
  9. {agentproc-0.7.0 → agentproc-0.8.0}/tests/test_scenarios.py +13 -3
  10. {agentproc-0.7.0 → agentproc-0.8.0}/tests/test_sdk.py +5 -1
  11. {agentproc-0.7.0 → agentproc-0.8.0}/setup.cfg +0 -0
  12. {agentproc-0.7.0 → agentproc-0.8.0}/src/agentproc/cli.py +0 -0
  13. {agentproc-0.7.0 → agentproc-0.8.0}/src/agentproc/hub.py +0 -0
  14. {agentproc-0.7.0 → agentproc-0.8.0}/src/agentproc/hub_data/PERMISSIONS.md +0 -0
  15. {agentproc-0.7.0 → agentproc-0.8.0}/src/agentproc/hub_data/README.md +0 -0
  16. {agentproc-0.7.0 → agentproc-0.8.0}/src/agentproc/hub_data/_shared/README.md +0 -0
  17. {agentproc-0.7.0 → agentproc-0.8.0}/src/agentproc/hub_data/_shared/stream_utils.js +0 -0
  18. {agentproc-0.7.0 → agentproc-0.8.0}/src/agentproc/hub_data/_shared/stream_utils.py +0 -0
  19. {agentproc-0.7.0 → agentproc-0.8.0}/src/agentproc/hub_data/agy/README.md +0 -0
  20. {agentproc-0.7.0 → agentproc-0.8.0}/src/agentproc/hub_data/agy/bridge.js +0 -0
  21. {agentproc-0.7.0 → agentproc-0.8.0}/src/agentproc/hub_data/agy/bridge.py +0 -0
  22. {agentproc-0.7.0 → agentproc-0.8.0}/src/agentproc/hub_data/agy/profile.yaml +0 -0
  23. {agentproc-0.7.0 → agentproc-0.8.0}/src/agentproc/hub_data/aider/README.md +0 -0
  24. {agentproc-0.7.0 → agentproc-0.8.0}/src/agentproc/hub_data/aider/bridge.js +0 -0
  25. {agentproc-0.7.0 → agentproc-0.8.0}/src/agentproc/hub_data/aider/bridge.py +0 -0
  26. {agentproc-0.7.0 → agentproc-0.8.0}/src/agentproc/hub_data/aider/profile.yaml +0 -0
  27. {agentproc-0.7.0 → agentproc-0.8.0}/src/agentproc/hub_data/claude-code/README.md +0 -0
  28. {agentproc-0.7.0 → agentproc-0.8.0}/src/agentproc/hub_data/claude-code/bridge.js +0 -0
  29. {agentproc-0.7.0 → agentproc-0.8.0}/src/agentproc/hub_data/claude-code/bridge.py +0 -0
  30. {agentproc-0.7.0 → agentproc-0.8.0}/src/agentproc/hub_data/claude-code/permission_map.test.js +0 -0
  31. {agentproc-0.7.0 → agentproc-0.8.0}/src/agentproc/hub_data/claude-code/profile.yaml +0 -0
  32. {agentproc-0.7.0 → agentproc-0.8.0}/src/agentproc/hub_data/codebuddy/README.md +0 -0
  33. {agentproc-0.7.0 → agentproc-0.8.0}/src/agentproc/hub_data/codebuddy/bridge.js +0 -0
  34. {agentproc-0.7.0 → agentproc-0.8.0}/src/agentproc/hub_data/codebuddy/bridge.py +0 -0
  35. {agentproc-0.7.0 → agentproc-0.8.0}/src/agentproc/hub_data/codebuddy/profile.yaml +0 -0
  36. {agentproc-0.7.0 → agentproc-0.8.0}/src/agentproc/hub_data/codex/README.md +0 -0
  37. {agentproc-0.7.0 → agentproc-0.8.0}/src/agentproc/hub_data/codex/bridge.js +0 -0
  38. {agentproc-0.7.0 → agentproc-0.8.0}/src/agentproc/hub_data/codex/bridge.py +0 -0
  39. {agentproc-0.7.0 → agentproc-0.8.0}/src/agentproc/hub_data/codex/permission_hook.py +0 -0
  40. {agentproc-0.7.0 → agentproc-0.8.0}/src/agentproc/hub_data/codex/permission_map.js +0 -0
  41. {agentproc-0.7.0 → agentproc-0.8.0}/src/agentproc/hub_data/codex/permission_map.test.js +0 -0
  42. {agentproc-0.7.0 → agentproc-0.8.0}/src/agentproc/hub_data/codex/profile.yaml +0 -0
  43. {agentproc-0.7.0 → agentproc-0.8.0}/src/agentproc/hub_data/cursor/README.md +0 -0
  44. {agentproc-0.7.0 → agentproc-0.8.0}/src/agentproc/hub_data/cursor/bridge.js +0 -0
  45. {agentproc-0.7.0 → agentproc-0.8.0}/src/agentproc/hub_data/cursor/bridge.py +0 -0
  46. {agentproc-0.7.0 → agentproc-0.8.0}/src/agentproc/hub_data/cursor/profile.yaml +0 -0
  47. {agentproc-0.7.0 → agentproc-0.8.0}/src/agentproc/hub_data/deepseek/README.md +0 -0
  48. {agentproc-0.7.0 → agentproc-0.8.0}/src/agentproc/hub_data/deepseek/bridge.js +0 -0
  49. {agentproc-0.7.0 → agentproc-0.8.0}/src/agentproc/hub_data/deepseek/bridge.py +0 -0
  50. {agentproc-0.7.0 → agentproc-0.8.0}/src/agentproc/hub_data/deepseek/profile.yaml +0 -0
  51. {agentproc-0.7.0 → agentproc-0.8.0}/src/agentproc/hub_data/echo-agent/README.md +0 -0
  52. {agentproc-0.7.0 → agentproc-0.8.0}/src/agentproc/hub_data/echo-agent/bridge.js +0 -0
  53. {agentproc-0.7.0 → agentproc-0.8.0}/src/agentproc/hub_data/echo-agent/bridge.py +0 -0
  54. {agentproc-0.7.0 → agentproc-0.8.0}/src/agentproc/hub_data/echo-agent/bridge.sh +0 -0
  55. {agentproc-0.7.0 → agentproc-0.8.0}/src/agentproc/hub_data/echo-agent/profile.yaml +0 -0
  56. {agentproc-0.7.0 → agentproc-0.8.0}/src/agentproc/hub_data/gemini-cli/README.md +0 -0
  57. {agentproc-0.7.0 → agentproc-0.8.0}/src/agentproc/hub_data/gemini-cli/bridge.js +0 -0
  58. {agentproc-0.7.0 → agentproc-0.8.0}/src/agentproc/hub_data/gemini-cli/bridge.py +0 -0
  59. {agentproc-0.7.0 → agentproc-0.8.0}/src/agentproc/hub_data/gemini-cli/profile.yaml +0 -0
  60. {agentproc-0.7.0 → agentproc-0.8.0}/src/agentproc/hub_data/kimi-code/README.md +0 -0
  61. {agentproc-0.7.0 → agentproc-0.8.0}/src/agentproc/hub_data/kimi-code/bridge.js +0 -0
  62. {agentproc-0.7.0 → agentproc-0.8.0}/src/agentproc/hub_data/kimi-code/bridge.py +0 -0
  63. {agentproc-0.7.0 → agentproc-0.8.0}/src/agentproc/hub_data/kimi-code/profile.yaml +0 -0
  64. {agentproc-0.7.0 → agentproc-0.8.0}/src/agentproc/hub_data/opencode/README.md +0 -0
  65. {agentproc-0.7.0 → agentproc-0.8.0}/src/agentproc/hub_data/opencode/bridge.js +0 -0
  66. {agentproc-0.7.0 → agentproc-0.8.0}/src/agentproc/hub_data/opencode/bridge.py +0 -0
  67. {agentproc-0.7.0 → agentproc-0.8.0}/src/agentproc/hub_data/opencode/profile.yaml +0 -0
  68. {agentproc-0.7.0 → agentproc-0.8.0}/src/agentproc/hub_data/pi/README.md +0 -0
  69. {agentproc-0.7.0 → agentproc-0.8.0}/src/agentproc/hub_data/pi/bridge.js +0 -0
  70. {agentproc-0.7.0 → agentproc-0.8.0}/src/agentproc/hub_data/pi/bridge.py +0 -0
  71. {agentproc-0.7.0 → agentproc-0.8.0}/src/agentproc/hub_data/pi/profile.yaml +0 -0
  72. {agentproc-0.7.0 → agentproc-0.8.0}/src/agentproc/hub_data/qwen-code/README.md +0 -0
  73. {agentproc-0.7.0 → agentproc-0.8.0}/src/agentproc/hub_data/qwen-code/bridge.js +0 -0
  74. {agentproc-0.7.0 → agentproc-0.8.0}/src/agentproc/hub_data/qwen-code/bridge.py +0 -0
  75. {agentproc-0.7.0 → agentproc-0.8.0}/src/agentproc/hub_data/qwen-code/profile.yaml +0 -0
  76. {agentproc-0.7.0 → agentproc-0.8.0}/src/agentproc/hub_data/recursive/README.md +0 -0
  77. {agentproc-0.7.0 → agentproc-0.8.0}/src/agentproc/hub_data/recursive/bridge.js +0 -0
  78. {agentproc-0.7.0 → agentproc-0.8.0}/src/agentproc/hub_data/recursive/bridge.py +0 -0
  79. {agentproc-0.7.0 → agentproc-0.8.0}/src/agentproc/hub_data/recursive/profile.yaml +0 -0
  80. {agentproc-0.7.0 → agentproc-0.8.0}/src/agentproc/hub_data/recursive/tests/parity.json +0 -0
  81. {agentproc-0.7.0 → agentproc-0.8.0}/src/agentproc/hub_data/recursive/tests/test_bridge_parity.js +0 -0
  82. {agentproc-0.7.0 → agentproc-0.8.0}/src/agentproc/hub_data/recursive/tests/test_bridge_parity.py +0 -0
  83. {agentproc-0.7.0 → agentproc-0.8.0}/src/agentproc/yaml.py +0 -0
  84. {agentproc-0.7.0 → agentproc-0.8.0}/src/agentproc.egg-info/dependency_links.txt +0 -0
  85. {agentproc-0.7.0 → agentproc-0.8.0}/src/agentproc.egg-info/entry_points.txt +0 -0
  86. {agentproc-0.7.0 → agentproc-0.8.0}/src/agentproc.egg-info/requires.txt +0 -0
  87. {agentproc-0.7.0 → agentproc-0.8.0}/src/agentproc.egg-info/top_level.txt +0 -0
  88. {agentproc-0.7.0 → agentproc-0.8.0}/tests/test_agentproc.py +0 -0
  89. {agentproc-0.7.0 → agentproc-0.8.0}/tests/test_bridges.py +0 -0
  90. {agentproc-0.7.0 → agentproc-0.8.0}/tests/test_conformance.py +0 -0
  91. {agentproc-0.7.0 → agentproc-0.8.0}/tests/test_diagnostics.py +0 -0
  92. {agentproc-0.7.0 → agentproc-0.8.0}/tests/test_hub.py +0 -0
  93. {agentproc-0.7.0 → agentproc-0.8.0}/tests/test_yaml.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: agentproc
3
- Version: 0.7.0
3
+ Version: 0.8.0
4
4
  Summary: AgentProc Protocol SDK + CLI — connect any Agent CLI to a messaging platform
5
5
  License: MIT
6
6
  Project-URL: Homepage, https://github.com/jeffkit/agentproc
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "agentproc"
7
- version = "0.7.0"
7
+ version = "0.8.0"
8
8
  description = "AgentProc Protocol SDK + CLI — connect any Agent CLI to a messaging platform"
9
9
  readme = "README.md"
10
10
  license = { text = "MIT" }
@@ -166,6 +166,64 @@ class AgentContext:
166
166
  sys.stdout.flush()
167
167
  return _NoopAwaitable()
168
168
 
169
+ def send_permission_request(self, req: Dict[str, Any]) -> None:
170
+ """Send a tool-permission request to the bridge.
171
+
172
+ Only valid when ``ctx.permission`` is True (the profile set
173
+ ``permission: true`` and the bridge enabled the channel). The bridge
174
+ surfaces the request to the user; the matching decision arrives on
175
+ stdin as a ``{"type":"permission_response",...}`` frame that
176
+ :meth:`read_permission_response` decodes.
177
+
178
+ ``req`` MUST include ``request_id`` (unique within the turn),
179
+ ``tool_name``, and ``input`` (object). ``description`` and
180
+ ``tool_use_id`` are optional.
181
+ """
182
+ if not self.permission:
183
+ raise RuntimeError(
184
+ "ctx.send_permission_request() requires profile.permission: "
185
+ "true — the bridge would otherwise not honor the request"
186
+ )
187
+ if not isinstance(req, dict):
188
+ raise TypeError("req must be a dict")
189
+ rid = req.get("request_id")
190
+ if not isinstance(rid, str) or not rid:
191
+ raise ValueError("req['request_id'] is required")
192
+ payload: Dict[str, Any] = {"type": "permission_request"}
193
+ for k in ("request_id", "tool_name", "input"):
194
+ payload[k] = req.get(k)
195
+ for k in ("description", "tool_use_id"):
196
+ if k in req:
197
+ payload[k] = req[k]
198
+ sys.stdout.write(
199
+ json.dumps(payload, ensure_ascii=False, separators=(",", ":")) + "\n"
200
+ )
201
+ sys.stdout.flush()
202
+
203
+ def read_permission_response(self) -> Optional[Dict[str, Any]]:
204
+ """Read the next ``{"type":"permission_response",...}`` frame from stdin.
205
+
206
+ Blocks until a frame arrives (or EOF). Returns the parsed object, or
207
+ ``None`` at EOF.
208
+
209
+ Only meaningful when ``ctx.permission`` is True — in the default
210
+ ``permission: false`` mode the bridge closes stdin after the turn
211
+ line, so this returns ``None`` immediately.
212
+ """
213
+ try:
214
+ line = sys.stdin.readline()
215
+ except Exception:
216
+ return None
217
+ if not line:
218
+ return None
219
+ try:
220
+ v = json.loads(line.rstrip("\r\n"))
221
+ except json.JSONDecodeError:
222
+ return None
223
+ if isinstance(v, dict):
224
+ return v
225
+ return None
226
+
169
227
 
170
228
  @dataclass
171
229
  class AgentResult:
@@ -45,6 +45,7 @@ __all__ = [
45
45
  "DEFAULT_TIMEOUT_SECS",
46
46
  "DEFAULT_KILL_GRACE_SECS",
47
47
  "DEFAULT_MAX_REPLY_CHARS",
48
+ "DEFAULT_TRUNCATION_SUFFIX",
48
49
  "EXIT_SUCCESS",
49
50
  "EXIT_ERROR",
50
51
  "EXIT_TIMEOUT",
@@ -73,6 +74,7 @@ PROTOCOL_VERSION = "0.3"
73
74
  DEFAULT_TIMEOUT_SECS = 1800
74
75
  DEFAULT_KILL_GRACE_SECS = 5
75
76
  DEFAULT_MAX_REPLY_CHARS = 8000
77
+ DEFAULT_TRUNCATION_SUFFIX = "\n\n…(truncated)"
76
78
 
77
79
  EXIT_SUCCESS = 0
78
80
  EXIT_ERROR = 1
@@ -207,6 +209,13 @@ def normalize_profile(raw: Dict[str, Any]) -> Dict[str, Any]:
207
209
  int(src["max_reply_chars"]) if _is_int_like(src.get("max_reply_chars"))
208
210
  else DEFAULT_MAX_REPLY_CHARS
209
211
  ),
212
+ # Spec profile YAML: optional truncation notice appended when the reply
213
+ # is capped. Defaults to "\n\n…(truncated)". An empty string disables
214
+ # the notice entirely (the cap still applies, just no visible marker).
215
+ "truncation_suffix": (
216
+ src["truncation_suffix"] if isinstance(src.get("truncation_suffix"), str)
217
+ else DEFAULT_TRUNCATION_SUFFIX
218
+ ),
210
219
  # Bridge-side hint: when False, the runner ignores {"type":"partial"}
211
220
  # events and assembles the reply from {"type":"text"} events only.
212
221
  "streaming": src.get("streaming", True) is not False,
@@ -545,6 +554,11 @@ def run(profile_raw: Dict[str, Any], options: RunOptions) -> RunResult:
545
554
 
546
555
  result = RunResult()
547
556
  text_chunks: List[str] = []
557
+ # Spec 5.4: once an error event arrives, subsequent partial/text events
558
+ # MUST be discarded (they cannot contribute to a failed turn's reply).
559
+ # `session` is exempt — last-wins still applies so the id for the next
560
+ # turn can be captured even after an error.
561
+ error_seen = False
548
562
  pending_permission_ids: set = set()
549
563
  stdin_lock = threading.Lock()
550
564
  stdin_closed = False
@@ -552,18 +566,13 @@ def run(profile_raw: Dict[str, Any], options: RunOptions) -> RunResult:
552
566
  _cumulative_partial_chars = 0
553
567
  _partials_truncated = False
554
568
  _max_chars = profile["max_reply_chars"]
555
- _trunc_suffix = "\n\n…(truncated)" if _max_chars == DEFAULT_MAX_REPLY_CHARS else ""
556
- # Two views on stderr:
557
- # - stderr_window: bounded sliding window (8 KB) for the on_stderr UI
558
- # callback, so a noisy agent cannot exhaust memory.
559
- # - stderr_full: bounded head capture (1 MB) used only for post-mortem
560
- # pattern diagnosis. The diagnostic patterns target interpreter-startup
561
- # errors (file/module not found) which appear in the first bytes, so a
562
- # head cap preserves the high-value signal without unbounded growth.
563
- # Beyond the cap the tail is dropped with a one-shot marker.
564
- stderr_window: List[str] = []
569
+ _trunc_suffix = profile["truncation_suffix"]
570
+ # Bounded head capture (1 MB) used for post-mortem pattern diagnosis.
571
+ # The diagnostic patterns target interpreter-startup errors (file/module
572
+ # not found) which appear in the first bytes, so a head cap preserves
573
+ # the high-value signal without unbounded growth. Beyond the cap the
574
+ # tail is dropped with a one-shot marker.
565
575
  stderr_full: List[str] = []
566
- STDERR_CAP = 8192
567
576
  STDERR_FULL_CAP = 1 << 20 # 1 MB
568
577
  stderr_full_len = 0
569
578
  stderr_full_truncated = False
@@ -579,10 +588,6 @@ def run(profile_raw: Dict[str, Any], options: RunOptions) -> RunResult:
579
588
  marker = "\n[agentproc runner] stderr capped at 1 MB; trailing output dropped\n"
580
589
  stderr_full.append(marker)
581
590
  stderr_full_truncated = True
582
- stderr_window.append(text)
583
- total = sum(len(s) for s in stderr_window)
584
- while total > STDERR_CAP and len(stderr_window) > 1:
585
- total -= len(stderr_window.pop(0))
586
591
 
587
592
  try:
588
593
  proc = subprocess.Popen(
@@ -668,7 +673,7 @@ def run(profile_raw: Dict[str, Any], options: RunOptions) -> RunResult:
668
673
  assert proc.stdout is not None
669
674
 
670
675
  def _handle_line(raw_line: str) -> None:
671
- nonlocal _cumulative_partial_chars, _partials_truncated
676
+ nonlocal _cumulative_partial_chars, _partials_truncated, error_seen
672
677
  line = raw_line.rstrip("\r")
673
678
  c = classify_line(line)
674
679
  kind = c["kind"]
@@ -689,7 +694,15 @@ def run(profile_raw: Dict[str, Any], options: RunOptions) -> RunResult:
689
694
  if options.on_protocol_line:
690
695
  options.on_protocol_line(line)
691
696
  elif kind == "partial":
692
- if streaming and options.on_partial and not _partials_truncated:
697
+ # Spec 5.4: post-error partials are discarded (not forwarded, not
698
+ # appended). on_protocol_line still fires so debug traces stay
699
+ # complete.
700
+ if (
701
+ not error_seen
702
+ and streaming
703
+ and options.on_partial
704
+ and not _partials_truncated
705
+ ):
693
706
  remaining = _max_chars - _cumulative_partial_chars
694
707
  if len(c["value"]) >= remaining:
695
708
  if remaining > 0:
@@ -703,11 +716,14 @@ def run(profile_raw: Dict[str, Any], options: RunOptions) -> RunResult:
703
716
  if options.on_protocol_line:
704
717
  options.on_protocol_line(line)
705
718
  elif kind == "text":
706
- text_chunks.append(c["value"])
719
+ # Spec 5.4: post-error text is discarded.
720
+ if not error_seen:
721
+ text_chunks.append(c["value"])
707
722
  if options.on_protocol_line:
708
723
  options.on_protocol_line(line)
709
724
  elif kind == "error":
710
725
  result.error = c["value"]
726
+ error_seen = True
711
727
  if options.on_error:
712
728
  options.on_error(c["value"])
713
729
  if options.on_protocol_line:
@@ -750,20 +766,28 @@ def run(profile_raw: Dict[str, Any], options: RunOptions) -> RunResult:
750
766
  try:
751
767
  decision = options.on_permission(req)
752
768
  if isinstance(decision, dict):
769
+ # Spec: when the bridge omits updated_input, the
770
+ # response MUST omit it too — the agent (or wrapped
771
+ # CLI) is responsible for falling back to the
772
+ # request's original input. Don't auto-fill
773
+ # req["input"] here: that would erase the
774
+ # distinction between "user accepted unchanged"
775
+ # and "user never touched it" for downstream CLIs
776
+ # (e.g. Claude Code's updatedInput semantics).
753
777
  updated = decision.get("updated_input")
754
778
  if updated is None and "updatedInput" in decision:
755
779
  updated = decision.get("updatedInput")
756
- if updated is None:
757
- updated = req.get("input")
758
- _write_permission_response({
780
+ response_decision: Dict[str, Any] = {
759
781
  "request_id": req["request_id"],
760
782
  "behavior": (
761
783
  "allow" if decision.get("behavior") == "allow"
762
784
  else "deny"
763
785
  ),
764
- "updated_input": updated if isinstance(updated, dict) else req.get("input"),
765
786
  "message": decision.get("message"),
766
- })
787
+ }
788
+ if isinstance(updated, dict):
789
+ response_decision["updated_input"] = updated
790
+ _write_permission_response(response_decision)
767
791
  except Exception as exc: # noqa: BLE001 — surface to agent as deny
768
792
  if options.on_stderr:
769
793
  options.on_stderr(
@@ -849,23 +873,24 @@ def run(profile_raw: Dict[str, Any], options: RunOptions) -> RunResult:
849
873
  exit_code = proc.wait()
850
874
 
851
875
  _close_stdin()
852
- stdout_thread.join(timeout=5)
853
- # Drain stderr fully before running post-mortem diagnosis. The diagnosis
854
- # reads the full capture, so we need the drain thread to have caught up.
855
- # Generous timeout since the process has already exited at most we wait
856
- # for the pipe buffer to flush.
857
- stderr_thread.join(timeout=10)
876
+ # Process has exited. Drain threads should hit EOF on their pipes within
877
+ # milliseconds and finish. The hard timeout here is a backstop for the
878
+ # rare case a grandchild inherited the stderr fd and is still alive it
879
+ # keeps the runner from hanging indefinitely without letting drain latency
880
+ # balloon past the kill_grace_secs budget. 1s each = at most ~2s of extra
881
+ # latency; diagnosis may be incomplete if it fires, but the post-mortem
882
+ # stderr patterns target interpreter-startup errors that land in the
883
+ # first bytes of stderr anyway, well within the 1MB head capture.
884
+ stdout_thread.join(timeout=1)
885
+ stderr_thread.join(timeout=1)
858
886
  if stderr_thread.is_alive():
859
- # Very rare; indicates a pathological agent that keeps stderr open.
860
- # The diagnosis may be incomplete, but we don't want to hang forever.
861
887
  if options.on_stderr:
862
888
  options.on_stderr("[agentproc runner] warning: stderr drain timed out; diagnosis may be incomplete")
863
889
 
864
890
  # Reply body = concatenation of {"type":"text"} events (direct, no separator).
865
891
  result.reply = "".join(text_chunks)
866
892
  if len(result.reply) > profile["max_reply_chars"]:
867
- suffix = "\n\n…(truncated)" if profile["max_reply_chars"] == DEFAULT_MAX_REPLY_CHARS else ""
868
- result.reply = result.reply[: profile["max_reply_chars"]] + suffix
893
+ result.reply = result.reply[: profile["max_reply_chars"]] + profile["truncation_suffix"]
869
894
 
870
895
  # If the agent exited non-zero with no error event, peek at its stderr for
871
896
  # common "command/file not found" patterns and surface a friendly hint.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: agentproc
3
- Version: 0.7.0
3
+ Version: 0.8.0
4
4
  Summary: AgentProc Protocol SDK + CLI — connect any Agent CLI to a messaging platform
5
5
  License: MIT
6
6
  Project-URL: Homepage, https://github.com/jeffkit/agentproc
@@ -84,6 +84,7 @@ tests/test_bridges.py
84
84
  tests/test_conformance.py
85
85
  tests/test_diagnostics.py
86
86
  tests/test_hub.py
87
+ tests/test_hub_bridge_conformance.py
87
88
  tests/test_runner.py
88
89
  tests/test_scenarios.py
89
90
  tests/test_sdk.py
@@ -0,0 +1,144 @@
1
+ """Hub bridge engine conformance (wire 0.3).
2
+
3
+ Drives the shared ``hub/_shared/stream_utils.run_bridge`` with a minimal
4
+ identity ``parse_event`` against the shared
5
+ ``spec/conformance/hub_bridge.json`` fixture. The Node SDK runs the same
6
+ fixture in ``sdk/node/src/hub_bridge_conformance.test.js``.
7
+
8
+ Together they guarantee the Python and Node hub bridge engines agree on
9
+ multi-line interaction semantics that ``run_bridge`` owns: error-mid-stream
10
+ preserving session, session-discovered-at-end ordering, empty-message +
11
+ attachment accepted, exit-code mapping, partial-as-reply fallback.
12
+
13
+ Per-CLI ``parse_event`` variations are NOT covered here — each hub profile
14
+ has its own ``test_bridges.py`` cases for that. This file pins the engine.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import io
20
+ import json
21
+ import sys
22
+ from pathlib import Path
23
+ from typing import Any, Dict, List
24
+
25
+ import pytest
26
+
27
+ HUB_ROOT = Path(__file__).resolve().parents[3] / "hub"
28
+ SDK_PYTHON_ROOT = Path(__file__).resolve().parents[1]
29
+ if str(HUB_ROOT) not in sys.path:
30
+ sys.path.insert(0, str(HUB_ROOT))
31
+
32
+ from _shared.stream_utils import EventResult, run_bridge # noqa: E402
33
+
34
+ FIXTURE_PATH = Path(__file__).resolve().parents[3] / "spec" / "conformance" / "hub_bridge.json"
35
+
36
+
37
+ def _load_scenarios():
38
+ data = json.loads(FIXTURE_PATH.read_text(encoding="utf-8"))
39
+ return [pytest.param(s, id=s["name"]) for s in data["scenarios"]]
40
+
41
+
42
+ def _identity_parse_event(event: dict) -> "EventResult | None":
43
+ t = event.get("type")
44
+ if t == "partial":
45
+ return EventResult(partial_text=event.get("text", ""))
46
+ if t == "text":
47
+ return EventResult(final_text=event.get("text", ""))
48
+ if t == "session":
49
+ sid = event.get("id")
50
+ return EventResult(session_id=sid) if isinstance(sid, str) else None
51
+ if t == "error":
52
+ return EventResult(error=event.get("message", ""))
53
+ return None
54
+
55
+
56
+ def _identity_build_args(_message: str, _session_id: str, _env) -> list[str]:
57
+ # Returned argv is never actually executed — the test patches
58
+ # subprocess.Popen with a fake proc. The list just needs a recognizable
59
+ # argv[0] for error messages.
60
+ return ["fake-cli"]
61
+
62
+
63
+ # ---------------------------------------------------------------------------
64
+ # Fake subprocess for run_bridge
65
+ # ---------------------------------------------------------------------------
66
+
67
+
68
+ class _FakePipe:
69
+ def __init__(self, lines: List[str]):
70
+ self._lines = list(lines)
71
+
72
+ def __iter__(self):
73
+ for line in self._lines:
74
+ yield line + "\n"
75
+
76
+ def read(self):
77
+ return ""
78
+
79
+
80
+ class _StderrReader:
81
+ def __init__(self, text: str):
82
+ self._text = text
83
+
84
+ def read(self):
85
+ return self._text
86
+
87
+
88
+ class _FakeProc:
89
+ def __init__(self, ndjson_lines: List[str], returncode: int, stderr: str):
90
+ self.stdout = _FakePipe(ndjson_lines)
91
+ self.stderr = _StderrReader(stderr)
92
+ self.returncode = returncode
93
+
94
+ def wait(self):
95
+ return self.returncode
96
+
97
+
98
+ @pytest.mark.parametrize("scenario", _load_scenarios())
99
+ def test_hub_bridge_conformance(scenario: dict, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture) -> None:
100
+ turn = scenario["turn"]
101
+ cli_stdout = scenario.get("cli_stdout", [])
102
+ cli_exit = scenario.get("cli_exit", 0)
103
+ cli_stderr = scenario.get("cli_stderr", "")
104
+
105
+ def _fake_popen(args, *a, **kw): # noqa: ANN001 — signature mirrors subprocess.Popen
106
+ return _FakeProc(cli_stdout, cli_exit, cli_stderr)
107
+
108
+ monkeypatch.setattr("subprocess.Popen", _fake_popen)
109
+
110
+ exit_code = run_bridge(
111
+ cli_name="fake-cli",
112
+ cli_install_hint="install hint",
113
+ build_args=_identity_build_args,
114
+ parse_event=_identity_parse_event,
115
+ turn=turn,
116
+ )
117
+
118
+ captured = capsys.readouterr()
119
+ actual_lines = [line for line in captured.out.split("\n") if line]
120
+
121
+ expect = scenario["expect"]
122
+ assert exit_code == expect["exit_code"], (
123
+ f"{scenario['name']}: exit got {exit_code}, expected {expect['exit_code']}"
124
+ )
125
+
126
+ if "stdout_lines" in expect:
127
+ assert actual_lines == expect["stdout_lines"], (
128
+ f"{scenario['name']}: stdout got {actual_lines!r}, expected {expect['stdout_lines']!r}"
129
+ )
130
+ return
131
+
132
+ if "stdout_lines_any_of" in expect:
133
+ candidates = expect["stdout_lines_any_of"]
134
+ assert any(actual_lines == cand for cand in candidates), (
135
+ f"{scenario['name']}: stdout got {actual_lines!r}, expected any of {candidates!r}"
136
+ )
137
+ return
138
+
139
+ if "stdout_lines_contains" in expect:
140
+ joined = "\n".join(actual_lines)
141
+ for needle in expect["stdout_lines_contains"]:
142
+ assert needle in joined, (
143
+ f"{scenario['name']}: expected substring {needle!r} in stdout, got {joined!r}"
144
+ )
@@ -97,6 +97,15 @@ class TestPermissionHelpers:
97
97
  {"request_id": "2", "behavior": "deny", "message": "nope"}
98
98
  ) == '{"type":"permission_response","request_id":"2","behavior":"deny","message":"nope"}'
99
99
 
100
+ def test_format_allow_without_updated_input_omits_field(self):
101
+ # allow without updated_input MUST omit the field — the agent/CLI is
102
+ # responsible for falling back to the request's original input. The
103
+ # runner must not pre-fill it (would erase the "user accepted
104
+ # unchanged" vs "user never touched it" distinction downstream).
105
+ assert format_permission_response(
106
+ {"request_id": "3", "behavior": "allow"}
107
+ ) == '{"type":"permission_response","request_id":"3","behavior":"allow"}'
108
+
100
109
  def test_is_valid(self):
101
110
  assert is_valid_permission_request({"request_id": "1", "tool_name": "Bash", "input": {}})
102
111
  assert not is_valid_permission_request({"request_id": "1", "tool_name": "Bash"})
@@ -265,6 +274,22 @@ class TestNormalizeProfile:
265
274
  p2 = normalize_profile({"command": "x", "env_inherit": "all"})
266
275
  assert "env_inherit" not in p2
267
276
 
277
+ def test_truncation_suffix_defaults_to_ellipsis(self):
278
+ p = normalize_profile({"command": "x"})
279
+ assert p["truncation_suffix"] == "\n\n…(truncated)"
280
+
281
+ def test_truncation_suffix_custom_value_is_honoured(self):
282
+ # A custom cap no longer silently strips the truncation notice —
283
+ # users get the default suffix unless they explicitly override.
284
+ p = normalize_profile({"command": "x", "max_reply_chars": 100})
285
+ assert p["truncation_suffix"] == "\n\n…(truncated)"
286
+ p2 = normalize_profile({"command": "x", "truncation_suffix": " [more]"})
287
+ assert p2["truncation_suffix"] == " [more]"
288
+
289
+ def test_truncation_suffix_empty_string_disables_notice(self):
290
+ p = normalize_profile({"command": "x", "truncation_suffix": ""})
291
+ assert p["truncation_suffix"] == ""
292
+
268
293
  def test_permission_defaults_false(self):
269
294
  assert normalize_profile({"command": "x"})["permission"] is False
270
295
  assert normalize_profile({"command": "x", "permission": True})["permission"] is True
@@ -75,6 +75,16 @@ def test_scenario_conformance(scenario: dict, tmp_path: Path) -> None:
75
75
  assert r.exit_code == expect["exit_code"], (
76
76
  f"{scenario['name']}: exit_code got {r.exit_code!r}, expected {expect['exit_code']!r}"
77
77
  )
78
- assert partials == expect["partials"], (
79
- f"{scenario['name']}: partials got {partials!r}, expected {expect['partials']!r}"
80
- )
78
+ # partials comparison: when `partials_any_of` is present, the scenario
79
+ # accepts any of the listed candidate sequences (used for spec-loose
80
+ # semantics like whether to emit a tail-truncated chunk vs nothing).
81
+ # Otherwise the strict `partials` equality applies.
82
+ if "partials_any_of" in expect:
83
+ candidates = expect["partials_any_of"]
84
+ assert any(partials == cand for cand in candidates), (
85
+ f"{scenario['name']}: partials got {partials!r}, expected any of {candidates!r}"
86
+ )
87
+ else:
88
+ assert partials == expect["partials"], (
89
+ f"{scenario['name']}: partials got {partials!r}, expected {expect['partials']!r}"
90
+ )
@@ -42,10 +42,14 @@ def _run_scenario(scenario: dict) -> subprocess.CompletedProcess:
42
42
  "PYTHONPATH": str(SRC_DIR),
43
43
  }
44
44
  turn_line = json.dumps(scenario["turn"]) + "\n"
45
+ # Scenarios that exercise the optional permission channel follow the turn
46
+ # with one or more {"type":"permission_response",...} frames the harness
47
+ # reads via ctx.read_permission_response().
48
+ extra = "".join(line + "\n" for line in scenario.get("stdin_after_turn", []))
45
49
  return subprocess.run(
46
50
  [sys.executable, str(HARNESS), scenario["handler"]],
47
51
  env=env,
48
- input=turn_line,
52
+ input=turn_line + extra,
49
53
  capture_output=True,
50
54
  text=True,
51
55
  )
File without changes
File without changes
File without changes