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.
- {forgexa_cli-1.13.3 → forgexa_cli-1.13.4}/PKG-INFO +1 -1
- {forgexa_cli-1.13.3 → forgexa_cli-1.13.4}/forgexa_cli/__init__.py +1 -1
- {forgexa_cli-1.13.3 → forgexa_cli-1.13.4}/forgexa_cli/daemon.py +211 -8
- {forgexa_cli-1.13.3 → forgexa_cli-1.13.4}/forgexa_cli.egg-info/PKG-INFO +1 -1
- {forgexa_cli-1.13.3 → forgexa_cli-1.13.4}/pyproject.toml +1 -1
- {forgexa_cli-1.13.3 → forgexa_cli-1.13.4}/README.md +0 -0
- {forgexa_cli-1.13.3 → forgexa_cli-1.13.4}/forgexa_cli/_build_config.py +0 -0
- {forgexa_cli-1.13.3 → forgexa_cli-1.13.4}/forgexa_cli/main.py +0 -0
- {forgexa_cli-1.13.3 → forgexa_cli-1.13.4}/forgexa_cli/py.typed +0 -0
- {forgexa_cli-1.13.3 → forgexa_cli-1.13.4}/forgexa_cli.egg-info/SOURCES.txt +0 -0
- {forgexa_cli-1.13.3 → forgexa_cli-1.13.4}/forgexa_cli.egg-info/dependency_links.txt +0 -0
- {forgexa_cli-1.13.3 → forgexa_cli-1.13.4}/forgexa_cli.egg-info/entry_points.txt +0 -0
- {forgexa_cli-1.13.3 → forgexa_cli-1.13.4}/forgexa_cli.egg-info/requires.txt +0 -0
- {forgexa_cli-1.13.3 → forgexa_cli-1.13.4}/forgexa_cli.egg-info/top_level.txt +0 -0
- {forgexa_cli-1.13.3 → forgexa_cli-1.13.4}/setup.cfg +0 -0
- {forgexa_cli-1.13.3 → forgexa_cli-1.13.4}/tests/test_auth_and_runtime_commands.py +0 -0
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
"""forgexa-cli — Forgexa command-line client."""
|
|
2
|
-
__version__ = "1.13.
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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):
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|