Skip to content

Commit 83cf39b

Browse files
author
Radovan Fuchs
committed
LCORE-1270: Added e2e tests for responses endpoint
1 parent 409cdc2 commit 83cf39b

8 files changed

Lines changed: 1116 additions & 11 deletions

File tree

tests/e2e/features/responses.feature

Lines changed: 409 additions & 4 deletions
Large diffs are not rendered by default.

tests/e2e/features/responses_streaming.feature

Lines changed: 493 additions & 0 deletions
Large diffs are not rendered by default.

tests/e2e/features/steps/common_http.py

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
from behave.runner import Context
1313

1414
from tests.e2e.utils.utils import (
15+
http_response_json_or_responses_sse_terminal,
1516
normalize_endpoint,
1617
replace_placeholders,
1718
validate_json,
@@ -171,11 +172,16 @@ def check_response_body_contains(context: Context, substring: str) -> None:
171172
Supports {MODEL} and {PROVIDER} placeholders in the substring so
172173
assertions work with any configured provider (e.g. unknown-provider
173174
error message includes the actual model id).
175+
176+
Matching is case-insensitive so LLM replies (e.g. ``Hello`` vs ``hello``)
177+
do not fail otherwise-identical scenarios.
174178
"""
175179
assert context.response is not None, "Request needs to be performed first"
176180
expected = replace_placeholders(context, substring)
181+
haystack = context.response.text.lower()
182+
needle = expected.lower()
177183
assert (
178-
expected in context.response.text
184+
needle in haystack
179185
), f"The response text '{context.response.text}' doesn't contain '{expected}'"
180186

181187

@@ -425,7 +431,7 @@ def check_response_partially(context: Context) -> None:
425431
in `context.text`, ignoring extra keys or values not specified.
426432
"""
427433
assert context.response is not None, "Request needs to be performed first"
428-
body = context.response.json()
434+
body = http_response_json_or_responses_sse_terminal(context.response)
429435
json_str = replace_placeholders(context, context.text or "{}")
430436
expected = json.loads(json_str)
431437
validate_json_partially(body, expected)

tests/e2e/features/steps/conversation.py

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,68 @@ def access_conversation_endpoint_get_specific(
5858
context.response = requests.get(url, headers=headers, timeout=DEFAULT_TIMEOUT)
5959

6060

61+
@step(
62+
"I use REST API conversation endpoint with the forked responses conversation id using HTTP GET method"
63+
)
64+
def access_forked_responses_conversation_get(context: Context) -> None:
65+
"""GET /conversations/{id} using the id stored after a responses API fork."""
66+
assert hasattr(
67+
context, "responses_fork_conversation_id"
68+
), "responses_fork_conversation_id not set; store fork id first"
69+
endpoint = "conversations"
70+
base = f"http://{context.hostname}:{context.port}"
71+
path = (
72+
f"{context.api_prefix}/{endpoint}/{context.responses_fork_conversation_id}"
73+
).replace("//", "/")
74+
url = base + path
75+
headers = context.auth_headers if hasattr(context, "auth_headers") else {}
76+
context.response = requests.get(url, headers=headers, timeout=DEFAULT_TIMEOUT)
77+
78+
79+
@step(
80+
"I use REST API conversation endpoint with the responses multi-turn baseline conversation id using HTTP GET method"
81+
)
82+
def access_responses_baseline_conversation_get(context: Context) -> None:
83+
"""GET /conversations/{id} for the linear thread before a responses fork."""
84+
assert hasattr(
85+
context, "responses_multi_turn_baseline_conversation_id"
86+
), "responses_multi_turn_baseline_conversation_id not set"
87+
endpoint = "conversations"
88+
base = f"http://{context.hostname}:{context.port}"
89+
cid = context.responses_multi_turn_baseline_conversation_id
90+
path = f"{context.api_prefix}/{endpoint}/{cid}".replace("//", "/")
91+
url = base + path
92+
headers = context.auth_headers if hasattr(context, "auth_headers") else {}
93+
context.response = requests.get(url, headers=headers, timeout=DEFAULT_TIMEOUT)
94+
95+
96+
@then("The GET conversation response id matches the forked responses conversation id")
97+
def check_get_matches_forked_responses_conversation_id(context: Context) -> None:
98+
"""Assert GET /conversations payload matches the fork id from /v1/responses."""
99+
assert context.response is not None
100+
assert hasattr(context, "responses_fork_conversation_id")
101+
response_json = context.response.json()
102+
assert response_json["conversation_id"] == context.responses_fork_conversation_id, (
103+
f"expected conversation_id {context.responses_fork_conversation_id!r}, "
104+
f"got {response_json.get('conversation_id')!r}"
105+
)
106+
107+
108+
@then(
109+
"The GET conversation response id matches the responses multi-turn baseline conversation id"
110+
)
111+
def check_get_matches_responses_baseline_conversation_id(context: Context) -> None:
112+
"""Assert GET /conversations payload matches the pre-fork thread id."""
113+
assert context.response is not None
114+
assert hasattr(context, "responses_multi_turn_baseline_conversation_id")
115+
expected = context.responses_multi_turn_baseline_conversation_id
116+
response_json = context.response.json()
117+
assert response_json["conversation_id"] == expected, (
118+
f"expected conversation_id {expected!r}, "
119+
f"got {response_json.get('conversation_id')!r}"
120+
)
121+
122+
61123
@when(
62124
"I use REST API conversation endpoint with conversation_id from above using HTTP DELETE method"
63125
)

tests/e2e/features/steps/llm_query_response.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -58,7 +58,10 @@ def ask_question_authorized(context: Context, endpoint: str) -> None:
5858
json_str = replace_placeholders(context, context.text or "{}")
5959

6060
data = json.loads(json_str)
61-
if endpoint == "streaming_query":
61+
use_sse = endpoint == "streaming_query" or (
62+
endpoint == "responses" and bool(data.get("stream"))
63+
)
64+
if use_sse:
6265
resp = requests.post(
6366
url,
6467
json=data,
Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
"""Behave steps for POST /v1/responses (LCORE Responses API) multi-turn tests."""
2+
3+
from __future__ import annotations
4+
5+
from typing import Any
6+
7+
from behave import step # pyright: ignore[reportAttributeAccessIssue]
8+
from behave.runner import Context
9+
10+
from tests.e2e.utils.utils import http_response_json_or_responses_sse_terminal
11+
12+
13+
def _successful_responses_result_object(context: Context) -> dict[str, Any]:
14+
"""Return the LCORE responses payload for a successful call (JSON or SSE)."""
15+
assert context.response is not None
16+
assert context.response.status_code == 200, context.response.text
17+
data = http_response_json_or_responses_sse_terminal(context.response)
18+
assert "id" in data and "conversation" in data, data
19+
return data
20+
21+
22+
@step("I store the first responses turn from the last response")
23+
def store_first_responses_turn(context: Context) -> None:
24+
"""Save response id and conversation from a successful responses call (JSON or SSE)."""
25+
assert context.response is not None, "Request needs to be performed first"
26+
data = _successful_responses_result_object(context)
27+
context.responses_first_response_id = data["id"]
28+
context.responses_conversation_id = data["conversation"]
29+
30+
31+
@step("I store the multi-turn baseline from the last responses response")
32+
def store_multi_turn_baseline(context: Context) -> None:
33+
"""Save conversation id after a continuation turn (thread before an optional fork)."""
34+
assert context.response is not None, "Request needs to be performed first"
35+
data = _successful_responses_result_object(context)
36+
context.responses_multi_turn_baseline_conversation_id = data["conversation"]
37+
context.responses_second_response_id = data["id"]
38+
39+
40+
@step("I store the forked responses conversation id from the last response")
41+
def store_forked_responses_conversation_id(context: Context) -> None:
42+
"""Save conversation id returned after branching from a non-latest ``previous_response_id``."""
43+
assert context.response is not None, "Request needs to be performed first"
44+
data = _successful_responses_result_object(context)
45+
context.responses_fork_conversation_id = data["conversation"]
46+
47+
48+
@step("The responses conversation id is different from the multi-turn baseline")
49+
def responses_conversation_differs_from_baseline(context: Context) -> None:
50+
"""Assert fork applied: returned conversation is not the baseline thread id."""
51+
assert context.response is not None
52+
data = _successful_responses_result_object(context)
53+
baseline = context.responses_multi_turn_baseline_conversation_id
54+
current = data["conversation"]
55+
assert (
56+
current != baseline
57+
), f"Expected a new conversation after fork, got same as baseline {baseline!r}"
58+
59+
60+
@step("The responses conversation id matches the multi-turn baseline")
61+
def responses_conversation_matches_baseline(context: Context) -> None:
62+
"""Assert continuation on latest turn keeps the same normalized conversation id."""
63+
assert context.response is not None
64+
data = _successful_responses_result_object(context)
65+
baseline = context.responses_multi_turn_baseline_conversation_id
66+
current = data["conversation"]
67+
assert current == baseline, f"Expected conversation {baseline!r}, got {current!r}"
68+
69+
70+
@step("The responses conversation id matches the first stored conversation")
71+
def responses_conversation_matches_first_stored(context: Context) -> None:
72+
"""Assert the response uses the same thread as ``I store the first responses turn``."""
73+
assert context.response is not None
74+
assert hasattr(
75+
context, "responses_conversation_id"
76+
), "responses_conversation_id not set; run the first-store step first"
77+
data = _successful_responses_result_object(context)
78+
expected = context.responses_conversation_id
79+
current = data["conversation"]
80+
assert current == expected, f"Expected conversation {expected!r}, got {current!r}"

tests/e2e/test_list.txt

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,13 +10,11 @@ features/feedback.feature
1010
features/health.feature
1111
features/info.feature
1212
features/responses.feature
13+
features/responses_streaming.feature
1314
features/query.feature
1415
features/rlsapi_v1.feature
1516
features/rlsapi_v1_errors.feature
1617
features/streaming_query.feature
1718
features/rest_api.feature
1819
features/mcp.feature
19-
features/mcp_servers_api.feature
20-
features/mcp_servers_api_auth.feature
21-
features/mcp_servers_api_no_config.feature
2220
features/models.feature

tests/e2e/utils/utils.py

Lines changed: 59 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
"""Unsorted utility functions to be used from other sources and test step definitions."""
22

3+
import json
34
import os
45
import shutil
56
import subprocess
@@ -163,6 +164,50 @@ def validate_json_partially(actual: Any, expected: Any) -> None:
163164
assert actual == expected, f"Value mismatch: expected {expected}, got {actual}"
164165

165166

167+
RESPONSES_SSE_TERMINAL_EVENT_TYPES = frozenset(
168+
{"response.completed", "response.incomplete", "response.failed"}
169+
)
170+
171+
172+
def parse_responses_sse_final_response_object(text: str) -> dict[str, Any]:
173+
"""Return the ``response`` object from the last terminal LCORE ``/responses`` SSE event."""
174+
last: dict[str, Any] | None = None
175+
for line in text.splitlines():
176+
stripped = line.strip()
177+
if not stripped.startswith("data:"):
178+
continue
179+
payload = stripped[5:].strip()
180+
if payload == "[DONE]":
181+
continue
182+
try:
183+
obj = json.loads(payload)
184+
except json.JSONDecodeError:
185+
continue
186+
if obj.get("type") in RESPONSES_SSE_TERMINAL_EVENT_TYPES and isinstance(
187+
obj.get("response"), dict
188+
):
189+
last = obj["response"]
190+
if last is None:
191+
raise AssertionError(
192+
"No terminal responses SSE event (completed/incomplete/failed) found in body"
193+
)
194+
return last
195+
196+
197+
def http_response_json_or_responses_sse_terminal(response: Any) -> Any:
198+
"""Decode JSON body or, for streaming ``/responses`` SSE, the terminal ``response`` object.
199+
200+
Non-SST responses use ``response.json()`` unchanged.
201+
"""
202+
text = response.text
203+
content_type = (response.headers.get("content-type") or "").lower()
204+
if "text/event-stream" in content_type or (
205+
"event:" in text and "data:" in text and "[DONE]" in text
206+
):
207+
return parse_responses_sse_final_response_object(text)
208+
return response.json()
209+
210+
166211
def switch_config(
167212
source_path: str, destination_path: str = "lightspeed-stack.yaml"
168213
) -> None:
@@ -320,4 +365,17 @@ def replace_placeholders(context: Context, text: str) -> str:
320365
"""
321366
result = text.replace("{MODEL}", context.default_model)
322367
result = result.replace("{PROVIDER}", context.default_provider)
323-
return result.replace("{VECTOR_STORE_ID}", context.faiss_vector_store_id)
368+
result = result.replace("{VECTOR_STORE_ID}", context.faiss_vector_store_id)
369+
if hasattr(context, "responses_first_response_id"):
370+
result = result.replace(
371+
"{RESPONSES_FIRST_RESPONSE_ID}", context.responses_first_response_id
372+
)
373+
if hasattr(context, "responses_conversation_id"):
374+
result = result.replace(
375+
"{RESPONSES_CONVERSATION_ID}", context.responses_conversation_id
376+
)
377+
if hasattr(context, "responses_second_response_id"):
378+
result = result.replace(
379+
"{RESPONSES_SECOND_RESPONSE_ID}", context.responses_second_response_id
380+
)
381+
return result

0 commit comments

Comments
 (0)