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:
host committed 2026-10-29 07:01:57 +08:00
1 parent 811912c841
commit f02c513729
9 files changed
+524 -40

No files matched your search

+215
View File
@@ -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()