Compare commits

..

1 Commits

Author SHA1 Message Date
core-devops c2a0bdea96 fix(ci): port CI/validate to .gitea/ + inline (closes main-red)
CI / Template validation (static) (push) Successful in 1m7s
CI / Adapter unit tests (push) Successful in 1m26s
CI / Template validation (static) (pull_request) Successful in 1m10s
CI / Adapter unit tests (pull_request) Successful in 1m12s
CI / Template validation (runtime) (pull_request) Successful in 6m10s
CI / Template validation (runtime) (push) Successful in 7m35s
CI / validate (push) Successful in 7s
CI / validate (pull_request) Successful in 5s
Class-A root fix for internal#326 (main-red sweep). The .github/ci.yml
used cross-repo `uses:` to molecule-ci/.github/workflows/validate-workspace-template.yml@main,
which Gitea 1.22.6 rejects (DEFAULT_ACTIONS_URL=github → 404, per
feedback_gitea_cross_repo_uses_blocked). Because Gitea 1.22.6 reads
.github/ as a fallback when .gitea/ is absent
(reference_per_repo_gitea_vs_github_actions_dir), the .github/ workflow
was firing and failing at parse time in 1s.

Fix: inline the validate-workspace-template logic directly. The canonical
validator in molecule-ci already self-clones into the runner via
`git clone --depth 1 https://git.moleculesai.app/molecule-ai/molecule-ci.git`,
so the inline port preserves single-source-of-truth — every CI run still
fetches the canonical validator script fresh.

Shape preserved from the source workflow:
  - validate-static (always runs, including fork PRs): secret-scan +
    --static-only validator
  - validate-runtime (skipped on fork PRs for security): pip install
    requirements.txt + import adapter.py + docker build smoke test
  - validate (aggregator): emits the single `validate` check name that
    historically gates branch protection
  - tests: per-repo adapter unit tests (preserved verbatim from
    .github/ci.yml)

