Files
multica/server/internal/handler/cancel_task_by_user_test.go
5132ac99eb MUL-7235: show cancellation actor in run history (#8249)
* MUL-7235: show cancellation actor in run history

Co-authored-by: multica-agent <[email protected]>

* fix(issues): simplify failed run recovery UI

Co-authored-by: multica-agent <[email protected]>

* Revert "fix(issues): simplify failed run recovery UI"

This reverts commit 1b337bbe46.

Co-authored-by: multica-agent <[email protected]>

* fix(tasks): keep cancellation attribution consistent

Co-authored-by: multica-agent <[email protected]>

---------

Co-authored-by: Lambda <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-09-10 21:24:35 +08:00

1018 lines
37 KiB
Go

package handler
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/multica-ai/multica/server/internal/testutil"
"github.com/multica-ai/multica/server/internal/util"
"github.com/multica-ai/multica/server/pkg/protocol"
)
// CancelTaskByUser (POST /api/tasks/{taskId}/cancel) used to key cancellation
// off issue_id / chat_session_id alone, which 404'd every task whose only
// source link was autopilot_run_id or quick_create context (MUL-2827). These
// tests pin the new behavior: tenancy flows through the task's owning agent,
// with chat-creator privacy and the private-agent visibility gate layered on.
// taskStatus reads a task's current status straight from the DB so reject
// paths can assert "no side effect before the access check".
func taskStatus(t *testing.T, taskID string) string {
t.Helper()
var status string
dbfx.QueryRow(t,
`SELECT status FROM agent_task_queue WHERE id = $1`, taskID,
).Scan(&status)
return status
}
// createAutopilotRunOnlyTask seeds the autopilot -> autopilot_run -> task chain
// that AutopilotService.dispatchRunOnly produces: a queued task with issue_id
// and chat_session_id NULL, linked only by autopilot_run_id. The autopilot is
// created in the agent's own workspace so the fixture works for foreign agents
// too.
func createAutopilotRunOnlyTask(t *testing.T, agentID string) string {
t.Helper()
var workspaceID, runtimeID string
dbfx.QueryRow(t,
`SELECT workspace_id, runtime_id FROM agent WHERE id = $1`, agentID,
).Scan(&workspaceID, &runtimeID)
autopilotID := dbfx.Insert(t, "autopilot", testutil.Cols{
"workspace_id": workspaceID,
"title": "cancel-runonly-ap",
"assignee_id": agentID,
"execution_mode": "run_only",
"created_by_type": "member",
"created_by_id": testUserID,
})
runID := dbfx.Insert(t, "autopilot_run", testutil.Cols{
"autopilot_id": autopilotID,
"source": "manual",
"status": "running",
})
taskID := dbfx.Task(t, agentID, testutil.Cols{
"runtime_id": runtimeID,
"autopilot_run_id": runID,
})
return taskID
}
// createForeignWorkspaceAgent stands up an isolated workspace + runtime + agent
// and returns the agent ID, for cross-tenant cancel tests.
func createForeignWorkspaceAgent(t *testing.T) string {
t.Helper()
workspaceID := dbfx.Insert(t, "workspace", testutil.Cols{
"name": "Foreign Cancel WS",
"slug": "foreign-cancel-ws",
"description": "cross-tenant cancel test",
"issue_prefix": "FCW",
})
runtimeID := dbfx.Insert(t, "agent_runtime", testutil.Cols{
"workspace_id": workspaceID,
"daemon_id": nil,
"name": "Foreign Cancel Runtime",
"runtime_mode": "cloud",
"provider": "foreign_runtime",
"status": "online",
"device_info": "Foreign runtime",
"metadata": testutil.Raw("'{}'::jsonb"),
"last_seen_at": testutil.Raw("now()"),
})
agentID := dbfx.Insert(t, "agent", testutil.Cols{
"workspace_id": workspaceID,
"name": "Foreign Cancel Agent",
"description": "",
"runtime_mode": "cloud",
"runtime_config": testutil.Raw("'{}'::jsonb"),
"runtime_id": runtimeID,
"visibility": "workspace",
"max_concurrent_tasks": 1,
})
return agentID
}
// createWorkspaceMemberUser adds a plain (non-owner/admin) member to the test
// workspace and returns the user ID. The member row cascades when the user is
// deleted (member.user_id ON DELETE CASCADE).
func createWorkspaceMemberUser(t *testing.T, name, email string) string {
t.Helper()
var userID string
dbfx.QueryRow(t,
`INSERT INTO "user" (name, email) VALUES ($1, $2) RETURNING id`, name, email,
).Scan(&userID)
t.Cleanup(func() { testPool.Exec(context.Background(), `DELETE FROM "user" WHERE id = $1`, userID) })
dbfx.Exec(t,
`INSERT INTO member (workspace_id, user_id, role) VALUES ($1, $2, 'member')`, testWorkspaceID, userID,
)
return userID
}
func cancelTaskByUserRequest(t *testing.T, userID, taskID string) *http.Request {
t.Helper()
req := newRequestAs(userID, "POST", "/api/tasks/"+taskID+"/cancel", nil)
req = withURLParam(req, "taskId", taskID)
return withChatTestWorkspaceCtx(t, req)
}
func cancelQueuedTaskByUserRequest(
t *testing.T,
userID, taskID, sessionID, action string,
) *http.Request {
t.Helper()
req := newRequestAs(
userID,
http.MethodPost,
"/api/tasks/"+taskID+"/cancel?expected_status=queued&chat_session_id="+
sessionID+"&queue_action="+action,
nil,
)
req = withURLParam(req, "taskId", taskID)
return withChatTestWorkspaceCtx(t, req)
}
func TestCancelTaskByUser_QueuedOnlyDoesNotCancelPromotedTask(t *testing.T) {
if testHandler == nil {
t.Skip("database not available")
}
agentID := createHandlerTestAgent(t, "CancelQueuedOnlyAgent", []byte("[]"))
sessionID := createHandlerTestChatSession(t, agentID)
taskID := insertPendingChatTask(t, agentID, sessionID, "running")
req := newRequestAs(
testUserID,
http.MethodPost,
"/api/tasks/"+taskID+"/cancel?expected_status=queued&chat_session_id="+sessionID+"&queue_action=remove",
nil,
)
req = withURLParam(req, "taskId", taskID)
w := httptest.NewRecorder()
testHandler.CancelTaskByUser(w, withChatTestWorkspaceCtx(t, req))
if w.Code != http.StatusConflict {
t.Fatalf("expected 409, got %d: %s", w.Code, w.Body.String())
}
if got := taskStatus(t, taskID); got != "running" {
t.Fatalf("queued-only cancellation changed promoted task status to %q", got)
}
}
func TestCancelTaskByUser_QueuedEditPersistsDraftRestore(t *testing.T) {
if testHandler == nil {
t.Skip("database not available")
}
agentID := createHandlerTestAgent(t, "QueuedEditRestoreAgent", []byte("[]"))
sessionID := createHandlerTestChatSession(t, agentID)
taskID, messageID, attachmentID := insertQueuedChatInputWithAttachment(
t,
agentID,
sessionID,
"edit this queued prompt",
)
w := httptest.NewRecorder()
testHandler.CancelTaskByUser(
w,
cancelQueuedTaskByUserRequest(t, testUserID, taskID, sessionID, "edit"),
)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
}
if got := taskStatus(t, taskID); got != "cancelled" {
t.Fatalf("queued edit task status = %q, want cancelled", got)
}
var response CancelTaskByUserResponse
if err := json.Unmarshal(w.Body.Bytes(), &response); err != nil {
t.Fatalf("decode queued edit cancellation: %v", err)
}
if got := response.CancelledBy; got == nil || got.Type != "member" || got.ID != testUserID || got.Name != handlerTestName {
t.Fatalf("cancelled_by = %#v, want member %s (%s)", got, handlerTestName, testUserID)
}
var restoreCount int
dbfx.QueryRow(t, `
SELECT count(*)
FROM chat_draft_restore
WHERE id = $1
AND chat_session_id = $2
AND task_id = $3
AND content = 'edit this queued prompt'
AND $4::uuid = ANY(attachment_ids)
`, messageID, sessionID, taskID, attachmentID).Scan(&restoreCount)
if restoreCount != 1 {
t.Fatalf("queued edit created %d durable restore rows", restoreCount)
}
var messageCount int
if err := testPool.QueryRow(
context.Background(),
`SELECT count(*) FROM chat_message WHERE id = $1`,
messageID,
).Scan(&messageCount); err != nil {
t.Fatalf("count edited queued message: %v", err)
}
if messageCount != 0 {
t.Fatalf("queued edit left %d input messages", messageCount)
}
var attachmentMessageID *string
if err := testPool.QueryRow(
context.Background(),
`SELECT chat_message_id::text FROM attachment WHERE id = $1`,
attachmentID,
).Scan(&attachmentMessageID); err != nil {
t.Fatalf("read detached queued attachment: %v", err)
}
if attachmentMessageID != nil {
t.Fatalf("queued edit attachment still bound to %q", *attachmentMessageID)
}
}
func TestCancelTaskByUser_QueuedEditRollsBackOnRestoreFailure(t *testing.T) {
if testHandler == nil {
t.Skip("database not available")
}
agentID := createHandlerTestAgent(t, "QueuedEditRollbackAgent", []byte("[]"))
sessionID := createHandlerTestChatSession(t, agentID)
taskID, messageID, attachmentID := insertQueuedChatInputWithAttachment(
t,
agentID,
sessionID,
"do not lose this queued prompt",
)
dbfx.Exec(t, `
INSERT INTO chat_draft_restore (id, chat_session_id, task_id, content, attachment_ids)
VALUES ($1, $2, $3, 'collision', '{}'::uuid[])
`, messageID, sessionID, taskID)
t.Cleanup(func() {
testPool.Exec(context.Background(), `DELETE FROM chat_draft_restore WHERE id = $1`, messageID)
})
w := httptest.NewRecorder()
testHandler.CancelTaskByUser(
w,
cancelQueuedTaskByUserRequest(t, testUserID, taskID, sessionID, "edit"),
)
if w.Code != http.StatusBadRequest {
t.Fatalf("expected 400, got %d: %s", w.Code, w.Body.String())
}
if got := taskStatus(t, taskID); got != "queued" {
t.Fatalf("failed queued edit left task status %q", got)
}
var bindingCount int
dbfx.QueryRow(t, `
SELECT count(*)
FROM chat_message AS message
JOIN attachment ON attachment.chat_message_id = message.id
WHERE message.id = $1 AND attachment.id = $2
`, messageID, attachmentID).Scan(&bindingCount)
if bindingCount != 1 {
t.Fatalf("failed queued edit did not roll back input binding: count = %d", bindingCount)
}
}
func TestCancelTaskByUser_QueuedRemoveDeletesAttachment(t *testing.T) {
if testHandler == nil {
t.Skip("database not available")
}
agentID := createHandlerTestAgent(t, "QueuedRemoveAttachmentAgent", []byte("[]"))
sessionID := createHandlerTestChatSession(t, agentID)
taskID, messageID, attachmentID := insertQueuedChatInputWithAttachment(
t,
agentID,
sessionID,
"discard this queued prompt",
)
w := httptest.NewRecorder()
testHandler.CancelTaskByUser(
w,
cancelQueuedTaskByUserRequest(t, testUserID, taskID, sessionID, "remove"),
)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
}
if got := taskStatus(t, taskID); got != "cancelled" {
t.Fatalf("queued remove task status = %q, want cancelled", got)
}
var rows int
dbfx.QueryRow(t, `
SELECT
(SELECT count(*) FROM chat_message WHERE id = $1) +
(SELECT count(*) FROM attachment WHERE id = $2)
`, messageID, attachmentID).Scan(&rows)
if rows != 0 {
t.Fatalf("queued remove left %d message/attachment rows", rows)
}
}
// createStartedEmptyChatTask seeds the exact shape the deferred cancel is about:
// a chat task the daemon has already started (started_at set) whose transcript is
// still empty, plus the user message that triggered it.
func createStartedEmptyChatTask(t *testing.T, sessionID, agentID, content string) (taskID, userMessageID string) {
t.Helper()
dbfx.QueryRow(t, `
INSERT INTO agent_task_queue (agent_id, runtime_id, status, priority, issue_id, chat_session_id, started_at)
VALUES ($1, (SELECT runtime_id FROM agent WHERE id = $1), 'running', 0, NULL, $2, now())
RETURNING id
`, agentID, sessionID).Scan(&taskID)
t.Cleanup(func() { testPool.Exec(context.Background(), `DELETE FROM agent_task_queue WHERE id = $1`, taskID) })
dbfx.QueryRow(t, `
INSERT INTO chat_message (chat_session_id, role, content, task_id)
VALUES ($1, 'user', $2, $3)
RETURNING id
`, sessionID, content, taskID).Scan(&userMessageID)
return taskID, userMessageID
}
func chatFinalizeDeferredAt(t *testing.T, taskID string) *time.Time {
t.Helper()
var at *time.Time
dbfx.QueryRow(t, `
SELECT chat_finalize_deferred_at FROM agent_task_queue WHERE id = $1
`, taskID).Scan(&at)
return at
}
// Rollout compatibility (#5219). Clients and server do not upgrade together, and
// a started-but-empty cancel is the one case where the prompt does NOT come back
// in the cancel response — it is deferred and later published as a durable
// draft-restore row, which only a client that knows about chat:cancel_finalized
// and the draft-restores endpoint can collect. An installed desktop build that
// predates all of that would read the empty response as "nothing to restore" and
// silently drop the user's input, so deferral is gated on the client advertising
// AppCapabilityChatDraftRestoreV1.
func TestCancelTaskByUser_StartedEmptyChat_WithDraftRestoreCapability_Defers(t *testing.T) {
if testHandler == nil {
t.Skip("database not available")
}
agentID := createHandlerTestAgent(t, "CancelChatDeferCapableAgent", []byte("[]"))
sessionID := createHandlerTestChatSession(t, agentID)
taskID, userMessageID := createStartedEmptyChatTask(t, sessionID, agentID, "defer this prompt")
req := cancelTaskByUserRequest(t, testUserID, taskID)
req.Header.Set("X-Client-Capabilities", protocol.AppCapabilityChatDraftRestoreV1)
w := httptest.NewRecorder()
testHandler.CancelTaskByUser(w, req)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
}
var resp CancelTaskByUserResponse
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatalf("decode cancel response: %v", err)
}
if resp.CancelledChatMessage != nil {
t.Fatalf("capable client must get no synchronous restore, got %#v", resp.CancelledChatMessage)
}
if chatFinalizeDeferredAt(t, taskID) == nil {
t.Fatal("expected the finalize-deferred marker to be set")
}
// The judgment is deferred, so the user message must still be there for the
// daemon ack / sweeper to settle.
var count int
dbfx.QueryRow(t,
`SELECT count(*) FROM chat_message WHERE id = $1`, userMessageID).Scan(&count)
if count != 1 {
t.Fatalf("expected the user message to survive the deferral, got %d rows", count)
}
}
func TestCancelTaskByUser_StartedEmptyChat_LegacyClient_StillGetsSynchronousRestore(t *testing.T) {
if testHandler == nil {
t.Skip("database not available")
}
agentID := createHandlerTestAgent(t, "CancelChatDeferLegacyAgent", []byte("[]"))
sessionID := createHandlerTestChatSession(t, agentID)
const userContent = "an old client must not lose this"
taskID, userMessageID := createStartedEmptyChatTask(t, sessionID, agentID, userContent)
// No X-Client-Capabilities: a build that predates the durable restore.
w := httptest.NewRecorder()
testHandler.CancelTaskByUser(w, cancelTaskByUserRequest(t, testUserID, taskID))
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
}
var resp CancelTaskByUserResponse
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatalf("decode cancel response: %v", err)
}
if resp.CancelledChatMessage == nil {
t.Fatal("legacy client must still get the synchronous restore payload")
}
if resp.CancelledChatMessage.MessageID != userMessageID ||
resp.CancelledChatMessage.Content != userContent ||
!resp.CancelledChatMessage.RestoreToInput {
t.Fatalf("restore payload mismatch: %#v", resp.CancelledChatMessage)
}
if at := chatFinalizeDeferredAt(t, taskID); at != nil {
t.Fatalf("legacy cancel must not defer, marker set at %v", at)
}
// Legacy semantics all the way: the message is settled synchronously, and no
// durable restore row is written for a client that could never read it.
var messages int
dbfx.QueryRow(t,
`SELECT count(*) FROM chat_message WHERE id = $1`, userMessageID).Scan(&messages)
if messages != 0 {
t.Fatalf("expected the user message to be deleted synchronously, got %d rows", messages)
}
var restores int
dbfx.QueryRow(t,
`SELECT count(*) FROM chat_draft_restore WHERE chat_session_id = $1`, sessionID).Scan(&restores)
if restores != 0 {
t.Fatalf("expected no durable restore for a legacy cancel, got %d", restores)
}
}
// TestCancelTaskByUser_RunOnlyAutopilot_Succeeds is the core MUL-2827 fix: a
// run_only autopilot task (issue_id + chat_session_id NULL, only
// autopilot_run_id set) is cancellable by a member of its agent's workspace.
func TestCancelTaskByUser_RunOnlyAutopilot_Succeeds(t *testing.T) {
if testHandler == nil {
t.Skip("database not available")
}
agentID := createHandlerTestAgent(t, "CancelRunOnlyAgent", []byte("[]"))
taskID := createAutopilotRunOnlyTask(t, agentID)
w := httptest.NewRecorder()
testHandler.CancelTaskByUser(w, cancelTaskByUserRequest(t, testUserID, taskID))
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
}
if got := taskStatus(t, taskID); got != "cancelled" {
t.Fatalf("task not cancelled: status = %q", got)
}
}
// TestCancelTaskByUser_RunOnlyAutopilot_CrossWorkspace_Returns404 verifies the
// tenant guard: a member of workspace A cannot cancel a run_only task whose
// agent lives in workspace B, and the task is not mutated before the check.
func TestCancelTaskByUser_RunOnlyAutopilot_CrossWorkspace_Returns404(t *testing.T) {
if testHandler == nil {
t.Skip("database not available")
}
foreignAgentID := createForeignWorkspaceAgent(t)
taskID := createAutopilotRunOnlyTask(t, foreignAgentID)
w := httptest.NewRecorder()
testHandler.CancelTaskByUser(w, cancelTaskByUserRequest(t, testUserID, taskID))
if w.Code != http.StatusNotFound {
t.Fatalf("expected 404, got %d: %s", w.Code, w.Body.String())
}
if got := taskStatus(t, taskID); got != "queued" {
t.Fatalf("foreign task was mutated: status = %q", got)
}
}
// TestCancelTaskByUser_QuickCreate_Succeeds verifies a quick_create task — no
// issue yet, no chat session, only context JSONB — is cancellable during its
// active window (the pre-issue-creation phase, i.e. whenever the user clicks X).
func TestCancelTaskByUser_QuickCreate_Succeeds(t *testing.T) {
if testHandler == nil {
t.Skip("database not available")
}
agentID := createHandlerTestAgent(t, "CancelQuickCreateAgent", []byte("[]"))
var taskID string
dbfx.QueryRow(t, `
INSERT INTO agent_task_queue (agent_id, runtime_id, status, priority, issue_id, context)
VALUES ($1, (SELECT runtime_id FROM agent WHERE id = $1), 'running', 0, NULL,
'{"type":"quick_create","workspace_id":"ws","prompt":"do a thing"}'::jsonb)
RETURNING id
`, agentID).Scan(&taskID)
t.Cleanup(func() { testPool.Exec(context.Background(), `DELETE FROM agent_task_queue WHERE id = $1`, taskID) })
w := httptest.NewRecorder()
testHandler.CancelTaskByUser(w, cancelTaskByUserRequest(t, testUserID, taskID))
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
}
if got := taskStatus(t, taskID); got != "cancelled" {
t.Fatalf("task not cancelled: status = %q", got)
}
}
// TestCancelTaskByUser_RetryClone_Autopilot_Succeeds verifies a retry clone of
// an autopilot task — which copies parent_task_id + autopilot_run_id verbatim,
// inheriting the NULL issue/chat links — is still cancellable.
func TestCancelTaskByUser_RetryClone_Autopilot_Succeeds(t *testing.T) {
if testHandler == nil {
t.Skip("database not available")
}
agentID := createHandlerTestAgent(t, "CancelRetryCloneAgent", []byte("[]"))
parentID := createAutopilotRunOnlyTask(t, agentID)
var cloneID string
dbfx.QueryRow(t, `
INSERT INTO agent_task_queue (agent_id, runtime_id, status, priority, autopilot_run_id, parent_task_id, attempt)
SELECT agent_id, runtime_id, 'queued', priority, autopilot_run_id, id, 1
FROM agent_task_queue WHERE id = $1
RETURNING id
`, parentID).Scan(&cloneID)
t.Cleanup(func() { testPool.Exec(context.Background(), `DELETE FROM agent_task_queue WHERE id = $1`, cloneID) })
w := httptest.NewRecorder()
testHandler.CancelTaskByUser(w, cancelTaskByUserRequest(t, testUserID, cloneID))
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
}
if got := taskStatus(t, cloneID); got != "cancelled" {
t.Fatalf("clone not cancelled: status = %q", got)
}
}
// TestCancelTaskByUser_IssueTask_Succeeds is a regression guard: issue-bound
// tasks (the original supported case) stay cancellable after the rewrite.
func TestCancelTaskByUser_IssueTask_Succeeds(t *testing.T) {
if testHandler == nil {
t.Skip("database not available")
}
agentID := createHandlerTestAgent(t, "CancelIssueTaskAgent", []byte("[]"))
var issueID, taskID string
dbfx.QueryRow(t, `
INSERT INTO issue (workspace_id, title, status, priority, creator_id, creator_type, number, position)
VALUES ($1, 'cancel-byid-issue', 'todo', 'medium', $2, 'member', 92001, 0)
RETURNING id
`, testWorkspaceID, testUserID).Scan(&issueID)
t.Cleanup(func() { testPool.Exec(context.Background(), `DELETE FROM issue WHERE id = $1`, issueID) })
dbfx.QueryRow(t, `
INSERT INTO agent_task_queue (agent_id, runtime_id, status, priority, issue_id)
VALUES ($1, (SELECT runtime_id FROM agent WHERE id = $1), 'queued', 0, $2)
RETURNING id
`, agentID, issueID).Scan(&taskID)
t.Cleanup(func() { testPool.Exec(context.Background(), `DELETE FROM agent_task_queue WHERE id = $1`, taskID) })
w := httptest.NewRecorder()
testHandler.CancelTaskByUser(w, cancelTaskByUserRequest(t, testUserID, taskID))
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
}
if got := taskStatus(t, taskID); got != "cancelled" {
t.Fatalf("task not cancelled: status = %q", got)
}
}
func TestCancelTaskByUser_DelegatedFailureRecoveryAcknowledgesSignal(t *testing.T) {
if testHandler == nil {
t.Skip("database not available")
}
agentID := createHandlerTestAgent(t, "CancelRecoveryTaskAgent", []byte("[]"))
issueID := dbfx.Issue(t, "cancel-recovery-task", testutil.Cols{
"status": "in_progress",
"priority": "medium",
"number": 92004,
})
var sourceTaskID, failedTaskID, recoveryCommentID, recoveryTaskID string
dbfx.QueryRow(t, `
INSERT INTO agent_task_queue (agent_id, runtime_id, issue_id, status, priority, completed_at)
VALUES ($1, (SELECT runtime_id FROM agent WHERE id = $1), $2, 'completed', 0, now())
RETURNING id
`, agentID, issueID).Scan(&sourceTaskID)
dbfx.QueryRow(t, `
INSERT INTO agent_task_queue (
agent_id, runtime_id, issue_id, status, priority, completed_at,
delegated_from_task_id, trigger_evidence_kind
)
VALUES ($1, (SELECT runtime_id FROM agent WHERE id = $1), $2, 'failed', 0, now(), $3, 'comment')
RETURNING id
`, agentID, issueID, sourceTaskID).Scan(&failedTaskID)
dbfx.QueryRow(t, `
INSERT INTO comment (issue_id, workspace_id, author_type, author_id, content, type, source_task_id)
VALUES ($1, $2, 'system', $3, 'resume delegated coordination', 'progress_update', $4)
RETURNING id
`, issueID, testWorkspaceID, testUserID, failedTaskID).Scan(&recoveryCommentID)
dbfx.QueryRow(t, `
INSERT INTO agent_task_queue (
agent_id, runtime_id, issue_id, status, priority, trigger_comment_id,
trigger_evidence_kind, trigger_evidence_ref_id
)
VALUES ($1, (SELECT runtime_id FROM agent WHERE id = $1), $2, 'queued', 0, $3,
'delegated_failure', $4)
RETURNING id
`, agentID, issueID, recoveryCommentID, failedTaskID).Scan(&recoveryTaskID)
w := httptest.NewRecorder()
testHandler.CancelTaskByUser(w, cancelTaskByUserRequest(t, testUserID, recoveryTaskID))
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
}
var status string
var acknowledged bool
dbfx.QueryRow(t, `
SELECT status, $2::uuid = ANY(delivered_comment_ids)
FROM agent_task_queue WHERE id = $1
`, recoveryTaskID, recoveryCommentID).Scan(&status, &acknowledged)
if status != "cancelled" || !acknowledged {
t.Fatalf("cancelled recovery status/ack = %q/%v, want cancelled/true", status, acknowledged)
}
}
// TestCancelTaskByUser_ChatTask_NonCreator_Returns403 preserves chat privacy:
// a workspace member who did not start the conversation cannot cancel its task.
func TestCancelTaskByUser_ChatTask_NonCreator_Returns403(t *testing.T) {
if testHandler == nil {
t.Skip("database not available")
}
agentID := createHandlerTestAgent(t, "CancelChatAgent", []byte("[]"))
sessionID := createHandlerTestChatSession(t, agentID) // creator = testUserID
otherUserID := createWorkspaceMemberUser(t, "Chat Bystander", "[email protected]")
var taskID string
dbfx.QueryRow(t, `
INSERT INTO agent_task_queue (agent_id, runtime_id, status, priority, issue_id, chat_session_id)
VALUES ($1, (SELECT runtime_id FROM agent WHERE id = $1), 'running', 0, NULL, $2)
RETURNING id
`, agentID, sessionID).Scan(&taskID)
t.Cleanup(func() { testPool.Exec(context.Background(), `DELETE FROM agent_task_queue WHERE id = $1`, taskID) })
w := httptest.NewRecorder()
testHandler.CancelTaskByUser(w, cancelTaskByUserRequest(t, otherUserID, taskID))
if w.Code != http.StatusForbidden {
t.Fatalf("expected 403, got %d: %s", w.Code, w.Body.String())
}
if got := taskStatus(t, taskID); got != "running" {
t.Fatalf("chat task was mutated: status = %q", got)
}
}
func TestCancelTaskByUser_ChatTaskWithTranscript_PersistsAssistantSnapshot(t *testing.T) {
if testHandler == nil {
t.Skip("database not available")
}
agentID := createHandlerTestAgent(t, "CancelChatTranscriptAgent", []byte("[]"))
sessionID := createHandlerTestChatSession(t, agentID)
var taskID string
dbfx.QueryRow(t, `
INSERT INTO agent_task_queue (agent_id, runtime_id, status, priority, issue_id, chat_session_id, created_at)
VALUES ($1, (SELECT runtime_id FROM agent WHERE id = $1), 'running', 0, NULL, $2, now() - interval '5 seconds')
RETURNING id
`, agentID, sessionID).Scan(&taskID)
t.Cleanup(func() { testPool.Exec(context.Background(), `DELETE FROM agent_task_queue WHERE id = $1`, taskID) })
dbfx.Exec(t, `
INSERT INTO chat_message (chat_session_id, role, content, task_id)
VALUES ($1, 'user', 'please answer', $2)
`, sessionID, taskID)
dbfx.Exec(t, `
INSERT INTO task_message (task_id, seq, type, content)
VALUES ($1, 1, 'text', 'partial answer')
`, taskID)
w := httptest.NewRecorder()
testHandler.CancelTaskByUser(w, cancelTaskByUserRequest(t, testUserID, taskID))
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
}
var resp CancelTaskByUserResponse
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatalf("decode cancel response: %v", err)
}
if resp.CancelledChatMessage != nil {
t.Fatalf("expected no restore payload when transcript exists, got %#v", resp.CancelledChatMessage)
}
if got := taskStatus(t, taskID); got != "cancelled" {
t.Fatalf("task not cancelled: status = %q", got)
}
var role, content, messageTaskID string
dbfx.QueryRow(t, `
SELECT role, content, COALESCE(task_id::text, '')
FROM chat_message
WHERE chat_session_id = $1 AND role = 'assistant'
`, sessionID).Scan(&role, &content, &messageTaskID)
if role != "assistant" || content != "Stopped." || messageTaskID != taskID {
t.Fatalf("assistant snapshot mismatch: role=%q content=%q task_id=%q", role, content, messageTaskID)
}
}
func TestCancelTaskByUser_ChatTaskWithoutTranscript_RestoresUserDraft(t *testing.T) {
if testHandler == nil {
t.Skip("database not available")
}
agentID := createHandlerTestAgent(t, "CancelChatNoTranscriptAgent", []byte("[]"))
sessionID := createHandlerTestChatSession(t, agentID)
var taskID string
dbfx.QueryRow(t, `
INSERT INTO agent_task_queue (agent_id, runtime_id, status, priority, issue_id, chat_session_id)
VALUES ($1, (SELECT runtime_id FROM agent WHERE id = $1), 'running', 0, NULL, $2)
RETURNING id
`, agentID, sessionID).Scan(&taskID)
t.Cleanup(func() { testPool.Exec(context.Background(), `DELETE FROM agent_task_queue WHERE id = $1`, taskID) })
var userMessageID string
const userContent = "keep this prompt"
dbfx.QueryRow(t, `
INSERT INTO chat_message (chat_session_id, role, content, task_id)
VALUES ($1, 'user', $2, $3)
RETURNING id
`, sessionID, userContent, taskID).Scan(&userMessageID)
w := httptest.NewRecorder()
testHandler.CancelTaskByUser(w, cancelTaskByUserRequest(t, testUserID, taskID))
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
}
var resp CancelTaskByUserResponse
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatalf("decode cancel response: %v", err)
}
if resp.CancelledChatMessage == nil {
t.Fatal("expected restore payload for empty transcript cancel")
}
if resp.CancelledChatMessage.MessageID != userMessageID ||
resp.CancelledChatMessage.Content != userContent ||
!resp.CancelledChatMessage.RestoreToInput {
t.Fatalf("restore payload mismatch: %#v", resp.CancelledChatMessage)
}
var count int
dbfx.QueryRow(t, `
SELECT count(*) FROM chat_message
WHERE chat_session_id = $1 AND role = 'assistant'
`, sessionID).Scan(&count)
if count != 0 {
t.Fatalf("expected no assistant snapshot for empty transcript, got %d", count)
}
dbfx.QueryRow(t, `
SELECT count(*) FROM chat_message
WHERE id = $1
`, userMessageID).Scan(&count)
if count != 0 {
t.Fatalf("expected linked user message to be deleted, got %d", count)
}
}
func TestCancelTaskByUser_ChatRetryRestoresRootInput(t *testing.T) {
if testHandler == nil {
t.Skip("database not available")
}
agentID := createHandlerTestAgent(t, "CancelChatRetryAgent", []byte("[]"))
sessionID := createHandlerTestChatSession(t, agentID)
var rootTaskID string
dbfx.QueryRow(t, `
INSERT INTO agent_task_queue (
agent_id, runtime_id, status, priority, chat_session_id, completed_at
)
VALUES ($1, (SELECT runtime_id FROM agent WHERE id = $1), 'completed', 0, $2, now())
RETURNING id
`, agentID, sessionID).Scan(&rootTaskID)
t.Cleanup(func() {
testPool.Exec(context.Background(), `DELETE FROM agent_task_queue WHERE id = $1`, rootTaskID)
})
if _, err := testPool.Exec(
context.Background(),
`UPDATE agent_task_queue SET chat_input_task_id = id WHERE id = $1`,
rootTaskID,
); err != nil {
t.Fatalf("seal root chat input: %v", err)
}
var messageID string
const content = "restore the retry input"
dbfx.QueryRow(t, `
INSERT INTO chat_message (chat_session_id, role, content, task_id)
VALUES ($1, 'user', $2, $3)
RETURNING id
`, sessionID, content, rootTaskID).Scan(&messageID)
var retryTaskID string
dbfx.QueryRow(t, `
INSERT INTO agent_task_queue (
agent_id, runtime_id, status, priority, chat_session_id,
parent_task_id, retry_of_task_id, chat_input_task_id, attempt
)
VALUES (
$1, (SELECT runtime_id FROM agent WHERE id = $1), 'queued', 0, $2,
$3, $3, $3, 1
)
RETURNING id
`, agentID, sessionID, rootTaskID).Scan(&retryTaskID)
t.Cleanup(func() {
testPool.Exec(context.Background(), `DELETE FROM agent_task_queue WHERE id = $1`, retryTaskID)
})
transcript, err := testHandler.Queries.ListChatMessages(
context.Background(),
util.MustParseUUID(sessionID),
)
if err != nil {
t.Fatalf("list transcript with queued retry: %v", err)
}
if len(transcript) != 1 || transcript[0].Content != content {
t.Fatalf("queued retry hid its historical root input: %+v", transcript)
}
w := httptest.NewRecorder()
testHandler.CancelTaskByUser(w, cancelTaskByUserRequest(t, testUserID, retryTaskID))
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
}
var resp CancelTaskByUserResponse
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatalf("decode retry cancel response: %v", err)
}
if resp.CancelledChatMessage == nil ||
resp.CancelledChatMessage.MessageID != messageID ||
resp.CancelledChatMessage.Content != content {
t.Fatalf("retry did not restore its root input: %#v", resp.CancelledChatMessage)
}
}
// TestCancelTaskByUser_ChatTaskWithBoundAttachment_SurvivesCancelAndRebinds
// guards the data-loss path on the empty-chat cancel: the user message bound to
// an attachment is deleted, and attachment.chat_message_id is ON DELETE CASCADE
// (server/migrations/083_attachment_chat_columns.up.sql), so without the
// detach-before-delete step the cancel would silently destroy the user's
// attachment. The detach (chat_message_id -> NULL, chat_session_id retained) is
// load-bearing, not an optimization; nothing else covered it. This pins:
//
// (a) the attachment row survives the cascade — still present, chat_message_id
// NULL, chat_session_id retained;
// (b) the cancel response returns it via cancelled_chat_message.attachments so
// the restored draft can re-show it;
// (c) re-sending the restored draft re-binds the surviving attachment to the
// new message in the same session.
func TestCancelTaskByUser_ChatTaskWithBoundAttachment_SurvivesCancelAndRebinds(t *testing.T) {
if testHandler == nil {
t.Skip("database not available")
}
agentID := createHandlerTestAgent(t, "CancelChatAttachAgent", []byte("[]"))
sessionID := createHandlerTestChatSession(t, agentID)
var taskID string
dbfx.QueryRow(t, `
INSERT INTO agent_task_queue (agent_id, runtime_id, status, priority, issue_id, chat_session_id)
VALUES ($1, (SELECT runtime_id FROM agent WHERE id = $1), 'running', 0, NULL, $2)
RETURNING id
`, agentID, sessionID).Scan(&taskID)
t.Cleanup(func() { testPool.Exec(context.Background(), `DELETE FROM agent_task_queue WHERE id = $1`, taskID) })
var userMessageID string
const userContent = "look at this attachment"
dbfx.QueryRow(t, `
INSERT INTO chat_message (chat_session_id, role, content, task_id)
VALUES ($1, 'user', $2, $3)
RETURNING id
`, sessionID, userContent, taskID).Scan(&userMessageID)
// Bind an attachment to that user message, exactly as a real send does:
// workspace-scoped, uploaded by the session creator, pointing at both the
// session and the message.
var attachmentID string
dbfx.QueryRow(t, `
INSERT INTO attachment (workspace_id, uploader_type, uploader_id, filename, url, content_type, size_bytes, chat_session_id, chat_message_id)
VALUES ($1, 'member', $2, 'cancel-survive.png', 'https://cdn.example.com/cancel-survive.png', 'image/png', 9, $3, $4)
RETURNING id::text
`, testWorkspaceID, testUserID, sessionID, userMessageID).Scan(&attachmentID)
t.Cleanup(func() { testPool.Exec(context.Background(), `DELETE FROM attachment WHERE id = $1`, attachmentID) })
// Cancel the empty chat task (no transcript) — this deletes the user message.
w := httptest.NewRecorder()
testHandler.CancelTaskByUser(w, cancelTaskByUserRequest(t, testUserID, taskID))
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
}
var resp CancelTaskByUserResponse
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatalf("decode cancel response: %v", err)
}
if resp.CancelledChatMessage == nil {
t.Fatal("expected restore payload for empty transcript cancel")
}
// (b) The cancel response carries the detached attachment back.
var returned *AttachmentResponse
for i := range resp.CancelledChatMessage.Attachments {
if resp.CancelledChatMessage.Attachments[i].ID == attachmentID {
returned = &resp.CancelledChatMessage.Attachments[i]
break
}
}
if returned == nil {
t.Fatalf("cancel response did not return the detached attachment: %#v", resp.CancelledChatMessage.Attachments)
}
// (a) The row survived the ON DELETE CASCADE: still present, detached from
// the deleted message, but still scoped to the session.
var count int
dbfx.QueryRow(t,
`SELECT count(*) FROM attachment WHERE id = $1`, attachmentID,
).Scan(&count)
if count != 1 {
t.Fatalf("attachment was cascade-deleted on cancel: count = %d", count)
}
var dbMessageID, dbSessionID *string
dbfx.QueryRow(t,
`SELECT chat_message_id::text, chat_session_id::text FROM attachment WHERE id = $1`, attachmentID,
).Scan(&dbMessageID, &dbSessionID)
if dbMessageID != nil {
t.Fatalf("expected chat_message_id detached to NULL, got %q", *dbMessageID)
}
if dbSessionID == nil || *dbSessionID != sessionID {
t.Fatalf("expected chat_session_id retained as %q, got %v", sessionID, dbSessionID)
}
// Sanity: the empty-cancel still deleted the user message itself.
dbfx.QueryRow(t,
`SELECT count(*) FROM chat_message WHERE id = $1`, userMessageID,
).Scan(&count)
if count != 0 {
t.Fatalf("expected linked user message to be deleted, got %d", count)
}
// (c) Re-sending the restored draft re-binds the surviving attachment to a
// fresh message in the same session — the whole reason for detaching.
sendReq := newRequest("POST", "/api/chat-sessions/"+sessionID+"/messages", map[string]any{
"content": userContent,
"attachment_ids": []string{attachmentID},
})
sendReq = withURLParam(sendReq, "sessionId", sessionID)
sendReq = withChatTestWorkspaceCtx(t, sendReq)
sendW := httptest.NewRecorder()
testHandler.SendChatMessage(sendW, sendReq)
if sendW.Code != http.StatusCreated {
t.Fatalf("resend: expected 201, got %d: %s", sendW.Code, sendW.Body.String())
}
var sendResp SendChatMessageResponse
if err := json.Unmarshal(sendW.Body.Bytes(), &sendResp); err != nil {
t.Fatalf("decode resend response: %v", err)
}
t.Cleanup(func() {
testPool.Exec(context.Background(), `DELETE FROM agent_task_queue WHERE id = $1`, sendResp.TaskID)
})
rebound := false
for _, id := range sendResp.AttachmentIDs {
if id == attachmentID {
rebound = true
break
}
}
if !rebound {
t.Fatalf("attachment not re-bound on resend: %#v", sendResp.AttachmentIDs)
}
dbfx.QueryRow(t,
`SELECT chat_message_id::text FROM attachment WHERE id = $1`, attachmentID,
).Scan(&dbMessageID)
if dbMessageID == nil || *dbMessageID != sendResp.MessageID {
t.Fatalf("expected attachment re-bound to new message %q, got %v", sendResp.MessageID, dbMessageID)
}
}
// TestCancelTaskByUser_PrivateAgent_PlainMember_Returns403 verifies the cancel
// endpoint mirrors the agent Activity / snapshot visibility gate: a plain
// member who cannot see a private agent's tasks cannot cancel them either.
func TestCancelTaskByUser_PrivateAgent_PlainMember_Returns403(t *testing.T) {
if testHandler == nil {
t.Skip("database not available")
}
agentID, _, memberID := privateAgentTestFixture(t)
taskID := createAutopilotRunOnlyTask(t, agentID)
w := httptest.NewRecorder()
testHandler.CancelTaskByUser(w, cancelTaskByUserRequest(t, memberID, taskID))
if w.Code != http.StatusForbidden {
t.Fatalf("expected 403, got %d: %s", w.Code, w.Body.String())
}
if got := taskStatus(t, taskID); got != "queued" {
t.Fatalf("task was mutated: status = %q", got)
}
}