feat: OpenMesh 基础平台与 MD/PDF 转换技能
- 后端: coworker 智能体框架, WS API, 文件上传, 附件处理 - 前端: Open WebUI, 文件全量走 upload API (含 MD/TXT/JSON 等文本类) - 技能: md-to-office (pandoc + wkhtmltopdf) - 修复: 上传文件路径丢失, Agent 搜索浪费, 输出文件跑到 uploads/ - 打包: PyInstaller one-dir, 预打包 pandoc/wkhtmltopdf/chromium
This commit is contained in:
152
tests/test_message_timestamps.py
Normal file
152
tests/test_message_timestamps.py
Normal file
@@ -0,0 +1,152 @@
|
||||
"""FB-005 (server half) — canonical messages carry a `ts` sidecar.
|
||||
|
||||
`ts` (unix seconds) is stamped when a message is appended to the canonical history,
|
||||
persisted, and served raw by `GET /v1/sessions/{id}/messages` — but, like `source` and
|
||||
`_display`, it must NEVER reach a provider payload."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import time
|
||||
|
||||
import aisuite as ai
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from coworker.engine import TurnEngine
|
||||
from coworker.permissions import PermissionEngine
|
||||
from coworker.providers import (
|
||||
AssistantTurn,
|
||||
ModelCapabilities,
|
||||
ProviderClient,
|
||||
ToolCall,
|
||||
)
|
||||
from coworker.server import SessionManager, create_app
|
||||
from coworker.tools import ToolRegistry
|
||||
|
||||
|
||||
class CapturingProvider(ProviderClient):
|
||||
"""Queued turns + a record of every message list the provider was handed."""
|
||||
|
||||
def __init__(self, turns):
|
||||
self._turns = list(turns)
|
||||
self.calls: list[list[dict]] = []
|
||||
|
||||
def complete(self, *, model, messages, tools=None, **settings):
|
||||
self.calls.append([dict(m) for m in messages])
|
||||
return self._turns.pop(0)
|
||||
|
||||
def capabilities(self, model):
|
||||
return ModelCapabilities()
|
||||
|
||||
|
||||
def _text(text):
|
||||
return AssistantTurn(text=text, finish_reason="stop")
|
||||
|
||||
|
||||
def _tool(name, args, call_id="call_1"):
|
||||
return AssistantTurn(
|
||||
tool_calls=[ToolCall(id=call_id, name=name, arguments=args)],
|
||||
finish_reason="tool_calls",
|
||||
)
|
||||
|
||||
|
||||
def _engine(tmp_path, turns):
|
||||
provider = CapturingProvider(turns)
|
||||
registry = ToolRegistry()
|
||||
registry.register_all(ai.toolkits.files(root=str(tmp_path), allow_write=True))
|
||||
engine = TurnEngine(
|
||||
provider=provider,
|
||||
registry=registry,
|
||||
permissions=PermissionEngine(workspace_root=tmp_path),
|
||||
model="gpt-5.5",
|
||||
)
|
||||
return engine, provider
|
||||
|
||||
|
||||
def _run(engine, text):
|
||||
async def _go():
|
||||
return [ev async for ev in engine.run(text)]
|
||||
|
||||
return asyncio.run(_go())
|
||||
|
||||
|
||||
def test_appended_messages_carry_ts(tmp_path):
|
||||
(tmp_path / "a.txt").write_text("hello", encoding="utf-8")
|
||||
engine, _ = _engine(
|
||||
tmp_path,
|
||||
[_tool("read_file", {"path": "a.txt"}), _text("it says hello")],
|
||||
)
|
||||
before = time.time()
|
||||
_run(engine, "read a.txt")
|
||||
after = time.time()
|
||||
assert [m["role"] for m in engine.messages] == [
|
||||
"user",
|
||||
"assistant",
|
||||
"tool",
|
||||
"assistant",
|
||||
]
|
||||
for m in engine.messages: # user, assistant, AND tool messages are stamped
|
||||
assert isinstance(m["ts"], float)
|
||||
assert before <= m["ts"] <= after
|
||||
|
||||
|
||||
def test_provider_payload_never_carries_ts(tmp_path):
|
||||
(tmp_path / "a.txt").write_text("hello", encoding="utf-8")
|
||||
engine, provider = _engine(
|
||||
tmp_path,
|
||||
[_tool("read_file", {"path": "a.txt"}), _text("done")],
|
||||
)
|
||||
_run(engine, "read a.txt")
|
||||
assert provider.calls, "provider should have been invoked"
|
||||
assert all("ts" not in m for call in provider.calls for m in call)
|
||||
# the strip is a copy — the canonical history keeps its timestamps
|
||||
assert all("ts" in m for m in engine.messages)
|
||||
|
||||
|
||||
def test_outbound_strip_is_unconditional(tmp_path):
|
||||
# Direct check on the single provider feed, no-context early-return path included.
|
||||
engine, _ = _engine(tmp_path, [])
|
||||
engine.messages.append({"role": "user", "content": "hi", "ts": 1700000000.0})
|
||||
out = engine._outbound_messages()
|
||||
assert all("ts" not in m for m in out)
|
||||
assert engine.messages[-1]["ts"] == 1700000000.0 # original untouched
|
||||
|
||||
|
||||
def _no_ts_keys(value) -> bool:
|
||||
if isinstance(value, dict):
|
||||
return all(k != "ts" and _no_ts_keys(v) for k, v in value.items())
|
||||
if isinstance(value, list):
|
||||
return all(_no_ts_keys(v) for v in value)
|
||||
return True
|
||||
|
||||
|
||||
def test_provider_adapters_drop_ts():
|
||||
"""Defense in depth: the native Anthropic/Gemini payload builders rebuild messages
|
||||
from role/content, so a `ts` that somehow slipped past the engine strip still never
|
||||
reaches the wire."""
|
||||
from coworker.providers.anthropic_provider import convert_messages as to_anthropic
|
||||
from coworker.providers.gemini_provider import convert_messages as to_gemini
|
||||
|
||||
history = [
|
||||
{"role": "system", "content": "be brief"},
|
||||
{"role": "user", "content": "hi", "ts": 1700000000.0},
|
||||
{"role": "assistant", "content": "hello", "ts": 1700000001.0},
|
||||
]
|
||||
for convert in (to_anthropic, to_gemini):
|
||||
_system, payload = convert(history)
|
||||
assert _no_ts_keys(payload)
|
||||
|
||||
|
||||
def test_messages_endpoint_returns_ts(tmp_path):
|
||||
manager = SessionManager(
|
||||
workspace=tmp_path, provider=CapturingProvider([_text("hi there")])
|
||||
)
|
||||
client = TestClient(create_app(manager))
|
||||
with client.websocket_connect("/ws/session/ts1") as ws:
|
||||
assert ws.receive_json()["type"] == "ready"
|
||||
ws.send_json({"type": "user_message", "text": "hello world"})
|
||||
while ws.receive_json()["type"] != "turn_done":
|
||||
pass
|
||||
msgs = client.get("/v1/sessions/ts1/messages").json()["messages"]
|
||||
stamped = [m for m in msgs if m.get("role") in ("user", "assistant")]
|
||||
assert stamped and all(isinstance(m.get("ts"), float) for m in stamped)
|
||||
Reference in New Issue
Block a user