|
12 | 12 | MetricSkillAssertionError, |
13 | 13 | SimulatedResponseError, |
14 | 14 | _delete_metric, |
| 15 | + _extract_metric_result, |
15 | 16 | _normalize_maql, |
16 | 17 | evaluate_agentic_metric_skill, |
17 | 18 | generate_simulated_response, |
18 | 19 | run_agentic_metric_skill, |
19 | 20 | ) |
20 | | -from gooddata_eval.core.models import ChatResult |
| 21 | +from gooddata_eval.core.models import ChatResult, ToolCallEvent |
| 22 | + |
| 23 | + |
| 24 | +def _create_metric_call(result: str) -> ToolCallEvent: |
| 25 | + return ToolCallEvent(function_name="create_metric", function_arguments="{}", result=result) |
| 26 | + |
| 27 | + |
| 28 | +_FAILED_RESULT = '{"data": {"isError": true, "error": {"text": "invalid MAQL"}}}' |
| 29 | + |
| 30 | + |
| 31 | +def test_extract_metric_result_skips_a_failed_retry_and_returns_the_successful_one(): |
| 32 | + """QA-29053 regression: agent self-corrects an invalid MAQL by retrying create_metric |
| 33 | + within the same turn; the successful retry must be captured, not the failed first call.""" |
| 34 | + calls = [ |
| 35 | + _create_metric_call(_FAILED_RESULT), |
| 36 | + _create_metric_call('{"data": {"metric_id": "m1", "maql": "SELECT {metric/foo}"}}'), |
| 37 | + ] |
| 38 | + assert _extract_metric_result(calls) == {"metric_id": "m1", "maql": "SELECT {metric/foo}"} |
| 39 | + |
| 40 | + |
| 41 | +def test_extract_metric_result_returns_none_when_every_attempt_failed(): |
| 42 | + calls = [_create_metric_call(_FAILED_RESULT), _create_metric_call(_FAILED_RESULT)] |
| 43 | + assert _extract_metric_result(calls) is None |
| 44 | + |
| 45 | + |
| 46 | +def test_extract_metric_result_skips_a_failed_call_after_an_earlier_success(): |
| 47 | + # The failed call is last, so reversed() reaches it first and must skip past it. |
| 48 | + calls = [_create_metric_call('{"data": {"metric_id": "m1"}}'), _create_metric_call(_FAILED_RESULT)] |
| 49 | + assert _extract_metric_result(calls) == {"metric_id": "m1"} |
| 50 | + |
| 51 | + |
| 52 | +def test_extract_metric_result_prefers_the_most_recent_successful_call(): |
| 53 | + """Two distinct successful create_metric calls in one turn (not a retry after a |
| 54 | + failure) -- the later one wins.""" |
| 55 | + calls = [ |
| 56 | + _create_metric_call('{"data": {"metric_id": "m1"}}'), |
| 57 | + _create_metric_call('{"data": {"metric_id": "m2"}}'), |
| 58 | + ] |
| 59 | + assert _extract_metric_result(calls) == {"metric_id": "m2"} |
| 60 | + |
| 61 | + |
| 62 | +def test_extract_metric_result_skips_a_non_dict_payload(): |
| 63 | + # The non-dict payload is last, so reversed() reaches it first and must skip past it. |
| 64 | + calls = [ |
| 65 | + _create_metric_call('{"data": {"metric_id": "m2"}}'), |
| 66 | + _create_metric_call('{"data": [{"metric_id": "m1"}]}'), |
| 67 | + ] |
| 68 | + assert _extract_metric_result(calls) == {"metric_id": "m2"} |
| 69 | + |
| 70 | + |
| 71 | +def test_extract_metric_result_skips_a_non_dict_decoded_result(): |
| 72 | + # The whole decoded result (not just its "data" field) is a non-dict here. |
| 73 | + calls = [_create_metric_call('{"metric_id": "m2"}'), _create_metric_call("[]")] |
| 74 | + assert _extract_metric_result(calls) == {"metric_id": "m2"} |
| 75 | + |
| 76 | + |
| 77 | +def test_extract_metric_result_skips_an_empty_payload(): |
| 78 | + # The empty payload is last, so reversed() reaches it first and must skip past it. |
| 79 | + calls = [ |
| 80 | + _create_metric_call('{"data": {"metric_id": "m2"}}'), |
| 81 | + _create_metric_call('{"data": {}}'), |
| 82 | + ] |
| 83 | + assert _extract_metric_result(calls) == {"metric_id": "m2"} |
21 | 84 |
|
22 | 85 |
|
23 | 86 | def test_normalize_maql_strips_whitespace(): |
@@ -269,6 +332,49 @@ def test_run_agentic_metric_skill_deletes_created_metric(): |
269 | 332 | mock_sdk._client.entities_api.delete_entity_metrics.assert_called_once_with("ws1", "foo_metric") |
270 | 333 |
|
271 | 334 |
|
| 335 | +def test_run_agentic_metric_skill_deletes_the_metric_created_by_a_self_corrected_retry(): |
| 336 | + """QA-29053 regression: a failed create_metric call followed by a successful retry, in the |
| 337 | + same turn, used to leave metric_id_to_delete unset -- the metric the retry created leaked |
| 338 | + into the shared workspace.""" |
| 339 | + mock_client = MagicMock() |
| 340 | + mock_client.create_conversation.return_value = "conv-1" |
| 341 | + mock_client.send_message.return_value = ChatResult.model_validate( |
| 342 | + { |
| 343 | + "textResponse": "done", |
| 344 | + "toolCallEvents": [ |
| 345 | + { |
| 346 | + "functionName": "create_metric", |
| 347 | + "functionArguments": "{}", |
| 348 | + "result": '{"data": {"isError": true, "error": {"text": "invalid MAQL"}}}', |
| 349 | + }, |
| 350 | + { |
| 351 | + "functionName": "create_metric", |
| 352 | + "functionArguments": "{}", |
| 353 | + "result": '{"data": {"maql": "SELECT {metric/foo}", "metric_id": "foo_metric"}}', |
| 354 | + }, |
| 355 | + ], |
| 356 | + "reasoningStepCount": 1, |
| 357 | + } |
| 358 | + ) |
| 359 | + with ( |
| 360 | + patch("gooddata_eval.core.agentic.metric_skill.ChatClient", return_value=mock_client), |
| 361 | + patch("gooddata_eval.core.agentic.metric_skill.GoodDataSdk") as mock_sdk_cls, |
| 362 | + ): |
| 363 | + mock_sdk = mock_sdk_cls.create.return_value |
| 364 | + summary = run_agentic_metric_skill( |
| 365 | + host="http://host/api/v1/actions/workspaces/ws1/ai", |
| 366 | + token="tok", |
| 367 | + workspace_id="ws1", |
| 368 | + question="Create metric foo", |
| 369 | + expected_output={"maql": "SELECT {metric/foo}"}, |
| 370 | + k=1, |
| 371 | + max_iterations=1, |
| 372 | + ) |
| 373 | + assert summary.best.metric_created is True |
| 374 | + assert summary.best.maql_correct is True |
| 375 | + mock_sdk._client.entities_api.delete_entity_metrics.assert_called_once_with("ws1", "foo_metric") |
| 376 | + |
| 377 | + |
272 | 378 | def test_run_agentic_metric_skill_deletes_metric_even_when_teardown_fails(): |
273 | 379 | # A metric is created, then conversation teardown raises; the created metric must still |
274 | 380 | # have been cleaned up (its deletion happens inside the per-run finally, before teardown). |
|
0 commit comments