forgexa-cli 1.13.3__tar.gz → 1.13.4__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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: forgexa-cli
3
- Version: 1.13.3
3
+ Version: 1.13.4
4
4
  Summary: Forgexa CLI — command-line client and AI agent runtime for the Forgexa platform
5
5
  Author-email: Jason Sun <dev.winds@gmail.com>
6
6
  License: MIT
@@ -1,2 +1,2 @@
1
1
  """forgexa-cli — Forgexa command-line client."""
2
- __version__ = "1.13.3"
2
+ __version__ = "1.13.4"
@@ -523,7 +523,7 @@ except (ImportError, ModuleNotFoundError):
523
523
  # DAEMON_VERSION is the protocol/logic version of the daemon code.
524
524
  # Kept in sync with pyproject.toml version via bump-version.sh.
525
525
  # CLIENT_TYPE identifies which packaging/distribution this daemon runs in.
526
- DAEMON_VERSION = "1.13.3"
526
+ DAEMON_VERSION = "1.13.4"
527
527
 
528
528
 
529
529
  def _detect_client_type() -> str:
@@ -794,28 +794,231 @@ def _prepare_ai_job_output(
794
794
  return output_content, scenario_items
795
795
 
796
796
 
797
+ # ── Self-contained agent-output extraction ────────────────────────────────────
798
+ # These functions are deliberately inlined here (duplicated from
799
+ # app.services.ai_execution_strategy / qa_ai_assistant) so that the daemon
800
+ # works correctly when running as the CLI or desktop standalone daemon where
801
+ # the `app` package is NOT on sys.path. Any time the originals change,
802
+ # keep these in sync.
803
+
804
+ def _daemon_extract_copilot_output(raw: str) -> str:
805
+ """Extract assistant text from Copilot CLI JSONL output."""
806
+ final_answer_ids: set[str] = set()
807
+ final_message_parts: list[str] = []
808
+ final_delta_parts: list[str] = []
809
+ fallback_message_parts: list[str] = []
810
+ fallback_delta_parts: list[str] = []
811
+
812
+ for line in raw.split("\n"):
813
+ line = line.strip()
814
+ if not line:
815
+ continue
816
+ try:
817
+ data = json.loads(line)
818
+ except json.JSONDecodeError:
819
+ continue
820
+ if not isinstance(data, dict):
821
+ continue
822
+
823
+ event_type = str(data.get("type", ""))
824
+ msg_data = data.get("data") or data
825
+
826
+ if event_type == "assistant.message_start":
827
+ if msg_data.get("phase") == "final_answer":
828
+ message_id = msg_data.get("messageId")
829
+ if isinstance(message_id, str) and message_id:
830
+ final_answer_ids.add(message_id)
831
+ continue
832
+
833
+ if event_type == "assistant.message":
834
+ content = msg_data.get("content", "")
835
+ if not isinstance(content, str) or not content.strip():
836
+ continue
837
+ message_id = msg_data.get("messageId")
838
+ if message_id in final_answer_ids or msg_data.get("phase") == "final_answer":
839
+ final_message_parts.append(content.strip())
840
+ else:
841
+ fallback_message_parts.append(content.strip())
842
+ continue
843
+
844
+ if event_type == "assistant.message_delta":
845
+ delta = msg_data.get("deltaContent", "")
846
+ if not isinstance(delta, str) or not delta:
847
+ continue
848
+ message_id = msg_data.get("messageId")
849
+ if message_id in final_answer_ids:
850
+ final_delta_parts.append(delta)
851
+ else:
852
+ fallback_delta_parts.append(delta)
853
+
854
+ if final_delta_parts:
855
+ return "".join(final_delta_parts).strip()
856
+ if final_message_parts:
857
+ return "\n\n".join(final_message_parts).strip()
858
+ if fallback_message_parts:
859
+ return "\n\n".join(fallback_message_parts).strip()
860
+ if fallback_delta_parts:
861
+ return "".join(fallback_delta_parts).strip()
862
+ return raw
863
+
864
+
865
+ def _daemon_extract_claude_output(raw: str) -> str:
866
+ """Extract result text from Claude CLI JSON/stream-json output."""
867
+ raw = raw.strip()
868
+ try:
869
+ data = json.loads(raw)
870
+ if isinstance(data, dict):
871
+ if "result" in data:
872
+ return data["result"]
873
+ if "content" in data:
874
+ blocks = data["content"]
875
+ if isinstance(blocks, list):
876
+ texts = [b.get("text", "") for b in blocks if b.get("type") == "text"]
877
+ return "\n".join(texts)
878
+ elif isinstance(data, list):
879
+ texts = [item.get("text", "") for item in data if isinstance(item, dict) and item.get("type") == "text"]
880
+ if texts:
881
+ return "\n".join(texts)
882
+ except (json.JSONDecodeError, KeyError, TypeError):
883
+ pass
884
+
885
+ lines = raw.split("\n")
886
+ for line in reversed(lines):
887
+ line = line.strip()
888
+ if not line.startswith("{"):
889
+ continue
890
+ try:
891
+ data = json.loads(line)
892
+ if isinstance(data, dict) and "result" in data:
893
+ result_val = data["result"]
894
+ if isinstance(result_val, str) and result_val.strip():
895
+ return result_val
896
+ except (json.JSONDecodeError, KeyError, TypeError):
897
+ continue
898
+
899
+ parts: list[str] = []
900
+ for line in lines:
901
+ line = line.strip()
902
+ if not line.startswith("{"):
903
+ continue
904
+ try:
905
+ data = json.loads(line)
906
+ if not isinstance(data, dict):
907
+ continue
908
+ ev_type = data.get("type", "")
909
+ if ev_type == "assistant":
910
+ msg = data.get("message") or {}
911
+ for block in msg.get("content", []):
912
+ if isinstance(block, dict) and block.get("type") == "text":
913
+ text = block.get("text", "")
914
+ if isinstance(text, str) and text.strip():
915
+ parts.append(text)
916
+ except (json.JSONDecodeError, KeyError, TypeError):
917
+ continue
918
+ if parts:
919
+ return "\n\n".join(parts)
920
+
921
+ brace_start = raw.rfind('{"type":"result"')
922
+ if brace_start == -1:
923
+ brace_start = raw.rfind('{"type": "result"')
924
+ if brace_start >= 0:
925
+ depth = 0
926
+ for i in range(brace_start, len(raw)):
927
+ if raw[i] == "{":
928
+ depth += 1
929
+ elif raw[i] == "}":
930
+ depth -= 1
931
+ if depth == 0:
932
+ try:
933
+ data = json.loads(raw[brace_start : i + 1])
934
+ if isinstance(data, dict) and "result" in data:
935
+ return data["result"]
936
+ except (json.JSONDecodeError, KeyError, TypeError):
937
+ pass
938
+ break
939
+ return raw
940
+
941
+
942
+ def _daemon_extract_opencode_output(raw: str) -> str:
943
+ """Extract text content from OpenCode JSONL output."""
944
+ parts: list[str] = []
945
+ for line in raw.split("\n"):
946
+ line = line.strip()
947
+ if not line:
948
+ continue
949
+ try:
950
+ data = json.loads(line)
951
+ except json.JSONDecodeError:
952
+ if line:
953
+ parts.append(line)
954
+ continue
955
+ if not isinstance(data, dict):
956
+ continue
957
+ ev_type = str(data.get("type", ""))
958
+ part = data.get("part") or {}
959
+ if ev_type == "text" and isinstance(part, dict):
960
+ text = part.get("text", "")
961
+ if isinstance(text, str) and text.strip():
962
+ parts.append(text)
963
+ return "\n".join(parts) if parts else raw
964
+
965
+
966
+ def _daemon_extract_agent_output(agent_id: str, raw: str) -> str:
967
+ """Dispatch to per-agent extraction; falls back to raw for unknown agents."""
968
+ if agent_id == "claude":
969
+ return _daemon_extract_claude_output(raw)
970
+ if agent_id == "copilot":
971
+ return _daemon_extract_copilot_output(raw)
972
+ if agent_id == "opencode":
973
+ return _daemon_extract_opencode_output(raw)
974
+ return raw
975
+
976
+
977
+ def _daemon_extract_json(text: str):
978
+ """Extract a JSON array or object from text (no external imports)."""
979
+ text = text.strip()
980
+ if text.startswith("```"):
981
+ text = text.split("\n", 1)[-1].rsplit("```", 1)[0].strip()
982
+ try:
983
+ return json.loads(text)
984
+ except json.JSONDecodeError:
985
+ pass
986
+ for start_char, end_char in [("[", "]"), ("{", "}")]:
987
+ start = text.find(start_char)
988
+ end = text.rfind(end_char)
989
+ if start != -1 and end > start:
990
+ try:
991
+ return json.loads(text[start : end + 1])
992
+ except json.JSONDecodeError:
993
+ continue
994
+ return None
995
+
996
+ # ─────────────────────────────────────────────────────────────────────────────
997
+
998
+
797
999
  def _normalize_ai_job_output(
798
1000
  *,
799
1001
  task_type: str,
800
1002
  agent_id: str,
801
1003
  raw_output: str,
802
1004
  ) -> tuple[str, list[dict] | None]:
803
- """Extract agent-specific final text before truncating for transport."""
1005
+ """Extract agent-specific final text before truncating for transport.
1006
+
1007
+ Uses self-contained helpers (_daemon_extract_*) so this function works
1008
+ both inside the backend server (where ``app.*`` is importable) and in the
1009
+ standalone CLI / desktop daemon (where it is not).
1010
+ """
804
1011
  if not raw_output:
805
1012
  return "", None
806
1013
 
807
1014
  if task_type not in {"qa_scenario_generate_prompt", "test_script_generate"}:
808
1015
  return raw_output, None
809
1016
 
810
- from app.services.ai_execution_strategy import AIExecutionStrategy
811
-
812
- extracted_output = AIExecutionStrategy._extract_agent_output(agent_id, raw_output)
1017
+ extracted_output = _daemon_extract_agent_output(agent_id, raw_output)
813
1018
  if task_type == "test_script_generate":
814
1019
  return extracted_output or raw_output, None
815
1020
 
816
- from app.services.qa_ai_assistant import QAAIAssistant
817
-
818
- parsed = QAAIAssistant._extract_json(extracted_output)
1021
+ parsed = _daemon_extract_json(extracted_output)
819
1022
  if isinstance(parsed, list):
820
1023
  return json.dumps(parsed), parsed
821
1024
  if isinstance(parsed, dict):
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: forgexa-cli
3
- Version: 1.13.3
3
+ Version: 1.13.4
4
4
  Summary: Forgexa CLI — command-line client and AI agent runtime for the Forgexa platform
5
5
  Author-email: Jason Sun <dev.winds@gmail.com>
6
6
  License: MIT
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "forgexa-cli"
3
- version = "1.13.3"
3
+ version = "1.13.4"
4
4
  description = "Forgexa CLI — command-line client and AI agent runtime for the Forgexa platform"
5
5
  requires-python = ">=3.9"
6
6
  license = { text = "MIT" }
File without changes
File without changes