feat: add conversation parameter + named session chaining

Like /title for sessions — clients can name conversations instead of
tracking response IDs manually:

  POST /v1/responses {input: 'hi', conversation: 'my-project'}
  POST /v1/responses {input: 'next step', conversation: 'my-project'}

Server automatically chains to the latest response in that conversation.
Mutually exclusive with previous_response_id (returns 400 if both set).
Not stored if store=false.

5 new tests (72 total).
This commit is contained in:
teknium1 2026-03-10 03:56:41 -07:00
parent f46bd77ad2
commit 93a22a4245
2 changed files with 139 additions and 0 deletions

View file

@ -137,6 +137,8 @@ class APIServerAdapter(BasePlatformAdapter):
self._runner: Optional["web.AppRunner"] = None
self._site: Optional["web.TCPSite"] = None
self._response_store = ResponseStore()
# Conversation name → latest response_id mapping
self._conversations: Dict[str, str] = {}
# ------------------------------------------------------------------
# Auth helper
@ -370,8 +372,21 @@ class APIServerAdapter(BasePlatformAdapter):
instructions = body.get("instructions")
previous_response_id = body.get("previous_response_id")
conversation = body.get("conversation")
store = body.get("store", True)
# conversation and previous_response_id are mutually exclusive
if conversation and previous_response_id:
return web.json_response(
{"error": {"message": "Cannot use both 'conversation' and 'previous_response_id'", "type": "invalid_request_error"}},
status=400,
)
# Resolve conversation name to latest response_id
if conversation:
previous_response_id = self._conversations.get(conversation)
# No error if conversation doesn't exist yet — it's a new conversation
# Normalize input to message list
input_messages: List[Dict[str, str]] = []
if isinstance(raw_input, str):
@ -489,6 +504,10 @@ class APIServerAdapter(BasePlatformAdapter):
"conversation_history": full_history,
"instructions": instructions,
})
# Update conversation mapping so the next request with the same
# conversation name automatically chains to this response
if conversation:
self._conversations[conversation] = response_id
return web.json_response(response_data)

View file

@ -1159,3 +1159,123 @@ class TestCORS:
assert resp.status == 200
assert resp.headers.get("Access-Control-Allow-Origin") == "*"
assert "Authorization" in resp.headers.get("Access-Control-Allow-Headers", "")
# ---------------------------------------------------------------------------
# Conversation parameter
# ---------------------------------------------------------------------------
class TestConversationParameter:
@pytest.mark.asyncio
async def test_conversation_creates_new(self, adapter):
"""First request with a conversation name works (new conversation)."""
app = _create_app(adapter)
async with TestClient(TestServer(app)) as cli:
with patch.object(adapter, "_run_agent", new_callable=AsyncMock) as mock_run:
mock_run.return_value = (
{"final_response": "Hello!", "messages": [], "api_calls": 1},
{"input_tokens": 10, "output_tokens": 5, "total_tokens": 15},
)
resp = await cli.post("/v1/responses", json={
"input": "hi",
"conversation": "my-chat",
})
assert resp.status == 200
data = await resp.json()
assert data["status"] == "completed"
# Conversation mapping should be set
assert "my-chat" in adapter._conversations
@pytest.mark.asyncio
async def test_conversation_chains_automatically(self, adapter):
"""Second request with same conversation name chains to first."""
app = _create_app(adapter)
async with TestClient(TestServer(app)) as cli:
with patch.object(adapter, "_run_agent", new_callable=AsyncMock) as mock_run:
mock_run.return_value = (
{"final_response": "First response", "messages": [], "api_calls": 1},
{"input_tokens": 10, "output_tokens": 5, "total_tokens": 15},
)
# First request
resp1 = await cli.post("/v1/responses", json={
"input": "hello",
"conversation": "test-conv",
})
assert resp1.status == 200
data1 = await resp1.json()
resp1_id = data1["id"]
# Second request — should chain
mock_run.return_value = (
{"final_response": "Second response", "messages": [], "api_calls": 1},
{"input_tokens": 20, "output_tokens": 10, "total_tokens": 30},
)
resp2 = await cli.post("/v1/responses", json={
"input": "follow up",
"conversation": "test-conv",
})
assert resp2.status == 200
# The second call should have received conversation history from the first
assert mock_run.call_count == 2
second_call_kwargs = mock_run.call_args_list[1]
history = second_call_kwargs.kwargs.get("conversation_history",
second_call_kwargs[1].get("conversation_history", []) if len(second_call_kwargs) > 1 else [])
# History should be non-empty (contains messages from first response)
assert len(history) > 0
@pytest.mark.asyncio
async def test_conversation_and_previous_response_id_conflict(self, adapter):
"""Cannot use both conversation and previous_response_id."""
app = _create_app(adapter)
async with TestClient(TestServer(app)) as cli:
resp = await cli.post("/v1/responses", json={
"input": "hi",
"conversation": "my-chat",
"previous_response_id": "resp_abc123",
})
assert resp.status == 400
data = await resp.json()
assert "Cannot use both" in data["error"]["message"]
@pytest.mark.asyncio
async def test_separate_conversations_are_isolated(self, adapter):
"""Different conversation names have independent histories."""
app = _create_app(adapter)
async with TestClient(TestServer(app)) as cli:
with patch.object(adapter, "_run_agent", new_callable=AsyncMock) as mock_run:
mock_run.return_value = (
{"final_response": "Response A", "messages": [], "api_calls": 1},
{"input_tokens": 10, "output_tokens": 5, "total_tokens": 15},
)
# Conversation A
await cli.post("/v1/responses", json={"input": "conv-a msg", "conversation": "conv-a"})
# Conversation B
mock_run.return_value = (
{"final_response": "Response B", "messages": [], "api_calls": 1},
{"input_tokens": 10, "output_tokens": 5, "total_tokens": 15},
)
await cli.post("/v1/responses", json={"input": "conv-b msg", "conversation": "conv-b"})
# They should have different response IDs in the mapping
assert adapter._conversations["conv-a"] != adapter._conversations["conv-b"]
@pytest.mark.asyncio
async def test_conversation_store_false_no_mapping(self, adapter):
"""If store=false, conversation mapping is not updated."""
app = _create_app(adapter)
async with TestClient(TestServer(app)) as cli:
with patch.object(adapter, "_run_agent", new_callable=AsyncMock) as mock_run:
mock_run.return_value = (
{"final_response": "Ephemeral", "messages": [], "api_calls": 1},
{"input_tokens": 10, "output_tokens": 5, "total_tokens": 15},
)
resp = await cli.post("/v1/responses", json={
"input": "hi",
"conversation": "ephemeral-chat",
"store": False,
})
assert resp.status == 200
# Conversation mapping should NOT be set since store=false
assert "ephemeral-chat" not in adapter._conversations