Gitea 1.22.6 compat additions:
  - env.GITHUB_SERVER_URL=https://git.moleculesai.app (workflow-level
    belt-and-suspenders per feedback_act_runner_github_server_url)
  - permissions: contents: read (defense-in-depth on GITHUB_TOKEN scope,
    matching the source workflow_call's permission posture)
  - actions/checkout pinned to SHA (v6.0.2) per molecule-core canonical
    port style

The .github/ original is preserved verbatim for future GitHub-mirror
compatibility (no behaviour change there).

Refs: internal#326
2026-05-11 12:30:26 -07:00
3 changed files with 236 additions and 275 deletions
+232
View File
@@ -0,0 +1,232 @@
name: CI
# Ported from .github/workflows/ci.yml on 2026-05-11 per internal#326
# (Class-A root: cross-repo `uses:` blocker for Gitea 1.22.6 —
# feedback_gitea_cross_repo_uses_blocked).
#
# Root cause of the main-red CI on this repo:
# The .github/ original used
# uses: molecule-ai/molecule-ci/.github/workflows/validate-workspace-template.yml@main
# which Gitea 1.22.6 rejects (DEFAULT_ACTIONS_URL=github → 404 against
# the remote repo even though it lives on the same Gitea instance).
# Gitea reads .github/ as a fallback when .gitea/ is absent
# (reference_per_repo_gitea_vs_github_actions_dir), so the .github/
# workflow was firing on Gitea and failing in 1s.
#
# Fix shape: inline the validation logic directly. The canonical
# validator in molecule-ai/molecule-ci already self-clones into the
# runner via a direct HTTPS `git clone` step (validate-workspace-template.yml
# does this verbatim) — so the inline port is just "do that clone +
# invoke the validator script in-place", preserving the
# single-source-of-truth property (each CI run still fetches the
# canonical validator fresh).
#
# Four-surface migration audit (feedback_gitea_actions_migration_audit_pattern):
# 1. YAML — no `workflow_dispatch.inputs`; no `merge_group`; preserved
# `on: [push, pull_request]` from the original. Added workflow-level
# env.GITHUB_SERVER_URL (feedback_act_runner_github_server_url).
# 2. Cache — `actions/setup-python` `cache: pip` preserved; works against
# Gitea's built-in cache server when runner.cache is configured.
# 3. Token — uses auto-injected GITHUB_TOKEN (Gitea-aliased). Validator
# job needs only `contents: read` (no write to issues/PRs).
# 4. Docs — anonymous git-clone of molecule-ci (no token in URL); the
# molecule-ci repo is public on the Gitea instance.
#
# Fork-PR semantics: validate-runtime is intentionally skipped on fork
# PRs because pip-install + docker-build + adapter-import are arbitrary
# code execution. Internal PRs and main pushes get full coverage. The
# `github.event.pull_request.head.repo.fork` field is null for non-PR
# events; the `!= true` comparison defaults to running.
#
# Cross-links:
# - internal#326 — parent tracking issue
# - molecule-ai/molecule-ci/.github/workflows/validate-workspace-template.yml — pattern source
# - molecule-ai/molecule-core/.gitea/workflows/ci.yml — Gitea port style reference
on: [push, pull_request]
env:
# Belt-and-suspenders against the runner-default trap
# (feedback_act_runner_github_server_url). Runners are configured
# with this env via /opt/molecule/runners/config.yaml runner.envs,
# but pinning at the workflow level protects against a runner
# regenerated without the config file.
GITHUB_SERVER_URL: https://git.moleculesai.app
# Defense-in-depth on the GITHUB_TOKEN scope. The validate-runtime job
# runs untrusted-by-design code from the calling repo — pip-installs
# requirements.txt (post-install hooks), imports adapter.py, and
# docker-builds the Dockerfile. Each primitive can execute arbitrary
# code with the token in env. Pinning `contents: read` means the worst
# a malicious template PR can do with the token is read public repo
# state — no write to issues, no push to branches, no comment-spam.
permissions:
contents: read
jobs:
validate-static:
name: Template validation (static)
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
# Canonical validator script lives in molecule-ci, fetched fresh on
# every run. Anonymous fetch of the public molecule-ci repo — no
# token needed; no actions/checkout cross-repo idiosyncrasies.
- name: Fetch molecule-ci canonical scripts
run: git clone --depth 1 https://git.moleculesai.app/molecule-ai/molecule-ci.git .molecule-ci-canonical
- uses: actions/setup-python@v5
with:
python-version: "3.11"
# Secret scan — the most important check. Always runs, including
# on fork PRs (no third-party code executes here).
- name: Check for secrets
run: |
python3 - << 'PYEOF'
import os, re, sys
from pathlib import Path
PATTERNS = [
re.compile(r'''["']sk-ant-[a-zA-Z0-9]{50,}["']'''),
re.compile(r'''["']ghp_[a-zA-Z0-9]{36,}["']'''),
re.compile(r'''["']AKIA[A-Z0-9]{16}["']'''),
re.compile(r'''["'][a-zA-Z0-9/+=]{40}["']'''),
re.compile(r'''["']sk_test_[a-zA-Z0-9]{24,}["']'''),
re.compile(r'''["']Bearer\s+[a-zA-Z0-9_.-]{20,}["']'''),
re.compile(r'''ghp_[a-zA-Z0-9]{36,}'''),
re.compile(r'''sk-ant-[a-zA-Z0-9]{50,}'''),
]
SKIP_DIRS = {'.molecule-ci', '.molecule-ci-canonical', '.git', 'node_modules', '__pycache__'}
EXTENSIONS = {'.yaml', '.yml', '.md', '.py', '.sh'}
def is_false_positive(line):
ctx = line.lower()
return '...' in ctx or '<example' in ctx or '</example' in ctx
root = Path(os.environ.get('GITHUB_WORKSPACE', '.'))
warnings = []
for dirpath, dirnames, filenames in os.walk(root):
dirnames[:] = [d for d in dirnames if d not in SKIP_DIRS]
for filename in filenames:
if Path(filename).suffix not in EXTENSIONS:
continue
filepath = Path(dirpath) / filename
try:
with open(filepath, 'r', encoding='utf-8', errors='ignore') as f:
for lineno, line in enumerate(f.readlines(), 1):
for pattern in PATTERNS:
for match in pattern.finditer(line):
if not is_false_positive(line):
warnings.append(f" {filepath}:{lineno}: {match.group(0)[:40]}...")
except Exception:
pass
if warnings:
print("::error::Potential secret found in committed files:")
for w in warnings:
print(w)
sys.exit(1)
else:
print("::notice::No secrets detected")
PYEOF
# Static-only validator — file existence checks, YAML parse,
# AST inspection of adapter.py (no import). Doesn't execute any
# third-party code; safe on fork PRs.
- run: pip install pyyaml -q
- run: python3 .molecule-ci-canonical/scripts/validate-workspace-template.py --static-only
validate-runtime:
name: Template validation (runtime)
runs-on: ubuntu-latest
timeout-minutes: 15
needs: validate-static
# Skip when the PR comes from a fork — those are external,
# untrusted, and would let attackers run pip install / docker build
# / adapter.py import on our runner.
if: github.event.pull_request.head.repo.fork != true
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Fetch molecule-ci canonical scripts
run: git clone --depth 1 https://git.moleculesai.app/molecule-ai/molecule-ci.git .molecule-ci-canonical
- uses: actions/setup-python@v5
with:
python-version: "3.11"
cache: "pip"
cache-dependency-path: requirements.txt
- run: pip install pyyaml -q
# Install the template's runtime dependencies so the validator's
# check_adapter_runtime_load() can import adapter.py the same way
# the workspace container does at boot. Without this, a
# syntactically-valid adapter that ImportErrors on a missing
# transitive dep would build clean and crash on first user prompt.
- if: hashFiles('requirements.txt') != ''
run: pip install -q -r requirements.txt
- if: hashFiles('requirements.txt') == ''
run: pip install -q molecule-ai-workspace-runtime
- run: python3 .molecule-ci-canonical/scripts/validate-workspace-template.py
- name: Docker build smoke test
if: hashFiles('Dockerfile') != ''
run: |
# Graceful skip when the runner's job-container can't reach the
# Docker daemon (e.g. /var/run/docker.sock not mounted into the
# act job container, or the in-container uid not in the docker
# group). Without this guard, CI stays red even when the
# template's Dockerfile is fine — see internal#222 for the
# proper runner-config fix.
if ! docker info >/dev/null 2>&1; then
echo "::warning::docker daemon unreachable from runner job container — skipping Docker build smoke (runner-config gap, not a template issue)."
exit 0
fi
docker build -t template-test . --no-cache 2>&1 | tail -5 && echo "Docker build succeeded"
# Aggregator that emits a single `validate` check name — matches the
# historical required-check name on this repo's branch protection.
validate:
name: validate
runs-on: ubuntu-latest
needs: [validate-static, validate-runtime]
if: always()
timeout-minutes: 1
steps:
- name: Aggregate
run: |
static="${{ needs.validate-static.result }}"
runtime="${{ needs.validate-runtime.result }}"
echo "validate-static: $static"
echo "validate-runtime: $runtime"
if [ "$static" != "success" ]; then
echo "::error::validate-static did not succeed: $static"
exit 1
fi
# Treat `skipped` as a pass for fork-PR semantics (validate-runtime
# is intentionally skipped on forks; static coverage is the gate).
if [ "$runtime" != "success" ] && [ "$runtime" != "skipped" ]; then
echo "::error::validate-runtime did not succeed: $runtime"
exit 1
fi
echo "::notice::Template validation aggregate passed (static=$static, runtime=$runtime)"
tests:
name: Adapter unit tests
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- uses: actions/setup-python@v5
with:
python-version: "3.11"
# pyyaml is the runtime dep that adapter.py's _load_providers reads
# /configs/config.yaml through. In production it arrives transitively
# via molecule-ai-workspace-runtime; in this minimal test env we
# install it explicitly so the YAML-loading code path is actually
# exercised (without it, _load_providers' broad except-Exception
# swallows the ImportError and silently falls back to _BUILTIN_PROVIDERS,
# which is exactly the behavior that bit us 2026-04-30 when CI
# claimed green on a build that couldn't route any third-party model).
- run: pip install -q pytest pytest-asyncio pyyaml
# Tests live under tests/ with their own pytest.ini that anchors
# rootdir there — keeps pytest from importing the package
# __init__.py (which does `from .adapter import ...` for runtime
# discovery and can't be satisfied without molecule_runtime
# installed). See tests/pytest.ini for the full rationale.
- run: python3 -m pytest tests/ -v
+4 -54
View File
@@ -377,37 +377,21 @@ def _format_process_error(exc: BaseException) -> str:
``_probe_claude_cli_error`` so the operator sees the real failure
reason (e.g. ``You've hit your limit · resets Apr 17``) instead of
chasing ghosts in the workspace logs.
internal#226: prefer ``exc._molecule_stream_detail`` — the failure
reason ``_run_query`` salvaged from the CLI's stream-json
``ResultMessage(is_error=True)`` (model 404, api_error_status, etc.)
before the SDK threw it away. That's the *exact* error for *this*
invocation; the ``_probe_claude_cli_error`` re-probe is a last resort
(it can't replay the failing ``--model``/``--system-prompt`` argv, so
it may even succeed and mislead — which is exactly what happened with
``--model claude-code`` on 2026-05-10).
"""
parts = [f"{type(exc).__name__}: {exc}"]
exit_code = getattr(exc, "exit_code", None)
if exit_code is not None:
parts.append(f"exit_code={exit_code}")
stream_detail = getattr(exc, "_molecule_stream_detail", None)
if stream_detail:
trimmed = stream_detail[:_PROCESS_ERROR_STDERR_MAX_CHARS]
if len(stream_detail) > _PROCESS_ERROR_STDERR_MAX_CHARS:
trimmed += f"... [{len(stream_detail) - _PROCESS_ERROR_STDERR_MAX_CHARS} more chars truncated]"
parts.append(f"cli_stream_error={trimmed!r}")
stderr = getattr(exc, "stderr", None)
if stderr:
trimmed = stderr[:_PROCESS_ERROR_STDERR_MAX_CHARS]
if len(stderr) > _PROCESS_ERROR_STDERR_MAX_CHARS:
trimmed += f"... [{len(stderr) - _PROCESS_ERROR_STDERR_MAX_CHARS} more chars truncated]"
parts.append(f"stderr={trimmed!r}")
elif exit_code is None and not stream_detail and _SWALLOWED_STDERR_MARKER in str(exc):
# #160: generic exception with the swallowed-stderr placeholder AND no
# stream detail to fall back on — probe the CLI directly as a last
# resort. (If _run_query salvaged a stream detail we already have the
# real error; the probe is unreliable since it can't replay the argv.)
elif exit_code is None and _SWALLOWED_STDERR_MARKER in str(exc):
# #160: generic exception with the swallowed-stderr placeholder.
# Probe the CLI directly — this is the only way to surface the real
# error when the SDK lost it in translation.
probed = _probe_claude_cli_error()
if probed:
parts.append(f"probed_cli_error={probed!r}")
@@ -602,12 +586,6 @@ class ClaudeSDKExecutor(AgentExecutor):
assistant_chunks: list[str] = []
result_text: str | None = None
session_id: str | None = None
# Captured from a ResultMessage(is_error=True) — the CLI's stream-json
# carries the *actual* failure reason (model 404, rate limit, auth) in
# the result text + api_error_status BEFORE the SDK throws a bare
# "Command failed with exit code 1" that loses it. Stashed so the
# except arm below can re-attach it (see _format_process_error).
stream_error_detail: str | None = None
self._active_stream = sdk.query(prompt=prompt, options=options)
try:
async for message in self._active_stream:
@@ -628,34 +606,6 @@ class ClaudeSDKExecutor(AgentExecutor):
if sid:
session_id = sid
result_text = getattr(message, "result", None)
if getattr(message, "is_error", False):
api_status = getattr(message, "api_error_status", None)
stream_error_detail = (
(f"api_error_status={api_status} " if api_status else "")
+ f"result={result_text!r}"
)
except BaseException as exc: # noqa: BLE001 — re-raised; we only annotate
# The claude-agent-sdk raises a bare Exception / ProcessError when
# the CLI subprocess errors mid-stream — but the actionable detail
# (model not found, rate limit, auth) arrived earlier as a
# ResultMessage(is_error) / synthetic AssistantMessage and is about
# to be discarded. Re-attach it so _format_process_error surfaces
# it instead of the useless "Check stderr output for details"
# placeholder. (The 2026-05-10 dev-team incident: `--model
# claude-code` → api_error_status=404, "There's an issue with the
# selected model (claude-code)" — invisible for an hour because
# the CLI wrote nothing to stderr and this text was thrown away.)
detail = stream_error_detail
if not detail:
last_assistant = "".join(assistant_chunks).strip()
if last_assistant:
detail = last_assistant[:_PROCESS_ERROR_STDERR_MAX_CHARS]
if detail and getattr(exc, "_molecule_stream_detail", None) is None:
try:
exc._molecule_stream_detail = detail # type: ignore[attr-defined]
except Exception: # pragma: no cover — exotic frozen exception
pass
raise
finally:
self._active_stream = None
text = result_text if result_text is not None else "".join(assistant_chunks)
-221
View File
@@ -1,221 +0,0 @@
"""Pin the CLI-stream-error surfacing in _run_query + _format_process_error.
When the `claude` CLI errors mid-stream, the claude-agent-sdk throws a bare
``Exception("Command failed with exit code 1 …")`` whose only text is the
useless ``Check stderr output for details`` placeholder — but the *actual*
failure reason (model 404, rate limit, auth) arrived a moment earlier as a
stream-json ``ResultMessage(is_error=True)`` carrying ``result`` text and
``api_error_status``. ``_run_query`` salvages that onto the exception
(``_molecule_stream_detail``); ``_format_process_error`` surfaces it.
Regression context: the 2026-05-10 dev-team incident — six lead workspaces
404ing on every turn (``--model claude-code`` → ``api_error_status=404``,
"There's an issue with the selected model (claude-code)"), invisible for an
hour because the CLI wrote nothing to stderr and that text was thrown away.
See internal#226.
Stub pattern mirrors test_runtime_wedge_mirror.py — same _ensure_module /
_ensure_attr / _load_executor helpers so a real-package install on a
workstation still wins over the stubs.
"""
import os
import sys
import types
from unittest.mock import MagicMock
import pytest
# ---- Stubs (mirror test_runtime_wedge_mirror.py) ----
def _ensure_module(dotted: str) -> types.ModuleType:
if dotted not in sys.modules:
sys.modules[dotted] = types.ModuleType(dotted)
return sys.modules[dotted]
def _ensure_attr(mod: types.ModuleType, name: str, value: object) -> None:
if not hasattr(mod, name):
setattr(mod, name, value)
def _install_executor_stubs():
sdk = _ensure_module("claude_agent_sdk")
_ensure_attr(sdk, "ClaudeAgentOptions", MagicMock(name="ClaudeAgentOptions"))
_ensure_attr(sdk, "AssistantMessage", type("AssistantMessage", (), {}))
_ensure_attr(sdk, "TextBlock", type("TextBlock", (), {}))
_ensure_attr(sdk, "ResultMessage", type("ResultMessage", (), {}))
_ensure_attr(sdk, "query", MagicMock(name="query"))
_ensure_module("a2a")
_ensure_module("a2a.server")
a2a_exec = _ensure_module("a2a.server.agent_execution")
_ensure_attr(a2a_exec, "AgentExecutor", type("AgentExecutor", (), {}))
_ensure_attr(a2a_exec, "RequestContext", type("RequestContext", (), {}))
a2a_events = _ensure_module("a2a.server.events")
_ensure_attr(a2a_events, "EventQueue", type("EventQueue", (), {}))
a2a_helpers = _ensure_module("a2a.helpers")
_ensure_attr(a2a_helpers, "new_text_message", lambda *_a, **_kw: None)
_ensure_module("molecule_runtime")
rw = _ensure_module("molecule_runtime.runtime_wedge")
_ensure_attr(rw, "mark_wedged", lambda *_a, **_kw: None)
_ensure_attr(rw, "clear_wedge", lambda *_a, **_kw: None)
helpers = _ensure_module("molecule_runtime.executor_helpers")
for name in (
"auto_push_hook", "brief_summary", "collect_outbound_files", "commit_memory",
"extract_attached_files", "extract_message_text", "get_a2a_instructions",
"get_hma_instructions", "read_delegation_results", "recall_memories",
"set_current_task",
):
_ensure_attr(helpers, name, lambda *_a, **_kw: ("" if "instr" in name or "summary" in name else None))
_ensure_attr(helpers, "collect_outbound_files", lambda *_a, **_kw: [])
_ensure_attr(helpers, "extract_attached_files", lambda *_a, **_kw: [])
_ensure_attr(helpers, "get_mcp_server_path", lambda *_a, **_kw: "/dev/null")
_ensure_attr(helpers, "get_system_prompt", lambda *_a, **_kw: "")
_ensure_attr(helpers, "sanitize_agent_error", lambda e: str(e))
_ensure_attr(helpers, "CONFIG_MOUNT", "/configs")
_ensure_attr(helpers, "WORKSPACE_MOUNT", "/workspace")
_ensure_attr(helpers, "MEMORY_CONTENT_MAX_CHARS", 10000)
def _load_executor():
_install_executor_stubs()
parent_dir = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
if parent_dir not in sys.path:
sys.path.insert(0, parent_dir)
sys.modules.pop("claude_sdk_executor", None)
import claude_sdk_executor # noqa: WPS433
return claude_sdk_executor
def _async_stream(messages, raise_at_end=None):
"""Build a fake `sdk.query(...)` return value: an async iterator that
yields ``messages`` then (optionally) raises — exactly the shape the
claude-agent-sdk produces when the CLI errors after emitting a
ResultMessage(is_error)."""
class _Stream:
def __aiter__(self):
return self
def __init__(self):
self._it = iter(messages)
async def __anext__(self):
try:
return next(self._it)
except StopIteration:
if raise_at_end is not None:
raise raise_at_end
raise StopAsyncIteration
return _Stream()
# ─── _format_process_error: surface the salvaged stream detail ───────────
def test_format_process_error_surfaces_molecule_stream_detail():
mod = _load_executor()
exc = Exception("Command failed with exit code 1 — Check stderr output for details")
exc._molecule_stream_detail = (
'api_error_status=404 result="There\'s an issue with the selected '
'model (claude-code). It may not exist or you may not have access."'
)
out = mod._format_process_error(exc)
assert "cli_stream_error=" in out
assert "api_error_status=404" in out
assert "claude-code" in out
# When we already salvaged the real error, don't ALSO re-probe the CLI
# (the probe can't replay the failing --model argv and may mislead).
assert "probed_cli_error" not in out
def test_format_process_error_still_probes_when_no_stream_detail(monkeypatch):
"""The #160 fallback (probe the CLI when only the swallowed-stderr
placeholder is present) still fires when _run_query had nothing to
salvage."""
mod = _load_executor()
monkeypatch.setattr(mod, "_probe_claude_cli_error", lambda: "You've hit your limit · resets Apr 17")
exc = Exception("Command failed with exit code 1 — Check stderr output for details")
out = mod._format_process_error(exc)
assert "probed_cli_error=" in out
assert "hit your limit" in out
def test_format_process_error_stream_detail_takes_precedence_over_probe(monkeypatch):
mod = _load_executor()
probe = MagicMock(name="_probe_claude_cli_error", return_value="<should not be called>")
monkeypatch.setattr(mod, "_probe_claude_cli_error", probe)
exc = Exception("Command failed with exit code 1 — Check stderr output for details")
exc._molecule_stream_detail = "api_error_status=429 result='rate limited'"
out = mod._format_process_error(exc)
assert "cli_stream_error=" in out
probe.assert_not_called()
# ─── _run_query: salvage the detail onto the raised exception ────────────
@pytest.mark.asyncio
async def test_run_query_annotates_exception_from_is_error_result_message():
mod = _load_executor()
sdk = sys.modules["claude_agent_sdk"]
rm = sdk.ResultMessage()
rm.session_id = "sess-1"
rm.result = "There's an issue with the selected model (claude-code)."
rm.is_error = True
rm.api_error_status = 404
boom = Exception("Command failed with exit code 1 (exit code: 1)\nCheck stderr output for details")
sdk.query = MagicMock(return_value=_async_stream([rm], raise_at_end=boom))
ex = mod.ClaudeSDKExecutor(system_prompt="", config_path="/tmp", heartbeat=None, model="opus")
with pytest.raises(Exception) as ei:
await ex._run_query("hi", options=MagicMock())
detail = getattr(ei.value, "_molecule_stream_detail", None)
assert detail is not None
assert "api_error_status=404" in detail
assert "claude-code" in detail
# And it threads through the formatter the executor's error path uses.
assert "cli_stream_error=" in mod._format_process_error(ei.value)
@pytest.mark.asyncio
async def test_run_query_falls_back_to_assistant_text_when_no_error_result():
mod = _load_executor()
sdk = sys.modules["claude_agent_sdk"]
tb = sdk.TextBlock()
tb.text = "helpful pre-crash context from the model"
am = sdk.AssistantMessage()
am.content = [tb]
boom = Exception("Command failed with exit code 1 — Check stderr output for details")
sdk.query = MagicMock(return_value=_async_stream([am], raise_at_end=boom))
ex = mod.ClaudeSDKExecutor(system_prompt="", config_path="/tmp", heartbeat=None, model="opus")
with pytest.raises(Exception) as ei:
await ex._run_query("hi", options=MagicMock())
assert getattr(ei.value, "_molecule_stream_detail", None) == "helpful pre-crash context from the model"
@pytest.mark.asyncio
async def test_run_query_clean_success_unaffected():
"""No exception → no annotation, normal QueryResult."""
mod = _load_executor()
sdk = sys.modules["claude_agent_sdk"]
rm = sdk.ResultMessage()
rm.session_id = "sess-ok"
rm.result = "done"
rm.is_error = False
sdk.query = MagicMock(return_value=_async_stream([rm]))
ex = mod.ClaudeSDKExecutor(system_prompt="", config_path="/tmp", heartbeat=None, model="opus")
res = await ex._run_query("hi", options=MagicMock())
assert res.text == "done"
assert res.session_id == "sess-ok"