mirror of
https://github.com/multica-ai/multica.git
synced 2026-07-05 13:29:44 +02:00
* feat(chat): workspace-scoped attachment binding + fire-and-forget send Uploads are now workspace-scoped: the chat session is created and attachments are bound to the message at send time, so a paste/drop no longer creates an empty session the user never sends. - LinkAttachmentsToChatMessage returns the ids it actually bound; the client diffs requested-vs-bound and warns on partial bind, replacing an extra listChatMessagesPage fetch. - Cancelling an empty chat task detaches attachments before deleting the user message (attachment FK is ON DELETE CASCADE) and returns them via cancelled_chat_message.attachments, so a restored draft can re-bind. - SendChatMessageResponse.attachment_ids has no omitempty: "requested but bound zero" serializes [] so the client can tell it apart from an older server and still warn. - Send is fire-and-forget: it no longer steals focus when the user has navigated to another session (guarded on the live store + new-chat agent id); the reply surfaces via the unread dot. commitInput gets clearEditor so a navigated-away commit doesn't wipe the editor now showing another session, while still clearing the sent draft's data. - Draft restore is session-aware so a failed fire-and-forget send restores into the session it was sent from, never the one the user moved to. - Removed the now-unreferenced migrateInputDraft store action. Verified: core/views typecheck, chat-input (15) / store (3) / api client (24) unit tests, go build + vet, handler SendChatMessage + CancelTaskByUser DB tests. Full make check / E2E left to CI. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * test(chat): guard attachment survival on empty-chat cancel Cancelling an empty chat task deletes the user message, and attachment.chat_message_id is ON DELETE CASCADE (migration 083), so the detach-before-delete in finalizeCancelledChatMessage is the only thing keeping the user's attachment from being silently destroyed. Nothing covered it. Add a DB regression test that binds an attachment to the cancelled user message and asserts: the row survives the cascade (chat_message_id NULL, chat_session_id retained), the cancel response returns it via cancelled_chat_message.attachments, and a resend re-binds it to the new message. Verified red when the detach step is removed. Related issue: MUL-3364 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Co-authored-by: multica-agent <github@multica.ai> * fix(comment): pessimistic submit for comment/reply composers The comment and reply composers cleared the editor after `await onSubmit` returned, with no in-flight lock. On a slow send the WS `comment:created` event already dropped the real comment into the timeline while the box still held the same text + spinner, so it read as two comments. And because `submitComment`/`submitReply` swallow errors (toast, no rethrow), a failed send still reached `clearContent` and silently discarded the user's draft. Recover the comment/reply portion of the closed #4236: make the submit callback resolve a success boolean (true on success, false on the caught failure), lock the editor while in flight (pointer-events-none + dimmed wrapper + aria-busy, since ContentEditor can't toggle Tiptap `editable` post-mount), keep the button spinning, and clear only on success — a failed send keeps the draft. Chat composer is out of scope (already reworked on this branch); attachment binding is untouched. Adds two view tests (in-flight lock then clear-on-success; failed send keeps the draft); both verified red against the un-fixed code. Related issue: MUL-3364 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Co-authored-by: multica-agent <github@multica.ai> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Co-authored-by: multica-agent <github@multica.ai>
620 lines
24 KiB
Go
620 lines
24 KiB
Go
package handler
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"testing"
|
|
)
|
|
|
|
// 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
|
|
if err := testPool.QueryRow(context.Background(),
|
|
`SELECT status FROM agent_task_queue WHERE id = $1`, taskID,
|
|
).Scan(&status); err != nil {
|
|
t.Fatalf("read task status: %v", err)
|
|
}
|
|
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()
|
|
ctx := context.Background()
|
|
|
|
var workspaceID, runtimeID string
|
|
if err := testPool.QueryRow(ctx,
|
|
`SELECT workspace_id, runtime_id FROM agent WHERE id = $1`, agentID,
|
|
).Scan(&workspaceID, &runtimeID); err != nil {
|
|
t.Fatalf("load agent workspace/runtime: %v", err)
|
|
}
|
|
|
|
var autopilotID string
|
|
if err := testPool.QueryRow(ctx, `
|
|
INSERT INTO autopilot (workspace_id, title, assignee_id, execution_mode, created_by_type, created_by_id)
|
|
VALUES ($1, 'cancel-runonly-ap', $2, 'run_only', 'member', $3)
|
|
RETURNING id
|
|
`, workspaceID, agentID, testUserID).Scan(&autopilotID); err != nil {
|
|
t.Fatalf("create autopilot: %v", err)
|
|
}
|
|
t.Cleanup(func() { testPool.Exec(context.Background(), `DELETE FROM autopilot WHERE id = $1`, autopilotID) })
|
|
|
|
var runID string
|
|
if err := testPool.QueryRow(ctx, `
|
|
INSERT INTO autopilot_run (autopilot_id, source, status)
|
|
VALUES ($1, 'manual', 'running')
|
|
RETURNING id
|
|
`, autopilotID).Scan(&runID); err != nil {
|
|
t.Fatalf("create autopilot_run: %v", err)
|
|
}
|
|
|
|
var taskID string
|
|
if err := testPool.QueryRow(ctx, `
|
|
INSERT INTO agent_task_queue (agent_id, runtime_id, status, priority, autopilot_run_id)
|
|
VALUES ($1, $2, 'queued', 0, $3)
|
|
RETURNING id
|
|
`, agentID, runtimeID, runID).Scan(&taskID); err != nil {
|
|
t.Fatalf("create run_only task: %v", err)
|
|
}
|
|
t.Cleanup(func() { testPool.Exec(context.Background(), `DELETE FROM agent_task_queue WHERE id = $1`, taskID) })
|
|
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()
|
|
ctx := context.Background()
|
|
|
|
var workspaceID string
|
|
if err := testPool.QueryRow(ctx, `
|
|
INSERT INTO workspace (name, slug, description, issue_prefix)
|
|
VALUES ('Foreign Cancel WS', 'foreign-cancel-ws', 'cross-tenant cancel test', 'FCW')
|
|
RETURNING id
|
|
`).Scan(&workspaceID); err != nil {
|
|
t.Fatalf("create foreign workspace: %v", err)
|
|
}
|
|
t.Cleanup(func() { testPool.Exec(context.Background(), `DELETE FROM workspace WHERE id = $1`, workspaceID) })
|
|
|
|
var runtimeID string
|
|
if err := testPool.QueryRow(ctx, `
|
|
INSERT INTO agent_runtime (workspace_id, daemon_id, name, runtime_mode, provider, status, device_info, metadata, last_seen_at)
|
|
VALUES ($1, NULL, 'Foreign Cancel Runtime', 'cloud', 'foreign_runtime', 'online', 'Foreign runtime', '{}'::jsonb, now())
|
|
RETURNING id
|
|
`, workspaceID).Scan(&runtimeID); err != nil {
|
|
t.Fatalf("create foreign runtime: %v", err)
|
|
}
|
|
|
|
var agentID string
|
|
if err := testPool.QueryRow(ctx, `
|
|
INSERT INTO agent (workspace_id, name, description, runtime_mode, runtime_config, runtime_id, visibility, max_concurrent_tasks)
|
|
VALUES ($1, 'Foreign Cancel Agent', '', 'cloud', '{}'::jsonb, $2, 'workspace', 1)
|
|
RETURNING id
|
|
`, workspaceID, runtimeID).Scan(&agentID); err != nil {
|
|
t.Fatalf("create foreign agent: %v", err)
|
|
}
|
|
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()
|
|
ctx := context.Background()
|
|
|
|
var userID string
|
|
if err := testPool.QueryRow(ctx,
|
|
`INSERT INTO "user" (name, email) VALUES ($1, $2) RETURNING id`, name, email,
|
|
).Scan(&userID); err != nil {
|
|
t.Fatalf("create user %s: %v", email, err)
|
|
}
|
|
t.Cleanup(func() { testPool.Exec(context.Background(), `DELETE FROM "user" WHERE id = $1`, userID) })
|
|
|
|
if _, err := testPool.Exec(ctx,
|
|
`INSERT INTO member (workspace_id, user_id, role) VALUES ($1, $2, 'member')`, testWorkspaceID, userID,
|
|
); err != nil {
|
|
t.Fatalf("add member %s: %v", email, err)
|
|
}
|
|
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)
|
|
}
|
|
|
|
// 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
|
|
if err := testPool.QueryRow(context.Background(), `
|
|
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); err != nil {
|
|
t.Fatalf("create quick_create task: %v", err)
|
|
}
|
|
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
|
|
if err := testPool.QueryRow(context.Background(), `
|
|
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); err != nil {
|
|
t.Fatalf("create retry clone: %v", err)
|
|
}
|
|
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
|
|
if err := testPool.QueryRow(context.Background(), `
|
|
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); err != nil {
|
|
t.Fatalf("create issue: %v", err)
|
|
}
|
|
t.Cleanup(func() { testPool.Exec(context.Background(), `DELETE FROM issue WHERE id = $1`, issueID) })
|
|
|
|
if err := testPool.QueryRow(context.Background(), `
|
|
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); err != nil {
|
|
t.Fatalf("create issue task: %v", err)
|
|
}
|
|
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_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", "cancel-chat-bystander@multica.test")
|
|
|
|
var taskID string
|
|
if err := testPool.QueryRow(context.Background(), `
|
|
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); err != nil {
|
|
t.Fatalf("create chat task: %v", err)
|
|
}
|
|
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
|
|
if err := testPool.QueryRow(context.Background(), `
|
|
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); err != nil {
|
|
t.Fatalf("create chat task: %v", err)
|
|
}
|
|
t.Cleanup(func() { testPool.Exec(context.Background(), `DELETE FROM agent_task_queue WHERE id = $1`, taskID) })
|
|
|
|
if _, err := testPool.Exec(context.Background(), `
|
|
INSERT INTO chat_message (chat_session_id, role, content, task_id)
|
|
VALUES ($1, 'user', 'please answer', $2)
|
|
`, sessionID, taskID); err != nil {
|
|
t.Fatalf("create linked user chat message: %v", err)
|
|
}
|
|
if _, err := testPool.Exec(context.Background(), `
|
|
INSERT INTO task_message (task_id, seq, type, content)
|
|
VALUES ($1, 1, 'text', 'partial answer')
|
|
`, taskID); err != nil {
|
|
t.Fatalf("create task message: %v", err)
|
|
}
|
|
|
|
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
|
|
if err := testPool.QueryRow(context.Background(), `
|
|
SELECT role, content, COALESCE(task_id::text, '')
|
|
FROM chat_message
|
|
WHERE chat_session_id = $1 AND role = 'assistant'
|
|
`, sessionID).Scan(&role, &content, &messageTaskID); err != nil {
|
|
t.Fatalf("read cancelled assistant chat message: %v", err)
|
|
}
|
|
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
|
|
if err := testPool.QueryRow(context.Background(), `
|
|
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); err != nil {
|
|
t.Fatalf("create chat task: %v", err)
|
|
}
|
|
t.Cleanup(func() { testPool.Exec(context.Background(), `DELETE FROM agent_task_queue WHERE id = $1`, taskID) })
|
|
|
|
var userMessageID string
|
|
const userContent = "keep this prompt"
|
|
if err := testPool.QueryRow(context.Background(), `
|
|
INSERT INTO chat_message (chat_session_id, role, content, task_id)
|
|
VALUES ($1, 'user', $2, $3)
|
|
RETURNING id
|
|
`, sessionID, userContent, taskID).Scan(&userMessageID); err != nil {
|
|
t.Fatalf("create linked user chat message: %v", err)
|
|
}
|
|
|
|
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
|
|
if err := testPool.QueryRow(context.Background(), `
|
|
SELECT count(*) FROM chat_message
|
|
WHERE chat_session_id = $1 AND role = 'assistant'
|
|
`, sessionID).Scan(&count); err != nil {
|
|
t.Fatalf("count assistant chat messages: %v", err)
|
|
}
|
|
if count != 0 {
|
|
t.Fatalf("expected no assistant snapshot for empty transcript, got %d", count)
|
|
}
|
|
if err := testPool.QueryRow(context.Background(), `
|
|
SELECT count(*) FROM chat_message
|
|
WHERE id = $1
|
|
`, userMessageID).Scan(&count); err != nil {
|
|
t.Fatalf("count deleted user chat message: %v", err)
|
|
}
|
|
if count != 0 {
|
|
t.Fatalf("expected linked user message to be deleted, got %d", count)
|
|
}
|
|
}
|
|
|
|
// 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
|
|
if err := testPool.QueryRow(context.Background(), `
|
|
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); err != nil {
|
|
t.Fatalf("create chat task: %v", err)
|
|
}
|
|
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"
|
|
if err := testPool.QueryRow(context.Background(), `
|
|
INSERT INTO chat_message (chat_session_id, role, content, task_id)
|
|
VALUES ($1, 'user', $2, $3)
|
|
RETURNING id
|
|
`, sessionID, userContent, taskID).Scan(&userMessageID); err != nil {
|
|
t.Fatalf("create linked user chat message: %v", err)
|
|
}
|
|
|
|
// 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
|
|
if err := testPool.QueryRow(context.Background(), `
|
|
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); err != nil {
|
|
t.Fatalf("seed bound attachment: %v", err)
|
|
}
|
|
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
|
|
if err := testPool.QueryRow(context.Background(),
|
|
`SELECT count(*) FROM attachment WHERE id = $1`, attachmentID,
|
|
).Scan(&count); err != nil {
|
|
t.Fatalf("count attachment: %v", err)
|
|
}
|
|
if count != 1 {
|
|
t.Fatalf("attachment was cascade-deleted on cancel: count = %d", count)
|
|
}
|
|
var dbMessageID, dbSessionID *string
|
|
if err := testPool.QueryRow(context.Background(),
|
|
`SELECT chat_message_id::text, chat_session_id::text FROM attachment WHERE id = $1`, attachmentID,
|
|
).Scan(&dbMessageID, &dbSessionID); err != nil {
|
|
t.Fatalf("read attachment after cancel: %v", err)
|
|
}
|
|
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.
|
|
if err := testPool.QueryRow(context.Background(),
|
|
`SELECT count(*) FROM chat_message WHERE id = $1`, userMessageID,
|
|
).Scan(&count); err != nil {
|
|
t.Fatalf("count user message: %v", err)
|
|
}
|
|
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)
|
|
}
|
|
if err := testPool.QueryRow(context.Background(),
|
|
`SELECT chat_message_id::text FROM attachment WHERE id = $1`, attachmentID,
|
|
).Scan(&dbMessageID); err != nil {
|
|
t.Fatalf("read attachment after resend: %v", err)
|
|
}
|
|
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)
|
|
}
|
|
}
|