← Files Dataiku HeadlessARCHIVED FILE
tests/test_cobuild.py
20.7 KB · Nov 11, 2026 · 12:58 UTC
# Copyright 2026 Dataiku SAS
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""Cobuild's small process-local conversation contract."""
import asyncio
import inspect
import json
import threading
from types import SimpleNamespace
import pytest
from dataiku_mcp.config import request
from dataiku_mcp.tools import cobuild
class Context:
async def info(self, _message):
pass
class Response:
def __init__(
self,
message="ok",
is_confirmation_request=False,
is_question_request=False,
title=None,
predefined_answers=None,
allow_custom_answer=None,
allow_multiple_answers=None,
default_answer_set=None,
):
self.message = message
self.type = (
"delete_confirmation_request"
if is_confirmation_request
else "ask_question_to_user_request"
if is_question_request
else "assistant_message"
)
self.is_error = False
self.is_confirmation_request = is_confirmation_request
self.is_question_request = is_question_request
self.objects_to_delete = (
[{"id": "dataset"}] if is_confirmation_request else None
)
self.deletion_impacts = None
self.title = title
self.predefined_answers = predefined_answers
self.allow_custom_answer = allow_custom_answer
self.allow_multiple_answers = allow_multiple_answers
self.default_answer_set = default_answer_set
class Conversation:
def __init__(self, conversation_id="conversation-1"):
self.conversation_id = conversation_id
self.pending_confirmation = False
self.pending_question = False
self.send_calls = []
self.answer_calls = []
self.question_answer_calls = []
self.next_send = Response()
self.next_answer = Response("confirmed")
self.next_question_answer = Response("question answered")
self.started = threading.Event()
self.release = None
self.error = None
def send_message(self, message, *, allow_edit_project=False):
self.send_calls.append((message, allow_edit_project))
self.started.set()
if self.release:
self.release.wait(timeout=5)
if self.error:
raise self.error
self.pending_confirmation = self.next_send.is_confirmation_request
self.pending_question = self.next_send.is_question_request
return self.next_send
def answer_confirmation(self, choice):
if not self.pending_confirmation:
raise ValueError("No pending confirmation")
self.pending_confirmation = self.next_answer.is_confirmation_request
self.pending_question = self.next_answer.is_question_request
self.answer_calls.append(choice)
return self.next_answer
def answer_question(self, answers, *, rejected=False, used_custom_answer=False):
if not self.pending_question:
raise ValueError("No pending question")
self.pending_confirmation = self.next_question_answer.is_confirmation_request
self.pending_question = self.next_question_answer.is_question_request
self.question_answer_calls.append((answers, rejected, used_custom_answer))
return self.next_question_answer
class Client:
def __init__(self, conversation=None):
self.conversation = conversation or Conversation()
def get_project(self, _project_key):
return SimpleNamespace(new_cobuild_conversation=lambda: self.conversation)
@pytest.fixture(autouse=True)
def environment(monkeypatch):
cobuild._conversations.clear()
active_instance = {"name": "instance-a", "url": "https://a.example.com"}
client = Client()
monkeypatch.setattr(cobuild, "get_dss_client", lambda: client)
monkeypatch.setattr(
request,
"get_pinned_instance",
lambda: SimpleNamespace(
name=active_instance["name"], url=active_instance["url"]
),
)
yield client, active_instance
for entry in cobuild._conversations.values():
if entry.sdk_conversation.release:
entry.sdk_conversation.release.set()
cobuild._conversations.clear()
def run(coroutine):
return asyncio.run(coroutine)
async def start():
return json.loads(await cobuild.start_cobuild_conversation("PROJECT", Context()))
def test_conversation_cannot_be_reused_by_another_http_user(environment):
result = run(start())
identity = request.bind_http_identity("https://idp.example", "other-user")
try:
with pytest.raises(ValueError, match="belongs to another user"):
run(send(result["conversation_id"]))
finally:
request.reset_http_identity(identity)
async def send(conversation_id="conversation-1", **kwargs):
return json.loads(
await cobuild.send_cobuild_message(
conversation_id, "PROJECT", "do work", Context(), **kwargs
)
)
async def answer(turn_id, choice="APPROVE"):
return json.loads(
await cobuild.answer_cobuild_confirmation(
"conversation-1", "PROJECT", turn_id, choice, Context()
)
)
async def answer_question(turn_id, answers, **kwargs):
return json.loads(
await cobuild.answer_cobuild_question(
"conversation-1", "PROJECT", turn_id, answers, Context(), **kwargs
)
)
async def poll(turn_id, conversation_id="conversation-1"):
return json.loads(
await cobuild.get_cobuild_turn_status(
conversation_id, "PROJECT", turn_id, Context()
)
)
async def wait_for_terminal(turn_id, conversation_id="conversation-1"):
for _ in range(100):
result = await poll(turn_id, conversation_id)
if result["status"] not in {"queued", "in_progress"}:
return result
await asyncio.sleep(0.01)
raise AssertionError("turn did not finish")
def test_edits_are_opt_in_and_tool_schema_matches(environment):
client, _ = environment
assert (
inspect.signature(cobuild.send_cobuild_message)
.parameters["allow_edit_project"]
.default
is False
)
async def scenario():
await start()
assert (await send())["status"] == "completed"
assert (await send(allow_edit_project=True))["status"] == "completed"
tool = await cobuild.mcp.get_tool("send_cobuild_message")
assert tool.parameters["properties"]["allow_edit_project"]["default"] is False
assert "wait_seconds" not in tool.parameters["properties"]
run(scenario())
assert client.conversation.send_calls == [("do work", False), ("do work", True)]
def test_confirmation_uses_the_exact_current_turn(environment):
client, _ = environment
client.conversation.next_send = Response("delete?", is_confirmation_request=True)
client.conversation.next_answer = Response(
"delete next?", is_confirmation_request=True
)
async def scenario():
await start()
proposal = await send(allow_edit_project=True)
assert proposal["is_confirmation_request"]
assert "confirmation_id" not in proposal
tool = await cobuild.mcp.get_tool("answer_cobuild_confirmation")
assert "turn_id" in tool.parameters["properties"]
assert "confirmation_id" not in tool.parameters["properties"]
with pytest.raises(ValueError, match="not the current turn_id") as wrong_turn:
await answer("wrong")
assert "get_cobuild_turn_status with" in str(wrong_turn.value)
assert proposal["turn_id"] in str(wrong_turn.value)
with pytest.raises(ValueError, match="does not request a question answer"):
await answer_question(proposal["turn_id"], [])
successor = await answer(proposal["turn_id"])
assert successor["is_confirmation_request"]
with pytest.raises(ValueError, match="not the current turn_id"):
await answer(proposal["turn_id"])
await answer(successor["turn_id"], "CANCEL")
run(scenario())
assert client.conversation.answer_calls == ["APPROVE", "CANCEL"]
def test_pending_confirmation_blocks_new_messages(environment):
client, _ = environment
client.conversation.next_send = Response("delete?", is_confirmation_request=True)
async def scenario():
await start()
confirmation = await send(allow_edit_project=True)
with pytest.raises(ValueError, match="pending confirmation") as blocked:
await send()
assert "get_cobuild_turn_status with" in str(blocked.value)
assert confirmation["turn_id"] in str(blocked.value)
run(scenario())
assert len(client.conversation.send_calls) == 1
def test_question_uses_the_exact_current_turn(environment):
client, _ = environment
client.conversation.next_send = Response(
"Which date column?",
is_question_request=True,
title="Choose a date column",
predefined_answers=["order_date", "ship_date"],
allow_custom_answer=True,
allow_multiple_answers=False,
default_answer_set=True,
)
client.conversation.next_question_answer = Response(
"Need another choice?", is_question_request=True
)
async def scenario():
await start()
question = await send()
assert question["is_question_request"]
assert question["question"] == {
"title": "Choose a date column",
"predefined_answers": ["order_date", "ship_date"],
"allow_custom_answer": True,
"allow_multiple_answers": False,
"default_answer_set": True,
}
tool = await cobuild.mcp.get_tool("answer_cobuild_question")
assert "turn_id" in tool.parameters["properties"]
assert "answers" in tool.parameters["required"]
with pytest.raises(ValueError, match="not the current turn_id"):
await answer_question("wrong", ["order_date"])
with pytest.raises(
ValueError, match="does not request a confirmation"
) as wrong_type:
await answer(question["turn_id"])
assert "get_cobuild_turn_status with" in str(wrong_type.value)
successor = await answer_question(
question["turn_id"], ["custom_date"], used_custom_answer=True
)
assert successor["is_question_request"]
with pytest.raises(ValueError, match="not the current turn_id"):
await answer_question(question["turn_id"], [])
await answer_question(successor["turn_id"], [], rejected=True)
run(scenario())
assert client.conversation.question_answer_calls == [
(["custom_date"], False, True),
([], True, False),
]
def test_pending_question_blocks_new_messages(environment):
client, _ = environment
client.conversation.next_send = Response("Which one?", is_question_request=True)
async def scenario():
await start()
question = await send()
with pytest.raises(ValueError, match="pending question") as blocked:
await send()
assert "get_cobuild_turn_status with" in str(blocked.value)
assert question["turn_id"] in str(blocked.value)
await answer_question(question["turn_id"], [])
run(scenario())
assert len(client.conversation.send_calls) == 1
def test_question_answer_validates_rejected_and_answers(environment):
_client, _ = environment
async def scenario():
await start()
with pytest.raises(ValueError, match="must be empty when rejected is true"):
await answer_question("turn-1", ["order_date"], rejected=True)
run(scenario())
def test_confirmation_request_does_not_depend_on_an_sdk_confirmation_id(environment):
client, _ = environment
response = Response("delete?")
response.type = "delete_confirmation_request"
response.is_confirmation_request = True
response.objects_to_delete = [{"id": "dataset"}]
client.conversation.next_send = response
async def scenario():
await start()
result = await send(allow_edit_project=True)
assert result["status"] == "completed"
assert result["is_confirmation_request"]
await answer(result["turn_id"], "CANCEL")
run(scenario())
def test_timeout_retains_one_turn_until_its_result_is_polled(environment, monkeypatch):
client, _ = environment
client.conversation.release = threading.Event()
monkeypatch.setattr(cobuild, "TURN_WAIT_TIMEOUT_SECONDS", 0)
async def scenario():
await start()
first = await send()
assert first["status"] in {"queued", "in_progress"}
with pytest.raises(ValueError, match=first["turn_id"]) as running:
await send()
assert "get_cobuild_turn_status with" in str(running.value)
assert '"project_key":"PROJECT"' in str(running.value)
client.conversation.release.set()
for _ in range(100):
if cobuild._conversations["conversation-1"].turn.task.done():
break
await asyncio.sleep(0.01)
with pytest.raises(ValueError, match="terminal result") as unobserved:
await send()
assert first["turn_id"] in str(unobserved.value)
assert (await poll(first["turn_id"]))["status"] == "completed"
replacement = await send()
assert replacement["status"] in {"queued", "in_progress"}
assert (await wait_for_terminal(replacement["turn_id"]))[
"status"
] == "completed"
with pytest.raises(ValueError, match="not the current turn_id"):
await poll(first["turn_id"])
run(scenario())
assert [message for message, _edits in client.conversation.send_calls] == [
"do work",
"do work",
]
def test_sdk_exception_does_not_wedge_the_conversation(environment):
client, _ = environment
client.conversation.error = ConnectionError("connection failed")
async def scenario():
await start()
failed = await send()
assert failed["status"] == "failed"
assert not failed["is_confirmation_request"]
assert not failed["is_question_request"]
assert await poll(failed["turn_id"]) == failed
client.conversation.error = None
assert (await send())["status"] == "completed"
run(scenario())
assert len(client.conversation.send_calls) == 2
def test_turn_status_waits_for_and_returns_the_terminal_result(
environment, monkeypatch
):
client, _ = environment
client.conversation.release = threading.Event()
monkeypatch.setattr(cobuild, "TURN_WAIT_TIMEOUT_SECONDS", 0)
async def scenario():
await start()
pending = await send()
monkeypatch.setattr(cobuild, "TURN_WAIT_TIMEOUT_SECONDS", 1)
waiter = asyncio.create_task(poll(pending["turn_id"]))
await asyncio.sleep(0.01)
assert not waiter.done()
client.conversation.release.set()
assert (await waiter)["status"] == "completed"
assert cobuild._conversations["conversation-1"].turn.observed
run(scenario())
def test_cancellation_keeps_the_turn_recoverable_from_listing(environment):
client, _ = environment
client.conversation.release = threading.Event()
async def scenario():
await start()
waiter = asyncio.create_task(send())
while not client.conversation.started.is_set():
await asyncio.sleep(0.01)
waiter.cancel()
with pytest.raises(asyncio.CancelledError):
await waiter
listing = json.loads(
await cobuild.list_cobuild_conversations("PROJECT", Context())
)
columns = listing["conversations"]["columns"]
row = dict(zip(columns, listing["conversations"]["rows"][0]))
assert row["current_turn_status"] == "in_progress"
client.conversation.release.set()
assert (await wait_for_terminal(row["current_turn_id"]))[
"status"
] == "completed"
run(scenario())
def test_queued_cobuild_turns_are_accepted_with_pollable_ids(environment, monkeypatch):
first = Conversation("conversation-1")
second = Conversation("conversation-2")
clients = [Client(first), Client(second)]
monkeypatch.setattr(
cobuild,
"get_dss_client",
lambda: (
clients[0] if "conversation-1" not in cobuild._conversations else clients[1]
),
)
monkeypatch.setattr(cobuild, "TURN_WAIT_TIMEOUT_SECONDS", 0)
async def scenario():
release_queue = asyncio.Event()
async def delayed_cobuild_call(func, *args, **kwargs):
await release_queue.wait()
return func(*args, **kwargs)
monkeypatch.setattr(cobuild, "run_cobuild_blocking", delayed_cobuild_call)
await start()
await start()
first_turn = await send("conversation-1")
second_turn = await send("conversation-2")
assert first_turn["status"] == "queued"
assert second_turn["status"] == "queued"
release_queue.set()
assert (await wait_for_terminal(first_turn["turn_id"], "conversation-1"))[
"status"
] == "completed"
assert (await wait_for_terminal(second_turn["turn_id"], "conversation-2"))[
"status"
] == "completed"
run(scenario())
def test_ownership_and_sdk_contract(environment):
_client, active_instance = environment
async def scenario():
await start()
with pytest.raises(ValueError, match="belongs to project"):
await cobuild.send_cobuild_message(
"conversation-1", "OTHER", "work", Context()
)
active_instance["name"] = "instance-b"
with pytest.raises(ValueError, match="belongs to instance"):
await send()
run(scenario())
PROJECT_URL = "https://a.example.com/projects/PROJECT/flow/"
def test_every_payload_carries_the_project_url(environment, monkeypatch):
client, _ = environment
client.conversation.next_send = Response(
"Which date column?", is_question_request=True
)
async def scenario():
assert (await start())["project_url"] == PROJECT_URL
question = await send()
assert question["is_question_request"]
assert question["project_url"] == PROJECT_URL
listing = json.loads(
await cobuild.list_cobuild_conversations("PROJECT", Context())
)
columns = listing["conversations"]["columns"]
row = dict(zip(columns, listing["conversations"]["rows"][0]))
assert row["project_url"] == PROJECT_URL
client.conversation.next_question_answer = Response("done")
assert (await answer_question(question["turn_id"], ["order_date"]))[
"project_url"
] == PROJECT_URL
client.conversation.release = threading.Event()
monkeypatch.setattr(cobuild, "TURN_WAIT_TIMEOUT_SECONDS", 0)
pending = await send()
assert pending["status"] in {"queued", "in_progress"}
assert pending["project_url"] == PROJECT_URL
with pytest.raises(ValueError, match=PROJECT_URL):
await send()
client.conversation.release.set()
assert (await wait_for_terminal(pending["turn_id"]))[
"project_url"
] == PROJECT_URL
run(scenario())
def test_failed_turn_payload_carries_the_project_url(environment):
client, _ = environment
client.conversation.error = ConnectionError("connection failed")
async def scenario():
await start()
failed = await send()
assert failed["status"] == "failed"
assert failed["project_url"] == PROJECT_URL
run(scenario())
def test_project_url_uses_the_conversation_pinned_instance(environment):
_client, active_instance = environment
async def scenario():
started = await start()
assert started["project_url"] == PROJECT_URL
entry = cobuild._conversations["conversation-1"]
# A later switch of the active instance must not retarget an existing
# conversation's URL.
active_instance["url"] = "https://b.example.com"
active_instance["name"] = "instance-b"
assert cobuild._project_url(entry) == PROJECT_URL
run(scenario())
def test_project_url_tolerates_a_trailing_slash_in_the_instance_url(environment):
_client, active_instance = environment
active_instance["url"] = "https://a.example.com/"
async def scenario():
assert (await start())["project_url"] == PROJECT_URL
run(scenario())
SHA-256: 618d9ece76cf4809fb79dbe91f0c2166f145f347566e3e6cd0b0805fd6756688