Add persistent conversations and update checks
This commit is contained in:
parent
a4b7ef034a
commit
12bed7b6ba
141
duck_core/api.py
141
duck_core/api.py
|
|
@ -13,6 +13,7 @@ from pydantic import BaseModel
|
|||
|
||||
from duck_core.approvals.service import ApprovalService
|
||||
from duck_core.config import get_settings
|
||||
from duck_core.conversations.store import ConversationStore
|
||||
from duck_core.events.store import EventStore
|
||||
from duck_core.experience.recorder import ExperienceRecorder
|
||||
from duck_core.memory.vector_memory import EmbeddingsUnavailableError, VectorMemory
|
||||
|
|
@ -26,15 +27,22 @@ logger = logging.getLogger(__name__)
|
|||
|
||||
class ChatRequest(BaseModel):
|
||||
message: str
|
||||
conversation_id: str | None = None
|
||||
workspace: str | None = None
|
||||
debug: bool = False
|
||||
|
||||
|
||||
class ConversationRequest(BaseModel):
|
||||
title: str | None = None
|
||||
workspace: str | None = None
|
||||
|
||||
|
||||
class ContinueRequest(BaseModel):
|
||||
approval_id: str
|
||||
|
||||
|
||||
def create_app() -> FastAPI:
|
||||
get_settings.cache_clear()
|
||||
settings = get_settings()
|
||||
if settings.api_host == "0.0.0.0":
|
||||
logger.warning(
|
||||
|
|
@ -49,6 +57,7 @@ def create_app() -> FastAPI:
|
|||
|
||||
task_store = TaskStore(settings.db_path)
|
||||
event_store = EventStore(settings.db_path)
|
||||
conversations = ConversationStore(settings.db_path)
|
||||
model_client = ModelClient()
|
||||
approvals = ApprovalService(settings.db_path)
|
||||
runtime = RuntimeLoop(task_store, event_store, model_client, approval_service=approvals)
|
||||
|
|
@ -60,6 +69,7 @@ def create_app() -> FastAPI:
|
|||
async def startup() -> None:
|
||||
await task_store.init()
|
||||
await event_store.init()
|
||||
await conversations.init()
|
||||
await approvals.init()
|
||||
await experience.init()
|
||||
|
||||
|
|
@ -108,8 +118,69 @@ def create_app() -> FastAPI:
|
|||
|
||||
@app.post("/v1/chat")
|
||||
async def chat(body: ChatRequest) -> dict[str, Any]:
|
||||
result = await runtime.run_chat(body.message, body.workspace or settings.workspace, body.debug)
|
||||
return result.__dict__
|
||||
conversation = await conversations.ensure(
|
||||
body.conversation_id,
|
||||
title_from_message(body.message),
|
||||
body.workspace or settings.workspace,
|
||||
)
|
||||
history = await conversation_history(conversation.conversation_id)
|
||||
result = await runtime.run_chat(
|
||||
body.message,
|
||||
conversation.workspace,
|
||||
body.debug,
|
||||
history_messages=history,
|
||||
)
|
||||
await conversations.add_message(
|
||||
conversation.conversation_id,
|
||||
"user",
|
||||
body.message,
|
||||
task_id=result.task_id,
|
||||
status=result.status,
|
||||
)
|
||||
await conversations.add_message(
|
||||
conversation.conversation_id,
|
||||
"assistant",
|
||||
result.final_response,
|
||||
reasoning_content=result.reasoning_content,
|
||||
task_id=result.task_id,
|
||||
status=result.status,
|
||||
)
|
||||
return {**result.__dict__, "conversation_id": conversation.conversation_id}
|
||||
|
||||
async def conversation_history(conversation_id: str) -> list[dict[str, str]]:
|
||||
messages = await conversations.list_messages(conversation_id, limit=20)
|
||||
return [
|
||||
{"role": message.role, "content": message.content}
|
||||
for message in messages
|
||||
if message.role in {"user", "assistant"} and message.content
|
||||
]
|
||||
|
||||
def title_from_message(message: str) -> str:
|
||||
title = " ".join(message.strip().split())
|
||||
return title[:60] or "New chat"
|
||||
|
||||
@app.get("/v1/conversations")
|
||||
async def list_conversations() -> list[dict[str, Any]]:
|
||||
return [conversation.model_dump() for conversation in await conversations.list()]
|
||||
|
||||
@app.post("/v1/conversations")
|
||||
async def create_conversation(body: ConversationRequest) -> dict[str, Any]:
|
||||
conversation = await conversations.create(
|
||||
body.title or "New chat",
|
||||
body.workspace or settings.workspace,
|
||||
)
|
||||
return conversation.model_dump()
|
||||
|
||||
@app.get("/v1/conversations/{conversation_id}")
|
||||
async def get_conversation(conversation_id: str) -> dict[str, Any]:
|
||||
conversation = await conversations.get(conversation_id)
|
||||
if conversation is None:
|
||||
raise HTTPException(status_code=404, detail="Conversation not found")
|
||||
messages = await conversations.list_messages(conversation_id)
|
||||
return {
|
||||
**conversation.model_dump(),
|
||||
"messages": [message.model_dump() for message in messages],
|
||||
}
|
||||
|
||||
def sse(event: str, payload: dict[str, Any]) -> str:
|
||||
return f"event: {event}\ndata: {json.dumps(payload, ensure_ascii=False)}\n\n"
|
||||
|
|
@ -128,16 +199,23 @@ def create_app() -> FastAPI:
|
|||
@app.post("/v1/chat/stream")
|
||||
async def chat_stream(body: ChatRequest) -> StreamingResponse:
|
||||
async def generator():
|
||||
conversation = await conversations.ensure(
|
||||
body.conversation_id,
|
||||
title_from_message(body.message),
|
||||
body.workspace or settings.workspace,
|
||||
)
|
||||
history = await conversation_history(conversation.conversation_id)
|
||||
task = await task_store.create_task(
|
||||
body.message, body.workspace or settings.workspace, body.debug
|
||||
body.message, conversation.workspace, body.debug
|
||||
)
|
||||
task_event = await event_store.append(
|
||||
task.task_id,
|
||||
"task_created",
|
||||
{
|
||||
"message": body.message,
|
||||
"workspace": body.workspace or settings.workspace,
|
||||
"workspace": conversation.workspace,
|
||||
"debug": body.debug,
|
||||
"conversation_id": conversation.conversation_id,
|
||||
},
|
||||
)
|
||||
yield sse("task_created", task_event.model_dump())
|
||||
|
|
@ -145,9 +223,9 @@ def create_app() -> FastAPI:
|
|||
reasoning_parts: list[str] = []
|
||||
content_parts: list[str] = []
|
||||
try:
|
||||
messages = runtime.context_builder.build_basic_messages(task)
|
||||
messages = runtime.context_builder.build_basic_messages(task, history)
|
||||
tool_observations = await runtime._run_action_loop(
|
||||
task.task_id, messages, body.workspace or settings.workspace
|
||||
task.task_id, messages, conversation.workspace
|
||||
)
|
||||
async for tool_event in emit_tool_events(task.task_id, task_event.sequence):
|
||||
yield tool_event
|
||||
|
|
@ -158,10 +236,25 @@ def create_app() -> FastAPI:
|
|||
"task_waiting_for_approval",
|
||||
{"observations": tool_observations},
|
||||
)
|
||||
await conversations.add_message(
|
||||
conversation.conversation_id,
|
||||
"user",
|
||||
body.message,
|
||||
task_id=task.task_id,
|
||||
status="waiting_for_approval",
|
||||
)
|
||||
await conversations.add_message(
|
||||
conversation.conversation_id,
|
||||
"assistant",
|
||||
"Waiting for approval.",
|
||||
task_id=task.task_id,
|
||||
status="waiting_for_approval",
|
||||
)
|
||||
yield sse(
|
||||
"done",
|
||||
{
|
||||
"task_id": task.task_id,
|
||||
"conversation_id": conversation.conversation_id,
|
||||
"status": "waiting_for_approval",
|
||||
"final_response": "Waiting for approval.",
|
||||
"reasoning_content": None,
|
||||
|
|
@ -213,6 +306,21 @@ def create_app() -> FastAPI:
|
|||
},
|
||||
)
|
||||
await task_store.complete_task(task.task_id, content)
|
||||
await conversations.add_message(
|
||||
conversation.conversation_id,
|
||||
"user",
|
||||
body.message,
|
||||
task_id=task.task_id,
|
||||
status="completed",
|
||||
)
|
||||
await conversations.add_message(
|
||||
conversation.conversation_id,
|
||||
"assistant",
|
||||
content,
|
||||
reasoning_content=reasoning_content,
|
||||
task_id=task.task_id,
|
||||
status="completed",
|
||||
)
|
||||
await event_store.append(
|
||||
task.task_id,
|
||||
"task_completed",
|
||||
|
|
@ -225,6 +333,7 @@ def create_app() -> FastAPI:
|
|||
"done",
|
||||
{
|
||||
"task_id": task.task_id,
|
||||
"conversation_id": conversation.conversation_id,
|
||||
"status": "completed",
|
||||
"final_response": content,
|
||||
"reasoning_content": reasoning_content,
|
||||
|
|
@ -297,6 +406,7 @@ def create_app() -> FastAPI:
|
|||
raise HTTPException(status_code=404, detail="Approval not found for task")
|
||||
if approval.decision is None:
|
||||
raise HTTPException(status_code=409, detail="Approval is still pending")
|
||||
conversation_id = await conversations.get_conversation_id_for_task(task_id)
|
||||
|
||||
async def generator():
|
||||
reasoning_parts: list[str] = []
|
||||
|
|
@ -329,10 +439,19 @@ def create_app() -> FastAPI:
|
|||
"task_waiting_for_approval",
|
||||
{"observations": tool_observations},
|
||||
)
|
||||
if conversation_id:
|
||||
await conversations.add_message(
|
||||
conversation_id,
|
||||
"assistant",
|
||||
"Waiting for approval.",
|
||||
task_id=task_id,
|
||||
status="waiting_for_approval",
|
||||
)
|
||||
yield sse(
|
||||
"done",
|
||||
{
|
||||
"task_id": task_id,
|
||||
"conversation_id": conversation_id,
|
||||
"status": "waiting_for_approval",
|
||||
"final_response": "Waiting for approval.",
|
||||
"reasoning_content": None,
|
||||
|
|
@ -378,6 +497,15 @@ def create_app() -> FastAPI:
|
|||
},
|
||||
)
|
||||
await task_store.complete_task(task_id, content)
|
||||
if conversation_id:
|
||||
await conversations.add_message(
|
||||
conversation_id,
|
||||
"assistant",
|
||||
content,
|
||||
reasoning_content=reasoning_content,
|
||||
task_id=task_id,
|
||||
status="completed",
|
||||
)
|
||||
await event_store.append(
|
||||
task_id,
|
||||
"task_completed",
|
||||
|
|
@ -390,6 +518,7 @@ def create_app() -> FastAPI:
|
|||
"done",
|
||||
{
|
||||
"task_id": task_id,
|
||||
"conversation_id": conversation_id,
|
||||
"status": "completed",
|
||||
"final_response": content,
|
||||
"reasoning_content": reasoning_content,
|
||||
|
|
|
|||
|
|
@ -2,10 +2,15 @@ from duck_core.tasks.state import TaskState
|
|||
|
||||
|
||||
class ContextBuilder:
|
||||
def build_basic_messages(self, task: TaskState) -> list[dict[str, str]]:
|
||||
def build_basic_messages(
|
||||
self,
|
||||
task: TaskState,
|
||||
history_messages: list[dict[str, str]] | None = None,
|
||||
) -> list[dict[str, str]]:
|
||||
return [
|
||||
*(history_messages or []),
|
||||
{
|
||||
"role": "user",
|
||||
"content": task.user_message,
|
||||
}
|
||||
},
|
||||
]
|
||||
|
|
|
|||
|
|
@ -0,0 +1,3 @@
|
|||
__all__ = ["ConversationStore"]
|
||||
|
||||
from duck_core.conversations.store import ConversationStore
|
||||
|
|
@ -0,0 +1,210 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from uuid import uuid4
|
||||
|
||||
import aiosqlite
|
||||
from pydantic import BaseModel
|
||||
|
||||
from duck_core.tasks.store import utc_now
|
||||
|
||||
|
||||
class Conversation(BaseModel):
|
||||
id: int | None = None
|
||||
conversation_id: str
|
||||
title: str
|
||||
workspace: str
|
||||
created_at: str
|
||||
updated_at: str
|
||||
|
||||
|
||||
class ConversationMessage(BaseModel):
|
||||
id: int | None = None
|
||||
conversation_id: str
|
||||
role: str
|
||||
content: str
|
||||
reasoning_content: str | None = None
|
||||
task_id: str | None = None
|
||||
status: str | None = None
|
||||
created_at: str
|
||||
|
||||
|
||||
class ConversationStore:
|
||||
def __init__(self, db_path: str):
|
||||
self.db_path = Path(db_path)
|
||||
|
||||
async def init(self) -> None:
|
||||
self.db_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
async with aiosqlite.connect(self.db_path) as db:
|
||||
await db.execute(
|
||||
"""
|
||||
create table if not exists conversations (
|
||||
id integer primary key autoincrement,
|
||||
conversation_id text not null unique,
|
||||
title text not null,
|
||||
workspace text not null,
|
||||
created_at text not null,
|
||||
updated_at text not null
|
||||
)
|
||||
"""
|
||||
)
|
||||
await db.execute(
|
||||
"""
|
||||
create table if not exists conversation_messages (
|
||||
id integer primary key autoincrement,
|
||||
conversation_id text not null,
|
||||
role text not null,
|
||||
content text not null,
|
||||
reasoning_content text,
|
||||
task_id text,
|
||||
status text,
|
||||
created_at text not null
|
||||
)
|
||||
"""
|
||||
)
|
||||
await db.commit()
|
||||
|
||||
async def create(self, title: str, workspace: str) -> Conversation:
|
||||
await self.init()
|
||||
now = utc_now()
|
||||
conversation_id = f"chat_{uuid4().hex[:12]}"
|
||||
async with aiosqlite.connect(self.db_path) as db:
|
||||
cursor = await db.execute(
|
||||
"""
|
||||
insert into conversations(conversation_id, title, workspace, created_at, updated_at)
|
||||
values (?, ?, ?, ?, ?)
|
||||
""",
|
||||
(conversation_id, title, workspace, now, now),
|
||||
)
|
||||
await db.commit()
|
||||
row_id = cursor.lastrowid
|
||||
return Conversation(
|
||||
id=row_id,
|
||||
conversation_id=conversation_id,
|
||||
title=title,
|
||||
workspace=workspace,
|
||||
created_at=now,
|
||||
updated_at=now,
|
||||
)
|
||||
|
||||
async def ensure(
|
||||
self, conversation_id: str | None, title: str, workspace: str
|
||||
) -> Conversation:
|
||||
if conversation_id:
|
||||
existing = await self.get(conversation_id)
|
||||
if existing is not None:
|
||||
return existing
|
||||
return await self.create(title, workspace)
|
||||
|
||||
async def get(self, conversation_id: str) -> Conversation | None:
|
||||
await self.init()
|
||||
async with aiosqlite.connect(self.db_path) as db:
|
||||
db.row_factory = aiosqlite.Row
|
||||
cursor = await db.execute(
|
||||
"select * from conversations where conversation_id = ?", (conversation_id,)
|
||||
)
|
||||
row = await cursor.fetchone()
|
||||
return self._row_to_conversation(row) if row else None
|
||||
|
||||
async def list(self, limit: int = 50) -> list[Conversation]:
|
||||
await self.init()
|
||||
async with aiosqlite.connect(self.db_path) as db:
|
||||
db.row_factory = aiosqlite.Row
|
||||
cursor = await db.execute(
|
||||
"select * from conversations order by updated_at desc limit ?", (limit,)
|
||||
)
|
||||
rows = await cursor.fetchall()
|
||||
return [self._row_to_conversation(row) for row in rows]
|
||||
|
||||
async def add_message(
|
||||
self,
|
||||
conversation_id: str,
|
||||
role: str,
|
||||
content: str,
|
||||
reasoning_content: str | None = None,
|
||||
task_id: str | None = None,
|
||||
status: str | None = None,
|
||||
) -> ConversationMessage:
|
||||
await self.init()
|
||||
now = utc_now()
|
||||
async with aiosqlite.connect(self.db_path) as db:
|
||||
cursor = await db.execute(
|
||||
"""
|
||||
insert into conversation_messages(
|
||||
conversation_id, role, content, reasoning_content, task_id, status, created_at
|
||||
) values (?, ?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(conversation_id, role, content, reasoning_content, task_id, status, now),
|
||||
)
|
||||
await db.execute(
|
||||
"update conversations set updated_at = ? where conversation_id = ?",
|
||||
(now, conversation_id),
|
||||
)
|
||||
await db.commit()
|
||||
row_id = cursor.lastrowid
|
||||
return ConversationMessage(
|
||||
id=row_id,
|
||||
conversation_id=conversation_id,
|
||||
role=role,
|
||||
content=content,
|
||||
reasoning_content=reasoning_content,
|
||||
task_id=task_id,
|
||||
status=status,
|
||||
created_at=now,
|
||||
)
|
||||
|
||||
async def list_messages(
|
||||
self, conversation_id: str, limit: int | None = None
|
||||
) -> list[ConversationMessage]:
|
||||
await self.init()
|
||||
sql = "select * from conversation_messages where conversation_id = ? order by id"
|
||||
params: tuple[Any, ...] = (conversation_id,)
|
||||
if limit is not None:
|
||||
sql = (
|
||||
"select * from (select * from conversation_messages where conversation_id = ? "
|
||||
"order by id desc limit ?) order by id"
|
||||
)
|
||||
params = (conversation_id, limit)
|
||||
async with aiosqlite.connect(self.db_path) as db:
|
||||
db.row_factory = aiosqlite.Row
|
||||
cursor = await db.execute(sql, params)
|
||||
rows = await cursor.fetchall()
|
||||
return [self._row_to_message(row) for row in rows]
|
||||
|
||||
async def get_conversation_id_for_task(self, task_id: str) -> str | None:
|
||||
await self.init()
|
||||
async with aiosqlite.connect(self.db_path) as db:
|
||||
cursor = await db.execute(
|
||||
"""
|
||||
select conversation_id from conversation_messages
|
||||
where task_id = ?
|
||||
order by id
|
||||
limit 1
|
||||
""",
|
||||
(task_id,),
|
||||
)
|
||||
row = await cursor.fetchone()
|
||||
return row[0] if row else None
|
||||
|
||||
def _row_to_conversation(self, row: aiosqlite.Row) -> Conversation:
|
||||
return Conversation(
|
||||
id=row["id"],
|
||||
conversation_id=row["conversation_id"],
|
||||
title=row["title"],
|
||||
workspace=row["workspace"],
|
||||
created_at=row["created_at"],
|
||||
updated_at=row["updated_at"],
|
||||
)
|
||||
|
||||
def _row_to_message(self, row: aiosqlite.Row) -> ConversationMessage:
|
||||
return ConversationMessage(
|
||||
id=row["id"],
|
||||
conversation_id=row["conversation_id"],
|
||||
role=row["role"],
|
||||
content=row["content"],
|
||||
reasoning_content=row["reasoning_content"],
|
||||
task_id=row["task_id"],
|
||||
status=row["status"],
|
||||
created_at=row["created_at"],
|
||||
)
|
||||
|
|
@ -37,7 +37,11 @@ class RuntimeLoop:
|
|||
self.max_tool_iterations = max_tool_iterations
|
||||
|
||||
async def run_chat(
|
||||
self, message: str, workspace: str | None = None, debug: bool = False
|
||||
self,
|
||||
message: str,
|
||||
workspace: str | None = None,
|
||||
debug: bool = False,
|
||||
history_messages: list[dict[str, str]] | None = None,
|
||||
) -> ChatResult:
|
||||
task = await self.task_store.create_task(message, workspace, debug)
|
||||
await self.event_store.append(
|
||||
|
|
@ -46,7 +50,7 @@ class RuntimeLoop:
|
|||
{"message": message, "workspace": workspace, "debug": debug},
|
||||
)
|
||||
try:
|
||||
messages = self.context_builder.build_basic_messages(task)
|
||||
messages = self.context_builder.build_basic_messages(task, history_messages)
|
||||
tool_observations = await self._run_action_loop(task.task_id, messages, workspace)
|
||||
if any(observation.get("requires_approval") for observation in tool_observations):
|
||||
await self.task_store.waiting_for_approval(task.task_id)
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ from duck_core.tools.base import Tool, ToolResult
|
|||
from duck_core.tools.file_read import FileReadTool
|
||||
from duck_core.tools.file_write import FileWriteTool
|
||||
from duck_core.tools.list_dir import ListDirTool
|
||||
from duck_core.tools.os_update_check import OsUpdateCheckTool
|
||||
from duck_core.tools.search_files import SearchFilesTool
|
||||
from duck_core.tools.shell_exec_safe import ShellExecSafeTool
|
||||
|
||||
|
|
@ -20,6 +21,7 @@ class ToolGateway:
|
|||
FileWriteTool(workspace),
|
||||
ListDirTool(workspace),
|
||||
SearchFilesTool(workspace),
|
||||
OsUpdateCheckTool(workspace),
|
||||
ShellExecSafeTool(workspace),
|
||||
]
|
||||
)
|
||||
|
|
|
|||
|
|
@ -0,0 +1,58 @@
|
|||
import shutil
|
||||
import subprocess
|
||||
from typing import Any
|
||||
|
||||
from duck_core.tools.base import ToolResult
|
||||
|
||||
|
||||
class OsUpdateCheckTool:
|
||||
name = "os_update_check"
|
||||
risk_level = "low"
|
||||
|
||||
def __init__(self, workspace: str, timeout_seconds: int = 60, max_lines: int = 300):
|
||||
self.workspace = workspace
|
||||
self.timeout_seconds = timeout_seconds
|
||||
self.max_lines = max_lines
|
||||
|
||||
async def run(self, args: dict[str, Any]) -> ToolResult:
|
||||
if shutil.which("apt"):
|
||||
return self._run_apt()
|
||||
return ToolResult(
|
||||
ok=False,
|
||||
error="No supported package manager found for update checks.",
|
||||
metadata={"supported_package_managers": ["apt"]},
|
||||
)
|
||||
|
||||
def _run_apt(self) -> ToolResult:
|
||||
try:
|
||||
completed = subprocess.run(
|
||||
["apt", "list", "--upgradable"],
|
||||
cwd=self.workspace,
|
||||
text=True,
|
||||
capture_output=True,
|
||||
timeout=self.timeout_seconds,
|
||||
check=False,
|
||||
)
|
||||
except subprocess.SubprocessError as exc:
|
||||
return ToolResult(ok=False, error=str(exc), metadata={"package_manager": "apt"})
|
||||
|
||||
lines = completed.stdout.splitlines()
|
||||
package_lines = [line for line in lines if "/" in line and "upgradable from:" in line]
|
||||
output_lines = lines[: self.max_lines]
|
||||
truncated = len(lines) > self.max_lines
|
||||
if truncated:
|
||||
output_lines.append(f"... truncated after {self.max_lines} lines")
|
||||
|
||||
return ToolResult(
|
||||
ok=completed.returncode == 0,
|
||||
output="\n".join(output_lines),
|
||||
error=completed.stderr or None,
|
||||
metadata={
|
||||
"package_manager": "apt",
|
||||
"command": "apt list --upgradable",
|
||||
"returncode": completed.returncode,
|
||||
"upgradable_count": len(package_lines),
|
||||
"refreshed_cache": False,
|
||||
"truncated": truncated,
|
||||
},
|
||||
)
|
||||
|
|
@ -58,9 +58,10 @@ class ShellExecSafeTool:
|
|||
async def run(self, args: dict[str, Any]) -> ToolResult:
|
||||
command = str(args.get("command", "")).strip()
|
||||
approved = bool(args.get("_approved"))
|
||||
allowed, reason = self._is_allowed(command, approved=approved)
|
||||
allowed, reason, blocked = self._is_allowed(command, approved=approved)
|
||||
if not allowed:
|
||||
return ToolResult(ok=False, error=reason, metadata={"requires_approval": True})
|
||||
metadata = {"blocked": True} if blocked else {"requires_approval": True}
|
||||
return ToolResult(ok=False, error=reason, metadata=metadata)
|
||||
try:
|
||||
completed = subprocess.run(
|
||||
command,
|
||||
|
|
@ -80,19 +81,29 @@ class ShellExecSafeTool:
|
|||
metadata={"returncode": completed.returncode, "command": command},
|
||||
)
|
||||
|
||||
def _is_allowed(self, command: str, approved: bool = False) -> tuple[bool, str | None]:
|
||||
def _is_allowed(
|
||||
self, command: str, approved: bool = False
|
||||
) -> tuple[bool, str | None, bool]:
|
||||
if not command:
|
||||
return False, "Empty command"
|
||||
return False, "Empty command", False
|
||||
lowered = command.lower()
|
||||
for blocked in BLOCKLIST:
|
||||
if lowered.startswith(blocked.lower()) or blocked.lower() in lowered:
|
||||
return False, f"Command is blocked: {blocked}"
|
||||
if approved:
|
||||
return True, None
|
||||
parts = shlex.split(command)
|
||||
for blocked in BLOCKLIST:
|
||||
if self._matches_blocked_command(lowered, parts, blocked):
|
||||
return False, f"Command is blocked: {blocked}", True
|
||||
if approved:
|
||||
return True, None, False
|
||||
prefix1 = parts[0] if parts else ""
|
||||
prefix2 = " ".join(parts[:2])
|
||||
prefix3 = " ".join(parts[:3])
|
||||
if prefix1 in ALLOWLIST or prefix2 in ALLOWLIST or prefix3 in ALLOWLIST:
|
||||
return True, None
|
||||
return False, "Command is outside allowlist and requires approval"
|
||||
return True, None, False
|
||||
return False, "Command is outside allowlist and requires approval", False
|
||||
|
||||
def _matches_blocked_command(
|
||||
self, lowered_command: str, parts: list[str], blocked: str
|
||||
) -> bool:
|
||||
lowered_blocked = blocked.lower()
|
||||
if " " in lowered_blocked or "|" in lowered_blocked:
|
||||
return lowered_command.startswith(lowered_blocked) or lowered_blocked in lowered_command
|
||||
return bool(parts) and parts[0].lower() == lowered_blocked
|
||||
|
|
|
|||
|
|
@ -1,6 +1,8 @@
|
|||
const state = {
|
||||
running: false,
|
||||
messages: [],
|
||||
currentConversationId: "",
|
||||
conversations: [],
|
||||
};
|
||||
|
||||
async function jsonFetch(url, options) {
|
||||
|
|
@ -52,6 +54,35 @@ function addMessage(role, content, meta = "", options = {}) {
|
|||
return article;
|
||||
}
|
||||
|
||||
function clearMessages() {
|
||||
const messages = document.querySelector("#messages");
|
||||
if (messages) messages.innerHTML = "";
|
||||
const events = document.querySelector("#events");
|
||||
if (events) events.innerHTML = "";
|
||||
}
|
||||
|
||||
function setConversationHeader(conversation) {
|
||||
const title = document.querySelector("#chat-title");
|
||||
const subtitle = document.querySelector("#chat-subtitle");
|
||||
if (title) title.textContent = conversation?.title || "Chat";
|
||||
if (subtitle) {
|
||||
subtitle.textContent = conversation?.workspace
|
||||
? `Workspace: ${conversation.workspace}`
|
||||
: "Messages are processed by the local Qwen role mapping through Duck Core.";
|
||||
}
|
||||
}
|
||||
|
||||
function addStoredMessage(message) {
|
||||
const article = addMessage(
|
||||
message.role,
|
||||
message.content,
|
||||
message.status || "saved",
|
||||
{reasoning: message.role === "assistant" && Boolean(message.reasoning_content)},
|
||||
);
|
||||
if (message.reasoning_content) finishInlineReasoning(article, message.reasoning_content);
|
||||
return article;
|
||||
}
|
||||
|
||||
function createInlineReasoning() {
|
||||
const section = document.createElement("section");
|
||||
section.className = "message-reasoning is-collapsed";
|
||||
|
|
@ -112,6 +143,9 @@ function formatToolCommand(tool, args) {
|
|||
if (tool === "shell_exec_safe") return `$ ${args.command || tool}`;
|
||||
if (tool === "file_read") return `$ file_read ${args.path || ""}`.trim();
|
||||
if (tool === "file_write") return `$ file_write ${args.path || ""}`.trim();
|
||||
if (tool === "list_dir") return `$ list_dir ${args.path || "."}`.trim();
|
||||
if (tool === "search_files") return `$ search_files ${args.query || ""}`.trim();
|
||||
if (tool === "os_update_check") return "$ os_update_check";
|
||||
return `$ ${tool || "tool"}`;
|
||||
}
|
||||
|
||||
|
|
@ -363,6 +397,9 @@ async function handleAssistantStreamEvent(pending, name, data, context) {
|
|||
if (data.task_id) context.taskId = data.task_id;
|
||||
if (name === "task_created") {
|
||||
context.taskId = data.task_id;
|
||||
if (data.payload?.conversation_id) {
|
||||
state.currentConversationId = data.payload.conversation_id;
|
||||
}
|
||||
setStatus("#task-status", data.task_id, "warn");
|
||||
return;
|
||||
}
|
||||
|
|
@ -396,6 +433,7 @@ async function handleAssistantStreamEvent(pending, name, data, context) {
|
|||
return;
|
||||
}
|
||||
if (name === "done") {
|
||||
if (data.conversation_id) state.currentConversationId = data.conversation_id;
|
||||
if (!context.contentStarted) {
|
||||
setMessagePending(pending, data.final_response || "No final content returned.");
|
||||
}
|
||||
|
|
@ -403,6 +441,7 @@ async function handleAssistantStreamEvent(pending, name, data, context) {
|
|||
setStatus("#task-status", data.task_id, data.status === "completed" ? "ok" : "warn");
|
||||
finishInlineReasoning(pending, data.reasoning_content);
|
||||
await refreshEvents(data.task_id);
|
||||
await refreshConversations();
|
||||
return;
|
||||
}
|
||||
if (name === "error") {
|
||||
|
|
@ -434,6 +473,59 @@ async function continueAfterInlineApproval(article, taskId, approvalId) {
|
|||
}
|
||||
}
|
||||
|
||||
async function refreshConversations() {
|
||||
const list = document.querySelector("#conversation-list");
|
||||
if (!list) return;
|
||||
state.conversations = await jsonFetch("/v1/conversations");
|
||||
list.innerHTML = "";
|
||||
if (!state.conversations.length) {
|
||||
const empty = document.createElement("p");
|
||||
empty.className = "conversation-empty";
|
||||
empty.textContent = "No saved chats.";
|
||||
list.append(empty);
|
||||
return;
|
||||
}
|
||||
for (const conversation of state.conversations) {
|
||||
const button = document.createElement("button");
|
||||
button.type = "button";
|
||||
button.className = "conversation-item";
|
||||
button.dataset.conversationId = conversation.conversation_id;
|
||||
button.classList.toggle("active", conversation.conversation_id === state.currentConversationId);
|
||||
|
||||
const title = document.createElement("strong");
|
||||
title.textContent = conversation.title;
|
||||
const workspace = document.createElement("span");
|
||||
workspace.textContent = conversation.workspace;
|
||||
button.append(title, workspace);
|
||||
list.append(button);
|
||||
}
|
||||
}
|
||||
|
||||
async function selectConversation(conversationId) {
|
||||
const conversation = await jsonFetch(`/v1/conversations/${conversationId}`);
|
||||
state.currentConversationId = conversation.conversation_id;
|
||||
document.querySelector("#workspace").value = conversation.workspace;
|
||||
setConversationHeader(conversation);
|
||||
clearMessages();
|
||||
if (!conversation.messages.length) {
|
||||
addMessage("assistant", "Новый чат готов.", "ready");
|
||||
} else {
|
||||
for (const message of conversation.messages) addStoredMessage(message);
|
||||
}
|
||||
await refreshConversations();
|
||||
}
|
||||
|
||||
async function createNewConversation() {
|
||||
const workspace = document.querySelector("#workspace").value || "./workspace";
|
||||
const conversation = await jsonFetch("/v1/conversations", {
|
||||
method: "POST",
|
||||
headers: {"Content-Type": "application/json"},
|
||||
body: JSON.stringify({title: "New chat", workspace}),
|
||||
});
|
||||
await selectConversation(conversation.conversation_id);
|
||||
setStatus("#task-status", "none");
|
||||
}
|
||||
|
||||
async function sendMessage() {
|
||||
if (state.running) return;
|
||||
const input = document.querySelector("#message");
|
||||
|
|
@ -451,6 +543,7 @@ async function sendMessage() {
|
|||
try {
|
||||
await streamChat({
|
||||
message,
|
||||
conversation_id: state.currentConversationId || null,
|
||||
workspace: document.querySelector("#workspace").value,
|
||||
debug: document.querySelector("#debug").checked,
|
||||
}, async ({name, data}) => {
|
||||
|
|
@ -500,11 +593,14 @@ function bindChat() {
|
|||
}
|
||||
});
|
||||
document.querySelector("#new-chat")?.addEventListener("click", () => {
|
||||
const messages = document.querySelector("#messages");
|
||||
messages.innerHTML = "";
|
||||
addMessage("assistant", "Новая сессия готова.", "ready");
|
||||
document.querySelector("#events").innerHTML = "";
|
||||
setStatus("#task-status", "none");
|
||||
createNewConversation().catch(console.error);
|
||||
});
|
||||
document.querySelector("#reload-chat")?.addEventListener("click", () => {
|
||||
if (state.currentConversationId) selectConversation(state.currentConversationId).catch(console.error);
|
||||
});
|
||||
document.querySelector("#conversation-list")?.addEventListener("click", (event) => {
|
||||
const item = event.target.closest("[data-conversation-id]");
|
||||
if (item) selectConversation(item.dataset.conversationId).catch(console.error);
|
||||
});
|
||||
document.querySelector("#messages")?.addEventListener("click", (event) => {
|
||||
const approvalButton = event.target.closest("[data-inline-approval-action]");
|
||||
|
|
@ -617,3 +713,8 @@ document.querySelector("#memory-search")?.addEventListener("click", async () =>
|
|||
bindChat();
|
||||
checkRuntime();
|
||||
loadSimplePages().catch(console.error);
|
||||
refreshConversations().then(() => {
|
||||
if (state.conversations[0]) {
|
||||
return selectConversation(state.conversations[0].conversation_id);
|
||||
}
|
||||
}).catch(console.error);
|
||||
|
|
|
|||
|
|
@ -233,7 +233,8 @@ button {
|
|||
}
|
||||
|
||||
.settings-panel,
|
||||
.status-panel {
|
||||
.status-panel,
|
||||
.conversation-panel {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
padding: 14px;
|
||||
|
|
@ -243,11 +244,79 @@ button {
|
|||
}
|
||||
|
||||
.settings-panel h2,
|
||||
.status-panel h2 {
|
||||
.status-panel h2,
|
||||
.conversation-panel h2 {
|
||||
font-size: 13px;
|
||||
color: #f8fafc;
|
||||
}
|
||||
|
||||
.panel-heading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.panel-heading button {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border: 0;
|
||||
border-radius: 7px;
|
||||
background: #f8fafc;
|
||||
color: #111827;
|
||||
font-size: 18px;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.conversation-list {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
max-height: 260px;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.conversation-item {
|
||||
display: grid;
|
||||
gap: 3px;
|
||||
width: 100%;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 7px;
|
||||
padding: 9px 10px;
|
||||
background: transparent;
|
||||
color: #e5edf7;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.conversation-item:hover,
|
||||
.conversation-item.active {
|
||||
background: var(--sidebar-soft);
|
||||
border-color: rgba(255,255,255,0.12);
|
||||
}
|
||||
|
||||
.conversation-item strong,
|
||||
.conversation-item span {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.conversation-item strong {
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.conversation-item span,
|
||||
.conversation-empty {
|
||||
color: #9ca3af;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.conversation-empty {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
label {
|
||||
display: grid;
|
||||
gap: 7px;
|
||||
|
|
|
|||
|
|
@ -25,6 +25,14 @@
|
|||
<a href="/experience">Experience</a>
|
||||
</nav>
|
||||
|
||||
<section class="conversation-panel" aria-labelledby="conversations-title">
|
||||
<div class="panel-heading">
|
||||
<h2 id="conversations-title">Chats</h2>
|
||||
<button id="new-chat" type="button" title="New chat">+</button>
|
||||
</div>
|
||||
<div id="conversation-list" class="conversation-list"></div>
|
||||
</section>
|
||||
|
||||
<section class="settings-panel" aria-labelledby="settings-title">
|
||||
<h2 id="settings-title">Session</h2>
|
||||
<label>
|
||||
|
|
@ -59,10 +67,10 @@
|
|||
<main class="chat-shell">
|
||||
<header class="chat-header">
|
||||
<div>
|
||||
<h2>Chat</h2>
|
||||
<p>Messages are processed by the local Qwen role mapping through Duck Core.</p>
|
||||
<h2 id="chat-title">Chat</h2>
|
||||
<p id="chat-subtitle">Messages are processed by the local Qwen role mapping through Duck Core.</p>
|
||||
</div>
|
||||
<button id="new-chat" class="secondary-button" type="button">New Chat</button>
|
||||
<button id="reload-chat" class="secondary-button" type="button">Reload</button>
|
||||
</header>
|
||||
|
||||
<section id="messages" class="messages" aria-live="polite">
|
||||
|
|
|
|||
|
|
@ -12,6 +12,9 @@ Available tools:
|
|||
Args: {"path": "."}
|
||||
- search_files: search text inside files in the current workspace.
|
||||
Args: {"query": "text to find", "path": ".", "glob": "*.py"}
|
||||
- os_update_check: check available operating system package updates without
|
||||
refreshing package manager caches and without sudo.
|
||||
Args: {}
|
||||
- shell_exec_safe: run a safe allowlisted shell command in the current workspace.
|
||||
Args: {"command": "pwd"}
|
||||
|
||||
|
|
@ -21,3 +24,6 @@ follow-up information. Return actions=[] when the observations are sufficient
|
|||
for the thinker to answer.
|
||||
Use only the listed tools. Keep actions minimal and directly tied to the user's
|
||||
request. Do not invent tool names.
|
||||
For user requests like "check updates in the system", use os_update_check.
|
||||
Do not use apt update, apt-get update, sudo, su, or package installation commands
|
||||
for update checks.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in New Issue