chat: 持久化继续消息,chat-history 交替历史
基线 main 811912c(PR #125 Muse 风格之后):服务端与单测与 9538825 相同;React 侧在 Muse 风格 ContinuePanel 上重放 chat-history 集成(新版输入框占位文案保持 Muse 风格的“消息”), console.html 由新版 src 重新构建。 - 新表 task_continue_messages:POST /continue 的用户消息与状态改回 queued 同一事务落盘,409 被拒不留 phantom 消息 - GET /tasks/{id}/chat-history:prompt + 继续 + 后续消息 + 链路按 时间交错拼好返回;部署前继续以 legacy_continue_count 如实标注 - React ContinuePanel 优先用 chat-history,旧服务端自动回退
This commit is contained in:
1 parent
811912c841
commit
f02c513729
9 files changed
+524
-40
No files matched your search
@@ -0,0 +1,215 @@
|
||||
"""继续消息持久化 + 交替历史单测:POST /continue 接受的用户
|
||||
消息必须落盘(与状态改回 queued 同一事务),GET
|
||||
/tasks/{id}/chat-history 按时间交错拼出 prompt / 继续 /
|
||||
后续消息 / 链路助手消息;409 的继续绝不能留 phantom 消息;
|
||||
部署前发生的继续在 audit 里只有任务 id、正文无法追回,
|
||||
接口要如实报 legacy_continue_count,不编造。
|
||||
|
||||
不跑真实模型回合、不联网:server 侧走本地临时端口 + 临时
|
||||
SQLite,链路通过 server.trace_dir 注入临时 jsonl。
|
||||
"""
|
||||
|
||||
import http.client
|
||||
import json
|
||||
import os
|
||||
import sqlite3
|
||||
import sys
|
||||
import tempfile
|
||||
import threading
|
||||
import time
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
try:
|
||||
from cloud.auth import AuthStore
|
||||
from cloud.server import create_server
|
||||
except ImportError: # discover -s cloud/tests 时把项目根目录补进 sys.path
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[2]))
|
||||
from cloud.auth import AuthStore
|
||||
from cloud.server import create_server
|
||||
|
||||
|
||||
class ChatHistoryTestCase(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.tmp = tempfile.TemporaryDirectory()
|
||||
self.db_path = os.path.join(self.tmp.name, "control.db")
|
||||
self.trace_dir = os.path.join(self.tmp.name, "traces")
|
||||
os.makedirs(self.trace_dir)
|
||||
self.auth = AuthStore(self.db_path)
|
||||
self.owner_id, self.owner_key = self.auth.create_user("owner")
|
||||
self.server = create_server(self.db_path, "127.0.0.1", 0)
|
||||
self.server.trace_dir = self.trace_dir
|
||||
self.port = self.server.server_address[1]
|
||||
self.thread = threading.Thread(
|
||||
target=self.server.serve_forever, daemon=True)
|
||||
self.thread.start()
|
||||
status, self.project = self._req(
|
||||
"POST", "/projects", {"name": "demo"}, self.owner_key)
|
||||
self.assertEqual(status, 201)
|
||||
|
||||
def tearDown(self):
|
||||
self.server.shutdown()
|
||||
self.server.server_close()
|
||||
self.thread.join(timeout=5)
|
||||
try:
|
||||
self.auth.conn.close()
|
||||
except Exception:
|
||||
pass
|
||||
self.tmp.cleanup()
|
||||
|
||||
def _req(self, method, path, body=None, key=None):
|
||||
conn = http.client.HTTPConnection(
|
||||
"127.0.0.1", self.port, timeout=10)
|
||||
hdrs = {}
|
||||
payload = None
|
||||
if key:
|
||||
hdrs["X-API-Key"] = key
|
||||
if body is not None:
|
||||
payload = json.dumps(body)
|
||||
hdrs["Content-Type"] = "application/json"
|
||||
conn.request(method, path, body=payload, headers=hdrs)
|
||||
resp = conn.getresponse()
|
||||
raw = resp.read()
|
||||
conn.close()
|
||||
try:
|
||||
return resp.status, json.loads(raw.decode("utf-8"))
|
||||
except (ValueError, UnicodeDecodeError):
|
||||
return resp.status, {}
|
||||
|
||||
def _task(self, status="completed"):
|
||||
_s, task = self._req(
|
||||
"POST", "/tasks",
|
||||
{"project_id": self.project["id"], "prompt": "original"},
|
||||
self.owner_key)
|
||||
self.assertEqual(_s, 201, task)
|
||||
if status != "queued":
|
||||
conn = sqlite3.connect(self.db_path)
|
||||
conn.execute("UPDATE tasks SET status = ? WHERE id = ?",
|
||||
(status, task["id"]))
|
||||
conn.commit()
|
||||
conn.close()
|
||||
return task["id"]
|
||||
|
||||
def _set_status(self, task_id, status):
|
||||
conn = sqlite3.connect(self.db_path)
|
||||
conn.execute("UPDATE tasks SET status = ? WHERE id = ?",
|
||||
(status, task_id))
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
def _chat(self, task_id, key=None):
|
||||
return self._req(
|
||||
"GET", "/tasks/%s/chat-history" % task_id,
|
||||
key=key or self.owner_key)
|
||||
|
||||
def test_continue_persisted_and_interleaved(self):
|
||||
tid = self._task("completed")
|
||||
s, _t = self._req(
|
||||
"POST", "/tasks/%s/continue" % tid,
|
||||
{"message": "first follow-up"}, self.owner_key)
|
||||
self.assertEqual(s, 200)
|
||||
self._set_status(tid, "completed")
|
||||
s, _t = self._req(
|
||||
"POST", "/tasks/%s/continue" % tid,
|
||||
{"message": "second follow-up"}, self.owner_key)
|
||||
self.assertEqual(s, 200)
|
||||
s, chat = self._chat(tid)
|
||||
self.assertEqual(s, 200, chat)
|
||||
self.assertEqual(chat["continue_messages_stored"], 2)
|
||||
# 两次继续都有 audit,全部已入库,缺口为 0。
|
||||
self.assertEqual(chat["legacy_continue_count"], 0)
|
||||
texts = [(m["role"], m["source"], m["text"])
|
||||
for m in chat["messages"]]
|
||||
self.assertEqual(texts, [
|
||||
("user", "prompt", "original"),
|
||||
("user", "continue", "first follow-up"),
|
||||
("user", "continue", "second follow-up"),
|
||||
])
|
||||
# 注入链路:把两次继续与任务创建时间钉到确定时间戳,
|
||||
# 助手回复落在两次继续之间与之后,按时间交错(不靠
|
||||
# 墙钟间隔赌顺序——两次继续在测试里只隔几十毫秒,
|
||||
# 助手 +500ms 本就该排在第二次继续之后)。
|
||||
conn = sqlite3.connect(self.db_path)
|
||||
conn.execute("UPDATE tasks SET created_at = ? WHERE id = ?",
|
||||
(1699999900.0, tid))
|
||||
conn.execute(
|
||||
"UPDATE task_continue_messages SET created_at = ?"
|
||||
" WHERE task_id = ? AND body = ?",
|
||||
(1700000000.0, tid, "first follow-up"))
|
||||
conn.execute(
|
||||
"UPDATE task_continue_messages SET created_at = ?"
|
||||
" WHERE task_id = ? AND body = ?",
|
||||
(1700000100.0, tid, "second follow-up"))
|
||||
conn.commit()
|
||||
conn.close()
|
||||
entries = [
|
||||
{"kind": "message", "id": "m1", "ts": 1700000050000,
|
||||
"phase": "final_answer", "text": "done first"},
|
||||
{"kind": "message", "id": "m2", "ts": 1700000150000,
|
||||
"phase": "final_answer", "text": "done second"},
|
||||
{"kind": "exec", "id": "e1", "ts": 1700000150600,
|
||||
"command": "ls", "exitCode": 0, "durationMs": 1,
|
||||
"cwd": "/tmp"},
|
||||
]
|
||||
with open(os.path.join(self.trace_dir, tid + ".jsonl"),
|
||||
"w", encoding="utf-8") as f:
|
||||
for e in entries:
|
||||
f.write(json.dumps(e) + "\n")
|
||||
s, chat = self._chat(tid)
|
||||
self.assertEqual(s, 200, chat)
|
||||
texts = [(m["role"], m["source"], m["text"])
|
||||
for m in chat["messages"]]
|
||||
self.assertEqual(texts, [
|
||||
("user", "prompt", "original"),
|
||||
("user", "continue", "first follow-up"),
|
||||
("assistant", "trace", "[final_answer] done first"),
|
||||
("user", "continue", "second follow-up"),
|
||||
("assistant", "trace", "[final_answer] done second"),
|
||||
("tool", "trace", "ls(exit=0,1ms,cwd=/tmp)"),
|
||||
])
|
||||
|
||||
def test_followup_and_legacy_gap_reported(self):
|
||||
# 后续消息仅支持进行中任务,completed 会 409。
|
||||
tid = self._task("running")
|
||||
s, _f = self._req(
|
||||
"POST", "/tasks/%s/followups" % tid,
|
||||
{"body": "ping while running", "mode": "direct"},
|
||||
self.owner_key)
|
||||
self.assertEqual(s, 201, _f)
|
||||
# 模拟部署前的一次继续:audit 里有记录、表里没有正文。
|
||||
conn = sqlite3.connect(self.db_path)
|
||||
conn.execute(
|
||||
"INSERT INTO audit_log(user_id, project_id, action,"
|
||||
" detail, created_at) VALUES(?, ?, 'task_continued', ?, ?)",
|
||||
(self.owner_id, self.project["id"], tid, time.time()))
|
||||
conn.commit()
|
||||
conn.close()
|
||||
s, chat = self._chat(tid)
|
||||
self.assertEqual(s, 200, chat)
|
||||
self.assertEqual(chat["continue_messages_stored"], 0)
|
||||
self.assertEqual(chat["legacy_continue_count"], 1)
|
||||
sources = [(m["role"], m["source"]) for m in chat["messages"]]
|
||||
self.assertEqual(sources, [
|
||||
("user", "prompt"), ("user", "followup")])
|
||||
|
||||
def test_rejected_continue_stores_nothing(self):
|
||||
tid = self._task("queued")
|
||||
code, _b = self._req(
|
||||
"POST", "/tasks/%s/continue" % tid,
|
||||
{"message": "should not persist"}, self.owner_key)
|
||||
self.assertEqual(code, 409)
|
||||
s, chat = self._chat(tid)
|
||||
self.assertEqual(s, 200, chat)
|
||||
self.assertEqual(chat["continue_messages_stored"], 0)
|
||||
self.assertEqual(len(chat["messages"]), 1) # 只有 prompt
|
||||
|
||||
def test_chat_history_auth(self):
|
||||
tid = self._task("completed")
|
||||
code, _b = self._chat(tid, key="bad-key")
|
||||
self.assertEqual(code, 401)
|
||||
code, _b = self._chat("no-such-task")
|
||||
self.assertEqual(code, 404)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in new issue
Block a user