diff --git a/skills/codealive-context-engine/SKILL.md b/skills/codealive-context-engine/SKILL.md index 0f3c9d7..a9e4057 100644 --- a/skills/codealive-context-engine/SKILL.md +++ b/skills/codealive-context-engine/SKILL.md @@ -218,7 +218,7 @@ or your local file-read tool before drawing conclusions about behavior. Retrieves the full source code content for artifacts found via search. Use this for external repositories you cannot access locally. ```bash -python scripts/fetch.py [identifier2...] +python scripts/fetch.py [identifier2...] [--data-source NAME_OR_ID] ``` | Constraint | Value | @@ -226,12 +226,27 @@ python scripts/fetch.py [identifier2...] | Max identifiers per request | 20 | | Identifiers source | `identifier` field from search results | | Identifier format | `{owner/repo}::{path}::{symbol}` (symbols), `{owner/repo}::{path}` (files) | +| `--data-source NAME_OR_ID` | Optional. Data source Name or Id (from a result's `Source:` line) to disambiguate an identifier indexed in more than one data source | For function-like artifacts the response includes a small **relationships preview** (up to 3 outgoing/incoming calls per direction). To see the full call graph, inheritance, or references, run `relationships.py` with the artifact's identifier. +**Disambiguating an identifier that lives in more than one data source.** Artifact +identifiers are unique only per data source, so the same identifier can belong to +more than one data source. If you fetch such an identifier without `--data-source`, +the backend returns a **409** listing the candidate data sources instead of picking +one for you. Every listed candidate **will** resolve, so the workflow is: call without +`--data-source` → read the 409 candidates → try one → if that data source isn't the one +you want, try the next. To resolve it: take the +`Source:` name or id shown next to the search result you want and pass it back — +`python scripts/fetch.py --data-source "backend"` (or the id). +The same `--data-source` flag works on `relationships.py`. If a `--data-source`-scoped +call finds nothing (the script prints a "nothing was found in data source …" hint), +the identifier belongs to a different data source or the selector is wrong: retry with +a different `Source:` value, or drop `--data-source` to get the 409 candidate list. + ### `relationships.py` — Drill into an Artifact's Relationship Graph Returns the full call graph (incoming/outgoing calls), inheritance hierarchy @@ -241,7 +256,7 @@ identifier and want to understand how the artifact relates to the rest of the codebase. ```bash -python scripts/relationships.py [--profile PROFILE] [--max-count N] +python scripts/relationships.py [--profile PROFILE] [--max-count N] [--data-source NAME_OR_ID] ``` | Option | Description | @@ -251,6 +266,7 @@ python scripts/relationships.py [--profile PROFILE] [--max-count N] | `--profile allRelevant` | Calls + inheritance (4 groups) | | `--profile referencesOnly` | Symbol references | | `--max-count N` | Max related artifacts per relationship type (1–1000, default 50) | +| `--data-source NAME_OR_ID` | Optional. Data source Name or Id to disambiguate an identifier indexed in more than one data source (same 409 contract as `fetch.py`) | | `--json` | Emit the raw JSON response instead of the formatted view | **When this adds value vs the fetch preview:** diff --git a/skills/codealive-context-engine/scripts/fetch.py b/skills/codealive-context-engine/scripts/fetch.py index 6986178..ae52d18 100644 --- a/skills/codealive-context-engine/scripts/fetch.py +++ b/skills/codealive-context-engine/scripts/fetch.py @@ -3,7 +3,7 @@ CodeAlive Fetch - Retrieve full content for code artifacts Usage: - python fetch.py [identifier2...] + python fetch.py [identifier2...] [--data-source NAME_OR_ID] Examples: # Fetch a single artifact (symbol) @@ -15,10 +15,18 @@ # Fetch multiple artifacts python fetch.py "my-org/backend::src/auth.py::login" "my-org/backend::src/utils.py::helper" + # Disambiguate an identifier that exists in more than one data source + # (use the dataSource name or id from a search result) + python fetch.py "my-org/backend::src/auth.py::login" --data-source "backend" + Identifiers come from semantic/grep search results (the `identifier` field). The format is: {owner/repo}::{path}::{symbol} (for symbols/chunks) {owner/repo}::{path} (for files) +Pass --data-source (a data source Name or Id from a search result's `dataSource`) +to disambiguate an identifier that exists in more than one data source. Without it, +an ambiguous identifier returns a 409 listing the candidate data sources. + Maximum 20 identifiers per request. """ @@ -83,11 +91,23 @@ def _format_relationships_preview(relationships: dict) -> list: return lines -def format_artifacts(data: dict) -> str: +def _data_source_miss_hint(data_source: str) -> str: + """Recovery hint when a data-source-scoped fetch returns nothing.""" + return ( + f'\n💡 Hint: nothing was found in data source "{data_source}". The identifier may belong to a ' + "different data source, or the --data-source value may be wrong. Try: re-run with --data-source " + "set to a different candidate (use the Source name or id from your search results, or run " + "datasources.py), or drop --data-source entirely — an ambiguous identifier then returns a 409 " + "listing the candidate data sources to choose from." + ) + + +def format_artifacts(data: dict, data_source: str = None) -> str: """Format fetched artifacts for display.""" artifacts = data.get("artifacts", []) if not artifacts: - return "No artifacts returned." + msg = "No artifacts returned." + return msg + _data_source_miss_hint(data_source) if data_source else msg output = [] count = 0 @@ -119,7 +139,8 @@ def format_artifacts(data: dict) -> str: has_any_relationships = True if not output: - return "No artifacts found." + msg = "No artifacts found." + return msg + _data_source_miss_hint(data_source) if data_source else msg output.append(f"\n({count} artifact(s))") @@ -144,7 +165,26 @@ def main(): sys.exit(1) sys.exit(0) - identifiers = sys.argv[1:] + identifiers = [] + data_source = None + i = 1 + while i < len(sys.argv): + arg = sys.argv[i] + if arg == "--data-source": + # Match the flag first, then require a value — otherwise a trailing "--data-source" + # with no value would be silently appended as an identifier. + if i + 1 >= len(sys.argv): + print("Error: --data-source requires a value.", file=sys.stderr) + sys.exit(1) + data_source = sys.argv[i + 1] + i += 2 + else: + identifiers.append(arg) + i += 1 + + if not identifiers: + print("Error: At least one identifier is required.", file=sys.stderr) + sys.exit(1) if len(identifiers) > 20: print("Error: Maximum 20 identifiers per request.", file=sys.stderr) @@ -154,11 +194,13 @@ def main(): client = CodeAliveClient() print(f"📥 Fetching {len(identifiers)} artifact(s)", file=sys.stderr) + if data_source: + print(f" data source: {data_source}", file=sys.stderr) print(file=sys.stderr) - result = client.fetch_artifacts(identifiers=identifiers) + result = client.fetch_artifacts(identifiers=identifiers, data_source=data_source) - print(format_artifacts(result)) + print(format_artifacts(result, data_source=data_source)) except Exception as e: print(f"❌ Error: {e}", file=sys.stderr) diff --git a/skills/codealive-context-engine/scripts/grep.py b/skills/codealive-context-engine/scripts/grep.py index f53aa59..2982d85 100755 --- a/skills/codealive-context-engine/scripts/grep.py +++ b/skills/codealive-context-engine/scripts/grep.py @@ -42,6 +42,24 @@ def format_grep_results(results: dict) -> str: output.append(f" File: {file_path}") if result.get("identifier"): output.append(f" Identifier: {result['identifier']}") + + # Surface the data-source name/id so they can be passed back as --data-source to + # fetch.py / relationships.py when an identifier is branch-ambiguous. + # dataSource may be a {name, id} object or a bare string, depending on the API response + # shape — handle both, mirroring search.py. + ds = result.get("dataSource") + if isinstance(ds, dict): + ds_name = ds.get("name") + ds_id = ds.get("id") + else: + ds_name = ds + ds_id = None + if ds_name and ds_id: + output.append(f" Source: {ds_name} (id: {ds_id})") + elif ds_name: + output.append(f" Source: {ds_name}") + elif ds_id: + output.append(f" Source: (id: {ds_id})") if result.get("matchCount") is not None: output.append(f" Match count: {result['matchCount']}") diff --git a/skills/codealive-context-engine/scripts/lib/api_client.py b/skills/codealive-context-engine/scripts/lib/api_client.py index 705481b..8ebda86 100644 --- a/skills/codealive-context-engine/scripts/lib/api_client.py +++ b/skills/codealive-context-engine/scripts/lib/api_client.py @@ -524,6 +524,7 @@ def grep_search( def fetch_artifacts( self, identifiers: List[str], + data_source: Optional[str] = None, ) -> Dict[str, Any]: """ Retrieve full content for code artifacts by their identifiers. @@ -536,6 +537,10 @@ def fetch_artifacts( Args: identifiers: List of artifact identifiers from search results (max 20) + data_source: Optional data-source Name or Id to disambiguate an identifier that + exists in more than one data source. Copy the `dataSource.name`/`dataSource.id` + from a search result. Omit for normal lookups; an ambiguous identifier without + it returns a 409 listing the candidate data sources. Returns: Dict with 'artifacts' list. Each artifact has identifier, content, @@ -545,6 +550,8 @@ def fetch_artifacts( the full list and other relationship profiles. """ body: Dict[str, Any] = {"identifiers": identifiers} + if data_source: + body["dataSource"] = data_source return self._make_request("POST", "/api/search/artifacts", body=body) def get_artifact_relationships( @@ -552,6 +559,7 @@ def get_artifact_relationships( identifier: str, profile: str = "callsOnly", max_count_per_type: int = 50, + data_source: Optional[str] = None, ) -> Dict[str, Any]: """ Retrieve relationship groups for a single artifact by profile. @@ -569,6 +577,9 @@ def get_artifact_relationships( - "referencesOnly": symbol references max_count_per_type: Max related artifacts per relationship type (1–1000, default 50). + data_source: Optional data-source Name or Id to disambiguate a source identifier + that exists in more than one data source. Omit for normal lookups; an ambiguous + identifier without it returns a 409 listing the candidate data sources. Returns: Dict with sourceIdentifier, profile, found, and a list of @@ -594,6 +605,8 @@ def get_artifact_relationships( "profile": api_profile, "maxCountPerType": max_count_per_type, } + if data_source: + body["dataSource"] = data_source return self._make_request( "POST", "/api/search/artifact-relationships", body=body ) @@ -665,8 +678,8 @@ def main(): print(" search [data_source2...] [--mode auto|fast|deep] [--description-detail short|full]") print(" semantic-search [data_source2...] [--path PATH] [--ext EXT] [--max-results N]") print(" grep-search [data_source2...] [--regex] [--path PATH] [--ext EXT] [--max-results N]") - print(" fetch [identifier2...]") - print(" relationships [--profile callsOnly|inheritanceOnly|allRelevant|referencesOnly] [--max-count N]") + print(" fetch [identifier2...] [--data-source NAME_OR_ID]") + print(" relationships [--profile callsOnly|inheritanceOnly|allRelevant|referencesOnly] [--max-count N] [--data-source NAME_OR_ID]") print(" chat [data_source2...] [--conversation-id ID]") sys.exit(1) @@ -791,12 +804,27 @@ def main(): elif command == "fetch": if len(sys.argv) < 3: - print("Usage: fetch [identifier2...]") + print("Usage: fetch [identifier2...] [--data-source NAME_OR_ID]") sys.exit(1) - identifiers = sys.argv[2:] + identifiers = [] + data_source = None + i = 2 + while i < len(sys.argv): + arg = sys.argv[i] + if arg == "--data-source": + # Match the flag first, then require a value — otherwise a trailing + # "--data-source" with no value would be silently appended as an identifier. + if i + 1 >= len(sys.argv): + print("Error: --data-source requires a value.", file=sys.stderr) + sys.exit(1) + data_source = sys.argv[i + 1] + i += 2 + else: + identifiers.append(arg) + i += 1 - result = client.fetch_artifacts(identifiers) + result = client.fetch_artifacts(identifiers, data_source=data_source) print(json.dumps(result, indent=2)) elif command == "relationships": @@ -807,20 +835,37 @@ def main(): identifier = sys.argv[2] profile = "callsOnly" max_count = 50 + data_source = None i = 3 while i < len(sys.argv): arg = sys.argv[i] - if arg == "--profile" and i + 1 < len(sys.argv): + # Value-bearing flags match on the name first, then require a value, so a trailing + # flag with no value reports "requires a value" instead of being silently skipped. + if arg == "--profile": + if i + 1 >= len(sys.argv): + print("Error: --profile requires a value.", file=sys.stderr) + sys.exit(1) profile = sys.argv[i + 1] i += 2 - elif arg == "--max-count" and i + 1 < len(sys.argv): + elif arg == "--max-count": + if i + 1 >= len(sys.argv): + print("Error: --max-count requires a value.", file=sys.stderr) + sys.exit(1) max_count = int(sys.argv[i + 1]) i += 2 + elif arg == "--data-source": + if i + 1 >= len(sys.argv): + print("Error: --data-source requires a value.", file=sys.stderr) + sys.exit(1) + data_source = sys.argv[i + 1] + i += 2 else: i += 1 - result = client.get_artifact_relationships(identifier, profile, max_count) + result = client.get_artifact_relationships( + identifier, profile, max_count, data_source=data_source + ) print(json.dumps(result, indent=2)) elif command == "chat": @@ -835,7 +880,12 @@ def main(): i = 3 while i < len(sys.argv): arg = sys.argv[i] - if arg == "--conversation-id" and i + 1 < len(sys.argv): + if arg == "--conversation-id": + # Match the flag first, then require a value — otherwise a trailing + # "--conversation-id" with no value would be silently appended as a data source. + if i + 1 >= len(sys.argv): + print("Error: --conversation-id requires a value.", file=sys.stderr) + sys.exit(1) conversation_id = sys.argv[i + 1] i += 2 else: diff --git a/skills/codealive-context-engine/scripts/relationships.py b/skills/codealive-context-engine/scripts/relationships.py index 5e417ab..273c970 100755 --- a/skills/codealive-context-engine/scripts/relationships.py +++ b/skills/codealive-context-engine/scripts/relationships.py @@ -11,7 +11,11 @@ this script gives you the full list and lets you switch profiles. Usage: - python relationships.py [--profile PROFILE] [--max-count N] + python relationships.py [--profile PROFILE] [--max-count N] [--data-source NAME_OR_ID] + +Pass --data-source (a data source Name or Id from a search result's `dataSource`) +to disambiguate an identifier that exists in more than one data source. Without it, +an ambiguous identifier returns a 409 listing the candidate data sources. Profiles: callsOnly (default) outgoing + incoming calls @@ -61,7 +65,7 @@ } -def format_relationships(data: dict) -> str: +def format_relationships(data: dict, data_source: str = None) -> str: """Format an artifact-relationships response for display.""" source_id = data.get("sourceIdentifier") or "" raw_profile = data.get("profile") or "" @@ -69,10 +73,19 @@ def format_relationships(data: dict) -> str: found = bool(data.get("found")) if not found: - return ( - f"Artifact not found or inaccessible: {source_id}\n" - f"(profile={profile})" - ) + lines = [ + f"Artifact not found or inaccessible: {source_id}", + f"(profile={profile})", + ] + if data_source: + lines.append( + f'\n💡 Hint: nothing matched in data source "{data_source}". The identifier may belong ' + "to a different data source, or the --data-source value may be wrong. Try: re-run with " + "--data-source set to a different candidate (use the Source name or id from your " + "search results, or run datasources.py), or drop --data-source entirely — an ambiguous " + "identifier then returns a 409 listing the candidate data sources to choose from." + ) + return "\n".join(lines) relationships = data.get("relationships") or [] @@ -142,20 +155,35 @@ def main(): identifier = sys.argv[1] profile = "callsOnly" max_count = 50 + data_source = None i = 2 while i < len(sys.argv): arg = sys.argv[i] - if arg == "--profile" and i + 1 < len(sys.argv): + # Value-bearing flags match on the name first, then require a value, so a trailing flag with + # no value reports "requires a value" rather than the misleading "unknown argument" below. + if arg == "--profile": + if i + 1 >= len(sys.argv): + print("Error: --profile requires a value.", file=sys.stderr) + sys.exit(1) profile = sys.argv[i + 1] i += 2 - elif arg == "--max-count" and i + 1 < len(sys.argv): + elif arg == "--max-count": + if i + 1 >= len(sys.argv): + print("Error: --max-count requires a value.", file=sys.stderr) + sys.exit(1) try: max_count = int(sys.argv[i + 1]) except ValueError: print(f"Error: --max-count expects an integer, got '{sys.argv[i + 1]}'", file=sys.stderr) sys.exit(1) i += 2 + elif arg == "--data-source": + if i + 1 >= len(sys.argv): + print("Error: --data-source requires a value.", file=sys.stderr) + sys.exit(1) + data_source = sys.argv[i + 1] + i += 2 elif arg == "--json": # Handled below — we strip it before calling format_relationships i += 1 @@ -171,18 +199,21 @@ def main(): print(f"🔗 Fetching {profile} relationships for: {identifier}", file=sys.stderr) print(f"⚙️ max-count={max_count}", file=sys.stderr) + if data_source: + print(f" data source: {data_source}", file=sys.stderr) print(file=sys.stderr) result = client.get_artifact_relationships( identifier=identifier, profile=profile, max_count_per_type=max_count, + data_source=data_source, ) if as_json: print(json.dumps(result, indent=2)) else: - print(format_relationships(result)) + print(format_relationships(result, data_source=data_source)) except Exception as e: print(f"❌ Error: {e}", file=sys.stderr) diff --git a/skills/codealive-context-engine/scripts/search.py b/skills/codealive-context-engine/scripts/search.py index 195fae1..99a21be 100755 --- a/skills/codealive-context-engine/scripts/search.py +++ b/skills/codealive-context-engine/scripts/search.py @@ -57,7 +57,12 @@ def format_search_results(results: dict) -> str: end_line = range_info.get("end", {}).get("line") or result.get("endLine") ds = result.get("dataSource", {}) - source_name = ds.get("name") if isinstance(ds, dict) else ds + if isinstance(ds, dict): + source_name = ds.get("name") + source_id = ds.get("id") + else: + source_name = ds + source_id = None kind = result.get("kind", "") identifier = result.get("identifier", "") @@ -84,8 +89,14 @@ def format_search_results(results: dict) -> str: short_id = identifier.split("::")[-1] if "::" in identifier else identifier if short_id != file_path: output.append(f" Symbol: {short_id}") - if source_name: + # Surface both the data-source name and id so they can be passed straight back as + # --data-source to fetch.py / relationships.py when an identifier is branch-ambiguous. + if source_name and source_id: + output.append(f" Source: {source_name} (id: {source_id})") + elif source_name: output.append(f" Source: {source_name}") + elif source_id: + output.append(f" Source: (id: {source_id})") if content_byte_size is not None: output.append(f" Size: {_format_byte_size(content_byte_size)}") if description: diff --git a/tests/test_cli_smoke.py b/tests/test_cli_smoke.py index 5625c16..00492ad 100644 --- a/tests/test_cli_smoke.py +++ b/tests/test_cli_smoke.py @@ -309,3 +309,84 @@ def test_check_auth_hook_normalizes_base_url_and_uses_repo_root_fallback(): assert result.returncode == 0 assert "https://codealive.example.com/settings/api-keys" in result.stdout assert str(REPO_ROOT / "skills" / "codealive-context-engine" / "setup.py") in result.stdout + + +def test_fetch_and_relationships_scripts_hint_when_data_source_misses(): + """A --data-source-scoped request that finds nothing must hint: try another source or drop it.""" + def fetch_handler(request): + body = json.loads(request["body"]) + assert body["dataSource"] == "backend" + # Scoped to a source that doesn't contain the identifier -> null content. + return 200, {"artifacts": [{"identifier": body["identifiers"][0], "content": None}]}, {} + + def relationships_handler(request): + body = json.loads(request["body"]) + assert body["dataSource"] == "backend" + return 200, { + "sourceIdentifier": body["identifier"], + "profile": body["profile"], + "found": False, + "relationships": [], + }, {} + + with mock_codealive_server( + { + ("POST", "/api/search/artifacts"): fetch_handler, + ("POST", "/api/search/artifact-relationships"): relationships_handler, + } + ) as (base_url, _requests): + env = { + **os.environ, + "CODEALIVE_API_KEY": "skill-test-key", + "CODEALIVE_BASE_URL": f"{base_url}/api", + } + + fetch = _run( + "fetch.py", "org/repo::src/auth.py::Missing", "--data-source", "backend", env=env + ) + rel = _run( + "relationships.py", "org/repo::src/auth.py::Missing", "--data-source", "backend", env=env + ) + + assert fetch.returncode == 0, fetch.stderr + assert "backend" in fetch.stdout + assert "--data-source" in fetch.stdout + assert "drop --data-source" in fetch.stdout + + assert rel.returncode == 0, rel.stderr + assert "backend" in rel.stdout + assert "drop --data-source" in rel.stdout + + +def test_fetch_and_relationships_scripts_error_on_data_source_without_value(): + """A trailing --data-source with no value must error, not be silently treated as an identifier.""" + env = {**os.environ, "CODEALIVE_API_KEY": "skill-test-key"} + + fetch = _run("fetch.py", "org/repo::src/auth.py::AuthService", "--data-source", env=env) + assert fetch.returncode != 0 + assert "--data-source requires a value" in fetch.stderr + + rel = _run("relationships.py", "org/repo::src/auth.py::AuthService", "--data-source", env=env) + assert rel.returncode != 0 + assert "--data-source requires a value" in rel.stderr + + +def test_grep_format_surfaces_dict_and_string_datasource(): + """format_grep_results surfaces the source whether dataSource is a {name,id} object or a string.""" + import importlib.util + + grep_path = SKILL_ROOT / "scripts" / "grep.py" + spec = importlib.util.spec_from_file_location("grep_under_test", grep_path) + grep_mod = importlib.util.module_from_spec(spec) + spec.loader.exec_module(grep_mod) + + dict_out = grep_mod.format_grep_results( + {"results": [{"identifier": "org/repo::a.py::F", "dataSource": {"name": "backend", "id": "abc123"}}]} + ) + assert "Source: backend (id: abc123)" in dict_out + + # A bare-string dataSource must still be surfaced (was silently dropped before). + str_out = grep_mod.format_grep_results( + {"results": [{"identifier": "org/repo::a.py::F", "dataSource": "backend"}]} + ) + assert "Source: backend" in str_out diff --git a/tests/test_setup_and_client.py b/tests/test_setup_and_client.py index a11c1b3..fbc507b 100644 --- a/tests/test_setup_and_client.py +++ b/tests/test_setup_and_client.py @@ -325,6 +325,70 @@ def test_api_client_get_artifact_relationships_rejects_unknown_profile(): raise AssertionError("ValueError was not raised for unknown profile") +def test_api_client_fetch_artifacts_forwards_data_source(): + received_bodies: list = [] + + def fetch_handler(request): + body = json.loads(request["body"]) + received_bodies.append(body) + return 200, {"artifacts": []}, {} + + with mock_codealive_server( + {("POST", "/api/search/artifacts"): fetch_handler} + ) as (base_url, _requests): + client = CodeAliveClient(api_key="skill-test-key", base_url=base_url) + # Omitted by default… + client.fetch_artifacts(["org/repo::src/a.py::F"]) + # …forwarded as DataSource when provided. + client.fetch_artifacts(["org/repo::src/a.py::F"], data_source="backend") + + assert "dataSource" not in received_bodies[0] + assert received_bodies[1]["dataSource"] == "backend" + + +def test_api_client_get_artifact_relationships_forwards_data_source(): + received_bodies: list = [] + + def relationships_handler(request): + body = json.loads(request["body"]) + received_bodies.append(body) + return 200, {"sourceIdentifier": body["identifier"], "profile": body["profile"], "found": True, "relationships": []}, {} + + with mock_codealive_server( + {("POST", "/api/search/artifact-relationships"): relationships_handler} + ) as (base_url, _requests): + client = CodeAliveClient(api_key="skill-test-key", base_url=base_url) + client.get_artifact_relationships("org/repo::src/a.py::F") + client.get_artifact_relationships("org/repo::src/a.py::F", data_source="ds-main") + + assert "dataSource" not in received_bodies[0] + assert received_bodies[1]["dataSource"] == "ds-main" + + +def test_api_client_ambiguous_409_surfaces_candidate_data_sources(): + # When an identifier is ambiguous and no data_source is supplied, the backend returns a 409 + # whose detail lists the candidate data sources. The client must surface those candidates so + # the agent can retry with --data-source rather than inventing a result. + def fetch_handler(request): + return 409, { + "title": "Ambiguous data source", + "detail": "Identifier matches 2 data sources: Name='backend' Id='ds-main', Name='backend-legacy' Id='ds-master'", + }, {} + + with mock_codealive_server( + {("POST", "/api/search/artifacts"): fetch_handler} + ) as (base_url, _requests): + client = CodeAliveClient(api_key="skill-test-key", base_url=base_url) + try: + client.fetch_artifacts(["org/repo::src/a.py::F"]) + except Exception as e: + message = str(e) + assert "409" in message + assert "backend" in message and "backend-legacy" in message + else: + raise AssertionError("Expected an exception for the ambiguous 409 response") + + # ===== Phase 1 — error contract & ObjectId preflight ===== def test_format_codealive_error_renders_rfc9457_problem_details():