Files
multica/server/internal/handler/daemon_chat_prompt_test.go
Bohan Jiang 9beb45b55b fix(chat): deliver every debounced user message to the agent (MUL-2968) (#3744)
The Lark short-window debounce (MUL-2968, #3742) can land several user
messages in a chat session before a single agent run fires. But the
daemon claim built the agent prompt from only the *single most recent*
user message (walk history backward, take first user message, break).

So 「看上海天气」then「还有青岛」debounced into one run, and the agent
received only 「还有青岛」— it answered Qingdao and never saw Shanghai.
The session itself was correct (both messages persisted); the gap was in
what the run delivered to the agent. Before debouncing this was masked
because each message got its own run.

Build the prompt from the whole unanswered set instead: the trailing run
of user messages after the last assistant reply (every completed/failed
run writes an assistant row, so the anchor advances one turn at a time —
the full burst on the first turn, only the new message(s) after a reply).
Attachments are collected from each included message. Extracted the
selection into a pure trailingUserMessages helper with table-driven unit
tests, plus a DB-backed claim test asserting both messages reach the
agent and that a post-reply message delivers alone.

Co-authored-by: J <j@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-06-04 13:12:32 +08:00

89 lines
2.1 KiB
Go

package handler
import (
"testing"
db "github.com/multica-ai/multica/server/pkg/db/generated"
)
func msg(role, content string) db.ChatMessage {
return db.ChatMessage{Role: role, Content: content}
}
func contents(msgs []db.ChatMessage) []string {
out := make([]string, len(msgs))
for i, m := range msgs {
out[i] = m.Content
}
return out
}
func eq(a, b []string) bool {
if len(a) != len(b) {
return false
}
for i := range a {
if a[i] != b[i] {
return false
}
}
return true
}
// TestTrailingUserMessages pins the message-selection logic behind the daemon
// chat prompt: the agent must receive every user message since its last reply
// (the MUL-2968 debounce can land several before one run fires), not just the
// most recent one.
func TestTrailingUserMessages(t *testing.T) {
cases := []struct {
name string
in []db.ChatMessage
want []string
}{
{
name: "debounced burst with no prior reply delivers all",
in: []db.ChatMessage{msg("user", "看上海天气"), msg("user", "还有青岛")},
want: []string{"看上海天气", "还有青岛"},
},
{
name: "only messages after the last assistant reply",
in: []db.ChatMessage{
msg("user", "old q"), msg("assistant", "old a"),
msg("user", "看上海天气"), msg("user", "还有青岛"),
},
want: []string{"看上海天气", "还有青岛"},
},
{
name: "single new message after a reply",
in: []db.ChatMessage{
msg("user", "看上海天气"), msg("user", "还有青岛"),
msg("assistant", "weather…"), msg("user", "深圳呢"),
},
want: []string{"深圳呢"},
},
{
name: "no trailing user message (last is assistant)",
in: []db.ChatMessage{msg("user", "hi"), msg("assistant", "done")},
want: []string{},
},
{
name: "empty history",
in: []db.ChatMessage{},
want: []string{},
},
{
name: "single user message",
in: []db.ChatMessage{msg("user", "hi")},
want: []string{"hi"},
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got := contents(trailingUserMessages(tc.in))
if !eq(got, tc.want) {
t.Fatalf("trailingUserMessages = %v, want %v", got, tc.want)
}
})
}
}