Add persistent conversations and update checks
This commit is contained in:
@@ -173,3 +173,7 @@ def test_continue_stream_executes_approved_tool_and_streams_answer(tmp_path, mon
|
||||
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"
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
import json
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from duck_core.api import create_app
|
||||
from duck_core.model_client import ModelResponse
|
||||
|
||||
|
||||
def test_conversations_api_stores_different_workspaces(tmp_path, monkeypatch):
|
||||
monkeypatch.setenv("DUCK_DB_PATH", str(tmp_path / "duck.sqlite3"))
|
||||
client = TestClient(create_app())
|
||||
|
||||
first = client.post(
|
||||
"/v1/conversations",
|
||||
json={"title": "Project A", "workspace": "/tmp/project-a"},
|
||||
).json()
|
||||
second = client.post(
|
||||
"/v1/conversations",
|
||||
json={"title": "Project B", "workspace": "/tmp/project-b"},
|
||||
).json()
|
||||
listed = client.get("/v1/conversations").json()
|
||||
|
||||
assert first["conversation_id"] != second["conversation_id"]
|
||||
assert {item["workspace"] for item in listed} >= {"/tmp/project-a", "/tmp/project-b"}
|
||||
|
||||
|
||||
def test_chat_api_persists_messages_in_conversation(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):
|
||||
if role == "action":
|
||||
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,
|
||||
)
|
||||
return ModelResponse(
|
||||
role=role,
|
||||
model="local-main",
|
||||
content="Первый ответ сохранен.",
|
||||
reasoning_content="short reasoning",
|
||||
raw={},
|
||||
latency_ms=1.0,
|
||||
)
|
||||
|
||||
monkeypatch.setattr("duck_core.model_client.ModelClient.chat", fake_chat)
|
||||
client = TestClient(create_app())
|
||||
conversation = client.post(
|
||||
"/v1/conversations",
|
||||
json={"title": "Saved chat", "workspace": str(tmp_path)},
|
||||
).json()
|
||||
|
||||
response = client.post(
|
||||
"/v1/chat",
|
||||
json={
|
||||
"conversation_id": conversation["conversation_id"],
|
||||
"message": "Запомни это сообщение",
|
||||
"debug": True,
|
||||
},
|
||||
).json()
|
||||
loaded = client.get(f"/v1/conversations/{conversation['conversation_id']}").json()
|
||||
|
||||
assert response["conversation_id"] == conversation["conversation_id"]
|
||||
assert loaded["workspace"] == str(tmp_path)
|
||||
assert [message["role"] for message in loaded["messages"]] == ["user", "assistant"]
|
||||
assert loaded["messages"][0]["content"] == "Запомни это сообщение"
|
||||
assert loaded["messages"][1]["content"] == "Первый ответ сохранен."
|
||||
assert loaded["messages"][1]["reasoning_content"] == "short reasoning"
|
||||
|
||||
|
||||
def test_conversation_history_is_sent_to_model(tmp_path, monkeypatch):
|
||||
monkeypatch.setenv("DUCK_DB_PATH", str(tmp_path / "duck.sqlite3"))
|
||||
seen_thinker_messages = []
|
||||
|
||||
async def fake_chat(self, role, messages, temperature=None, max_output_tokens=None, response_format=None):
|
||||
if role == "action":
|
||||
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,
|
||||
)
|
||||
seen_thinker_messages.append(messages)
|
||||
return ModelResponse(
|
||||
role=role,
|
||||
model="local-main",
|
||||
content=f"answer {len(seen_thinker_messages)}",
|
||||
reasoning_content=None,
|
||||
raw={},
|
||||
latency_ms=1.0,
|
||||
)
|
||||
|
||||
monkeypatch.setattr("duck_core.model_client.ModelClient.chat", fake_chat)
|
||||
client = TestClient(create_app())
|
||||
conversation = client.post(
|
||||
"/v1/conversations",
|
||||
json={"title": "History", "workspace": str(tmp_path)},
|
||||
).json()
|
||||
|
||||
client.post(
|
||||
"/v1/chat",
|
||||
json={"conversation_id": conversation["conversation_id"], "message": "first"},
|
||||
)
|
||||
client.post(
|
||||
"/v1/chat",
|
||||
json={"conversation_id": conversation["conversation_id"], "message": "second"},
|
||||
)
|
||||
|
||||
second_call_content = [message["content"] for message in seen_thinker_messages[-1]]
|
||||
assert second_call_content == ["first", "answer 1", "second"]
|
||||
@@ -101,6 +101,41 @@ class FakeMultiStepToolModelClient:
|
||||
)
|
||||
|
||||
|
||||
class FakeUpdateCheckModelClient:
|
||||
async def chat(self, role, messages):
|
||||
if role == "action":
|
||||
actions = []
|
||||
if not any("tool_observations" in message["content"] for message in messages):
|
||||
actions = [{"tool": "os_update_check", "args": {}, "reason": "Check OS updates"}]
|
||||
return ModelResponse(
|
||||
role=role,
|
||||
model="local-main",
|
||||
content=json.dumps(
|
||||
{
|
||||
"kind": "action_directive",
|
||||
"intent": "check system updates",
|
||||
"risk_level": "low",
|
||||
"actions": actions,
|
||||
}
|
||||
),
|
||||
reasoning_content=None,
|
||||
raw={},
|
||||
latency_ms=5.0,
|
||||
)
|
||||
assert role == "thinker"
|
||||
observation_text = "\n".join(message["content"] for message in messages)
|
||||
assert "os_update_check" in observation_text
|
||||
assert "requires_approval" not in observation_text
|
||||
return ModelResponse(
|
||||
role=role,
|
||||
model="local-main",
|
||||
content="Updates checked without approval loop.",
|
||||
reasoning_content=None,
|
||||
raw={},
|
||||
latency_ms=12.0,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_runtime_executes_action_directive_tool_and_finishes_with_observation(tmp_path):
|
||||
(tmp_path / "note.txt").write_text("hello from tool")
|
||||
@@ -142,6 +177,25 @@ async def test_runtime_runs_multiple_tool_steps_before_final_answer(tmp_path):
|
||||
assert finished_tools == ["list_dir", "file_read"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_runtime_checks_system_updates_without_approval_loop(tmp_path):
|
||||
db_path = str(tmp_path / "duck.sqlite3")
|
||||
task_store = TaskStore(db_path)
|
||||
event_store = EventStore(db_path)
|
||||
loop = RuntimeLoop(task_store, event_store, FakeUpdateCheckModelClient())
|
||||
|
||||
result = await loop.run_chat("Привет. Проверь обновления в системе", str(tmp_path), debug=True)
|
||||
events = await event_store.list_events(result.task_id)
|
||||
|
||||
assert result.status == "completed"
|
||||
assert not any(event.event_type == "tool_approval_requested" for event in events)
|
||||
assert any(
|
||||
event.event_type == "tool_call_finished"
|
||||
and event.payload["tool"] == "os_update_check"
|
||||
for event in events
|
||||
)
|
||||
|
||||
|
||||
class FakeApprovalModelClient:
|
||||
async def chat(self, role, messages):
|
||||
if role == "action":
|
||||
|
||||
@@ -3,6 +3,7 @@ import pytest
|
||||
from duck_core.tools.file_read import FileReadTool
|
||||
from duck_core.tools.file_write import FileWriteTool
|
||||
from duck_core.tools.gateway import ToolGateway
|
||||
from duck_core.tools.os_update_check import OsUpdateCheckTool
|
||||
from duck_core.tools.shell_exec_safe import ShellExecSafeTool
|
||||
|
||||
|
||||
@@ -26,9 +27,15 @@ async def test_shell_tool_blocks_dangerous_commands(tmp_path):
|
||||
|
||||
allowed = await shell.run({"command": "pwd"})
|
||||
blocked = await shell.run({"command": "rm -rf ."})
|
||||
sudo = await shell.run({"command": "sudo apt update"})
|
||||
|
||||
assert allowed.ok is True
|
||||
assert blocked.ok is False
|
||||
assert blocked.metadata.get("requires_approval") is not True
|
||||
assert blocked.metadata["blocked"] is True
|
||||
assert sudo.ok is False
|
||||
assert sudo.metadata.get("requires_approval") is not True
|
||||
assert sudo.metadata["blocked"] is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -76,3 +83,26 @@ async def test_tool_gateway_searches_file_contents(tmp_path):
|
||||
assert "src/app.py:1:duck tool gateway" in result.output
|
||||
assert result.metadata["matches"] == 1
|
||||
assert escaped.ok is False
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_os_update_check_reports_apt_upgradable_packages(monkeypatch, tmp_path):
|
||||
class Completed:
|
||||
returncode = 0
|
||||
stdout = "Listing...\\nbootlogd/stable 3.14-4 amd64 [upgradable from: 3.06-4]\\n"
|
||||
stderr = "WARNING: apt does not have a stable CLI interface.\\n"
|
||||
|
||||
monkeypatch.setattr("duck_core.tools.os_update_check.shutil.which", lambda name: "/usr/bin/apt")
|
||||
monkeypatch.setattr(
|
||||
"duck_core.tools.os_update_check.subprocess.run",
|
||||
lambda *args, **kwargs: Completed(),
|
||||
)
|
||||
tool = OsUpdateCheckTool(str(tmp_path))
|
||||
|
||||
result = await tool.run({})
|
||||
|
||||
assert result.ok is True
|
||||
assert "bootlogd" in result.output
|
||||
assert result.metadata["package_manager"] == "apt"
|
||||
assert result.metadata["upgradable_count"] == 1
|
||||
assert result.metadata["refreshed_cache"] is False
|
||||
|
||||
Reference in New Issue
Block a user