vojo/apps/ai-bot/threads_test.go

70 lines
2.9 KiB
Go

package main
import "testing"
// TestResolveThreadRoot pins the conversation-routing gate — the single place that decides
// whether a trigger continues a thread, roots a NEW conversation, or stays on the main
// timeline. The load-bearing invariant is the LAST case: a group is NEVER auto-threaded, so
// the threading feature can't change group behavior. Auto-threading in 1:1 DMs is always on
// (no flag); the only gate is isDM.
func TestResolveThreadRoot(t *testing.T) {
inThread := &MessageContent{RelatesTo: &RelatesTo{RelType: "m.thread", EventID: "$root"}}
topLevel := &MessageContent{}
reply := &MessageContent{RelatesTo: &RelatesTo{RelType: "", EventID: "", InReplyTo: &InReplyTo{EventID: "$x"}}}
ev := &Event{EventID: "$trigger"}
cases := []struct {
name string
isDM bool
mc *MessageContent
want string
}{
{"existing thread continues (DM)", true, inThread, "$root"},
{"existing thread continues (group)", false, inThread, "$root"},
{"DM top-level roots a new thread on the trigger", true, topLevel, "$trigger"},
{"GROUP top-level never auto-threads", false, topLevel, ""},
{"DM plain reply (no m.thread) roots a new thread", true, reply, "$trigger"},
{"GROUP plain reply never auto-threads", false, reply, ""},
}
for _, c := range cases {
b := &Bot{}
if got := b.resolveThreadRoot(c.isDM, ev, c.mc); got != c.want {
t.Errorf("%s: resolveThreadRoot = %q, want %q", c.name, got, c.want)
}
}
}
// TestBuildNoticeContentThreadRelation asserts the reply lands where resolveThreadRoot
// decided: a non-empty threadRoot emits an m.thread relation (so the answer joins the
// conversation), an empty one emits only m.in_reply_to (a plain top-level reply).
func TestBuildNoticeContentThreadRelation(t *testing.T) {
threaded := buildNoticeContent("$reply", "@u:vojo.chat", "$root", "hi")
rel, ok := threaded["m.relates_to"].(map[string]any)
if !ok {
t.Fatalf("m.relates_to missing or wrong type: %T", threaded["m.relates_to"])
}
if rel["rel_type"] != "m.thread" {
t.Errorf("rel_type = %v, want m.thread", rel["rel_type"])
}
if rel["event_id"] != "$root" {
t.Errorf("event_id = %v, want $root", rel["event_id"])
}
if rel["is_falling_back"] != true {
t.Errorf("is_falling_back = %v, want true", rel["is_falling_back"])
}
if inReply, _ := rel["m.in_reply_to"].(map[string]any); inReply["event_id"] != "$reply" {
t.Errorf("m.in_reply_to.event_id = %v, want $reply", inReply["event_id"])
}
topLevel := buildNoticeContent("$reply", "@u:vojo.chat", "", "hi")
rel2, ok := topLevel["m.relates_to"].(map[string]any)
if !ok {
t.Fatalf("m.relates_to missing or wrong type: %T", topLevel["m.relates_to"])
}
if _, hasThread := rel2["rel_type"]; hasThread {
t.Errorf("a top-level reply must not carry rel_type, got %v", rel2["rel_type"])
}
if inReply, _ := rel2["m.in_reply_to"].(map[string]any); inReply["event_id"] != "$reply" {
t.Errorf("m.in_reply_to.event_id = %v, want $reply", inReply["event_id"])
}
}