180 lines
6.5 KiB
Python
180 lines
6.5 KiB
Python
from fastapi.testclient import TestClient
|
|
import json
|
|
import re
|
|
|
|
from duck_core.model_client import ModelResponse
|
|
|
|
from duck_core.api import create_app
|
|
|
|
|
|
def test_stream_chat_endpoint_emits_sse_reasoning_and_content(tmp_path, monkeypatch):
|
|
monkeypatch.setenv("DUCK_DB_PATH", str(tmp_path / "duck.sqlite3"))
|
|
|
|
async def fake_chat(self, role, messages):
|
|
return ModelResponse(
|
|
role=role,
|
|
model="local-main",
|
|
content=json.dumps(
|
|
{
|
|
"kind": "action_directive",
|
|
"intent": "answer directly",
|
|
"risk_level": "none",
|
|
"actions": [],
|
|
}
|
|
),
|
|
reasoning_content=None,
|
|
raw={},
|
|
latency_ms=1.0,
|
|
)
|
|
|
|
async def fake_stream_chat(self, role, messages):
|
|
yield {"type": "reasoning_delta", "delta": "thinking"}
|
|
yield {"type": "content_delta", "delta": "answer"}
|
|
|
|
monkeypatch.setattr("duck_core.model_client.ModelClient.chat", fake_chat)
|
|
monkeypatch.setattr("duck_core.model_client.ModelClient.stream_chat", fake_stream_chat)
|
|
app = create_app()
|
|
client = TestClient(app)
|
|
|
|
with client.stream(
|
|
"POST",
|
|
"/v1/chat/stream",
|
|
json={"message": "hello", "workspace": "./workspace", "debug": True},
|
|
) as response:
|
|
body = "".join(response.iter_text())
|
|
|
|
assert response.status_code == 200
|
|
assert "event: reasoning_delta" in body
|
|
assert "event: content_delta" in body
|
|
assert "event: done" in body
|
|
assert "thinking" in body
|
|
assert "answer" in body
|
|
|
|
|
|
def test_stream_chat_endpoint_executes_tool_before_streaming_answer(tmp_path, monkeypatch):
|
|
monkeypatch.setenv("DUCK_DB_PATH", str(tmp_path / "duck.sqlite3"))
|
|
(tmp_path / "note.txt").write_text("stream tool content")
|
|
|
|
async def fake_chat(self, role, messages, temperature=None, max_output_tokens=None, response_format=None):
|
|
assert role == "action"
|
|
if any("tool_observations" in message["content"] for message in messages):
|
|
actions = []
|
|
else:
|
|
actions = [
|
|
{
|
|
"tool": "file_read",
|
|
"args": {"path": "note.txt"},
|
|
"reason": "User asked for file contents",
|
|
}
|
|
]
|
|
return ModelResponse(
|
|
role=role,
|
|
model="local-main",
|
|
content=json.dumps(
|
|
{
|
|
"kind": "action_directive",
|
|
"intent": "read requested file",
|
|
"risk_level": "low",
|
|
"actions": actions,
|
|
}
|
|
),
|
|
reasoning_content=None,
|
|
raw={},
|
|
latency_ms=1.0,
|
|
)
|
|
|
|
async def fake_stream_chat(self, role, messages):
|
|
assert role == "thinker"
|
|
assert any("tool_observations" in message["content"] for message in messages)
|
|
yield {"type": "content_delta", "delta": "answer from tool"}
|
|
|
|
monkeypatch.setattr("duck_core.model_client.ModelClient.chat", fake_chat)
|
|
monkeypatch.setattr("duck_core.model_client.ModelClient.stream_chat", fake_stream_chat)
|
|
client = TestClient(create_app())
|
|
|
|
with client.stream(
|
|
"POST",
|
|
"/v1/chat/stream",
|
|
json={"message": "read note.txt", "workspace": str(tmp_path), "debug": True},
|
|
) as response:
|
|
body = "".join(response.iter_text())
|
|
|
|
assert response.status_code == 200
|
|
assert "event: tool_call_started" in body
|
|
assert "event: tool_call_finished" in body
|
|
assert "stream tool content" in body
|
|
assert "event: content_delta" in body
|
|
assert "answer from tool" in body
|
|
assert "event: done" in body
|
|
|
|
|
|
def test_continue_stream_executes_approved_tool_and_streams_answer(tmp_path, monkeypatch):
|
|
monkeypatch.setenv("DUCK_DB_PATH", str(tmp_path / "duck.sqlite3"))
|
|
|
|
async def fake_chat(self, role, messages, temperature=None, max_output_tokens=None, response_format=None):
|
|
assert role == "action"
|
|
if any("tool_observations" in message["content"] for message in messages):
|
|
actions = []
|
|
else:
|
|
actions = [
|
|
{
|
|
"tool": "shell_exec_safe",
|
|
"args": {"command": "uname -a"},
|
|
"reason": "User asked for system information",
|
|
}
|
|
]
|
|
return ModelResponse(
|
|
role=role,
|
|
model="local-main",
|
|
content=json.dumps(
|
|
{
|
|
"kind": "action_directive",
|
|
"intent": "run command",
|
|
"risk_level": "medium",
|
|
"actions": actions,
|
|
}
|
|
),
|
|
reasoning_content=None,
|
|
raw={},
|
|
latency_ms=1.0,
|
|
)
|
|
|
|
async def fake_stream_chat(self, role, messages):
|
|
assert role == "thinker"
|
|
observation_message = next(message for message in messages if "tool_observations" in message["content"])
|
|
assert "uname" in observation_message["content"]
|
|
yield {"type": "content_delta", "delta": "continued after approval"}
|
|
|
|
monkeypatch.setattr("duck_core.model_client.ModelClient.chat", fake_chat)
|
|
monkeypatch.setattr("duck_core.model_client.ModelClient.stream_chat", fake_stream_chat)
|
|
client = TestClient(create_app())
|
|
|
|
with client.stream(
|
|
"POST",
|
|
"/v1/chat/stream",
|
|
json={"message": "run uname", "workspace": str(tmp_path), "debug": True},
|
|
) as response:
|
|
initial_body = "".join(response.iter_text())
|
|
task_id = re.search(r'"task_id"\s*:\s*"([^"]+)"', initial_body).group(1)
|
|
pending = client.get("/v1/approvals/pending").json()
|
|
approval = next(item for item in pending if item["task_id"] == task_id)
|
|
client.post(f"/v1/approvals/{approval['approval_id']}/allow_once")
|
|
|
|
with client.stream(
|
|
"POST",
|
|
f"/v1/tasks/{approval['task_id']}/continue/stream",
|
|
json={"approval_id": approval["approval_id"]},
|
|
) as response:
|
|
body = "".join(response.iter_text())
|
|
|
|
assert "event: tool_approval_requested" in initial_body
|
|
assert response.status_code == 200
|
|
assert "event: tool_call_finished" in body
|
|
assert "event: content_delta" in body
|
|
assert "continued after approval" in body
|
|
assert "event: done" in body
|
|
conversation_id = re.search(r'"conversation_id"\s*:\s*"([^"]+)"', initial_body).group(1)
|
|
conversation = client.get(f"/v1/conversations/{conversation_id}").json()
|
|
assert conversation["messages"][-1]["content"] == "continued after approval"
|
|
assert conversation["messages"][-1]["status"] == "completed"
|