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:
175
tests/test_autotitle.py
Normal file
175
tests/test_autotitle.py
Normal file
@@ -0,0 +1,175 @@
|
||||
"""FB-010 — LLM auto-titles.
|
||||
|
||||
After a user turn completes, the manager fires ONE fire-and-forget completion on the
|
||||
session's own provider/model to generate a 4-5 word title, stored in `auto_title` —
|
||||
never in `title`, so a manual rename always wins. The small-talk sentinel earns exactly
|
||||
one retry (with both openers) after the next user turn; every failure is swallowed."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import time
|
||||
|
||||
from coworker.providers import AssistantTurn, ModelCapabilities, ProviderClient
|
||||
from coworker.server.manager import SessionManager
|
||||
|
||||
|
||||
class TitleAwareProvider(ProviderClient):
|
||||
"""Chat turns come from one queue; title calls (recognized by the titling system
|
||||
prompt) are answered from another — so the fire-and-forget title call can never
|
||||
steal a scripted chat turn."""
|
||||
|
||||
def __init__(self, chat_turns, titles):
|
||||
self._chat = list(chat_turns)
|
||||
self._titles = list(titles)
|
||||
self.title_calls: list[list[dict]] = []
|
||||
|
||||
def complete(self, *, model, messages, tools=None, **settings):
|
||||
if messages and "title chat sessions" in str(messages[0].get("content", "")):
|
||||
self.title_calls.append([dict(m) for m in messages])
|
||||
item = self._titles.pop(0)
|
||||
if isinstance(item, Exception):
|
||||
raise item
|
||||
return AssistantTurn(text=item, finish_reason="stop")
|
||||
return self._chat.pop(0)
|
||||
|
||||
def capabilities(self, model):
|
||||
return ModelCapabilities()
|
||||
|
||||
|
||||
def _text(text):
|
||||
return AssistantTurn(text=text, finish_reason="stop")
|
||||
|
||||
|
||||
async def _turn(mgr: SessionManager, sid: str, text: str) -> None:
|
||||
"""Drive one user turn the way the server does: run, save, mark idle (the
|
||||
auto-title hook), then wait for the fire-and-forget call to settle."""
|
||||
engine = mgr.get_engine(sid, agent="chat")
|
||||
mgr.mark_running(sid)
|
||||
async for _ in engine.run(text):
|
||||
pass
|
||||
mgr.save(sid, engine)
|
||||
mgr.mark_idle(sid)
|
||||
deadline = time.time() + 5.0
|
||||
while sid in mgr._autotitle_inflight and time.time() < deadline:
|
||||
await asyncio.sleep(0.005)
|
||||
|
||||
|
||||
def _mgr(tmp_path, chat_turns, titles):
|
||||
provider = TitleAwareProvider(chat_turns, titles)
|
||||
return SessionManager(workspace=tmp_path, provider=provider), provider
|
||||
|
||||
|
||||
async def test_title_set_after_first_turn(tmp_path):
|
||||
mgr, provider = _mgr(tmp_path, [_text("sure")], ["Japan Trip Planning Help"])
|
||||
await _turn(mgr, "s1", "help me plan a trip to japan")
|
||||
|
||||
assert len(provider.title_calls) == 1
|
||||
# The agent's first reply rides along as titling evidence (owner ruling 2026-08-24).
|
||||
assert provider.title_calls[0][-1]["content"] == (
|
||||
"help me plan a trip to japan\n\n[the assistant's first reply]\nsure"
|
||||
)
|
||||
assert mgr.session_store.title_state("s1")["auto_title"] == (
|
||||
"Japan Trip Planning Help"
|
||||
)
|
||||
# display precedence: the auto title wins over the title_from snapshot everywhere
|
||||
assert mgr.session_store.load("s1").title == "Japan Trip Planning Help"
|
||||
row = next(s for s in mgr.list_sessions() if s["session_id"] == "s1")
|
||||
assert row["title"] == "Japan Trip Planning Help"
|
||||
|
||||
|
||||
async def test_small_talk_sentinel_then_retry_with_both_openers(tmp_path):
|
||||
mgr, provider = _mgr(
|
||||
tmp_path,
|
||||
[_text("hey!"), _text("on it")],
|
||||
["small-talk", "Quarterly Report Draft"],
|
||||
)
|
||||
await _turn(mgr, "s2", "hey")
|
||||
assert mgr.session_store.title_state("s2")["auto_title"] is None
|
||||
assert mgr.session_store.load("s2").title == "hey" # title_from fallback stands
|
||||
|
||||
await _turn(mgr, "s2", "help me draft the quarterly report")
|
||||
assert len(provider.title_calls) == 2
|
||||
retry_opener = provider.title_calls[1][-1]["content"]
|
||||
assert "hey" in retry_opener and "quarterly report" in retry_opener
|
||||
assert mgr.session_store.load("s2").title == "Quarterly Report Draft"
|
||||
|
||||
|
||||
async def test_gives_up_after_second_sentinel(tmp_path):
|
||||
mgr, provider = _mgr(
|
||||
tmp_path,
|
||||
[_text("hi"), _text("hello"), _text("yo")],
|
||||
["small-talk", "small-talk", "Should Never Be Asked"],
|
||||
)
|
||||
await _turn(mgr, "s3", "hey")
|
||||
await _turn(mgr, "s3", "how are you")
|
||||
await _turn(mgr, "s3", "good morning")
|
||||
assert len(provider.title_calls) == 2 # attempt 1 + the single retry, then give up
|
||||
assert mgr.session_store.title_state("s3")["auto_title"] is None
|
||||
assert mgr.session_store.load("s3").title == "hey"
|
||||
|
||||
|
||||
async def test_manual_rename_always_wins(tmp_path):
|
||||
# rename AFTER the auto title landed
|
||||
mgr, _ = _mgr(tmp_path, [_text("ok")], ["Login Bug Investigation"])
|
||||
await _turn(mgr, "s4", "the login page 500s")
|
||||
assert mgr.session_store.load("s4").title == "Login Bug Investigation"
|
||||
mgr.rename_session("s4", "Prod incident 42")
|
||||
assert mgr.session_store.load("s4").title == "Prod incident 42"
|
||||
# ...and a late-landing generated title can't displace it
|
||||
assert mgr.session_store.set_auto_title("s4", "sneaky") is False
|
||||
assert mgr.session_store.load("s4").title == "Prod incident 42"
|
||||
|
||||
|
||||
async def test_rename_before_generation_blocks_it(tmp_path):
|
||||
mgr, provider = _mgr(
|
||||
tmp_path, [_text("hi"), _text("ok")], ["small-talk", "Should Never Be Asked"]
|
||||
)
|
||||
await _turn(mgr, "s5", "hey") # sentinel — no title yet
|
||||
mgr.rename_session("s5", "My Thread")
|
||||
await _turn(mgr, "s5", "help me plan the offsite")
|
||||
assert len(provider.title_calls) == 1 # the retry never fired
|
||||
assert mgr.session_store.load("s5").title == "My Thread"
|
||||
|
||||
|
||||
async def test_provider_failure_is_swallowed(tmp_path):
|
||||
mgr, provider = _mgr(tmp_path, [_text("ok")], [RuntimeError("model down")])
|
||||
await _turn(mgr, "s6", "summarize this doc") # must not raise
|
||||
assert mgr.session_store.title_state("s6")["auto_title"] is None
|
||||
assert mgr.session_store.load("s6").title == "summarize this doc"
|
||||
|
||||
|
||||
async def test_sanitizes_and_rejects_absurd_output(tmp_path):
|
||||
# surrounding quotes stripped + whitespace collapsed
|
||||
mgr, _ = _mgr(tmp_path, [_text("ok")], ['"Fix Login Bug"'])
|
||||
await _turn(mgr, "s7", "the login page 500s")
|
||||
assert mgr.session_store.load("s7").title == "Fix Login Bug"
|
||||
|
||||
# absurdly long output (>80 chars) is a failure, not a title
|
||||
mgr2, _ = _mgr(tmp_path / "b", [_text("ok")], ["x" * 90])
|
||||
await _turn(mgr2, "s8", "the login page 500s")
|
||||
assert mgr2.session_store.title_state("s8")["auto_title"] is None
|
||||
|
||||
|
||||
def test_turn_start_titles_before_the_turn_completes(tmp_path):
|
||||
# Owner catch 2026-08-24: titling used to ride ONLY turn completion, so a long
|
||||
# agentic turn left the session titled by its first message for the whole run. The
|
||||
# turn-start hook titles on the user's words the moment they land.
|
||||
async def go():
|
||||
mgr, provider = _mgr(tmp_path, [_text("working on it…")], ["Scan the API for secrets"])
|
||||
sid = "early1"
|
||||
engine = mgr.get_engine(sid, agent="chat")
|
||||
engine.messages.append({"role": "user", "content": "scan code for vuln and secrets"})
|
||||
mgr.save(sid, engine)
|
||||
mgr.mark_running(sid) # the turn is RUNNING — no completion in sight
|
||||
mgr._maybe_autotitle(sid) # the app's turn_start hook
|
||||
deadline = time.time() + 5.0
|
||||
while sid in mgr._autotitle_inflight and time.time() < deadline:
|
||||
await asyncio.sleep(0.005)
|
||||
state = mgr.session_store.title_state(sid)
|
||||
assert state["auto_title"] == "Scan the API for secrets"
|
||||
# The completion hook later, same openers: skipped WITHOUT burning attempt 2.
|
||||
mgr.mark_idle(sid)
|
||||
assert mgr._autotitle_attempts[sid] == 1
|
||||
|
||||
asyncio.run(go())
|
||||
Reference in New Issue
Block a user