Replay an open run from its first event #54

Merged
weichen merged 2 commits from fix/live-run-visibility into main 2026-10-03 15:25:37 +00:00
3 changed files with 70 additions and 1 deletions
+51
View File
@@ -371,6 +371,57 @@ func TestHITLChoiceFlow(t *testing.T) {
}
}
func TestConversationResumeSeq(t *testing.T) {
srv, st := newTestServer(t)
h := srv.Handler()
_, convID := setupConversation(t, h)
runID := postMessage(t, h, convID, "#hitl")
deadline := time.Now().Add(5 * time.Second)
var waiting map[string]any
for time.Now().Before(deadline) && waiting == nil {
code, d := doJSON(t, h, "GET", "/api/conversations/"+convID, nil)
if code == 200 {
if cards, ok := d["activeHitl"].([]any); ok && len(cards) > 0 {
waiting = d
}
}
time.Sleep(30 * time.Millisecond)
}
if waiting == nil {
t.Fatal("no active hitl card")
}
lastSeq := int64(waiting["lastSeq"].(float64))
resumeSeq := int64(waiting["resumeSeq"].(float64))
var first int64
for _, e := range replayEvents(t, st, convID) {
if string(e.RunID) == runID {
first = e.Seq
break
}
}
if first == 0 {
t.Fatal("run has no events")
}
if resumeSeq != first-1 {
t.Fatalf("resumeSeq = %d, want first-1 = %d", resumeSeq, first-1)
}
if resumeSeq >= lastSeq {
t.Fatalf("resumeSeq %d should be before lastSeq %d while the run is open", resumeSeq, lastSeq)
}
cardID := waiting["activeHitl"].([]any)[0].(map[string]any)["id"].(string)
mustCode(t, h, "POST", "/api/hitl/"+cardID+"/resolve", map[string]any{"choiceId": "md"})
terminal := waitRunTerminal(t, h, runID, 5*time.Second)
if terminal["status"] != "completed" {
t.Fatalf("run status after resolve: %v", terminal["status"])
}
_, settled := doJSON(t, h, "GET", "/api/conversations/"+convID, nil)
if int64(settled["resumeSeq"].(float64)) != int64(settled["lastSeq"].(float64)) {
t.Fatalf("resumeSeq = %v, lastSeq = %v, want them equal after the run ends", settled["resumeSeq"], settled["lastSeq"])
}
}
func TestHITLFreeTextPivot(t *testing.T) {
srv, _ := newTestServer(t)
h := srv.Handler()
+8 -1
View File
@@ -492,9 +492,16 @@ func (s *Server) conversationDetail(w http.ResponseWriter, r *http.Request) {
runs, _ := s.st.ListRunsByConversation(id)
cards, _ := s.st.ActiveHITLCards(owner, id)
lastSeq, _ := s.st.LastEventSeq(id)
// resumeSeq:进行中的 run 从它的第一条事件之前重放,刷新或切回时补回已产生的过程。
resumeSeq := lastSeq
if open, err := s.st.LatestOpenRun(id); err == nil {
if seq, ok, ferr := s.st.FirstRunEventSeq(id, open.ID); ferr == nil && ok && seq > 0 {
resumeSeq = seq - 1
}
}
writeJSON(w, http.StatusOK, map[string]any{
"conversation": conv, "messages": msgs, "runs": runs,
"activeHitl": cards, "lastSeq": lastSeq,
"activeHitl": cards, "lastSeq": lastSeq, "resumeSeq": resumeSeq,
})
}
+11
View File
@@ -338,6 +338,17 @@ func (s *Store) LastEventSeq(conversationID string) (int64, error) {
return seq.Int64, nil
}
// FirstRunEventSeq 返回这个 run 在会话事件流里的第一条 seq。
// 没有事件时 ok 为 false。调用方用 seq-1 作为重放游标。
func (s *Store) FirstRunEventSeq(conversationID, runID string) (seq int64, ok bool, err error) {
var got sql.NullInt64
err = s.db.QueryRow(s.q(`SELECT MIN(seq) FROM events WHERE conversation_id=? AND run_id=?`), conversationID, runID).Scan(&got)
if err != nil || !got.Valid {
return 0, false, err
}
return got.Int64, true, nil
}
func newID(kind string) string {
b := make([]byte, 6)
rand.Read(b)