Files
multica/server/internal/handler/chat_input_ownership_test.go
Bohan Jiang cb87dd106b feat(chat): task-owned direct-chat input batches + explicit no_response outcome (MUL-4351) (#5195)
* feat(chat): task-owned direct-chat input batches + explicit no_response outcome (MUL-4351)

Direct (web/mobile) chat no longer uses the last-assistant-row as an implicit
input cursor. Each direct send now owns an immutable input batch:

- agent_task_queue.chat_input_task_id makes a task the owner of the user
  messages it must consume; the send path creates the task + user message +
  attachment bindings + session touch in one transaction, and the daemon is
  notified only after commit. A claim reads exactly that batch, so a message
  that arrives mid-run belongs to the next task and is never absorbed.
- Auto-retry inherits the root input owner and is queued at a bumped priority,
  created inside FailTask's transaction so no newer chat task can jump ahead.
- CompleteTask writes exactly one assistant outcome inside the completion
  transaction: a normal message, or a visible no_response outcome (with a
  non-empty English fallback) when the final output is empty. The write failing
  rolls the completion back and the handler returns 5xx so the daemon retries;
  the status CAS keeps it idempotent. chat:done carries message_kind.
- Web/desktop/mobile render no_response as a localized 'no text reply' state
  (keeping the tool timeline), suppress Copy, keep it unread, and keep the
  session-list preview non-blank.
- Legacy/channel tasks (chat_input_task_id NULL) keep the trailing-message
  selector, so a rolling deploy never replays Slack/Lark history.

Co-authored-by: multica-agent <github@multica.ai>

* fix(chat): scope no_response to direct tasks; don't cancel task on input read error (MUL-4351)

Addresses PR review (Niko):
- writeChatCompletionOutcome only writes a no_response row for task-owned
  direct tasks (chat_input_task_id set). Legacy/channel (Slack/Lark) tasks keep
  the prior behavior: empty output writes no assistant row, so chat:done carries
  empty content and the channel outbound silently drops it — the no_response
  fallback body never reaches an external channel.
- The daemon claim distinguishes a genuine zero-input batch from a failed
  input read: on ListChatInputMessages / ListChatMessages error it returns 5xx
  and preserves the dispatched task for redelivery instead of cancelling a valid
  task on a transient DB error.

Co-authored-by: multica-agent <github@multica.ai>

---------

Co-authored-by: J <j@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-07-10 16:22:42 +08:00

387 lines
15 KiB
Go

package handler
import (
"context"
"encoding/json"
"testing"
db "github.com/multica-ai/multica/server/pkg/db/generated"
"github.com/multica-ai/multica/server/pkg/protocol"
)
// setupDirectChatSession creates a runtime-guard agent (with its registered
// runtime + daemon) and a non-intro chat session for direct (web/mobile) chat
// ownership tests.
func setupDirectChatSession(t *testing.T, ctx context.Context, title string) (agentID, sessionID, runtimeID, daemonID string) {
t.Helper()
agentID, runtimeID, daemonID = createRuntimeGuardAgent(t, ctx)
if err := testPool.QueryRow(ctx, `
INSERT INTO chat_session (workspace_id, agent_id, creator_id, title)
VALUES ($1, $2, $3, $4)
RETURNING id
`, testWorkspaceID, agentID, testUserID, title).Scan(&sessionID); err != nil {
t.Fatalf("setup: create chat session: %v", err)
}
t.Cleanup(func() { testPool.Exec(ctx, `DELETE FROM chat_session WHERE id = $1`, sessionID) })
return agentID, sessionID, runtimeID, daemonID
}
// sendDirectChat drives the transactional direct-send service path and returns
// the owning task id. The user message is created inside the same transaction
// with task_id = the new task, and the task owns its own input batch.
func sendDirectChat(t *testing.T, ctx context.Context, agentID, sessionID, content string) string {
t.Helper()
sess, err := testHandler.Queries.GetChatSession(ctx, parseUUID(sessionID))
if err != nil {
t.Fatalf("load chat session: %v", err)
}
ag, err := testHandler.Queries.GetAgent(ctx, parseUUID(agentID))
if err != nil {
t.Fatalf("load agent: %v", err)
}
res, err := testHandler.TaskService.SendDirectChatMessage(ctx, sess, ag, parseUUID(testUserID), content, nil, "member", parseUUID(testUserID))
if err != nil {
t.Fatalf("SendDirectChatMessage: %v", err)
}
return uuidToString(res.Task.ID)
}
func markTaskRunning(t *testing.T, ctx context.Context, taskID string) {
t.Helper()
if _, err := testPool.Exec(ctx, `
UPDATE agent_task_queue SET status = 'running', started_at = now(), dispatched_at = now()
WHERE id = $1
`, taskID); err != nil {
t.Fatalf("mark task running: %v", err)
}
}
func completeResult(t *testing.T, output string) []byte {
t.Helper()
b, err := json.Marshal(TaskCompleteRequest{Output: output})
if err != nil {
t.Fatalf("marshal complete result: %v", err)
}
return b
}
// TestDirectChat_TaskOwnsItsOwnInputBatch is the core input-boundary contract
// (MUL-4351): each direct send owns exactly the user message it created. When
// U1→T1 and U2→T2 are both queued, T1's claim must deliver ONLY U1 (never
// "U1\n\nU2" the way the trailing-message selector would), and after T1
// completes, T2 delivers ONLY U2.
func TestDirectChat_TaskOwnsItsOwnInputBatch(t *testing.T) {
if testHandler == nil {
t.Skip("database not available")
}
ctx := context.Background()
agentID, sessionID, runtimeID, daemonID := setupDirectChatSession(t, ctx, "ownership chat")
t1 := sendDirectChat(t, ctx, agentID, sessionID, "看上海天气")
t2 := sendDirectChat(t, ctx, agentID, sessionID, "还有青岛")
// Both tasks own their own input batch.
assertTaskInputOwner(t, ctx, t1, t1)
assertTaskInputOwner(t, ctx, t2, t2)
claimed := claimTaskForRuntimeGuard(t, runtimeID, daemonID)
if claimed.ChatMessage != "看上海天气" {
t.Fatalf("first claim must deliver ONLY its owned message; got %q", claimed.ChatMessage)
}
// Complete the first task (the older T1, claimed first by created_at order),
// then the next claim must deliver only the other message rather than a
// coalesced pair. The claim leaves T1 dispatched; move it to running so the
// completion CAS (WHERE status='running') applies.
markTaskRunning(t, ctx, t1)
if _, err := testHandler.TaskService.CompleteTask(ctx, parseUUID(t1), completeResult(t, "上海晴"), "", ""); err != nil {
t.Fatalf("complete first task: %v", err)
}
claimed2 := claimTaskForRuntimeGuard(t, runtimeID, daemonID)
if claimed2.ChatMessage != "还有青岛" {
t.Fatalf("second claim must deliver ONLY the second owned message; got %q", claimed2.ChatMessage)
}
}
// TestDirectChat_RunningTaskDoesNotAbsorbNewMessage pins the acceptance case:
// while T1 is running, a message sent from another surface lands on T2 and is
// never folded into T1's already-sealed input batch.
func TestDirectChat_RunningTaskDoesNotAbsorbNewMessage(t *testing.T) {
if testHandler == nil {
t.Skip("database not available")
}
ctx := context.Background()
agentID, sessionID, _, _ := setupDirectChatSession(t, ctx, "no-absorb chat")
t1 := sendDirectChat(t, ctx, agentID, sessionID, "first")
markTaskRunning(t, ctx, t1)
// A second message arrives mid-run and gets its own task.
t2 := sendDirectChat(t, ctx, agentID, sessionID, "second")
if t1 == t2 {
t.Fatal("second send must create a distinct task")
}
// T1's owned batch is still exactly {first}; the mid-run message belongs to T2.
owned1, err := testHandler.Queries.ListChatInputMessages(ctx, parseUUID(t1))
if err != nil {
t.Fatalf("list T1 owned input: %v", err)
}
if len(owned1) != 1 || owned1[0].Content != "first" {
t.Fatalf("T1 must own only its own message; got %+v", msgContents(owned1))
}
owned2, err := testHandler.Queries.ListChatInputMessages(ctx, parseUUID(t2))
if err != nil {
t.Fatalf("list T2 owned input: %v", err)
}
if len(owned2) != 1 || owned2[0].Content != "second" {
t.Fatalf("T2 must own the mid-run message; got %+v", msgContents(owned2))
}
}
// TestCompleteTask_ChatEmptyOutputWritesNoResponse: an empty final output is a
// visible, terminal no_response outcome — exactly one assistant row with
// message_kind='no_response' and a non-empty fallback body, task completed, and
// no auto-retry.
func TestCompleteTask_ChatEmptyOutputWritesNoResponse(t *testing.T) {
if testHandler == nil {
t.Skip("database not available")
}
ctx := context.Background()
agentID, sessionID, _, _ := setupDirectChatSession(t, ctx, "no-response chat")
taskID := sendDirectChat(t, ctx, agentID, sessionID, "do a tool-only thing")
markTaskRunning(t, ctx, taskID)
// Whitespace-only output trims to empty → no_response.
if _, err := testHandler.TaskService.CompleteTask(ctx, parseUUID(taskID), completeResult(t, " "), "", ""); err != nil {
t.Fatalf("complete task: %v", err)
}
rows := assistantRows(t, ctx, sessionID)
if len(rows) != 1 {
t.Fatalf("expected exactly one assistant outcome, got %d", len(rows))
}
if rows[0].MessageKind != protocol.ChatMessageKindNoResponse {
t.Fatalf("expected message_kind=no_response, got %q", rows[0].MessageKind)
}
if rows[0].Content == "" {
t.Fatal("no_response row must carry a non-empty fallback body for old clients")
}
assertTaskStatus(t, ctx, taskID, "completed")
assertNoRetryChild(t, ctx, taskID)
}
// TestCompleteTask_ChatNonEmptyOutputWritesMessage: a normal reply becomes a
// single ordinary assistant message.
func TestCompleteTask_ChatNonEmptyOutputWritesMessage(t *testing.T) {
if testHandler == nil {
t.Skip("database not available")
}
ctx := context.Background()
agentID, sessionID, _, _ := setupDirectChatSession(t, ctx, "reply chat")
taskID := sendDirectChat(t, ctx, agentID, sessionID, "hello")
markTaskRunning(t, ctx, taskID)
if _, err := testHandler.TaskService.CompleteTask(ctx, parseUUID(taskID), completeResult(t, "hi there"), "sess-1", "/tmp/wd"); err != nil {
t.Fatalf("complete task: %v", err)
}
rows := assistantRows(t, ctx, sessionID)
if len(rows) != 1 {
t.Fatalf("expected exactly one assistant message, got %d", len(rows))
}
if rows[0].MessageKind != protocol.ChatMessageKindMessage {
t.Fatalf("expected message_kind=message, got %q", rows[0].MessageKind)
}
if rows[0].Content != "hi there" {
t.Fatalf("expected content 'hi there', got %q", rows[0].Content)
}
}
// TestCompleteTask_ChatCallbackIdempotent: a replayed completion callback must
// not write a second assistant outcome.
func TestCompleteTask_ChatCallbackIdempotent(t *testing.T) {
if testHandler == nil {
t.Skip("database not available")
}
ctx := context.Background()
agentID, sessionID, _, _ := setupDirectChatSession(t, ctx, "idempotent chat")
taskID := sendDirectChat(t, ctx, agentID, sessionID, "hey")
markTaskRunning(t, ctx, taskID)
res := completeResult(t, "reply")
if _, err := testHandler.TaskService.CompleteTask(ctx, parseUUID(taskID), res, "", ""); err != nil {
t.Fatalf("first complete: %v", err)
}
// Replay: the status CAS fails, so this is an idempotent no-op success.
if _, err := testHandler.TaskService.CompleteTask(ctx, parseUUID(taskID), res, "", ""); err != nil {
t.Fatalf("replayed complete must be idempotent success, got %v", err)
}
if rows := assistantRows(t, ctx, sessionID); len(rows) != 1 {
t.Fatalf("expected exactly one assistant outcome after replay, got %d", len(rows))
}
}
// TestFailTask_ChatRetryInheritsInputOwnerAndPriority: a transient failure of a
// task-owned direct task creates a retry child that reuses the SAME input owner
// (so it reads the same user messages) and is queued at a bumped priority so it
// is claimed ahead of fresh chat tasks.
func TestFailTask_ChatRetryInheritsInputOwnerAndPriority(t *testing.T) {
if testHandler == nil {
t.Skip("database not available")
}
ctx := context.Background()
agentID, sessionID, _, _ := setupDirectChatSession(t, ctx, "retry chat")
rootID := sendDirectChat(t, ctx, agentID, sessionID, "root question")
markTaskRunning(t, ctx, rootID)
if _, err := testHandler.TaskService.FailTask(ctx, parseUUID(rootID), "runtime went away", "", "", "runtime_offline"); err != nil {
t.Fatalf("fail task: %v", err)
}
var childID, childOwner string
var childPriority, childAttempt int
var childStatus string
if err := testPool.QueryRow(ctx, `
SELECT id, chat_input_task_id, priority, status, attempt
FROM agent_task_queue
WHERE parent_task_id = $1
`, rootID).Scan(&childID, &childOwner, &childPriority, &childStatus, &childAttempt); err != nil {
t.Fatalf("expected a retry child, got: %v", err)
}
if childOwner != rootID {
t.Fatalf("retry child must inherit the root input owner %s, got %s", rootID, childOwner)
}
if childPriority < 3 {
t.Fatalf("chat retry must be bumped above fresh chat priority (2); got %d", childPriority)
}
if childStatus != "queued" {
t.Fatalf("retry child must be queued, got %q", childStatus)
}
// The root direct task starts at attempt 1, so its first retry is attempt 2.
var rootAttempt int
if err := testPool.QueryRow(ctx, `SELECT attempt FROM agent_task_queue WHERE id = $1`, rootID).Scan(&rootAttempt); err != nil {
t.Fatalf("read root attempt: %v", err)
}
if childAttempt != rootAttempt+1 {
t.Fatalf("retry child attempt must be root+1 (%d), got %d", rootAttempt+1, childAttempt)
}
// The child reads the same input batch: the root's user message.
owned, err := testHandler.Queries.ListChatInputMessages(ctx, parseUUID(childOwner))
if err != nil {
t.Fatalf("list child owned input: %v", err)
}
if len(owned) != 1 || owned[0].Content != "root question" {
t.Fatalf("retry child must read the root input batch; got %+v", msgContents(owned))
}
}
// ---- helpers ----
func msgContents(msgs []db.ChatMessage) []string {
out := make([]string, 0, len(msgs))
for _, m := range msgs {
out = append(out, m.Content)
}
return out
}
func assistantRows(t *testing.T, ctx context.Context, sessionID string) []db.ChatMessage {
t.Helper()
all, err := testHandler.Queries.ListChatMessages(ctx, parseUUID(sessionID))
if err != nil {
t.Fatalf("list chat messages: %v", err)
}
var out []db.ChatMessage
for _, m := range all {
if m.Role == "assistant" {
out = append(out, m)
}
}
return out
}
func assertTaskInputOwner(t *testing.T, ctx context.Context, taskID, wantOwner string) {
t.Helper()
var owner string
if err := testPool.QueryRow(ctx, `SELECT chat_input_task_id FROM agent_task_queue WHERE id = $1`, taskID).Scan(&owner); err != nil {
t.Fatalf("read chat_input_task_id: %v", err)
}
if owner != wantOwner {
t.Fatalf("task %s input owner = %s, want %s", taskID, owner, wantOwner)
}
}
func assertTaskStatus(t *testing.T, ctx context.Context, taskID, want string) {
t.Helper()
var status string
if err := testPool.QueryRow(ctx, `SELECT status FROM agent_task_queue WHERE id = $1`, taskID).Scan(&status); err != nil {
t.Fatalf("read task status: %v", err)
}
if status != want {
t.Fatalf("task %s status = %q, want %q", taskID, status, want)
}
}
func assertNoRetryChild(t *testing.T, ctx context.Context, taskID string) {
t.Helper()
var n int
if err := testPool.QueryRow(ctx, `SELECT count(*) FROM agent_task_queue WHERE parent_task_id = $1`, taskID).Scan(&n); err != nil {
t.Fatalf("count retry children: %v", err)
}
if n != 0 {
t.Fatalf("expected no retry child for a completed no_response turn, got %d", n)
}
}
// insertChannelChatTask creates a running chat task with chat_input_task_id NULL
// — the legacy/channel (Slack/Lark) shape — directly, bypassing the task-owned
// direct-send path.
func insertChannelChatTask(t *testing.T, ctx context.Context, agentID, runtimeID, sessionID string) string {
t.Helper()
var taskID string
if err := testPool.QueryRow(ctx, `
INSERT INTO agent_task_queue (agent_id, runtime_id, chat_session_id, status, priority, started_at, dispatched_at)
VALUES ($1, $2, $3, 'running', 2, now(), now())
RETURNING id
`, agentID, runtimeID, sessionID).Scan(&taskID); err != nil {
t.Fatalf("setup: create channel chat task: %v", err)
}
return taskID
}
// TestCompleteTask_ChannelEmptyOutputWritesNoRow pins the MUL-4351 review fix:
// a legacy/channel task (chat_input_task_id NULL) that completes with empty
// output must NOT write an assistant row — so chat:done carries empty content
// and the Slack/Lark outbound keeps silently dropping it. The no_response
// fallback body must never reach an external channel. A non-empty channel
// completion still writes an ordinary message.
func TestCompleteTask_ChannelEmptyOutputWritesNoRow(t *testing.T) {
if testHandler == nil {
t.Skip("database not available")
}
ctx := context.Background()
agentID, sessionID, runtimeID, _ := setupDirectChatSession(t, ctx, "channel-like chat")
// Empty output → no row at all.
emptyTask := insertChannelChatTask(t, ctx, agentID, runtimeID, sessionID)
if _, err := testHandler.TaskService.CompleteTask(ctx, parseUUID(emptyTask), completeResult(t, " "), "", ""); err != nil {
t.Fatalf("complete channel task (empty): %v", err)
}
if rows := assistantRows(t, ctx, sessionID); len(rows) != 0 {
t.Fatalf("channel empty completion must write NO assistant row (Slack/Lark silent-drop), got %d", len(rows))
}
// Non-empty output → one ordinary message (kind 'message', not no_response).
textTask := insertChannelChatTask(t, ctx, agentID, runtimeID, sessionID)
if _, err := testHandler.TaskService.CompleteTask(ctx, parseUUID(textTask), completeResult(t, "channel reply"), "", ""); err != nil {
t.Fatalf("complete channel task (text): %v", err)
}
rows := assistantRows(t, ctx, sessionID)
if len(rows) != 1 {
t.Fatalf("channel non-empty completion must write exactly one message, got %d", len(rows))
}
if rows[0].MessageKind != protocol.ChatMessageKindMessage || rows[0].Content != "channel reply" {
t.Fatalf("channel message = kind %q content %q, want message/'channel reply'", rows[0].MessageKind, rows[0].Content)
}
}