- 后端: coworker 智能体框架, WS API, 文件上传, 附件处理 - 前端: Open WebUI, 文件全量走 upload API (含 MD/TXT/JSON 等文本类) - 技能: md-to-office (pandoc + wkhtmltopdf) - 修复: 上传文件路径丢失, Agent 搜索浪费, 输出文件跑到 uploads/ - 打包: PyInstaller one-dir, 预打包 pandoc/wkhtmltopdf/chromium
182 lines
6.8 KiB
Python
182 lines
6.8 KiB
Python
"""Phase 2 gate — the Inbox: 3 item kinds, the resolve state machine, reconciliation, approver."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
|
|
from coworker.inbox import (
|
|
KIND_APPROVAL,
|
|
KIND_NOTIFICATION,
|
|
STATE_RESOLVED,
|
|
InboxStore,
|
|
inbox_approver,
|
|
)
|
|
|
|
|
|
def test_add_and_filter(tmp_path):
|
|
store = InboxStore(tmp_path / "inbox.json")
|
|
store.add_approval("s1", "Run shell?")
|
|
store.add_question("s1", "Which env?")
|
|
store.add_notification("s2", "Report ready")
|
|
assert len(store.list(session_id="s1")) == 2
|
|
assert len(store.pending("s1")) == 2
|
|
assert store.list(session_id="s2")[0].kind == KIND_NOTIFICATION
|
|
|
|
|
|
def test_resolve_is_idempotent_first_responder_wins(tmp_path):
|
|
store = InboxStore(tmp_path / "inbox.json")
|
|
item = store.add_approval("s1", "Run shell?")
|
|
assert store.resolve(item.id, "allow") is True
|
|
# A second resolution from any surface is a no-op; the first answer stands.
|
|
assert store.resolve(item.id, "deny") is False
|
|
got = store.get(item.id)
|
|
assert got.state == STATE_RESOLVED and got.resolution == "allow"
|
|
|
|
|
|
def test_resolve_unknown_item(tmp_path):
|
|
store = InboxStore(tmp_path / "inbox.json")
|
|
assert store.resolve("nope", "allow") is False
|
|
|
|
|
|
def test_persistence(tmp_path):
|
|
store = InboxStore(tmp_path / "inbox.json")
|
|
item = store.add_approval("s1", "Run shell?")
|
|
store.resolve(item.id, "allow")
|
|
reloaded = InboxStore(tmp_path / "inbox.json")
|
|
assert reloaded.get(item.id).resolution == "allow"
|
|
|
|
|
|
def test_reconcile_on_resume(tmp_path):
|
|
store = InboxStore(tmp_path / "inbox.json")
|
|
answered = store.add_approval("s1", "Deploy?")
|
|
store.resolve(answered.id, "allow")
|
|
store.add_question("s1", "Still pending?")
|
|
store.add_approval("other", "Not mine")
|
|
out = store.reconcile_on_resume("s1")
|
|
assert [i["title"] for i in out["pending"]] == ["Still pending?"]
|
|
assert [i["title"] for i in out["recap"]] == ["Deploy?"]
|
|
|
|
|
|
def test_inbox_approver_allow(tmp_path):
|
|
async def run():
|
|
store = InboxStore(tmp_path / "inbox.json")
|
|
from coworker.engine import ApprovalOutcome, PermissionRequest
|
|
|
|
approver = inbox_approver(store, "s1")
|
|
req = PermissionRequest("run_shell", {}, None, "needs approval")
|
|
|
|
async def resolve_soon():
|
|
for _ in range(200):
|
|
pend = store.pending("s1")
|
|
if pend:
|
|
store.resolve(pend[0].id, "allow")
|
|
return
|
|
await asyncio.sleep(0.001)
|
|
|
|
outcome, _ = await asyncio.gather(approver(req), resolve_soon())
|
|
assert outcome is ApprovalOutcome.ONCE
|
|
# The approval came in as an Inbox item.
|
|
assert store.list(session_id="s1")[0].kind == KIND_APPROVAL
|
|
|
|
asyncio.run(run())
|
|
|
|
|
|
def test_inbox_approver_deny(tmp_path):
|
|
async def run():
|
|
store = InboxStore(tmp_path / "inbox.json")
|
|
from coworker.engine import ApprovalOutcome, PermissionRequest
|
|
|
|
approver = inbox_approver(store, "s1")
|
|
req = PermissionRequest("rm", {}, None, "danger")
|
|
|
|
async def resolve_soon():
|
|
for _ in range(200):
|
|
pend = store.pending("s1")
|
|
if pend:
|
|
store.resolve(pend[0].id, "deny")
|
|
return
|
|
await asyncio.sleep(0.001)
|
|
|
|
outcome, _ = await asyncio.gather(approver(req), resolve_soon())
|
|
assert outcome is ApprovalOutcome.DENY
|
|
|
|
asyncio.run(run())
|
|
|
|
|
|
def test_args_preview():
|
|
from coworker.inbox import args_preview
|
|
|
|
assert (
|
|
args_preview({"path": "g.txt", "content": "buy milk"})
|
|
== "path: g.txt · content: buy milk"
|
|
)
|
|
assert args_preview(None) == "" and args_preview({}) == ""
|
|
assert "\n" not in args_preview({"x": "a\nb\nc"}) # newlines collapsed
|
|
assert args_preview({"content": "z" * 300}).endswith("…") # long values truncated
|
|
|
|
|
|
def test_approval_body_includes_tool_args():
|
|
from coworker.engine import PermissionRequest
|
|
from coworker.server.manager import _approval_body
|
|
|
|
req = PermissionRequest(
|
|
"write_file", {"path": "groceries.txt", "content": "buy milk"}, None, ""
|
|
)
|
|
body = _approval_body(req)
|
|
assert "groceries.txt" in body and "buy milk" in body # the card now shows *what*
|
|
|
|
req2 = PermissionRequest("rm", {"path": "/x"}, None, "destructive")
|
|
assert _approval_body(req2).startswith("destructive") # reason leads when present
|
|
|
|
|
|
def test_approval_body_strips_boilerplate_reason():
|
|
"""OPE-136 found-in-testing: the live card filters the engine's default
|
|
"requires approval" boilerplate — the parked/mirrored body must not bake it in."""
|
|
from coworker.engine import PermissionRequest
|
|
from coworker.server.manager import _approval_body
|
|
|
|
req = PermissionRequest("write_file", {"path": "g.txt"}, None, "requires approval")
|
|
body = _approval_body(req)
|
|
assert "requires approval" not in body
|
|
assert "g.txt" in body # the args preview still carries the evidence
|
|
|
|
|
|
def test_approval_prompt_data_carries_mcp_evidence():
|
|
"""§35 parity: the parked item's data holds the same category, destination, and
|
|
(non-boilerplate) reason the live card's event carries — so a reopened session
|
|
renders the identical scope chip and evidence, never the vague fallback."""
|
|
from types import SimpleNamespace
|
|
|
|
from coworker.engine import PermissionRequest
|
|
from coworker.server.manager import SessionManager
|
|
|
|
fake_mgr = SimpleNamespace(
|
|
task_store=SimpleNamespace(task_for_run_session=lambda _s: None)
|
|
)
|
|
req = PermissionRequest(
|
|
"mcp__my_jira__searchJiraIssuesUsingJql",
|
|
{"cloudId": "0bbb", "jql": "ORDER BY created DESC"},
|
|
SimpleNamespace(category="mcp"),
|
|
"requires approval",
|
|
mcp_destination={"transport": "http", "host": "mcp.atlassian.com"},
|
|
)
|
|
data = SessionManager.approval_prompt_data(fake_mgr, "s1", req)
|
|
assert data["category"] == "mcp"
|
|
assert data["mcp_destination"] == {"transport": "http", "host": "mcp.atlassian.com"}
|
|
assert "reason" not in data # boilerplate never travels
|
|
|
|
real = PermissionRequest(
|
|
"mcp__my_jira__editJiraIssue",
|
|
{"issueIdOrKey": "OPE-1"},
|
|
SimpleNamespace(category="mcp"),
|
|
"the reviewer wasn't sure this edit was asked for",
|
|
mcp_destination={"transport": "http", "host": "mcp.atlassian.com"},
|
|
)
|
|
real_data = SessionManager.approval_prompt_data(fake_mgr, "s1", real)
|
|
assert real_data["reason"] == "the reviewer wasn't sure this edit was asked for"
|
|
|
|
# Non-MCP requests stay lean: no category/destination keys invented for them.
|
|
plain = PermissionRequest("write_file", {"path": "g.txt"}, None, "requires approval")
|
|
plain_data = SessionManager.approval_prompt_data(fake_mgr, "s1", plain)
|
|
assert "mcp_destination" not in plain_data and "category" not in plain_data
|