Skip to content

Commit 7a3bde3

Browse files
authored
Add DataSource parameter for artifacts endpoints (#18)
* Add DataSource parameter for artifacts endpoints * PR feedback for artifacts endpoints
1 parent 49091e7 commit 7a3bde3

7 files changed

Lines changed: 363 additions & 8 deletions

src/tests/test_artifact_relationships.py

Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -372,6 +372,117 @@ async def test_explicit_profile_maps_correctly(self, mock_get_api_key):
372372

373373
call_args = mock_client.post.call_args
374374
assert call_args[1]["json"]["profile"] == "InheritanceOnly"
375+
# No data_source supplied => omitted from the body.
376+
assert "dataSource" not in call_args[1]["json"]
377+
378+
@pytest.mark.asyncio
379+
@patch("tools.artifact_relationships.get_api_key_from_context")
380+
async def test_forwards_data_source(self, mock_get_api_key):
381+
mock_get_api_key.return_value = "test_key"
382+
383+
ctx = MagicMock(spec=Context)
384+
ctx.debug = AsyncMock()
385+
ctx.error = AsyncMock()
386+
387+
mock_response = MagicMock()
388+
mock_response.json.return_value = {
389+
"sourceIdentifier": "id",
390+
"profile": "CallsOnly",
391+
"found": True,
392+
"relationships": [],
393+
}
394+
mock_response.raise_for_status = MagicMock()
395+
396+
mock_client = AsyncMock()
397+
mock_client.post.return_value = mock_response
398+
399+
mock_context = MagicMock()
400+
mock_context.client = mock_client
401+
mock_context.base_url = "https://app.codealive.ai"
402+
ctx.request_context.lifespan_context = mock_context
403+
404+
await get_artifact_relationships(
405+
ctx=ctx,
406+
identifier="id",
407+
data_source="backend",
408+
)
409+
410+
assert mock_client.post.call_args[1]["json"]["dataSource"] == "backend"
411+
412+
@pytest.mark.asyncio
413+
@patch("tools.artifact_relationships.get_api_key_from_context")
414+
async def test_whitespace_data_source_omitted(self, mock_get_api_key):
415+
"""A whitespace-only data_source normalizes to None: not sent to the backend
416+
and not echoed in the not-found hint (preserves the 409-on-ambiguity fallback)."""
417+
mock_get_api_key.return_value = "test_key"
418+
419+
ctx = MagicMock(spec=Context)
420+
ctx.debug = AsyncMock()
421+
ctx.error = AsyncMock()
422+
423+
mock_response = MagicMock()
424+
mock_response.json.return_value = {
425+
"sourceIdentifier": "id",
426+
"profile": "CallsOnly",
427+
"found": False,
428+
}
429+
mock_response.raise_for_status = MagicMock()
430+
431+
mock_client = AsyncMock()
432+
mock_client.post.return_value = mock_response
433+
434+
mock_context = MagicMock()
435+
mock_context.client = mock_client
436+
mock_context.base_url = "https://app.codealive.ai"
437+
ctx.request_context.lifespan_context = mock_context
438+
439+
result = await get_artifact_relationships(
440+
ctx=ctx,
441+
identifier="id",
442+
data_source=" ",
443+
)
444+
445+
assert "dataSource" not in mock_client.post.call_args[1]["json"]
446+
# The confusing `... in data source " "` hint must not appear.
447+
assert '" "' not in result["hint"]
448+
449+
@pytest.mark.asyncio
450+
@patch("tools.artifact_relationships.get_api_key_from_context")
451+
async def test_ambiguous_409_surfaces_candidate_data_sources(self, mock_get_api_key):
452+
import httpx
453+
454+
mock_get_api_key.return_value = "test_key"
455+
456+
ctx = MagicMock(spec=Context)
457+
ctx.debug = AsyncMock()
458+
ctx.error = AsyncMock()
459+
460+
mock_response = MagicMock()
461+
mock_response.status_code = 409
462+
mock_response.text = (
463+
'{"detail": "Identifier matches 2 data sources: '
464+
"Name='backend' Id='ds-main', Name='backend-legacy' Id='ds-master'\"}"
465+
)
466+
mock_response.raise_for_status.side_effect = httpx.HTTPStatusError(
467+
"Conflict", request=MagicMock(), response=mock_response
468+
)
469+
470+
mock_client = AsyncMock()
471+
mock_client.post.return_value = mock_response
472+
473+
mock_context = MagicMock()
474+
mock_context.client = mock_client
475+
mock_context.base_url = "https://app.codealive.ai"
476+
ctx.request_context.lifespan_context = mock_context
477+
478+
with pytest.raises(ToolError) as exc:
479+
await get_artifact_relationships(ctx=ctx, identifier="org/repo::path::Symbol")
480+
481+
message = str(exc.value)
482+
assert "409" in message
483+
# The candidate data sources from the backend 409 must be surfaced, plus the data_source retry hint.
484+
assert "backend" in message and "backend-legacy" in message
485+
assert "data_source" in message
375486

376487
@pytest.mark.asyncio
377488
async def test_empty_identifier_raises_tool_error(self):
@@ -446,3 +557,21 @@ async def test_not_found_response_renders_correctly(self, mock_get_api_key):
446557

447558
assert data["found"] is False
448559
assert "relationships" not in data
560+
561+
def test_not_found_hint_with_data_source_suggests_retry_or_omit(self):
562+
payload = _build_relationships_dict(
563+
{"sourceIdentifier": "org/repo::path::S", "profile": "CallsOnly", "found": False},
564+
data_source="backend",
565+
)
566+
hint = payload["hint"]
567+
assert "backend" in hint
568+
assert "data_source" in hint
569+
assert "omit" in hint.lower()
570+
571+
def test_not_found_hint_without_data_source_is_generic(self):
572+
payload = _build_relationships_dict(
573+
{"sourceIdentifier": "org/repo::path::S", "profile": "CallsOnly", "found": False},
574+
)
575+
hint = payload["hint"]
576+
assert "data_source" not in hint
577+
assert "fresh identifier" in hint

src/tests/test_fetch_artifacts.py

Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -365,6 +365,39 @@ def test_hint_absent_when_no_artifacts_have_content(self):
365365
assert "<hint>" not in result
366366

367367

368+
class TestBuildArtifactsXmlDataSourceMissHint:
369+
"""When a data_source was supplied but nothing was found, hint to retry or drop it."""
370+
371+
def test_hint_when_data_source_scoped_returns_nothing(self):
372+
data = {"artifacts": [
373+
{"identifier": "repo::a.ts::F", "content": None, "contentByteSize": None},
374+
]}
375+
result = _build_artifacts_xml(data, data_source="backend")
376+
assert "<hint>" in result
377+
assert "backend" in result
378+
# Guides toward the two recovery moves.
379+
assert "data_source" in result
380+
assert "omit" in result.lower()
381+
382+
def test_hint_when_empty_artifacts_and_data_source(self):
383+
result = _build_artifacts_xml({"artifacts": []}, data_source="ds-main")
384+
assert "ds-main" in result and "<hint>" in result
385+
386+
def test_no_miss_hint_when_data_source_resolved_content(self):
387+
data = {"artifacts": [
388+
{"identifier": "repo::a.ts::F", "content": "code", "contentByteSize": 4},
389+
]}
390+
result = _build_artifacts_xml(data, data_source="backend")
391+
assert "omit data_source" not in result
392+
393+
def test_no_miss_hint_without_data_source(self):
394+
data = {"artifacts": [
395+
{"identifier": "repo::a.ts::F", "content": None, "contentByteSize": None},
396+
]}
397+
result = _build_artifacts_xml(data)
398+
assert "<hint>" not in result
399+
400+
368401
@pytest.mark.asyncio
369402
@patch('tools.fetch_artifacts.get_api_key_from_context')
370403
async def test_fetch_artifacts_returns_xml(mock_get_api_key):
@@ -476,6 +509,81 @@ async def test_fetch_artifacts_posts_correct_body(mock_get_api_key):
476509
body = call_args.kwargs["json"]
477510
assert body["identifiers"] == ["id1", "id2"]
478511
assert "names" not in body
512+
# No data_source supplied => the field is omitted (preserves the 409-on-ambiguity fallback).
513+
assert "dataSource" not in body
514+
515+
516+
@pytest.mark.asyncio
517+
@patch('tools.fetch_artifacts.get_api_key_from_context')
518+
async def test_fetch_artifacts_forwards_data_source(mock_get_api_key):
519+
"""data_source (Name or Id) is forwarded as the DataSource body field when provided."""
520+
mock_get_api_key.return_value = "test_key"
521+
522+
ctx = MagicMock(spec=Context)
523+
ctx.info = AsyncMock()
524+
ctx.warning = AsyncMock()
525+
ctx.error = AsyncMock()
526+
527+
mock_response = MagicMock()
528+
mock_response.json.return_value = {"artifacts": []}
529+
mock_response.raise_for_status = MagicMock()
530+
531+
mock_client = AsyncMock()
532+
mock_client.post.return_value = mock_response
533+
534+
mock_codealive_context = MagicMock()
535+
mock_codealive_context.client = mock_client
536+
mock_codealive_context.base_url = "https://app.codealive.ai"
537+
538+
ctx.request_context.lifespan_context = mock_codealive_context
539+
ctx.request_context.headers = {"authorization": "Bearer test_key"}
540+
541+
await fetch_artifacts(
542+
ctx=ctx,
543+
identifiers=["id1"],
544+
data_source="backend",
545+
)
546+
547+
body = mock_client.post.call_args.kwargs["json"]
548+
assert body["dataSource"] == "backend"
549+
550+
551+
@pytest.mark.asyncio
552+
@patch('tools.fetch_artifacts.get_api_key_from_context')
553+
async def test_fetch_artifacts_whitespace_data_source_omitted(mock_get_api_key):
554+
"""A whitespace-only data_source normalizes to None: not sent to the backend
555+
and not echoed in the not-found hint (preserves the 409-on-ambiguity fallback)."""
556+
mock_get_api_key.return_value = "test_key"
557+
558+
ctx = MagicMock(spec=Context)
559+
ctx.info = AsyncMock()
560+
ctx.warning = AsyncMock()
561+
ctx.error = AsyncMock()
562+
563+
mock_response = MagicMock()
564+
mock_response.json.return_value = {"artifacts": []}
565+
mock_response.raise_for_status = MagicMock()
566+
567+
mock_client = AsyncMock()
568+
mock_client.post.return_value = mock_response
569+
570+
mock_codealive_context = MagicMock()
571+
mock_codealive_context.client = mock_client
572+
mock_codealive_context.base_url = "https://app.codealive.ai"
573+
574+
ctx.request_context.lifespan_context = mock_codealive_context
575+
ctx.request_context.headers = {"authorization": "Bearer test_key"}
576+
577+
result = await fetch_artifacts(
578+
ctx=ctx,
579+
identifiers=["id1"],
580+
data_source=" ",
581+
)
582+
583+
body = mock_client.post.call_args.kwargs["json"]
584+
assert "dataSource" not in body
585+
# The confusing `... data source " "` hint must not appear.
586+
assert '" "' not in result
479587

480588

481589
@pytest.mark.asyncio

src/tests/test_response_transformer.py

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -294,10 +294,34 @@ def test_data_preservation(self):
294294
assert first["identifier"] == "CodeAlive-AI/codealive-mcp::src/tools/search.py::codebase_search"
295295
assert first["contentByteSize"] == 8500
296296
assert first["description"] == "Main search function"
297+
# Data-source identity must be surfaced (not stripped) so the agent can feed it back
298+
# as `data_source` to disambiguate a branch-blind identifier.
299+
assert first["dataSource"] == {"id": "685b21230e3822f4efa9d073", "name": "codealive-mcp"}
297300

298301
assert second["path"] == "README.md"
299302
assert second["kind"] == "Chunk"
300303
assert second["description"] == "Search documentation section"
304+
assert second["dataSource"] == {"id": "685b21230e3822f4efa9d073", "name": "codealive-mcp"}
305+
306+
def test_grep_transform_surfaces_data_source(self):
307+
response = {
308+
"results": [
309+
{
310+
"kind": "File",
311+
"identifier": "owner/repo::src/auth.py",
312+
"location": {"path": "src/auth.py"},
313+
"matchCount": 1,
314+
"matches": [
315+
{"lineNumber": 3, "startColumn": 0, "endColumn": 4, "lineText": "auth"}
316+
],
317+
"dataSource": {"type": "repository", "id": "ds-main", "name": "backend"},
318+
}
319+
]
320+
}
321+
322+
result = transform_grep_response(response)
323+
324+
assert result["results"][0]["dataSource"] == {"id": "ds-main", "name": "backend"}
301325

302326
def test_grep_transform_preserves_match_previews(self):
303327
response = {

0 commit comments

Comments
 (0)