package task_test

import (
	"bytes"
	"context"
	"encoding/json"
	"fmt"
	"io"
	"mime/multipart"
	"net/http"
	"net/http/httptest"
	"net/url"
	"strings"
	"testing"
	"time"

	"github.com/gin-gonic/gin"
	"github.com/stretchr/testify/require"

	"github.com/tidbcloud/AgentTeam/internal/api/dto"
	. "github.com/tidbcloud/AgentTeam/internal/api/testkit"
	"github.com/tidbcloud/AgentTeam/internal/store/agent"
	"github.com/tidbcloud/AgentTeam/internal/store/attachment"
	"github.com/tidbcloud/AgentTeam/internal/store/message"
	"github.com/tidbcloud/AgentTeam/internal/store/taskboard"
)

func setTaskStatusForAPITest(t *testing.T, runtime Runtime, taskID string, status string, completedByMemberID string) {
	t.Helper()

	var completedBy any
	var completedAt any
	if status == "done" {
		completedBy = completedByMemberID
		completedAt = time.Now().UTC()
	}
	_, err := runtime.Pool.Exec(runtime.Ctx, `
UPDATE tasks
SET status = $2,
    completed_by_member_id = $3,
    completed_by_ref = $3,
    completed_at = $4,
    last_activity_at = NOW(),
    updated_at = NOW()
WHERE id = $1
`, taskID, status, completedBy, completedAt)
	require.NoError(t, err)
}

func getTaskDetailForAPITest(t *testing.T, client *http.Client, baseURL string, workspaceID string, accessToken string, taskID string) dto.TaskDetailResponse {
	t.Helper()

	var detail dto.TaskDetailResponse
	response := DoJSON(t, client, http.MethodGet, baseURL+"/api/v1/workspaces/"+workspaceID+"/tasks/"+taskID, nil, WorkspaceHeaders(accessToken, workspaceID), &detail)
	require.Equal(t, http.StatusOK, response.StatusCode)
	return detail
}

func listAllChannelTasksForAPITest(
	t *testing.T,
	client *http.Client,
	baseURL string,
	workspaceID string,
	channelID string,
	includeSubtasks bool,
	headers map[string]string,
) []dto.Task {
	t.Helper()

	query := url.Values{"limit": {"100"}}
	if includeSubtasks {
		query.Set("include_subtasks", "true")
	}
	rows := make([]dto.Task, 0)
	for {
		var page dto.TaskBrowseResponse
		response := DoJSON(
			t,
			client,
			http.MethodGet,
			baseURL+"/api/v1/workspaces/"+url.PathEscape(workspaceID)+"/channels/"+url.PathEscape(channelID)+"/tasks?"+query.Encode(),
			nil,
			headers,
			&page,
		)
		require.Equal(t, http.StatusOK, response.StatusCode)
		rows = append(rows, page.Rows...)
		if !page.HasMore {
			return rows
		}
		require.NotEmpty(t, page.NextCursor)
		query.Set("cursor", page.NextCursor)
	}
}

func TestWorkspaceTasksListHonorsChannelVisibility(t *testing.T) {
	t.Parallel()

	runtime := StartRuntime(t)

	router := gin.New()
	runtime.RegisterRoutes(router)
	server := httptest.NewServer(router)
	defer server.Close()

	client := server.Client()

	owner, workspaceID, allChannelID := SetupTaskHTTPActor(
		t,
		client,
		server.URL,
		"workspace-tasks-owner@example.com",
		"workspace-tasks-owner",
	)
	member := AcceptTaskHTTPInvite(
		t,
		runtime,
		client,
		server.URL,
		owner.User.ID,
		workspaceID,
		"workspace-tasks-member@example.com",
		"workspace-tasks-member",
	)

	var privateChannel ChannelResponse
	response := DoJSON(t, client, http.MethodPost, server.URL+"/api/v1/workspaces/"+workspaceID+"/channels", map[string]any{
		"name":        "exec-room",
		"description": "private execution room",
	}, WorkspaceHeaders(owner.AccessToken, workspaceID), &privateChannel)
	require.Equal(t, http.StatusOK, response.StatusCode)

	var visibleCreated taskboard.TaskPage
	response = DoJSON(t, client, http.MethodPost, server.URL+"/api/v1/workspaces/"+workspaceID+"/channels/"+allChannelID+"/tasks", map[string]any{
		"tasks": []map[string]any{{"title": "Visible task"}},
	}, WorkspaceHeaders(owner.AccessToken, workspaceID), &visibleCreated)
	require.Equal(t, http.StatusOK, response.StatusCode)
	require.Len(t, visibleCreated.Tasks, 1)

	var hiddenCreated taskboard.TaskPage
	response = DoJSON(t, client, http.MethodPost, server.URL+"/api/v1/workspaces/"+workspaceID+"/channels/"+privateChannel.ID+"/tasks", map[string]any{
		"tasks": []map[string]any{{"title": "Hidden task"}},
	}, WorkspaceHeaders(owner.AccessToken, workspaceID), &hiddenCreated)
	require.Equal(t, http.StatusOK, response.StatusCode)
	require.Len(t, hiddenCreated.Tasks, 1)

	var memberTasks dto.TaskBrowseResponse
	response = DoJSON(t, client, http.MethodPost, server.URL+"/api/v1/workspaces/"+workspaceID+"/tasks/workspace/browse", map[string]any{"limit": 100}, WorkspaceHeaders(member.AccessToken, workspaceID), &memberTasks)
	require.Equal(t, http.StatusOK, response.StatusCode)
	require.Len(t, memberTasks.Rows, 1)
	require.Equal(t, visibleCreated.Tasks[0].ID, memberTasks.Rows[0].ID)

	var ownerTasks dto.TaskBrowseResponse
	response = DoJSON(t, client, http.MethodPost, server.URL+"/api/v1/workspaces/"+workspaceID+"/tasks/workspace/browse", map[string]any{"limit": 100}, WorkspaceHeaders(owner.AccessToken, workspaceID), &ownerTasks)
	require.Equal(t, http.StatusOK, response.StatusCode)
	require.Len(t, ownerTasks.Rows, 2)
}

func TestTaskDetailEndpointIncludesParentTaskSummary(t *testing.T) {
	t.Parallel()

	runtime := StartRuntime(t)

	router := gin.New()
	runtime.RegisterRoutes(router)
	server := httptest.NewServer(router)
	defer server.Close()

	client := server.Client()

	auth, workspaceID, channelID := SetupTaskHTTPActor(
		t,
		client,
		server.URL,
		"task-detail-parent@example.com",
		"task-detail-parent",
	)

	var parentCreated taskboard.TaskPage
	response := DoJSON(t, client, http.MethodPost, server.URL+"/api/v1/workspaces/"+workspaceID+"/channels/"+channelID+"/tasks", map[string]any{
		"tasks": []map[string]any{{"title": "Parent task"}},
	}, WorkspaceHeaders(auth.AccessToken, workspaceID), &parentCreated)
	require.Equal(t, http.StatusOK, response.StatusCode)
	require.Len(t, parentCreated.Tasks, 1)

	var childCreated taskboard.TaskPage
	response = DoJSON(t, client, http.MethodPost, server.URL+"/api/v1/workspaces/"+workspaceID+"/channels/"+channelID+"/tasks", map[string]any{
		"tasks": []map[string]any{{"title": "Child task"}},
	}, WorkspaceHeaders(auth.AccessToken, workspaceID), &childCreated)
	require.Equal(t, http.StatusOK, response.StatusCode)
	require.Len(t, childCreated.Tasks, 1)

	response = DoJSON(t, client, http.MethodPatch, server.URL+"/api/v1/workspaces/"+workspaceID+"/tasks/"+childCreated.Tasks[0].ID, map[string]any{
		"parentTaskRef": parentCreated.Tasks[0].ID,
	}, WorkspaceHeaders(auth.AccessToken, workspaceID), nil)
	require.Equal(t, http.StatusOK, response.StatusCode)

	var detail dto.TaskDetailResponse
	response = DoJSON(t, client, http.MethodGet, server.URL+"/api/v1/workspaces/"+workspaceID+"/tasks/"+childCreated.Tasks[0].ID, nil, WorkspaceHeaders(auth.AccessToken, workspaceID), &detail)
	require.Equal(t, http.StatusOK, response.StatusCode)
	require.Equal(t, parentCreated.Tasks[0].ID, detail.Detail.Task.ParentTaskRef)
	require.NotNil(t, detail.Detail.ParentTask)
	require.Equal(t, parentCreated.Tasks[0].ID, detail.Detail.ParentTask.ID)
	require.Equal(t, parentCreated.Tasks[0].TaskNumber, detail.Detail.ParentTask.TaskNumber)
	require.Equal(t, parentCreated.Tasks[0].Title, detail.Detail.ParentTask.Title)
}

func TestTaskNotificationPolicyAllowsReadableChannelMemberWithoutTaskScopeMembership(t *testing.T) {
	t.Parallel()

	runtime := StartRuntime(t)

	router := gin.New()
	runtime.RegisterRoutes(router)
	server := httptest.NewServer(router)
	defer server.Close()

	client := server.Client()

	owner, workspaceID, allChannelID := SetupTaskHTTPActor(
		t,
		client,
		server.URL,
		"task-policy-readable-owner@example.com",
		"task-policy-readable-owner",
	)
	member := AcceptTaskHTTPInvite(
		t,
		runtime,
		client,
		server.URL,
		owner.User.ID,
		workspaceID,
		"task-policy-readable-member@example.com",
		"task-policy-readable-member",
	)
	memberID := TaskWorkspaceMemberIDForUser(t, runtime, workspaceID, member.User.ID)

	var created taskboard.TaskPage
	response := DoJSON(t, client, http.MethodPost, server.URL+"/api/v1/workspaces/"+workspaceID+"/channels/"+allChannelID+"/tasks", map[string]any{
		"tasks": []map[string]any{{"title": "Readable task policy"}},
	}, WorkspaceHeaders(owner.AccessToken, workspaceID), &created)
	require.Equal(t, http.StatusOK, response.StatusCode)
	require.Len(t, created.Tasks, 1)
	createdTask := created.Tasks[0]

	requireNoActiveScopeMembership(t, runtime, createdTask.TaskScopeID, memberID)

	var detail dto.TaskDetailResponse
	response = DoJSON(t, client, http.MethodGet, server.URL+"/api/v1/workspaces/"+workspaceID+"/tasks/"+createdTask.ID, nil, WorkspaceHeaders(member.AccessToken, workspaceID), &detail)
	require.Equal(t, http.StatusOK, response.StatusCode)
	require.Equal(t, createdTask.ID, detail.Detail.Task.ID)

	var policy dto.TaskNotificationPolicyResponse
	response = DoJSON(t, client, http.MethodGet, server.URL+"/api/v1/workspaces/"+workspaceID+"/tasks/"+createdTask.ID+"/notification-policy", nil, WorkspaceHeaders(member.AccessToken, workspaceID), &policy)
	require.Equal(t, http.StatusOK, response.StatusCode)
	require.Equal(t, createdTask.ID, policy.Task.ID)
	require.Equal(t, "task", policy.ScopeType)
	require.Equal(t, createdTask.TaskScopeID, policy.ScopeID)
	require.Equal(t, "task", policy.Panel.ScopeType)
	require.Equal(t, createdTask.TaskScopeID, policy.Panel.ScopeID)
	require.Equal(t, "mute", policy.Panel.Self.State)
	require.Empty(t, policy.Panel.Agents)
	require.NotNil(t, policy.WatcherPreview)
	require.Empty(t, policy.WatcherPreview)
	require.Zero(t, policy.WatcherCount)
	requireNoActiveScopeMembership(t, runtime, createdTask.TaskScopeID, memberID)

	var emptyRoster dto.TaskWatcherPageResponse
	response = DoJSON(t, client, http.MethodGet, server.URL+"/api/v1/workspaces/"+workspaceID+"/tasks/"+createdTask.ID+"/watchers", nil, WorkspaceHeaders(member.AccessToken, workspaceID), &emptyRoster)
	require.Equal(t, http.StatusOK, response.StatusCode, "source-Channel readability, not pre-existing Task membership, authorizes the roster")
	require.NotNil(t, emptyRoster.Watchers)
	require.Empty(t, emptyRoster.Watchers)
	require.Zero(t, emptyRoster.WatcherCount)
	require.False(t, emptyRoster.HasMore)

	var updated dto.TaskNotificationPolicyResponse
	response = DoJSON(t, client, http.MethodPut, server.URL+"/api/v1/workspaces/"+workspaceID+"/tasks/"+createdTask.ID+"/notification-policy/self", map[string]any{
		"state": "active",
	}, WorkspaceHeaders(member.AccessToken, workspaceID), &updated)
	require.Equal(t, http.StatusOK, response.StatusCode)
	require.Equal(t, "active", updated.Panel.Self.State)
	require.Equal(t, 1, updated.WatcherCount)
	require.Len(t, updated.WatcherPreview, 1)
	require.Equal(t, memberID, updated.WatcherPreview[0].MemberID)
	requireActiveScopeMembership(t, runtime, createdTask.TaskScopeID, memberID)

	var activeRoster dto.TaskWatcherPageResponse
	response = DoJSON(t, client, http.MethodGet, server.URL+"/api/v1/workspaces/"+workspaceID+"/tasks/"+createdTask.ID+"/watchers", nil, WorkspaceHeaders(member.AccessToken, workspaceID), &activeRoster)
	require.Equal(t, http.StatusOK, response.StatusCode)
	require.Equal(t, 1, activeRoster.WatcherCount)
	require.Len(t, activeRoster.Watchers, 1)
	require.Equal(t, memberID, activeRoster.Watchers[0].MemberID)
}

func TestWorkspaceTasksListIncludeSubtasksIsOptIn(t *testing.T) {
	t.Parallel()

	runtime := StartRuntime(t)

	router := gin.New()
	runtime.RegisterRoutes(router)
	server := httptest.NewServer(router)
	defer server.Close()

	client := server.Client()

	owner, workspaceID, allChannelID := SetupTaskHTTPActor(
		t,
		client,
		server.URL,
		"workspace-subtasks-owner@example.com",
		"workspace-subtasks-owner",
	)

	var created taskboard.TaskPage
	response := DoJSON(t, client, http.MethodPost, server.URL+"/api/v1/workspaces/"+workspaceID+"/channels/"+allChannelID+"/tasks", map[string]any{
		"tasks": []map[string]any{{"title": "Workspace root task"}},
	}, WorkspaceHeaders(owner.AccessToken, workspaceID), &created)
	require.Equal(t, http.StatusOK, response.StatusCode)
	require.Len(t, created.Tasks, 1)

	var subtasks taskboard.TaskPage
	response = DoJSON(t, client, http.MethodPost, server.URL+"/api/v1/workspaces/"+workspaceID+"/tasks/"+created.Tasks[0].ID+"/subtasks", map[string]any{
		"tasks": []map[string]any{{"title": "Workspace child task"}},
	}, WorkspaceHeaders(owner.AccessToken, workspaceID), &subtasks)
	require.Equal(t, http.StatusOK, response.StatusCode)
	require.Len(t, subtasks.Tasks, 1)

	var defaultList dto.TaskBrowseResponse
	response = DoJSON(t, client, http.MethodPost, server.URL+"/api/v1/workspaces/"+workspaceID+"/tasks/workspace/browse", map[string]any{"limit": 100}, WorkspaceHeaders(owner.AccessToken, workspaceID), &defaultList)
	require.Equal(t, http.StatusOK, response.StatusCode)
	require.Len(t, defaultList.Rows, 1)
	require.Equal(t, "Workspace root task", defaultList.Rows[0].Title)

	var includeList dto.TaskBrowseResponse
	response = DoJSON(t, client, http.MethodPost, server.URL+"/api/v1/workspaces/"+workspaceID+"/tasks/workspace/browse", map[string]any{"limit": 100, "includeSubtasks": true}, WorkspaceHeaders(owner.AccessToken, workspaceID), &includeList)
	require.Equal(t, http.StatusOK, response.StatusCode)
	require.Len(t, includeList.Rows, 2)
	require.Equal(t, "Workspace root task", includeList.Rows[0].Title)
	require.Equal(t, "Workspace child task", includeList.Rows[1].Title)
}

func TestChannelTasksListIncludeSubtasksIsOptIn(t *testing.T) {
	t.Parallel()

	runtime := StartRuntime(t)

	router := gin.New()
	runtime.RegisterRoutes(router)
	server := httptest.NewServer(router)
	defer server.Close()

	client := server.Client()

	owner, workspaceID, allChannelID := SetupTaskHTTPActor(
		t,
		client,
		server.URL,
		"channel-subtasks-owner@example.com",
		"channel-subtasks-owner",
	)

	var created taskboard.TaskPage
	response := DoJSON(t, client, http.MethodPost, server.URL+"/api/v1/workspaces/"+workspaceID+"/channels/"+allChannelID+"/tasks", map[string]any{
		"tasks": []map[string]any{{"title": "Channel root task"}},
	}, WorkspaceHeaders(owner.AccessToken, workspaceID), &created)
	require.Equal(t, http.StatusOK, response.StatusCode)
	require.Len(t, created.Tasks, 1)

	var subtasks taskboard.TaskPage
	response = DoJSON(t, client, http.MethodPost, server.URL+"/api/v1/workspaces/"+workspaceID+"/tasks/"+created.Tasks[0].ID+"/subtasks", map[string]any{
		"tasks": []map[string]any{{"title": "Channel child task"}},
	}, WorkspaceHeaders(owner.AccessToken, workspaceID), &subtasks)
	require.Equal(t, http.StatusOK, response.StatusCode)
	require.Len(t, subtasks.Tasks, 1)

	defaultTasks := listAllChannelTasksForAPITest(
		t, client, server.URL, workspaceID, allChannelID, false, WorkspaceHeaders(owner.AccessToken, workspaceID),
	)
	require.Len(t, defaultTasks, 1)
	require.Equal(t, "Channel root task", defaultTasks[0].Title)

	allTasks := listAllChannelTasksForAPITest(
		t, client, server.URL, workspaceID, allChannelID, true, WorkspaceHeaders(owner.AccessToken, workspaceID),
	)
	require.Len(t, allTasks, 2)
	require.Equal(t, "Channel root task", allTasks[0].Title)
	require.Equal(t, "Channel child task", allTasks[1].Title)
}

func TestTaskBrowseChannelRespectsSearchSortAndCursor(t *testing.T) {
	t.Parallel()

	runtime := StartRuntime(t)

	router := gin.New()
	runtime.RegisterRoutes(router)
	server := httptest.NewServer(router)
	defer server.Close()

	client := server.Client()

	owner, workspaceID, allChannelID := SetupTaskHTTPActor(
		t,
		client,
		server.URL,
		"channel-task-browse-owner@example.com",
		"channel-task-browse-owner",
	)

	var created taskboard.TaskPage
	response := DoJSON(t, client, http.MethodPost, server.URL+"/api/v1/workspaces/"+workspaceID+"/channels/"+allChannelID+"/tasks", map[string]any{
		"tasks": []map[string]any{
			{"title": "Alpha Blocked One"},
			{"title": "Alpha Blocked Two"},
			{"title": "Beta Open Three"},
		},
	}, WorkspaceHeaders(owner.AccessToken, workspaceID), &created)
	require.Equal(t, http.StatusOK, response.StatusCode)
	require.Len(t, created.Tasks, 3)

	for index, task := range created.Tasks[:2] {
		at := time.Date(2026, 4, 24, 9+index, 0, 0, 0, time.UTC)
		_, err := runtime.Pool.Exec(runtime.Ctx, `
UPDATE tasks
SET blocked_reason = 'Waiting on blocker',
    created_at = $2,
    last_activity_at = $2,
    updated_at = $2
WHERE id = $1
`, task.ID, at)
		require.NoError(t, err)
	}
	_, err := runtime.Pool.Exec(runtime.Ctx, `
UPDATE tasks
SET created_at = $2,
    last_activity_at = $2,
    updated_at = $2
WHERE id = $1
`, created.Tasks[2].ID, time.Date(2026, 4, 24, 11, 0, 0, 0, time.UTC))
	require.NoError(t, err)

	var firstPage dto.TaskBrowseResponse
	response = DoJSON(t, client, http.MethodPost, server.URL+"/api/v1/workspaces/"+workspaceID+"/tasks/channel/"+allChannelID+"/browse", map[string]any{
		"limit":        1,
		"searchQuery":  "alpha",
		"quickFilters": []string{"blocked"},
		"sortMode":     "recently_updated",
	}, WorkspaceHeaders(owner.AccessToken, workspaceID), &firstPage)
	require.Equal(t, http.StatusOK, response.StatusCode)
	require.Equal(t, int64(2), firstPage.TotalCount)
	require.Len(t, firstPage.Rows, 1)
	require.Equal(t, "Alpha Blocked Two", firstPage.Rows[0].Title)
	require.True(t, firstPage.HasMore)
	require.NotEmpty(t, firstPage.NextCursor)

	var secondPage dto.TaskBrowseResponse
	response = DoJSON(t, client, http.MethodPost, server.URL+"/api/v1/workspaces/"+workspaceID+"/tasks/channel/"+allChannelID+"/browse", map[string]any{
		"limit":        1,
		"searchQuery":  "alpha",
		"quickFilters": []string{"blocked"},
		"sortMode":     "recently_updated",
		"cursor":       firstPage.NextCursor,
	}, WorkspaceHeaders(owner.AccessToken, workspaceID), &secondPage)
	require.Equal(t, http.StatusOK, response.StatusCode)
	require.Equal(t, int64(2), secondPage.TotalCount)
	require.Len(t, secondPage.Rows, 1)
	require.Equal(t, "Alpha Blocked One", secondPage.Rows[0].Title)
	require.False(t, secondPage.HasMore)
	require.Empty(t, secondPage.NextCursor)
}

func TestTaskBoardBrowseReturnsColumnPagesAndCompatListsRemainTaskPage(t *testing.T) {
	t.Parallel()

	runtime := StartRuntime(t)

	router := gin.New()
	runtime.RegisterRoutes(router)
	server := httptest.NewServer(router)
	defer server.Close()

	client := server.Client()

	owner, workspaceID, allChannelID := SetupTaskHTTPActor(
		t,
		client,
		server.URL,
		"task-board-browse-owner@example.com",
		"task-board-browse-owner",
	)
	memberID := TaskWorkspaceMemberIDForUser(t, runtime, workspaceID, owner.User.ID)

	titles := []map[string]any{
		{"title": "Alpha Todo"},
		{"title": "Alpha Claimed Todo"},
		{"title": "Alpha Review"},
		{"title": "Alpha Done One"},
		{"title": "Alpha Done Two"},
		{"title": "Beta Ignored"},
	}
	for index := 1; index <= 11; index++ {
		titles = append(titles, map[string]any{
			"title": "Alpha In Progress " + time.Date(2026, 1, index, 0, 0, 0, 0, time.UTC).Format("02"),
		})
	}

	var created taskboard.TaskPage
	response := DoJSON(t, client, http.MethodPost, server.URL+"/api/v1/workspaces/"+workspaceID+"/channels/"+allChannelID+"/tasks", map[string]any{
		"tasks": titles,
	}, WorkspaceHeaders(owner.AccessToken, workspaceID), &created)
	require.Equal(t, http.StatusOK, response.StatusCode)
	require.Len(t, created.Tasks, len(titles))

	tasksByTitle := make(map[string]taskboard.Task, len(created.Tasks))
	for _, task := range created.Tasks {
		tasksByTitle[task.Title] = task
	}

	taskTimes := map[string]time.Time{
		"Alpha Todo":         time.Date(2026, 4, 24, 13, 0, 0, 0, time.UTC),
		"Alpha Claimed Todo": time.Date(2026, 4, 24, 14, 0, 0, 0, time.UTC),
		"Alpha Review":       time.Date(2026, 4, 24, 15, 0, 0, 0, time.UTC),
		"Alpha Done One":     time.Date(2026, 4, 24, 16, 0, 0, 0, time.UTC),
		"Alpha Done Two":     time.Date(2026, 4, 24, 17, 0, 0, 0, time.UTC),
		"Beta Ignored":       time.Date(2026, 4, 24, 18, 0, 0, 0, time.UTC),
	}
	for index := 1; index <= 11; index++ {
		taskTimes["Alpha In Progress "+time.Date(2026, 1, index, 0, 0, 0, 0, time.UTC).Format("02")] =
			time.Date(2026, 4, 24, 18+index, 0, 0, 0, time.UTC)
	}

	for title, at := range taskTimes {
		task := tasksByTitle[title]
		status := "todo"
		var assigneeMember any
		var assignedAt any
		var completedAt any
		switch title {
		case "Alpha Claimed Todo":
			assigneeMember = memberID
			assignedAt = at
		case "Alpha Review":
			status = "in_review"
		case "Alpha Done One", "Alpha Done Two":
			status = "done"
			completedAt = at
		case "Beta Ignored":
			status = "in_progress"
		default:
			if strings.HasPrefix(title, "Alpha In Progress ") {
				status = "in_progress"
			}
		}

		_, err := runtime.Pool.Exec(runtime.Ctx, `
UPDATE tasks
SET status = $2,
    assignee_member_id = $3,
    assigned_at = $4,
    claimed_by_member_id = $3,
    claimed_by_ref = $3,
    claimed_at = $4,
    completed_at = $5,
    created_at = $6,
    last_activity_at = $6,
    updated_at = $6
WHERE id = $1
`, task.ID, status, assigneeMember, assignedAt, completedAt, at)
		require.NoError(t, err)
	}

	var boardPage dto.TaskBoardBrowseResponse
	response = DoJSON(t, client, http.MethodPost, server.URL+"/api/v1/workspaces/"+workspaceID+"/tasks/channel/"+allChannelID+"/board/browse", map[string]any{
		"searchQuery": "alpha",
		"sortMode":    "recently_updated",
	}, WorkspaceHeaders(owner.AccessToken, workspaceID), &boardPage)
	require.Equal(t, http.StatusOK, response.StatusCode)
	require.Equal(t, 10, boardPage.QueryEcho.Limit)
	require.Equal(t, int64(16), boardPage.TotalCount)
	require.Equal(t, int64(11), boardPage.ColumnCounts[string(taskboard.TaskBrowseColumnInProgress)])
	require.Equal(t, int64(2), boardPage.ColumnCounts[string(taskboard.TaskBrowseColumnDone)])
	require.Len(t, boardPage.Columns[string(taskboard.TaskBrowseColumnInProgress)].Tasks, 10)
	require.True(t, boardPage.Columns[string(taskboard.TaskBrowseColumnInProgress)].HasMore)
	require.NotEmpty(t, boardPage.Columns[string(taskboard.TaskBrowseColumnInProgress)].NextCursor)
	require.Len(t, boardPage.Columns[string(taskboard.TaskBrowseColumnDone)].Tasks, 2)
	require.Equal(t, int64(2), boardPage.Columns[string(taskboard.TaskBrowseColumnDone)].Count)
	require.False(t, boardPage.Columns[string(taskboard.TaskBrowseColumnDone)].HasMore)

	var workspaceBoardPage dto.TaskBoardBrowseResponse
	response = DoJSON(t, client, http.MethodPost, server.URL+"/api/v1/workspaces/"+workspaceID+"/tasks/workspace/board/browse", map[string]any{
		"limit":       5,
		"searchQuery": "alpha",
		"sortMode":    "recently_updated",
	}, WorkspaceHeaders(owner.AccessToken, workspaceID), &workspaceBoardPage)
	require.Equal(t, http.StatusOK, response.StatusCode)
	require.Equal(t, 5, workspaceBoardPage.QueryEcho.Limit)
	require.Equal(t, int64(16), workspaceBoardPage.TotalCount)
	require.Equal(t, int64(11), workspaceBoardPage.ColumnCounts[string(taskboard.TaskBrowseColumnInProgress)])
	require.Equal(t, int64(2), workspaceBoardPage.ColumnCounts[string(taskboard.TaskBrowseColumnDone)])
	require.Len(t, workspaceBoardPage.Columns[string(taskboard.TaskBrowseColumnInProgress)].Tasks, 5)
	require.True(t, workspaceBoardPage.Columns[string(taskboard.TaskBrowseColumnInProgress)].HasMore)
	require.Len(t, workspaceBoardPage.Columns[string(taskboard.TaskBrowseColumnDone)].Tasks, 2)
	require.False(t, workspaceBoardPage.Columns[string(taskboard.TaskBrowseColumnDone)].HasMore)

	channelTasks := listAllChannelTasksForAPITest(
		t, client, server.URL, workspaceID, allChannelID, false, WorkspaceHeaders(owner.AccessToken, workspaceID),
	)
	require.NotEmpty(t, channelTasks)
}

func TestTasksCreateClaimCompleteDelete(t *testing.T) {
	t.Parallel()

	runtime := StartRuntime(t)

	router := gin.New()
	runtime.RegisterRoutes(router)
	server := httptest.NewServer(router)
	defer server.Close()

	client := server.Client()

	var auth dto.AuthTokensResponse
	response := DoJSON(t, client, http.MethodPost, server.URL+"/api/v1/auth/register", map[string]any{
		"email":    "member@example.com",
		"password": TestUserPassword,
		"name":     "member",
	}, nil, &auth)
	require.Equal(t, http.StatusOK, response.StatusCode)

	var workspaces []dto.WorkspaceResponse
	response = DoJSON(t, client, http.MethodGet, server.URL+"/api/v1/workspaces", nil, map[string]string{
		"Authorization": fmt.Sprintf("Bearer %s", auth.AccessToken),
	}, &workspaces)
	require.Equal(t, http.StatusOK, response.StatusCode)
	require.NotEmpty(t, workspaces)
	workspaceID := workspaces[0].ID

	var channels []ChannelResponse
	response = DoJSON(t, client, http.MethodGet, server.URL+"/api/v1/workspaces/"+workspaceID+"/channels", nil, map[string]string{
		"Authorization":  fmt.Sprintf("Bearer %s", auth.AccessToken),
		"X-Workspace-Id": workspaceID,
	}, &channels)
	require.Equal(t, http.StatusOK, response.StatusCode)
	require.NotEmpty(t, channels)

	channelID := channels[0].ID

	var created taskboard.TaskPage
	response = DoJSON(t, client, http.MethodPost, server.URL+"/api/v1/workspaces/"+workspaceID+"/channels/"+channelID+"/tasks", map[string]any{
		"tasks": []map[string]any{{"title": "Task A"}},
	}, map[string]string{
		"Authorization":  fmt.Sprintf("Bearer %s", auth.AccessToken),
		"X-Workspace-Id": workspaceID,
	}, &created)
	require.Equal(t, http.StatusOK, response.StatusCode)
	require.Len(t, created.Tasks, 1)
	taskID := created.Tasks[0].ID
	require.NotEmpty(t, taskID)
	require.Equal(t, "todo", created.Tasks[0].Status)

	listed := listAllChannelTasksForAPITest(t, client, server.URL, workspaceID, channelID, false, map[string]string{
		"Authorization":  fmt.Sprintf("Bearer %s", auth.AccessToken),
		"X-Workspace-Id": workspaceID,
	})
	require.Len(t, listed, 1)
	require.Equal(t, taskID, listed[0].ID)

	var claimResponse struct {
		Task taskboard.Task `json:"task"`
	}
	response = DoJSON(t, client, http.MethodPatch, server.URL+"/api/v1/workspaces/"+workspaceID+"/tasks/"+taskID+"/claim", nil, map[string]string{
		"Authorization":  fmt.Sprintf("Bearer %s", auth.AccessToken),
		"X-Workspace-Id": workspaceID,
	}, &claimResponse)
	require.Equal(t, http.StatusOK, response.StatusCode)
	require.Equal(t, "todo", claimResponse.Task.Status)
	require.NotNil(t, claimResponse.Task.AssigneeProfile)
	require.NotEmpty(t, claimResponse.Task.AssigneeProfile.PrincipalID)

	var unclaimResponse struct {
		Task taskboard.Task `json:"task"`
	}
	response = DoJSON(t, client, http.MethodPatch, server.URL+"/api/v1/workspaces/"+workspaceID+"/tasks/"+taskID+"/unclaim", nil, map[string]string{
		"Authorization":  fmt.Sprintf("Bearer %s", auth.AccessToken),
		"X-Workspace-Id": workspaceID,
	}, &unclaimResponse)
	require.Equal(t, http.StatusOK, response.StatusCode)
	require.Equal(t, "todo", unclaimResponse.Task.Status)
	require.Nil(t, unclaimResponse.Task.AssigneeProfile)

	response = DoJSON(t, client, http.MethodPatch, server.URL+"/api/v1/workspaces/"+workspaceID+"/tasks/"+taskID+"/claim", nil, map[string]string{
		"Authorization":  fmt.Sprintf("Bearer %s", auth.AccessToken),
		"X-Workspace-Id": workspaceID,
	}, &claimResponse)
	require.Equal(t, http.StatusOK, response.StatusCode)
	require.NotNil(t, claimResponse.Task.AssigneeProfile)
	require.NotEmpty(t, claimResponse.Task.AssigneeProfile.PrincipalID)

	setTaskStatusForAPITest(t, runtime, taskID, "in_progress", "")
	progressResponse := getTaskDetailForAPITest(t, client, server.URL, workspaceID, auth.AccessToken, taskID)
	require.Equal(t, "in_progress", progressResponse.Detail.Task.Status)

	setTaskStatusForAPITest(t, runtime, taskID, "in_review", "")
	reviewResponse := getTaskDetailForAPITest(t, client, server.URL, workspaceID, auth.AccessToken, taskID)
	require.Equal(t, "in_review", reviewResponse.Detail.Task.Status)

	setTaskStatusForAPITest(t, runtime, taskID, "done", TaskWorkspaceMemberIDForUser(t, runtime, workspaceID, auth.User.ID))
	doneResponse := getTaskDetailForAPITest(t, client, server.URL, workspaceID, auth.AccessToken, taskID)
	require.Equal(t, "done", doneResponse.Detail.Task.Status)
	require.NotNil(t, doneResponse.Detail.Task.CompletedAt)

	response = DoJSON(t, client, http.MethodDelete, server.URL+"/api/v1/workspaces/"+workspaceID+"/tasks/"+taskID, nil, map[string]string{
		"Authorization":  fmt.Sprintf("Bearer %s", auth.AccessToken),
		"X-Workspace-Id": workspaceID,
	}, nil)
	require.Equal(t, http.StatusOK, response.StatusCode)
}

func TestTaskClaimAndUnclaimOnlyAffectTheCurrentAssignee(t *testing.T) {
	t.Parallel()

	runtime := StartRuntime(t)

	router := gin.New()
	runtime.RegisterRoutes(router)
	server := httptest.NewServer(router)
	defer server.Close()

	client := server.Client()
	owner, workspaceID, channelID := SetupTaskHTTPActor(t, client, server.URL, "task-cleanup-owner@example.com", "task-cleanup-owner")
	member := AcceptTaskHTTPInvite(t, runtime, client, server.URL, owner.User.ID, workspaceID, "task-cleanup-member@example.com", "task-cleanup-member")

	var created taskboard.TaskPage
	response := DoJSON(t, client, http.MethodPost, server.URL+"/api/v1/workspaces/"+workspaceID+"/channels/"+channelID+"/tasks", map[string]any{
		"tasks": []map[string]any{
			{"title": "Member-owned task"},
			{"title": "In-progress member task"},
			{"title": "Owner-owned task"},
			{"title": "Regular member can close a task"},
		},
	}, WorkspaceHeaders(owner.AccessToken, workspaceID), &created)
	require.Equal(t, http.StatusOK, response.StatusCode)
	require.Len(t, created.Tasks, 4)

	claimedTaskID := created.Tasks[0].ID
	inProgressTaskID := created.Tasks[1].ID
	lockedClaimTaskID := created.Tasks[2].ID
	todoTaskID := created.Tasks[3].ID

	response = DoJSON(t, client, http.MethodPatch, server.URL+"/api/v1/workspaces/"+workspaceID+"/tasks/"+claimedTaskID+"/claim", nil, WorkspaceHeaders(member.AccessToken, workspaceID), nil)
	require.Equal(t, http.StatusOK, response.StatusCode)

	var unclaimResponse struct {
		Task taskboard.Task `json:"task"`
	}
	response = DoJSON(t, client, http.MethodPatch, server.URL+"/api/v1/workspaces/"+workspaceID+"/tasks/"+claimedTaskID+"/unclaim", nil, WorkspaceHeaders(owner.AccessToken, workspaceID), &unclaimResponse)
	require.Equal(t, http.StatusConflict, response.StatusCode)
	response = DoJSON(t, client, http.MethodPatch, server.URL+"/api/v1/workspaces/"+workspaceID+"/tasks/"+claimedTaskID+"/unclaim", nil, WorkspaceHeaders(member.AccessToken, workspaceID), &unclaimResponse)
	require.Equal(t, http.StatusOK, response.StatusCode)
	require.Equal(t, "todo", unclaimResponse.Task.Status)
	require.Nil(t, unclaimResponse.Task.AssigneeProfile)

	response = DoJSON(t, client, http.MethodPatch, server.URL+"/api/v1/workspaces/"+workspaceID+"/tasks/"+lockedClaimTaskID+"/claim", nil, WorkspaceHeaders(owner.AccessToken, workspaceID), nil)
	require.Equal(t, http.StatusOK, response.StatusCode)
	var takeoverClaimResponse struct {
		Task taskboard.Task `json:"task"`
	}
	response = DoJSON(t, client, http.MethodPatch, server.URL+"/api/v1/workspaces/"+workspaceID+"/tasks/"+lockedClaimTaskID+"/claim", nil, WorkspaceHeaders(member.AccessToken, workspaceID), &takeoverClaimResponse)
	require.Equal(t, http.StatusConflict, response.StatusCode)
	response = DoJSON(t, client, http.MethodPatch, server.URL+"/api/v1/workspaces/"+workspaceID+"/tasks/"+lockedClaimTaskID+"/unclaim", nil, WorkspaceHeaders(owner.AccessToken, workspaceID), &unclaimResponse)
	require.Equal(t, http.StatusOK, response.StatusCode)
	require.Nil(t, unclaimResponse.Task.AssigneeProfile)

	setTaskStatusForAPITest(t, runtime, todoTaskID, "in_progress", "")
	doneResponse := getTaskDetailForAPITest(t, client, server.URL, workspaceID, member.AccessToken, todoTaskID)
	require.Equal(t, "in_progress", doneResponse.Detail.Task.Status)
	require.Nil(t, doneResponse.Detail.Task.CompletedAt)
	setTaskStatusForAPITest(t, runtime, todoTaskID, "in_review", "")
	doneResponse = getTaskDetailForAPITest(t, client, server.URL, workspaceID, member.AccessToken, todoTaskID)
	require.Equal(t, "in_review", doneResponse.Detail.Task.Status)
	require.Nil(t, doneResponse.Detail.Task.CompletedAt)
	setTaskStatusForAPITest(t, runtime, todoTaskID, "done", TaskWorkspaceMemberIDForUser(t, runtime, workspaceID, member.User.ID))
	doneResponse = getTaskDetailForAPITest(t, client, server.URL, workspaceID, member.AccessToken, todoTaskID)
	require.Equal(t, "done", doneResponse.Detail.Task.Status)
	require.NotNil(t, doneResponse.Detail.Task.CompletedAt)

	response = DoJSON(t, client, http.MethodPatch, server.URL+"/api/v1/workspaces/"+workspaceID+"/tasks/"+inProgressTaskID+"/claim", nil, WorkspaceHeaders(member.AccessToken, workspaceID), nil)
	require.Equal(t, http.StatusOK, response.StatusCode)
	setTaskStatusForAPITest(t, runtime, inProgressTaskID, "in_progress", "")
	setTaskStatusForAPITest(t, runtime, inProgressTaskID, "in_review", "")
	setTaskStatusForAPITest(t, runtime, inProgressTaskID, "done", TaskWorkspaceMemberIDForUser(t, runtime, workspaceID, owner.User.ID))
	doneResponse = getTaskDetailForAPITest(t, client, server.URL, workspaceID, owner.AccessToken, inProgressTaskID)
	require.Equal(t, "done", doneResponse.Detail.Task.Status)
	require.NotNil(t, doneResponse.Detail.Task.CompletedAt)
}

func TestTasksDetailLifecycle(t *testing.T) {
	t.Parallel()

	runtime := StartRuntime(t)

	router := gin.New()
	runtime.RegisterRoutes(router)
	server := httptest.NewServer(router)
	defer server.Close()

	client := server.Client()
	auth, workspaceID, channelID := SetupTaskHTTPActor(t, client, server.URL, "task-detail-owner@example.com", "task-detail-owner")

	var created taskboard.TaskPage
	response := DoJSON(t, client, http.MethodPost, server.URL+"/api/v1/workspaces/"+workspaceID+"/channels/"+channelID+"/tasks", map[string]any{
		"tasks": []map[string]any{{"title": "Ship detail lifecycle"}},
	}, map[string]string{
		"Authorization":  fmt.Sprintf("Bearer %s", auth.AccessToken),
		"X-Workspace-Id": workspaceID,
	}, &created)
	require.Equal(t, http.StatusOK, response.StatusCode)
	require.Len(t, created.Tasks, 1)
	taskID := created.Tasks[0].ID

	response = DoJSON(t, client, http.MethodPatch, server.URL+"/api/v1/workspaces/"+workspaceID+"/tasks/"+taskID+"/claim", nil, map[string]string{
		"Authorization":  fmt.Sprintf("Bearer %s", auth.AccessToken),
		"X-Workspace-Id": workspaceID,
	}, nil)
	require.Equal(t, http.StatusOK, response.StatusCode)

	setTaskStatusForAPITest(t, runtime, taskID, "in_progress", "")

	var detailResponse struct {
		Detail dto.TaskDetail `json:"detail"`
	}
	response = DoJSON(t, client, http.MethodGet, server.URL+"/api/v1/workspaces/"+workspaceID+"/tasks/"+taskID, nil, map[string]string{
		"Authorization":  fmt.Sprintf("Bearer %s", auth.AccessToken),
		"X-Workspace-Id": workspaceID,
	}, &detailResponse)
	require.Equal(t, http.StatusOK, response.StatusCode)
	require.Equal(t, taskID, detailResponse.Detail.Task.ID)

	var patchResponse struct {
		Task taskboard.Task `json:"task"`
	}
	response = DoJSON(t, client, http.MethodPatch, server.URL+"/api/v1/workspaces/"+workspaceID+"/tasks/"+taskID, map[string]any{
		"description": "Lock the detail contract before wiring the board UI.",
		"kind":        "feature",
		"priority":    "p1",
		"pinnedMessageRefs": []string{
			"#all:msg-1",
			"#all:msg-2",
		},
		"relevantURLs": []string{
			"https://example.com/spec",
		},
		"workspaceRefs": []string{
			"specs/task/board.md",
		},
		"acceptanceCriteria": []map[string]any{
			{"text": "Overview fields round-trip through the detail endpoint"},
			{"text": "Reviewer metadata is visible"},
		},
		"reviewChecklist": []map[string]any{
			{"text": "Regression coverage attached"},
		},
		"blockedReason": "Waiting on detail routes",
	}, map[string]string{
		"Authorization":  fmt.Sprintf("Bearer %s", auth.AccessToken),
		"X-Workspace-Id": workspaceID,
	}, &patchResponse)
	require.Equal(t, http.StatusOK, response.StatusCode)
	require.Equal(t, "Lock the detail contract before wiring the board UI.", patchResponse.Task.Description)
	require.Equal(t, "Feature", patchResponse.Task.Kind)
	require.Equal(t, "High", patchResponse.Task.Priority)

	response = DoJSON(t, client, http.MethodPost, server.URL+"/api/v1/workspaces/"+workspaceID+"/tasks/"+taskID+"/checkpoints", map[string]any{
		"text": "Added the task detail migration",
	}, map[string]string{
		"Authorization":  fmt.Sprintf("Bearer %s", auth.AccessToken),
		"X-Workspace-Id": workspaceID,
	}, nil)
	require.Equal(t, http.StatusOK, response.StatusCode)

	response = DoJSON(t, client, http.MethodGet, server.URL+"/api/v1/workspaces/"+workspaceID+"/tasks/"+taskID, nil, map[string]string{
		"Authorization":  fmt.Sprintf("Bearer %s", auth.AccessToken),
		"X-Workspace-Id": workspaceID,
	}, &detailResponse)
	require.Equal(t, http.StatusOK, response.StatusCode)
	require.Equal(t, "Feature", detailResponse.Detail.Task.Kind)
	require.Equal(t, "High", detailResponse.Detail.Task.Priority)
	require.Equal(t, []string{"#all:msg-1", "#all:msg-2"}, detailResponse.Detail.PinnedMessageRefs)
	require.Equal(t, []string{"https://example.com/spec"}, detailResponse.Detail.RelevantURLs)
	require.Equal(t, []string{"specs/task/board.md"}, detailResponse.Detail.WorkspaceRefs)
}

func TestTaskBoardSettings(t *testing.T) {
	t.Parallel()

	runtime := StartRuntime(t)

	router := gin.New()
	runtime.RegisterRoutes(router)
	server := httptest.NewServer(router)
	defer server.Close()

	client := server.Client()
	owner, workspaceID, _ := SetupTaskHTTPActor(t, client, server.URL, "task-settings-owner@example.com", "task-settings-owner")
	member := AcceptTaskHTTPInvite(t, runtime, client, server.URL, owner.User.ID, workspaceID, "task-settings-member@example.com", "task-settings-member")

	var settings struct {
		CanManage       bool                        `json:"canManage"`
		PriorityOptions []taskboard.TaskFieldOption `json:"priorityOptions"`
		KindOptions     []taskboard.TaskFieldOption `json:"kindOptions"`
	}
	response := DoJSON(t, client, http.MethodGet, server.URL+"/api/v1/workspaces/"+workspaceID+"/task-board/options", nil, WorkspaceHeaders(member.AccessToken, workspaceID), &settings)
	require.Equal(t, http.StatusOK, response.StatusCode)
	require.False(t, settings.CanManage)
	require.Equal(t, []string{"p0", "p1", "p2", "p3"}, TaskFieldOptionKeys(settings.PriorityOptions))
	require.Equal(t, []string{"feature", "bug", "design", "product", "refactor", "docs", "test", "chore", "research", "spike", "release", "ops"}, TaskFieldOptionKeys(settings.KindOptions))
	require.NotContains(t, TaskFieldOptionKeys(settings.KindOptions), "data")
	require.Contains(t, FindTaskFieldOptionByKey(t, settings.PriorityOptions, "p0").Aliases, "critical")
	require.Contains(t, FindTaskFieldOptionByKey(t, settings.KindOptions, "feature").Aliases, "feat")
}

func TestTaskTaxonomyPermissions(t *testing.T) {
	t.Parallel()

	runtime := StartRuntime(t)

	router := gin.New()
	runtime.RegisterRoutes(router)
	server := httptest.NewServer(router)
	defer server.Close()

	client := server.Client()
	owner, workspaceID, _ := SetupTaskHTTPActor(t, client, server.URL, "task-taxonomy-owner@example.com", "task-taxonomy-owner")
	admin := AcceptTaskHTTPInvite(t, runtime, client, server.URL, owner.User.ID, workspaceID, "task-taxonomy-admin@example.com", "task-taxonomy-admin")
	member := AcceptTaskHTTPInvite(t, runtime, client, server.URL, owner.User.ID, workspaceID, "task-taxonomy-member@example.com", "task-taxonomy-member")

	adminMemberID := TaskWorkspaceMemberIDForUser(t, runtime, workspaceID, admin.User.ID)
	response := DoJSON(t, client, http.MethodPatch, server.URL+"/api/v1/workspaces/"+workspaceID+"/members/"+adminMemberID, map[string]any{
		"role": "admin",
	}, AuthHeaders(owner.AccessToken), nil)
	require.Equal(t, http.StatusOK, response.StatusCode)

	var forbidden struct {
		Error string `json:"error"`
	}
	response = DoJSON(t, client, http.MethodPost, server.URL+"/api/v1/workspaces/"+workspaceID+"/task-board/options", map[string]any{
		"fieldKind": "kind",
		"key":       "enhancement",
		"label":     "Enhancement",
	}, WorkspaceHeaders(member.AccessToken, workspaceID), &forbidden)
	require.Equal(t, http.StatusForbidden, response.StatusCode)
	require.Equal(t, "forbidden", forbidden.Error)

	var created struct {
		Option taskboard.TaskFieldOption `json:"option"`
	}
	response = DoJSON(t, client, http.MethodPost, server.URL+"/api/v1/workspaces/"+workspaceID+"/task-board/options", map[string]any{
		"fieldKind": "kind",
		"key":       "enhancement",
		"label":     "Enhancement",
		"aliases":   []string{"enh"},
	}, WorkspaceHeaders(owner.AccessToken, workspaceID), &created)
	require.Equal(t, http.StatusOK, response.StatusCode)
	require.Equal(t, "enhancement", created.Option.Key)

	var renamed struct {
		Option taskboard.TaskFieldOption `json:"option"`
	}
	response = DoJSON(t, client, http.MethodPatch, server.URL+"/api/v1/workspaces/"+workspaceID+"/task-board/options/"+created.Option.ID, map[string]any{
		"label":   "Enhancement Work",
		"aliases": []string{"enh"},
	}, WorkspaceHeaders(admin.AccessToken, workspaceID), &renamed)
	require.Equal(t, http.StatusOK, response.StatusCode)
	require.Equal(t, "Enhancement Work", renamed.Option.Label)

	var second struct {
		Option taskboard.TaskFieldOption `json:"option"`
	}
	response = DoJSON(t, client, http.MethodPost, server.URL+"/api/v1/workspaces/"+workspaceID+"/task-board/options", map[string]any{
		"fieldKind": "kind",
		"key":       "insight",
		"label":     "Insight",
	}, WorkspaceHeaders(owner.AccessToken, workspaceID), &second)
	require.Equal(t, http.StatusOK, response.StatusCode)

	response = DoJSON(t, client, http.MethodPost, server.URL+"/api/v1/workspaces/"+workspaceID+"/task-board/options/"+second.Option.ID+"/reorder", map[string]any{
		"position": 0,
	}, WorkspaceHeaders(admin.AccessToken, workspaceID), nil)
	require.Equal(t, http.StatusOK, response.StatusCode)

	response = DoJSON(t, client, http.MethodPost, server.URL+"/api/v1/workspaces/"+workspaceID+"/task-board/options/"+created.Option.ID+"/deactivate", map[string]any{}, WorkspaceHeaders(admin.AccessToken, workspaceID), &renamed)
	require.Equal(t, http.StatusOK, response.StatusCode)
	require.False(t, renamed.Option.IsActive)

	response = DoJSON(t, client, http.MethodPost, server.URL+"/api/v1/workspaces/"+workspaceID+"/task-board/options/"+created.Option.ID+"/reactivate", map[string]any{}, WorkspaceHeaders(admin.AccessToken, workspaceID), &renamed)
	require.Equal(t, http.StatusOK, response.StatusCode)
	require.True(t, renamed.Option.IsActive)

	var settings struct {
		CanManage   bool                        `json:"canManage"`
		KindOptions []taskboard.TaskFieldOption `json:"kindOptions"`
	}
	response = DoJSON(t, client, http.MethodGet, server.URL+"/api/v1/workspaces/"+workspaceID+"/task-board/options", nil, WorkspaceHeaders(owner.AccessToken, workspaceID), &settings)
	require.Equal(t, http.StatusOK, response.StatusCode)
	require.True(t, settings.CanManage)
	require.Equal(t, "insight", settings.KindOptions[0].Key)
}

func TestTaskStructuredFieldUpdate(t *testing.T) {
	t.Parallel()

	runtime := StartRuntime(t)

	router := gin.New()
	runtime.RegisterRoutes(router)
	server := httptest.NewServer(router)
	defer server.Close()

	client := server.Client()
	owner, workspaceID, channelID := SetupTaskHTTPActor(t, client, server.URL, "task-structured-owner@example.com", "task-structured-owner")

	var settings struct {
		PriorityOptions []taskboard.TaskFieldOption `json:"priorityOptions"`
		KindOptions     []taskboard.TaskFieldOption `json:"kindOptions"`
	}
	response := DoJSON(t, client, http.MethodGet, server.URL+"/api/v1/workspaces/"+workspaceID+"/task-board/options", nil, WorkspaceHeaders(owner.AccessToken, workspaceID), &settings)
	require.Equal(t, http.StatusOK, response.StatusCode)

	var created taskboard.TaskPage
	response = DoJSON(t, client, http.MethodPost, server.URL+"/api/v1/workspaces/"+workspaceID+"/channels/"+channelID+"/tasks", map[string]any{
		"tasks": []map[string]any{{"title": "Ship structured taxonomy update"}},
	}, WorkspaceHeaders(owner.AccessToken, workspaceID), &created)
	require.Equal(t, http.StatusOK, response.StatusCode)
	require.Len(t, created.Tasks, 1)
	taskID := created.Tasks[0].ID

	feature := FindTaskFieldOptionByKey(t, settings.KindOptions, "feature")
	p1 := FindTaskFieldOptionByKey(t, settings.PriorityOptions, "p1")

	var updated struct {
		Task taskboard.Task `json:"task"`
	}
	response = DoJSON(t, client, http.MethodPatch, server.URL+"/api/v1/workspaces/"+workspaceID+"/tasks/"+taskID, map[string]any{
		"kindOptionId":     feature.ID,
		"priorityOptionId": p1.ID,
	}, WorkspaceHeaders(owner.AccessToken, workspaceID), &updated)
	require.Equal(t, http.StatusOK, response.StatusCode)
	require.NotNil(t, updated.Task.KindField)
	require.Equal(t, feature.ID, updated.Task.KindField.OptionID)
	require.Equal(t, "feature", updated.Task.KindField.Key)
	require.NotNil(t, updated.Task.PriorityField)
	require.Equal(t, p1.ID, updated.Task.PriorityField.OptionID)
	require.Equal(t, "p1", updated.Task.PriorityField.Key)

	var detail struct {
		Detail dto.TaskDetail `json:"detail"`
	}
	response = DoJSON(t, client, http.MethodGet, server.URL+"/api/v1/workspaces/"+workspaceID+"/tasks/"+taskID, nil, WorkspaceHeaders(owner.AccessToken, workspaceID), &detail)
	require.Equal(t, http.StatusOK, response.StatusCode)
	require.NotNil(t, detail.Detail.Task.KindField)
	require.Equal(t, "feature", detail.Detail.Task.KindField.Key)
	require.NotNil(t, detail.Detail.Task.PriorityField)
	require.Equal(t, "p1", detail.Detail.Task.PriorityField.Key)

	response = DoJSON(t, client, http.MethodPatch, server.URL+"/api/v1/workspaces/"+workspaceID+"/tasks/"+taskID, map[string]any{
		"kindOptionId":     "",
		"priorityOptionId": "",
	}, WorkspaceHeaders(owner.AccessToken, workspaceID), &updated)
	require.Equal(t, http.StatusOK, response.StatusCode)
	require.NotNil(t, updated.Task.KindField)
	require.True(t, updated.Task.KindField.IsNone)
	require.NotNil(t, updated.Task.PriorityField)
	require.True(t, updated.Task.PriorityField.IsNone)
}

func TestTaskBoardViewStateAPI(t *testing.T) {
	t.Parallel()

	runtime := StartRuntime(t)

	router := gin.New()
	runtime.RegisterRoutes(router)
	server := httptest.NewServer(router)
	defer server.Close()

	client := server.Client()
	owner, workspaceID, channelID := SetupTaskHTTPActor(t, client, server.URL, "task-view-state-owner@example.com", "task-view-state-owner")
	member := AcceptTaskHTTPInvite(t, runtime, client, server.URL, owner.User.ID, workspaceID, "task-view-state-member@example.com", "task-view-state-member")

	var getResponse struct {
		ViewState taskboard.TaskBoardViewState `json:"viewState"`
	}
	response := DoJSON(t, client, http.MethodGet, server.URL+"/api/v1/workspaces/"+workspaceID+"/tasks/channel/"+channelID+"/view-state", nil, WorkspaceHeaders(owner.AccessToken, workspaceID), &getResponse)
	require.Equal(t, http.StatusOK, response.StatusCode)
	require.Equal(t, "board", getResponse.ViewState.View)
	require.Equal(t, "manual", getResponse.ViewState.SortMode)
	require.Empty(t, getResponse.ViewState.SearchQuery)
	require.Empty(t, getResponse.ViewState.QuickFilters)
	require.Empty(t, getResponse.ViewState.StructuredFilters)
	require.False(t, getResponse.ViewState.DoneCollapsed)

	ownerState := taskboard.TaskBoardViewState{
		View:         "list",
		SearchQuery:  "release board",
		QuickFilters: []string{"blocked", "created_by_me"},
		StructuredFilters: map[string][]string{
			"kind":     []string{"feature", "legacy/custom"},
			"priority": []string{"none", "p1"},
		},
		SortMode:      "manual",
		DoneCollapsed: true,
	}
	var putResponse struct {
		ViewState taskboard.TaskBoardViewState `json:"viewState"`
	}
	response = DoJSON(t, client, http.MethodPut, server.URL+"/api/v1/workspaces/"+workspaceID+"/tasks/channel/"+channelID+"/view-state", ownerState, WorkspaceHeaders(owner.AccessToken, workspaceID), &putResponse)
	require.Equal(t, http.StatusOK, response.StatusCode)
	require.Equal(t, ownerState, putResponse.ViewState)

	getResponse = struct {
		ViewState taskboard.TaskBoardViewState `json:"viewState"`
	}{}
	response = DoJSON(t, client, http.MethodGet, server.URL+"/api/v1/workspaces/"+workspaceID+"/tasks/channel/"+channelID+"/view-state", nil, WorkspaceHeaders(owner.AccessToken, workspaceID), &getResponse)
	require.Equal(t, http.StatusOK, response.StatusCode)
	require.Equal(t, ownerState, getResponse.ViewState)

	var secondaryChannel ChannelResponse
	response = DoJSON(t, client, http.MethodPost, server.URL+"/api/v1/workspaces/"+workspaceID+"/channels", map[string]any{
		"name": "build",
	}, WorkspaceHeaders(owner.AccessToken, workspaceID), &secondaryChannel)
	require.Equal(t, http.StatusOK, response.StatusCode)

	getResponse = struct {
		ViewState taskboard.TaskBoardViewState `json:"viewState"`
	}{}
	response = DoJSON(t, client, http.MethodGet, server.URL+"/api/v1/workspaces/"+workspaceID+"/tasks/channel/"+secondaryChannel.ID+"/view-state", nil, WorkspaceHeaders(owner.AccessToken, workspaceID), &getResponse)
	require.Equal(t, http.StatusOK, response.StatusCode)
	require.Equal(t, "board", getResponse.ViewState.View)
	require.Equal(t, "manual", getResponse.ViewState.SortMode)
	require.Empty(t, getResponse.ViewState.QuickFilters)

	memberState := taskboard.TaskBoardViewState{
		View:         "board",
		SearchQuery:  "builder",
		QuickFilters: []string{"assigned_to_agents"},
		StructuredFilters: map[string][]string{
			"assignee": {"agt_member"},
		},
		SortMode:      "manual",
		DoneCollapsed: true,
	}
	putResponse = struct {
		ViewState taskboard.TaskBoardViewState `json:"viewState"`
	}{}
	response = DoJSON(t, client, http.MethodPut, server.URL+"/api/v1/workspaces/"+workspaceID+"/tasks/channel/"+channelID+"/view-state", memberState, WorkspaceHeaders(member.AccessToken, workspaceID), &putResponse)
	require.Equal(t, http.StatusOK, response.StatusCode)
	require.Equal(t, memberState, putResponse.ViewState)

	getResponse = struct {
		ViewState taskboard.TaskBoardViewState `json:"viewState"`
	}{}
	response = DoJSON(t, client, http.MethodGet, server.URL+"/api/v1/workspaces/"+workspaceID+"/tasks/channel/"+channelID+"/view-state", nil, WorkspaceHeaders(owner.AccessToken, workspaceID), &getResponse)
	require.Equal(t, http.StatusOK, response.StatusCode)
	require.Equal(t, ownerState, getResponse.ViewState)

	getResponse = struct {
		ViewState taskboard.TaskBoardViewState `json:"viewState"`
	}{}
	response = DoJSON(t, client, http.MethodGet, server.URL+"/api/v1/workspaces/"+workspaceID+"/tasks/channel/"+channelID+"/view-state", nil, WorkspaceHeaders(member.AccessToken, workspaceID), &getResponse)
	require.Equal(t, http.StatusOK, response.StatusCode)
	require.Equal(t, memberState, getResponse.ViewState)
}

func TestTaskBoardSavedViewsAPI(t *testing.T) {
	t.Parallel()

	runtime := StartRuntime(t)

	router := gin.New()
	runtime.RegisterRoutes(router)
	server := httptest.NewServer(router)
	defer server.Close()

	client := server.Client()
	owner, workspaceID, _ := SetupTaskHTTPActor(t, client, server.URL, "task-saved-view-owner@example.com", "task-saved-view-owner")
	member := AcceptTaskHTTPInvite(t, runtime, client, server.URL, owner.User.ID, workspaceID, "task-saved-view-member@example.com", "task-saved-view-member")

	var listResponse struct {
		SavedViews []dto.TaskBoardSavedView `json:"savedViews"`
	}
	response := DoJSON(t, client, http.MethodGet, server.URL+"/api/v1/workspaces/"+workspaceID+"/task-board/saved-views", nil, WorkspaceHeaders(owner.AccessToken, workspaceID), &listResponse)
	require.Equal(t, http.StatusOK, response.StatusCode)
	require.Empty(t, listResponse.SavedViews)

	ownerState := taskboard.TaskBoardViewState{
		View:         "list",
		SearchQuery:  "  blocked   tasks  ",
		QuickFilters: []string{"blocked", "unknown"},
		StructuredFilters: map[string][]string{
			"assignee": []string{"mem_assignee"},
			"channel":  []string{"ignored"},
		},
		SortMode:      "recently_updated",
		DoneCollapsed: true,
	}
	var createResponse struct {
		SavedView dto.TaskBoardSavedView `json:"savedView"`
	}
	response = DoJSON(t, client, http.MethodPost, server.URL+"/api/v1/workspaces/"+workspaceID+"/task-board/saved-views", map[string]any{
		"name":      "  Blocked   Tasks  ",
		"viewState": ownerState,
	}, WorkspaceHeaders(owner.AccessToken, workspaceID), &createResponse)
	require.Equal(t, http.StatusOK, response.StatusCode)
	require.NotEmpty(t, createResponse.SavedView.ID)
	require.Equal(t, "Blocked Tasks", createResponse.SavedView.Name)
	require.Equal(t, "list", createResponse.SavedView.ViewState.View)
	require.Equal(t, "blocked tasks", createResponse.SavedView.ViewState.SearchQuery)
	require.Equal(t, []string{"blocked"}, createResponse.SavedView.ViewState.QuickFilters)
	require.Equal(t, map[string][]string{
		"assignee": {"mem_assignee"},
		"channel":  {"ignored"},
	}, createResponse.SavedView.ViewState.StructuredFilters)
	require.True(t, createResponse.SavedView.ViewState.DoneCollapsed)

	var conflictResponse map[string]any
	response = DoJSON(t, client, http.MethodPost, server.URL+"/api/v1/workspaces/"+workspaceID+"/task-board/saved-views", map[string]any{
		"name":      "Blocked Tasks",
		"viewState": ownerState,
	}, WorkspaceHeaders(owner.AccessToken, workspaceID), &conflictResponse)
	require.Equal(t, http.StatusConflict, response.StatusCode)

	var memberCreateResponse struct {
		SavedView dto.TaskBoardSavedView `json:"savedView"`
	}
	response = DoJSON(t, client, http.MethodPost, server.URL+"/api/v1/workspaces/"+workspaceID+"/task-board/saved-views", map[string]any{
		"name": "Blocked Tasks",
		"viewState": taskboard.TaskBoardViewState{
			View:         "board",
			QuickFilters: []string{"blocked"},
			SortMode:     "priority",
		},
	}, WorkspaceHeaders(member.AccessToken, workspaceID), &memberCreateResponse)
	require.Equal(t, http.StatusOK, response.StatusCode)
	require.NotEqual(t, createResponse.SavedView.ID, memberCreateResponse.SavedView.ID)

	listResponse = struct {
		SavedViews []dto.TaskBoardSavedView `json:"savedViews"`
	}{}
	response = DoJSON(t, client, http.MethodGet, server.URL+"/api/v1/workspaces/"+workspaceID+"/task-board/saved-views", nil, WorkspaceHeaders(owner.AccessToken, workspaceID), &listResponse)
	require.Equal(t, http.StatusOK, response.StatusCode)
	require.Len(t, listResponse.SavedViews, 1)
	require.Equal(t, createResponse.SavedView.ID, listResponse.SavedViews[0].ID)

	var updateResponse struct {
		SavedView dto.TaskBoardSavedView `json:"savedView"`
	}
	response = DoJSON(t, client, http.MethodPatch, server.URL+"/api/v1/workspaces/"+workspaceID+"/task-board/saved-views/"+createResponse.SavedView.ID, map[string]any{
		"name": "Blocked work",
		"viewState": taskboard.TaskBoardViewState{
			View:         "board",
			QuickFilters: []string{"blocked"},
			SortMode:     "priority",
		},
	}, WorkspaceHeaders(owner.AccessToken, workspaceID), &updateResponse)
	require.Equal(t, http.StatusOK, response.StatusCode)
	require.Equal(t, createResponse.SavedView.ID, updateResponse.SavedView.ID)
	require.Equal(t, "Blocked work", updateResponse.SavedView.Name)
	require.Equal(t, []string{"blocked"}, updateResponse.SavedView.ViewState.QuickFilters)

	response = DoJSON(t, client, http.MethodDelete, server.URL+"/api/v1/workspaces/"+workspaceID+"/task-board/saved-views/"+createResponse.SavedView.ID, nil, WorkspaceHeaders(member.AccessToken, workspaceID), &map[string]any{})
	require.Equal(t, http.StatusNotFound, response.StatusCode)

	response = DoJSON(t, client, http.MethodDelete, server.URL+"/api/v1/workspaces/"+workspaceID+"/task-board/saved-views/"+createResponse.SavedView.ID, nil, WorkspaceHeaders(owner.AccessToken, workspaceID), &map[string]any{})
	require.Equal(t, http.StatusOK, response.StatusCode)

	listResponse = struct {
		SavedViews []dto.TaskBoardSavedView `json:"savedViews"`
	}{}
	response = DoJSON(t, client, http.MethodGet, server.URL+"/api/v1/workspaces/"+workspaceID+"/task-board/saved-views", nil, WorkspaceHeaders(owner.AccessToken, workspaceID), &listResponse)
	require.Equal(t, http.StatusOK, response.StatusCode)
	require.Empty(t, listResponse.SavedViews)
}

func TestTaskDiscussionAPI(t *testing.T) {
	t.Parallel()

	runtime := StartRuntime(t)

	router := gin.New()
	runtime.RegisterRoutes(router)
	server := httptest.NewServer(router)
	defer server.Close()

	client := server.Client()
	auth, workspaceID, channelID := SetupTaskHTTPActor(t, client, server.URL, "task-discussion-owner@example.com", "task-discussion-owner")
	ownerMemberID := TaskWorkspaceMemberIDForUser(t, runtime, workspaceID, auth.User.ID)

	var created taskboard.TaskPage
	response := DoJSON(t, client, http.MethodPost, server.URL+"/api/v1/workspaces/"+workspaceID+"/channels/"+channelID+"/tasks", map[string]any{
		"tasks": []map[string]any{{"title": "Ship task discussion"}},
	}, WorkspaceHeaders(auth.AccessToken, workspaceID), &created)
	require.Equal(t, http.StatusOK, response.StatusCode)
	require.Len(t, created.Tasks, 1)
	taskID := created.Tasks[0].ID
	posted := postTaskScopeDiscussionMessage(t, client, server.URL, auth.AccessToken, workspaceID, taskID, "Please keep the rollout constrained to the task workbench.", nil)
	require.Equal(t, int64(1), posted.Task.DiscussionCount)
	require.Equal(t, "Please keep the rollout constrained to the task workbench.", posted.Entry.Body)
	require.Equal(t, auth.User.ID, posted.Entry.CreatedBy.ID)
	require.Equal(t, auth.User.Name, posted.Entry.CreatedBy.Name)
	require.NotNil(t, posted.Task.LastActivityAt)

	var detail dto.TaskDetailResponse
	response = DoJSON(t, client, http.MethodGet, server.URL+"/api/v1/workspaces/"+workspaceID+"/tasks/"+taskID, nil, WorkspaceHeaders(auth.AccessToken, workspaceID), &detail)
	require.Equal(t, http.StatusOK, response.StatusCode)
	require.Equal(t, int64(1), detail.Detail.Task.DiscussionCount)
	require.Len(t, taskDetailDiscussionEntries(detail.Detail), 1)
	require.Equal(t, posted.Entry.ID, taskDetailDiscussionEntries(detail.Detail)[0].ID)
	require.Equal(t, posted.Entry.Body, taskDetailDiscussionEntries(detail.Detail)[0].Body)
	require.Equal(t, auth.User.ID, taskDetailDiscussionEntries(detail.Detail)[0].CreatedBy.ID)
	require.Equal(t, auth.User.Name, taskDetailDiscussionEntries(detail.Detail)[0].CreatedBy.Name)
	require.NotNil(t, taskDetailDiscussionEntries(detail.Detail)[0].AuthorProfile)
	require.Equal(t, message.AuthorActorTypeHuman, taskDetailDiscussionEntries(detail.Detail)[0].AuthorProfile.ActorType)
	require.Equal(t, auth.User.ID, taskDetailDiscussionEntries(detail.Detail)[0].AuthorProfile.PrincipalID)
	require.Equal(t, ownerMemberID, taskDetailDiscussionEntries(detail.Detail)[0].AuthorProfile.MemberID)
	require.NotNil(t, taskDetailDiscussionEntries(detail.Detail)[0].Source)
	require.Equal(t, message.ContributionSourceKindHumanCompose, taskDetailDiscussionEntries(detail.Detail)[0].Source.Kind)
	require.Equal(t, message.ContributionSourceRefTypeMessage, taskDetailDiscussionEntries(detail.Detail)[0].Source.RefType)
	require.Equal(t, posted.Entry.ID, taskDetailDiscussionEntries(detail.Detail)[0].Source.RefID)

	var detailByNumber dto.TaskDetailResponse
	response = DoJSON(
		t,
		client,
		http.MethodGet,
		server.URL+"/api/v1/workspaces/"+workspaceID+"/channels/"+channelID+"/tasks/1",
		nil,
		WorkspaceHeaders(auth.AccessToken, workspaceID),
		&detailByNumber,
	)
	require.Equal(t, http.StatusOK, response.StatusCode)
	require.Equal(t, taskID, detailByNumber.Detail.Task.ID)
	require.Equal(t, int64(1), detailByNumber.Detail.Task.TaskNumber)
	require.Equal(t, channelID, detailByNumber.Detail.Task.ChannelID)
	require.Len(t, taskDetailDiscussionEntries(detailByNumber.Detail), 1)
	require.Equal(t, posted.Entry.ID, taskDetailDiscussionEntries(detailByNumber.Detail)[0].ID)

	var timelineResponse dto.TaskTimelineListResponse
	response = DoJSON(t, client, http.MethodGet, server.URL+"/api/v1/workspaces/"+workspaceID+"/tasks/"+taskID+"/timeline", nil, WorkspaceHeaders(auth.AccessToken, workspaceID), &timelineResponse)
	require.Equal(t, http.StatusOK, response.StatusCode)
	timelineDiscussions := taskTimelineDiscussionEntries(timelineResponse)
	require.Len(t, timelineDiscussions, 1)
	require.Equal(t, posted.Entry.ID, timelineDiscussions[0].ID)
	require.Equal(t, posted.Entry.Body, timelineDiscussions[0].Body)
	require.Equal(t, auth.User.ID, timelineDiscussions[0].CreatedBy.ID)
	require.Equal(t, auth.User.Name, timelineDiscussions[0].CreatedBy.Name)
	require.NotNil(t, timelineDiscussions[0].AuthorProfile)
	require.Equal(t, message.AuthorActorTypeHuman, timelineDiscussions[0].AuthorProfile.ActorType)
	require.Equal(t, auth.User.ID, timelineDiscussions[0].AuthorProfile.PrincipalID)
	require.NotNil(t, timelineDiscussions[0].Source)
	require.Equal(t, message.ContributionSourceKindHumanCompose, timelineDiscussions[0].Source.Kind)
	require.False(t, timelineResponse.HasMore)
	require.Empty(t, timelineResponse.NextCursor)

	var editedMessage dto.AppMessage
	response = DoJSON(
		t,
		client,
		http.MethodPatch,
		server.URL+"/api/v1/workspaces/"+workspaceID+"/scopes/"+posted.Task.TaskScopeID+"/messages/"+posted.Entry.ID,
		map[string]any{"content": "Edited task discussion content"},
		WorkspaceHeaders(auth.AccessToken, workspaceID),
		&editedMessage,
	)
	require.Equal(t, http.StatusOK, response.StatusCode)
	require.Equal(t, posted.Entry.ID, editedMessage.ID)
	require.Equal(t, "Edited task discussion content", editedMessage.Content)
	require.NotNil(t, editedMessage.EditedAt)
	require.Equal(t, ownerMemberID, editedMessage.EditedByMemberID)
	require.EqualValues(t, 1, editedMessage.EditRevision)

	var detailAfterEdit dto.TaskDetailResponse
	response = DoJSON(t, client, http.MethodGet, server.URL+"/api/v1/workspaces/"+workspaceID+"/tasks/"+taskID, nil, WorkspaceHeaders(auth.AccessToken, workspaceID), &detailAfterEdit)
	require.Equal(t, http.StatusOK, response.StatusCode)
	require.Len(t, taskDetailDiscussionEntries(detailAfterEdit.Detail), 1)
	editedDetailDiscussion := taskDetailDiscussionEntries(detailAfterEdit.Detail)[0]
	require.Equal(t, "Edited task discussion content", editedDetailDiscussion.Body)
	require.NotNil(t, editedDetailDiscussion.EditedAt)
	require.Equal(t, ownerMemberID, editedDetailDiscussion.EditedByMemberID)
	require.EqualValues(t, 1, editedDetailDiscussion.EditRevision)

	timelineResponse = dto.TaskTimelineListResponse{}
	response = DoJSON(t, client, http.MethodGet, server.URL+"/api/v1/workspaces/"+workspaceID+"/tasks/"+taskID+"/timeline", nil, WorkspaceHeaders(auth.AccessToken, workspaceID), &timelineResponse)
	require.Equal(t, http.StatusOK, response.StatusCode)
	timelineDiscussions = taskTimelineDiscussionEntries(timelineResponse)
	require.Len(t, timelineDiscussions, 1)
	require.Equal(t, "Edited task discussion content", timelineDiscussions[0].Body)
	require.NotNil(t, timelineDiscussions[0].EditedAt)
	require.Equal(t, ownerMemberID, timelineDiscussions[0].EditedByMemberID)
	require.EqualValues(t, 1, timelineDiscussions[0].EditRevision)

	var reactionResponse dto.AppMessageReactionMutationResponse
	response = DoJSON(t, client, http.MethodPut, server.URL+WorkspaceMessageReactionPath(workspaceID, posted.Task.TaskScopeID, posted.Entry.ID, "👍"), nil, WorkspaceHeaders(auth.AccessToken, workspaceID), &reactionResponse)
	require.Equal(t, http.StatusOK, response.StatusCode)
	require.True(t, reactionResponse.Changed)
	require.Len(t, reactionResponse.Reactions, 1)
	require.Equal(t, "👍", reactionResponse.Reactions[0].Emoji)
	require.Equal(t, 1, reactionResponse.Reactions[0].Count)
	require.Len(t, reactionResponse.Reactions[0].Reactors, 1)
	require.Equal(t, ownerMemberID, reactionResponse.Reactions[0].Reactors[0].AuthorProfile.MemberID)

	detailAfterReaction := dto.TaskDetailResponse{}
	response = DoJSON(t, client, http.MethodGet, server.URL+"/api/v1/workspaces/"+workspaceID+"/tasks/"+taskID, nil, WorkspaceHeaders(auth.AccessToken, workspaceID), &detailAfterReaction)
	require.Equal(t, http.StatusOK, response.StatusCode)
	require.Len(t, taskDetailDiscussionEntries(detailAfterReaction.Detail), 1)
	require.Len(t, taskDetailDiscussionEntries(detailAfterReaction.Detail)[0].Reactions, 1)
	require.Equal(t, "👍", taskDetailDiscussionEntries(detailAfterReaction.Detail)[0].Reactions[0].Emoji)
	require.Equal(t, 1, taskDetailDiscussionEntries(detailAfterReaction.Detail)[0].Reactions[0].Count)

	timelineResponse = dto.TaskTimelineListResponse{}
	response = DoJSON(t, client, http.MethodGet, server.URL+"/api/v1/workspaces/"+workspaceID+"/tasks/"+taskID+"/timeline", nil, WorkspaceHeaders(auth.AccessToken, workspaceID), &timelineResponse)
	require.Equal(t, http.StatusOK, response.StatusCode)
	timelineDiscussions = taskTimelineDiscussionEntries(timelineResponse)
	require.Len(t, timelineDiscussions, 1)
	require.Len(t, timelineDiscussions[0].Reactions, 1)
	require.Equal(t, "👍", timelineDiscussions[0].Reactions[0].Emoji)
	require.Equal(t, 1, timelineDiscussions[0].Reactions[0].Count)

	var removedReaction dto.AppMessageReactionMutationResponse
	response = DoJSON(t, client, http.MethodDelete, server.URL+WorkspaceMessageReactionPath(workspaceID, posted.Task.TaskScopeID, posted.Entry.ID, "👍"), nil, WorkspaceHeaders(auth.AccessToken, workspaceID), &removedReaction)
	require.Equal(t, http.StatusOK, response.StatusCode)
	require.True(t, removedReaction.Changed)
	require.Empty(t, removedReaction.Reactions)

	timelineResponse = dto.TaskTimelineListResponse{}
	response = DoJSON(t, client, http.MethodGet, server.URL+"/api/v1/workspaces/"+workspaceID+"/tasks/"+taskID+"/timeline", nil, WorkspaceHeaders(auth.AccessToken, workspaceID), &timelineResponse)
	require.Equal(t, http.StatusOK, response.StatusCode)
	timelineDiscussions = taskTimelineDiscussionEntries(timelineResponse)
	require.Len(t, timelineDiscussions, 1)
	require.Empty(t, timelineDiscussions[0].Reactions)
	require.False(t, timelineResponse.HasMore)
	require.Empty(t, timelineResponse.NextCursor)
}

func TestTaskDiscussionAttachmentCanBeDownloaded(t *testing.T) {
	t.Parallel()

	runtime := StartRuntime(t)

	router := gin.New()
	runtime.RegisterRoutes(router)
	server := httptest.NewServer(router)
	defer server.Close()

	client := server.Client()
	auth, workspaceID, channelID := SetupTaskHTTPActor(t, client, server.URL, "task-discussion-attachment@example.com", "task-discussion-attachment")

	var created taskboard.TaskPage
	response := DoJSON(t, client, http.MethodPost, server.URL+"/api/v1/workspaces/"+workspaceID+"/channels/"+channelID+"/tasks", map[string]any{
		"tasks": []map[string]any{{"title": "Attach log to task discussion"}},
	}, WorkspaceHeaders(auth.AccessToken, workspaceID), &created)
	require.Equal(t, http.StatusOK, response.StatusCode)
	require.Len(t, created.Tasks, 1)
	taskID := created.Tasks[0].ID

	uploaded := uploadTaskTestAttachment(t, client, server.URL, auth.AccessToken, workspaceID, channelID, "debug.log", []byte("task attachment body"))
	body := "Please inspect this log"
	posted := postTaskScopeDiscussionMessage(t, client, server.URL, auth.AccessToken, workspaceID, taskID, body, []string{uploaded.ID})
	require.Equal(t, body, posted.Entry.Body)
	require.Len(t, posted.Entry.Attachments, 1)
	require.Equal(t, uploaded.ID, posted.Entry.Attachments[0].ID)
	require.Equal(t, uploaded.Filename, posted.Entry.Attachments[0].Filename)

	var detail dto.TaskDetailResponse
	response = DoJSON(t, client, http.MethodGet, server.URL+"/api/v1/workspaces/"+workspaceID+"/tasks/"+taskID, nil, WorkspaceHeaders(auth.AccessToken, workspaceID), &detail)
	require.Equal(t, http.StatusOK, response.StatusCode)
	require.NotEmpty(t, taskDetailDiscussionEntries(detail.Detail))
	require.Len(t, taskDetailDiscussionEntries(detail.Detail)[0].Attachments, 1)
	require.Equal(t, uploaded.ID, taskDetailDiscussionEntries(detail.Detail)[0].Attachments[0].ID)

	var claimedMessageID string
	var claimedScopeID string
	require.NoError(t, runtime.Pool.QueryRow(runtime.Ctx, `
SELECT COALESCE(message_id, ''), scope_id
FROM attachments
WHERE id = $1
`, uploaded.ID).Scan(&claimedMessageID, &claimedScopeID))
	require.Equal(t, posted.Entry.ID, claimedMessageID)
	require.Equal(t, created.Tasks[0].TaskScopeID, claimedScopeID)

	response = DoJSON(t, client, http.MethodPost, server.URL+WorkspaceScopeMessagesPath(workspaceID, channelID), map[string]any{
		"scopeId":       channelID,
		"content":       "task-claimed attachment must not be claimable by a message",
		"attachmentIds": []string{uploaded.ID},
	}, WorkspaceHeaders(auth.AccessToken, workspaceID), nil)
	require.Equal(t, http.StatusNotFound, response.StatusCode)
	require.NoError(t, runtime.Pool.QueryRow(runtime.Ctx, `
SELECT COALESCE(message_id, ''), scope_id
FROM attachments
WHERE id = $1
`, uploaded.ID).Scan(&claimedMessageID, &claimedScopeID))
	require.Equal(t, posted.Entry.ID, claimedMessageID)
	require.Equal(t, created.Tasks[0].TaskScopeID, claimedScopeID)

	downloadRequest, err := http.NewRequestWithContext(context.Background(), http.MethodGet, server.URL+"/api/v1/workspaces/"+workspaceID+"/attachments/"+uploaded.ID, nil)
	require.NoError(t, err)
	downloadRequest.Header.Set("Authorization", fmt.Sprintf("Bearer %s", auth.AccessToken))
	downloadRequest.Header.Set("X-Workspace-Id", workspaceID)
	downloadResponse, err := client.Do(downloadRequest)
	require.NoError(t, err)
	defer downloadResponse.Body.Close()
	require.Equal(t, http.StatusOK, downloadResponse.StatusCode)
	downloadedBody, err := io.ReadAll(downloadResponse.Body)
	require.NoError(t, err)
	require.Equal(t, []byte("task attachment body"), downloadedBody)

	attachmentOnly := uploadTaskTestAttachment(t, client, server.URL, auth.AccessToken, workspaceID, channelID, "screenshot.png", []byte("task attachment only body"))
	attachmentOnlyPosted := postTaskScopeDiscussionMessage(t, client, server.URL, auth.AccessToken, workspaceID, taskID, "", []string{attachmentOnly.ID})
	require.Empty(t, attachmentOnlyPosted.Entry.Body)
	require.Len(t, attachmentOnlyPosted.Entry.Attachments, 1)
	require.Equal(t, attachmentOnly.ID, attachmentOnlyPosted.Entry.Attachments[0].ID)

	var attachmentOnlyReaction dto.AppMessageReactionMutationResponse
	response = DoJSON(t, client, http.MethodPut, server.URL+WorkspaceMessageReactionPath(workspaceID, attachmentOnlyPosted.Task.TaskScopeID, attachmentOnlyPosted.Entry.ID, "👍"), nil, WorkspaceHeaders(auth.AccessToken, workspaceID), &attachmentOnlyReaction)
	require.Equal(t, http.StatusOK, response.StatusCode)
	require.True(t, attachmentOnlyReaction.Changed)
	require.Len(t, attachmentOnlyReaction.Reactions, 1)
}

func TestTaskDetailDescriptionAttachmentCanBeDownloaded(t *testing.T) {
	t.Parallel()

	runtime := StartRuntime(t)

	router := gin.New()
	runtime.RegisterRoutes(router)
	server := httptest.NewServer(router)
	defer server.Close()

	client := server.Client()
	auth, workspaceID, channelID := SetupTaskHTTPActor(t, client, server.URL, "task-detail-attachment@example.com", "task-detail-attachment")

	var created taskboard.TaskPage
	response := DoJSON(t, client, http.MethodPost, server.URL+"/api/v1/workspaces/"+workspaceID+"/channels/"+channelID+"/tasks", map[string]any{
		"tasks": []map[string]any{{"title": "Attach evidence to task description"}},
	}, WorkspaceHeaders(auth.AccessToken, workspaceID), &created)
	require.Equal(t, http.StatusOK, response.StatusCode)
	require.Len(t, created.Tasks, 1)
	taskID := created.Tasks[0].ID

	uploaded := uploadTaskTestAttachment(t, client, server.URL, auth.AccessToken, workspaceID, channelID, "detail.png", []byte("task detail attachment body"))
	description := "Evidence attached in the task description.\n\n![detail.png](<attachment://" + uploaded.ID + ">)"
	response = DoJSON(t, client, http.MethodPatch, server.URL+"/api/v1/workspaces/"+workspaceID+"/tasks/"+taskID, map[string]any{
		"description":   description,
		"attachmentIds": []string{uploaded.ID},
	}, WorkspaceHeaders(auth.AccessToken, workspaceID), nil)
	require.Equal(t, http.StatusOK, response.StatusCode)

	var detail dto.TaskDetailResponse
	response = DoJSON(t, client, http.MethodGet, server.URL+"/api/v1/workspaces/"+workspaceID+"/tasks/"+taskID, nil, WorkspaceHeaders(auth.AccessToken, workspaceID), &detail)
	require.Equal(t, http.StatusOK, response.StatusCode)
	require.Equal(t, description, detail.Detail.Task.Description)
	require.Empty(t, taskDetailDiscussionEntries(detail.Detail))

	var claimedMessageID string
	var claimedScopeID string
	var claimedMessageType string
	require.NoError(t, runtime.Pool.QueryRow(runtime.Ctx, `
SELECT COALESCE(a.message_id, ''), a.scope_id, COALESCE(sm.message_type, '')
FROM attachments a
LEFT JOIN scope_messages sm
  ON sm.id = a.message_id
 AND sm.scope_id = a.scope_id
WHERE a.id = $1
`, uploaded.ID).Scan(&claimedMessageID, &claimedScopeID, &claimedMessageType))
	require.NotEmpty(t, claimedMessageID)
	require.Equal(t, channelID, claimedScopeID)
	require.Equal(t, message.MessageTypeTaskDescriptionAttachment, claimedMessageType)

	downloadRequest, err := http.NewRequestWithContext(context.Background(), http.MethodGet, server.URL+"/api/v1/workspaces/"+workspaceID+"/attachments/"+uploaded.ID, nil)
	require.NoError(t, err)
	downloadRequest.Header.Set("Authorization", fmt.Sprintf("Bearer %s", auth.AccessToken))
	downloadRequest.Header.Set("X-Workspace-Id", workspaceID)
	downloadResponse, err := client.Do(downloadRequest)
	require.NoError(t, err)
	defer downloadResponse.Body.Close()
	require.Equal(t, http.StatusOK, downloadResponse.StatusCode)
	downloadedBody, err := io.ReadAll(downloadResponse.Body)
	require.NoError(t, err)
	require.Equal(t, []byte("task detail attachment body"), downloadedBody)
}

func TestTaskDiscussionCannotClaimMessageAttachment(t *testing.T) {
	t.Parallel()

	runtime := StartRuntime(t)

	router := gin.New()
	runtime.RegisterRoutes(router)
	server := httptest.NewServer(router)
	defer server.Close()

	client := server.Client()
	owner, workspaceID, sourceChannelID := SetupTaskHTTPActor(t, client, server.URL, "task-discussion-dual-owner@example.com", "task-discussion-dual-owner")
	var taskChannel ChannelResponse
	response := DoJSON(t, client, http.MethodPost, server.URL+"/api/v1/workspaces/"+workspaceID+"/channels", map[string]any{
		"name":        "task-discussion-dual-task",
		"description": "private task channel",
	}, WorkspaceHeaders(owner.AccessToken, workspaceID), &taskChannel)
	require.Equal(t, http.StatusOK, response.StatusCode)

	var created taskboard.TaskPage
	response = DoJSON(t, client, http.MethodPost, server.URL+"/api/v1/workspaces/"+workspaceID+"/channels/"+taskChannel.ID+"/tasks", map[string]any{
		"tasks": []map[string]any{{"title": "Dual claim attachment"}},
	}, WorkspaceHeaders(owner.AccessToken, workspaceID), &created)
	require.Equal(t, http.StatusOK, response.StatusCode)
	require.Len(t, created.Tasks, 1)
	taskID := created.Tasks[0].ID
	_, err := runtime.Pool.Exec(runtime.Ctx, `
UPDATE tasks
SET source_thread_channel_id = $2
WHERE id = $1
`, taskID, sourceChannelID)
	require.NoError(t, err)

	uploaded := uploadTaskTestAttachment(t, client, server.URL, owner.AccessToken, workspaceID, sourceChannelID, "dual.log", []byte("dual attachment body"))

	var posted message.AppMessage
	response = DoJSON(t, client, http.MethodPost, server.URL+WorkspaceScopeMessagesPath(workspaceID, sourceChannelID), map[string]any{
		"scopeId":       sourceChannelID,
		"content":       "dual attachment thread message",
		"attachmentIds": []string{uploaded.ID},
	}, WorkspaceHeaders(owner.AccessToken, workspaceID), &posted)
	require.Equal(t, http.StatusOK, response.StatusCode)
	require.Equal(t, sourceChannelID, posted.ChannelID)

	response = DoJSON(t, client, http.MethodPost, server.URL+WorkspaceScopeMessagesPath(workspaceID, created.Tasks[0].TaskScopeID), map[string]any{
		"scopeId":       created.Tasks[0].TaskScopeID,
		"content":       "Dual-send attachment\n\n[dual.log](<attachment://" + uploaded.ID + ">)",
		"attachmentIds": []string{uploaded.ID},
	}, WorkspaceHeaders(owner.AccessToken, workspaceID), nil)
	require.Equal(t, http.StatusNotFound, response.StatusCode)

	var messageID string
	require.NoError(t, runtime.Pool.QueryRow(runtime.Ctx, `
SELECT COALESCE(message_id, '')
FROM attachments
WHERE id = $1
`, uploaded.ID).Scan(&messageID))
	require.Equal(t, posted.ID, messageID)
}

func uploadTaskTestAttachment(t *testing.T, client *http.Client, baseURL string, accessToken string, workspaceID string, scopeID string, filename string, content []byte) attachment.AppAttachment {
	t.Helper()

	var multipartBody bytes.Buffer
	writer := multipart.NewWriter(&multipartBody)
	require.NoError(t, writer.WriteField("scopeId", scopeID))
	part, err := writer.CreateFormFile("files", filename)
	require.NoError(t, err)
	_, err = part.Write(content)
	require.NoError(t, err)
	require.NoError(t, writer.Close())

	request, err := http.NewRequestWithContext(context.Background(), http.MethodPost, baseURL+"/api/v1/workspaces/"+workspaceID+"/attachments", &multipartBody)
	require.NoError(t, err)
	request.Header.Set("Content-Type", writer.FormDataContentType())
	request.Header.Set("Authorization", fmt.Sprintf("Bearer %s", accessToken))
	request.Header.Set("X-Workspace-Id", workspaceID)

	response, err := client.Do(request)
	require.NoError(t, err)
	defer response.Body.Close()
	require.Equal(t, http.StatusOK, response.StatusCode)

	var uploaded struct {
		Attachments []attachment.AppAttachment `json:"attachments"`
	}
	require.NoError(t, json.NewDecoder(response.Body).Decode(&uploaded))
	require.Len(t, uploaded.Attachments, 1)
	return uploaded.Attachments[0]
}

func TestTaskDiscussionDispatch(t *testing.T) {
	t.Parallel()

	runtime := StartRuntime(t)

	router := gin.New()
	runtime.RegisterRoutes(router)
	server := httptest.NewServer(router)
	defer server.Close()

	client := server.Client()
	auth, workspaceID, channelID := SetupTaskHTTPActor(t, client, server.URL, "task-discussion-dispatch@example.com", "task-discussion-dispatch")

	var machineResponse dto.CreateMachineResponse
	response := DoJSON(t, client, http.MethodPost, server.URL+"/api/v1/workspaces/"+workspaceID+"/machines", map[string]any{
		"name": "task-discussion-machine",
	}, AuthHeaders(auth.AccessToken), &machineResponse)
	require.Equal(t, http.StatusOK, response.StatusCode)
	require.NotEmpty(t, machineResponse.Machine.ID)

	var alphaAgent agent.AppAgent
	response = DoJSON(t, client, http.MethodPost, server.URL+"/api/v1/workspaces/"+workspaceID+"/agents", map[string]any{
		"name":      "alpha-agent",
		"runtime":   "codex",
		"machineId": machineResponse.Machine.ID,
	}, WorkspaceHeaders(auth.AccessToken, workspaceID), &alphaAgent)
	require.Equal(t, http.StatusOK, response.StatusCode)
	require.NotEmpty(t, alphaAgent.ID)

	var betaAgent agent.AppAgent
	response = DoJSON(t, client, http.MethodPost, server.URL+"/api/v1/workspaces/"+workspaceID+"/agents", map[string]any{
		"name":      "beta-agent",
		"runtime":   "codex",
		"machineId": machineResponse.Machine.ID,
	}, WorkspaceHeaders(auth.AccessToken, workspaceID), &betaAgent)
	require.Equal(t, http.StatusOK, response.StatusCode)
	require.NotEmpty(t, betaAgent.ID)

	var created taskboard.TaskPage
	response = DoJSON(t, client, http.MethodPost, server.URL+"/api/v1/workspaces/"+workspaceID+"/channels/"+channelID+"/tasks", map[string]any{
		"tasks": []map[string]any{{"title": "Continue from task discussion"}},
	}, WorkspaceHeaders(auth.AccessToken, workspaceID), &created)
	require.Equal(t, http.StatusOK, response.StatusCode)
	require.Len(t, created.Tasks, 1)
	taskID := created.Tasks[0].ID
	posted := postTaskScopeDiscussionMessage(t, client, server.URL, auth.AccessToken, workspaceID, taskID, "@alpha-agent continue this task from the task discussion.", nil)
	require.Equal(t, int64(1), posted.Task.DiscussionCount)

	var channelPage message.AppMessagePage
	response = DoJSON(t, client, http.MethodGet, server.URL+WorkspaceScopeMessagesPathWithLimit(workspaceID, channelID, 50), nil, WorkspaceHeaders(auth.AccessToken, workspaceID), &channelPage)
	require.Equal(t, http.StatusOK, response.StatusCode)
	require.Empty(t, channelPage.Messages)

	var alphaMemberID string
	require.NoError(t, runtime.Pool.QueryRow(runtime.Ctx, `SELECT member_id FROM agents WHERE id = $1`, alphaAgent.ID).Scan(&alphaMemberID))
	var betaMemberID string
	require.NoError(t, runtime.Pool.QueryRow(runtime.Ctx, `SELECT member_id FROM agents WHERE id = $1`, betaAgent.ID).Scan(&betaMemberID))
	require.NotEmpty(t, posted.Task.TaskScopeID)

	var channelTaskDispatchRows int
	require.NoError(t, runtime.Pool.QueryRow(runtime.Ctx, `
SELECT COUNT(*)
FROM scope_messages
WHERE workspace_id = $1
  AND scope_id = $2
  AND message_type = 'task_dispatch'
`, workspaceID, channelID).Scan(&channelTaskDispatchRows))
	require.Equal(t, 0, channelTaskDispatchRows, "task discussion delivery must not create a hidden source-channel task_dispatch carrier")

	var taskDiscussionRows int
	require.NoError(t, runtime.Pool.QueryRow(runtime.Ctx, `
SELECT COUNT(*)
FROM scope_messages
WHERE workspace_id = $1
  AND scope_id = $2
  AND id = $3
  AND message_type = 'task_discussion'
`, workspaceID, posted.Task.TaskScopeID, posted.Entry.ID).Scan(&taskDiscussionRows))
	require.Equal(t, 1, taskDiscussionRows)

	var taskScopeMembershipRows int
	require.NoError(t, runtime.Pool.QueryRow(runtime.Ctx, `
SELECT COUNT(*)
FROM scope_memberships
WHERE scope_id = $1
  AND member_id = $2
  AND visibility = 'public'
  AND effective_from_seq <= 1
  AND effective_to_seq IS NULL
  AND removed_at IS NULL
`, posted.Task.TaskScopeID, alphaMemberID).Scan(&taskScopeMembershipRows))
	require.Equal(t, 1, taskScopeMembershipRows)

	var taskScopeNoWakeRows int
	require.NoError(t, runtime.Pool.QueryRow(runtime.Ctx, `
SELECT COUNT(*)
FROM scope_notification_settings
WHERE scope_id = $1
  AND member_id = $2
  AND state = 'no-wake'
`, posted.Task.TaskScopeID, alphaMemberID).Scan(&taskScopeNoWakeRows))
	require.Equal(t, 1, taskScopeNoWakeRows, "explicit task discussion mentions join the task scope but keep quiet by default")

	var taskScopeBoundedAutoWakeRows int
	require.NoError(t, runtime.Pool.QueryRow(runtime.Ctx, `
SELECT COUNT(*)
FROM scope_notification_settings
WHERE scope_id = $1
  AND member_id = $2
  AND state = 'auto-wake'
`, posted.Task.TaskScopeID, alphaMemberID).Scan(&taskScopeBoundedAutoWakeRows))
	require.Equal(t, 0, taskScopeBoundedAutoWakeRows)

	alphaMessages, err := runtime.Store.ReceiveAgentMessages(runtime.Ctx, agent.AuthenticatedAgent{
		AgentID:     alphaAgent.ID,
		WorkspaceID: workspaceID,
		MemberID:    alphaMemberID,
	}, 10)
	require.NoError(t, err)
	require.Len(t, alphaMessages, 1)
	require.Equal(t, message.MessageTypeTaskDiscussion, alphaMessages[0].MessageType)
	require.Empty(t, alphaMessages[0].Metadata)

	betaMessages, err := runtime.Store.ReceiveAgentMessages(runtime.Ctx, agent.AuthenticatedAgent{
		AgentID:     betaAgent.ID,
		WorkspaceID: workspaceID,
		MemberID:    betaMemberID,
	}, 10)
	require.NoError(t, err)
	require.Empty(t, betaMessages)
}

func TestTaskDiscussionDispatchWakeSurvivesThreadReadStateAdvance(t *testing.T) {
	t.Parallel()

	runtime := StartRuntime(t)

	router := gin.New()
	runtime.RegisterRoutes(router)
	server := httptest.NewServer(router)
	defer server.Close()

	client := server.Client()
	auth, workspaceID, channelID := SetupTaskHTTPActor(t, client, server.URL, "task-discussion-read-state-wake@example.com", "task-discussion-read-state-wake")

	var machineResponse dto.CreateMachineResponse
	response := DoJSON(t, client, http.MethodPost, server.URL+"/api/v1/workspaces/"+workspaceID+"/machines", map[string]any{
		"name": "task-discussion-read-state-machine",
	}, AuthHeaders(auth.AccessToken), &machineResponse)
	require.Equal(t, http.StatusOK, response.StatusCode)

	var alphaAgent agent.AppAgent
	response = DoJSON(t, client, http.MethodPost, server.URL+"/api/v1/workspaces/"+workspaceID+"/agents", map[string]any{
		"name":      "read-state-wake-agent",
		"runtime":   "codex",
		"machineId": machineResponse.Machine.ID,
	}, WorkspaceHeaders(auth.AccessToken, workspaceID), &alphaAgent)
	require.Equal(t, http.StatusOK, response.StatusCode)

	var alphaMemberID string
	require.NoError(t, runtime.Pool.QueryRow(runtime.Ctx, `SELECT member_id FROM agents WHERE id = $1`, alphaAgent.ID).Scan(&alphaMemberID))

	var created taskboard.TaskPage
	response = DoJSON(t, client, http.MethodPost, server.URL+"/api/v1/workspaces/"+workspaceID+"/channels/"+channelID+"/tasks", map[string]any{
		"tasks": []map[string]any{{"title": "Task dispatch remains pending after thread read state advances"}},
	}, WorkspaceHeaders(auth.AccessToken, workspaceID), &created)
	require.Equal(t, http.StatusOK, response.StatusCode)
	require.Len(t, created.Tasks, 1)
	taskID := created.Tasks[0].ID
	postTaskScopeDiscussionMessage(t, client, server.URL, auth.AccessToken, workspaceID, taskID, "@read-state-wake-agent continue this task after the board cursor moves.", nil)

	// Advancing the source channel cursor must not consume task-scope discussion delivery.
	require.NoError(t, message.WriteThreadReadStateByGlobalSeq(runtime.Ctx, runtime.Pool, alphaMemberID, channelID, 100, 100, time.Now().UTC()))

	hasUnread, err := runtime.Store.AgentHasUnreadMessages(runtime.Ctx, agent.AuthenticatedAgent{
		AgentID:     alphaAgent.ID,
		WorkspaceID: workspaceID,
		MemberID:    alphaMemberID,
	})
	require.NoError(t, err)
	require.True(t, hasUnread)

	alphaMessages, err := runtime.Store.ReceiveAgentMessages(runtime.Ctx, agent.AuthenticatedAgent{
		AgentID:     alphaAgent.ID,
		WorkspaceID: workspaceID,
		MemberID:    alphaMemberID,
	}, 10)
	require.NoError(t, err)
	require.Len(t, alphaMessages, 1)
	require.Equal(t, message.MessageTypeTaskDiscussion, alphaMessages[0].MessageType)
}

func TestTaskDiscussionDispatchIncludesBoundedSourceThreadContext(t *testing.T) {
	t.Parallel()

	runtime := StartRuntime(t)

	router := gin.New()
	runtime.RegisterRoutes(router)
	server := httptest.NewServer(router)
	defer server.Close()

	client := server.Client()
	auth, workspaceID, channelID := SetupTaskHTTPActor(t, client, server.URL, "task-source-thread-context@example.com", "task-source-thread-context")

	var parentMessage message.AppMessage
	response := DoJSON(t, client, http.MethodPost, server.URL+WorkspaceScopeMessagesPath(workspaceID, channelID), map[string]any{
		"scopeId": channelID,
		"content": "parent source-thread context for task agent",
	}, WorkspaceHeaders(auth.AccessToken, workspaceID), &parentMessage)
	require.Equal(t, http.StatusOK, response.StatusCode)
	require.NotEmpty(t, parentMessage.ID)

	var threadSummary message.ReplyThreadSummary
	response = DoJSON(t, client, http.MethodPost, server.URL+"/api/v1/workspaces/"+workspaceID+"/channels/"+channelID+"/threads", map[string]any{
		"parentMessageId": parentMessage.ID,
	}, WorkspaceHeaders(auth.AccessToken, workspaceID), &threadSummary)
	require.Equal(t, http.StatusOK, response.StatusCode)
	require.NotEmpty(t, threadSummary.ThreadChannelID)

	var beforeTaskMessage message.AppMessage
	response = DoJSON(t, client, http.MethodPost, server.URL+WorkspaceScopeMessagesPath(workspaceID, threadSummary.ThreadChannelID), map[string]any{
		"scopeId": threadSummary.ThreadChannelID,
		"content": "thread clue before the task card",
	}, WorkspaceHeaders(auth.AccessToken, workspaceID), &beforeTaskMessage)
	require.Equal(t, http.StatusOK, response.StatusCode)

	created, err := runtime.TaskBoard.CreateTasks(runtime.Ctx, taskboard.CreateTasksRequest{
		Context: taskboard.NewUserServiceContext(workspaceID, auth.User.ID, auth.User.Name),
		Board:   taskboard.BoardRef{ChannelID: channelID},
		Titles:  []string{"follow source thread context"},
		Metadata: &taskboard.CreateTaskMetadata{
			SourceThreadChannelID:       threadSummary.ThreadChannelID,
			SourceThreadParentChannelID: channelID,
			SourceThreadParentMessageID: parentMessage.ID,
			SourceThreadRef:             "#source-thread-context",
		},
	})
	require.NoError(t, err)
	require.Len(t, created.Tasks, 1)
	var afterTaskMessage message.AppMessage
	response = DoJSON(t, client, http.MethodPost, server.URL+WorkspaceScopeMessagesPath(workspaceID, threadSummary.ThreadChannelID), map[string]any{
		"scopeId": threadSummary.ThreadChannelID,
		"content": "thread clue after the task card",
	}, WorkspaceHeaders(auth.AccessToken, workspaceID), &afterTaskMessage)
	require.Equal(t, http.StatusOK, response.StatusCode)

	var machineResponse dto.CreateMachineResponse
	response = DoJSON(t, client, http.MethodPost, server.URL+"/api/v1/workspaces/"+workspaceID+"/machines", map[string]any{
		"name": "task-source-thread-context-machine",
	}, AuthHeaders(auth.AccessToken), &machineResponse)
	require.Equal(t, http.StatusOK, response.StatusCode)
	require.NotEmpty(t, machineResponse.Machine.ID)

	var alphaAgent agent.AppAgent
	response = DoJSON(t, client, http.MethodPost, server.URL+"/api/v1/workspaces/"+workspaceID+"/agents", map[string]any{
		"name":      "source-thread-context-agent",
		"runtime":   "codex",
		"machineId": machineResponse.Machine.ID,
	}, WorkspaceHeaders(auth.AccessToken, workspaceID), &alphaAgent)
	require.Equal(t, http.StatusOK, response.StatusCode)
	require.NotEmpty(t, alphaAgent.ID)

	var alphaMemberID string
	require.NoError(t, runtime.Pool.QueryRow(runtime.Ctx, `SELECT member_id FROM agents WHERE id = $1`, alphaAgent.ID).Scan(&alphaMemberID))

	tasks := listAllChannelTasksForAPITest(
		t, client, server.URL, workspaceID, channelID, false, WorkspaceHeaders(auth.AccessToken, workspaceID),
	)
	require.Len(t, tasks, 1)
	taskID := tasks[0].ID
	require.NotNil(t, tasks[0].SourceThread)
	require.Equal(t, threadSummary.ThreadChannelID, tasks[0].SourceThread.ThreadChannelID)

	var contextSourceCount int
	require.NoError(t, runtime.Pool.QueryRow(runtime.Ctx, `
SELECT COUNT(*)
FROM tasks t
JOIN scope_message_sources refs
  ON refs.target_scope_id = t.task_scope_id
 AND refs.source_kind = 'share'
WHERE t.id = $1
`, taskID).Scan(&contextSourceCount))
	require.Equal(t, 0, contextSourceCount)
	posted := postTaskScopeDiscussionMessage(t, client, server.URL, auth.AccessToken, workspaceID, taskID, "@source-thread-context-agent use the source thread clues.", nil)
	require.Equal(t, int64(1), posted.Task.DiscussionCount)

	alphaMessages, err := runtime.Store.ReceiveAgentMessages(runtime.Ctx, agent.AuthenticatedAgent{
		AgentID:     alphaAgent.ID,
		WorkspaceID: workspaceID,
		MemberID:    alphaMemberID,
	}, 10)
	require.NoError(t, err)
	require.Len(t, alphaMessages, 1)
	require.Equal(t, message.MessageTypeTaskDiscussion, alphaMessages[0].MessageType)

	require.Empty(t, alphaMessages[0].Metadata)

	require.NoError(t, runtime.Pool.QueryRow(runtime.Ctx, `
SELECT COUNT(*)
FROM tasks t
JOIN scope_message_sources refs
  ON refs.target_scope_id = t.task_scope_id
 AND refs.source_kind = 'share'
WHERE t.id = $1
`, taskID).Scan(&contextSourceCount))
	require.Equal(t, 0, contextSourceCount, "sourceThreadContext must remain ephemeral and not write task scope context sources")
}

func TestSubtaskDiscussionDispatchUsesSubtaskScopeDelivery(t *testing.T) {
	t.Parallel()

	runtime := StartRuntime(t)

	router := gin.New()
	runtime.RegisterRoutes(router)
	server := httptest.NewServer(router)
	defer server.Close()

	client := server.Client()
	auth, workspaceID, channelID := SetupTaskHTTPActor(t, client, server.URL, "subtask-source-thread@example.com", "subtask-source-thread")

	var parentMessage message.AppMessage
	response := DoJSON(t, client, http.MethodPost, server.URL+WorkspaceScopeMessagesPath(workspaceID, channelID), map[string]any{
		"scopeId": channelID,
		"content": "root parent source context for subtask dispatch",
	}, WorkspaceHeaders(auth.AccessToken, workspaceID), &parentMessage)
	require.Equal(t, http.StatusOK, response.StatusCode)

	var threadSummary message.ReplyThreadSummary
	response = DoJSON(t, client, http.MethodPost, server.URL+"/api/v1/workspaces/"+workspaceID+"/channels/"+channelID+"/threads", map[string]any{
		"parentMessageId": parentMessage.ID,
	}, WorkspaceHeaders(auth.AccessToken, workspaceID), &threadSummary)
	require.Equal(t, http.StatusOK, response.StatusCode)
	require.NotEmpty(t, threadSummary.ThreadChannelID)

	var threadMessage message.AppMessage
	response = DoJSON(t, client, http.MethodPost, server.URL+WorkspaceScopeMessagesPath(workspaceID, threadSummary.ThreadChannelID), map[string]any{
		"scopeId": threadSummary.ThreadChannelID,
		"content": "root thread clue stays on the root source thread",
	}, WorkspaceHeaders(auth.AccessToken, workspaceID), &threadMessage)
	require.Equal(t, http.StatusOK, response.StatusCode)

	parentCreated, err := runtime.TaskBoard.CreateTasks(runtime.Ctx, taskboard.CreateTasksRequest{
		Context: taskboard.NewUserServiceContext(workspaceID, auth.User.ID, auth.User.Name),
		Board:   taskboard.BoardRef{ChannelID: channelID},
		Titles:  []string{"Root source-thread task"},
		Metadata: &taskboard.CreateTaskMetadata{
			SourceThreadChannelID:       threadSummary.ThreadChannelID,
			SourceThreadParentChannelID: channelID,
			SourceThreadParentMessageID: parentMessage.ID,
			SourceThreadRef:             "#root-source-thread",
		},
	})
	require.NoError(t, err)
	require.Len(t, parentCreated.Tasks, 1)
	require.NotEmpty(t, parentCreated.Tasks[0].TaskScopeID)
	require.NotNil(t, parentCreated.Tasks[0].SourceThread)

	subtasks, err := runtime.TaskBoard.CreateSubtasks(runtime.Ctx, taskboard.CreateSubtasksRequest{
		Context: taskboard.NewUserServiceContext(workspaceID, auth.User.ID, auth.User.Name),
		Task:    taskboard.TaskRef{TaskID: parentCreated.Tasks[0].ID},
		Titles:  []string{"Child task that receives task-scope discussion"},
	})
	require.NoError(t, err)
	require.Len(t, subtasks.Tasks, 1)
	require.Equal(t, parentCreated.Tasks[0].ID, subtasks.Tasks[0].ParentTaskRef)
	require.NotEmpty(t, subtasks.Tasks[0].TaskScopeID)
	require.NotEqual(t, parentCreated.Tasks[0].TaskScopeID, subtasks.Tasks[0].TaskScopeID)
	require.Nil(t, subtasks.Tasks[0].SourceThread)

	var machineResponse dto.CreateMachineResponse
	response = DoJSON(t, client, http.MethodPost, server.URL+"/api/v1/workspaces/"+workspaceID+"/machines", map[string]any{
		"name": "subtask-source-thread-machine",
	}, AuthHeaders(auth.AccessToken), &machineResponse)
	require.Equal(t, http.StatusOK, response.StatusCode)

	var alphaAgent agent.AppAgent
	response = DoJSON(t, client, http.MethodPost, server.URL+"/api/v1/workspaces/"+workspaceID+"/agents", map[string]any{
		"name":      "subtask-source-agent",
		"runtime":   "codex",
		"machineId": machineResponse.Machine.ID,
	}, WorkspaceHeaders(auth.AccessToken, workspaceID), &alphaAgent)
	require.Equal(t, http.StatusOK, response.StatusCode)

	var alphaMemberID string
	require.NoError(t, runtime.Pool.QueryRow(runtime.Ctx, `SELECT member_id FROM agents WHERE id = $1`, alphaAgent.ID).Scan(&alphaMemberID))
	posted := postTaskScopeDiscussionMessage(t, client, server.URL, auth.AccessToken, workspaceID, subtasks.Tasks[0].ID, "@subtask-source-agent continue in the child task scope.", nil)
	require.Equal(t, subtasks.Tasks[0].ID, posted.Task.ID)
	require.Equal(t, subtasks.Tasks[0].TaskScopeID, posted.Task.TaskScopeID)

	alphaMessages, err := runtime.Store.ReceiveAgentMessages(runtime.Ctx, agent.AuthenticatedAgent{
		AgentID:     alphaAgent.ID,
		WorkspaceID: workspaceID,
		MemberID:    alphaMemberID,
	}, 10)
	require.NoError(t, err)
	require.Len(t, alphaMessages, 1)
	require.Equal(t, message.MessageTypeTaskDiscussion, alphaMessages[0].MessageType)
	require.Equal(t, "task", alphaMessages[0].ChannelType)
	childTaskScopeNames := []string{
		posted.Task.TaskScopeID,
		"scope/task/" + posted.Task.TaskScopeID,
	}
	parentTaskScopeNames := []string{
		parentCreated.Tasks[0].TaskScopeID,
		"scope/task/" + parentCreated.Tasks[0].TaskScopeID,
	}
	require.Contains(t, childTaskScopeNames, alphaMessages[0].ChannelName)
	require.NotContains(t, parentTaskScopeNames, alphaMessages[0].ChannelName)
	require.Empty(t, alphaMessages[0].Metadata)

	var contextSourceCount int
	require.NoError(t, runtime.Pool.QueryRow(runtime.Ctx, `
SELECT COUNT(*)
FROM scope_message_sources
WHERE target_scope_id = $1
  AND source_kind = 'share'
`, posted.Task.TaskScopeID).Scan(&contextSourceCount))
	require.Equal(t, 0, contextSourceCount, "subtask discussion dispatch must stay ephemeral and not copy root source-thread context into the subtask scope")
}

func TestTaskDiscussionDispatchSourceThreadContextIncludesPeopleCollaboratorMessage(t *testing.T) {
	t.Parallel()

	runtime := StartRuntime(t)

	router := gin.New()
	runtime.RegisterRoutes(router)
	server := httptest.NewServer(router)
	defer server.Close()

	client := server.Client()
	owner, workspaceID, _ := SetupTaskHTTPActor(t, client, server.URL, "task-source-thread-people-owner@example.com", "task-source-thread-people-owner")
	collaborator := AcceptTaskHTTPInvite(t, runtime, client, server.URL, owner.User.ID, workspaceID, "task-source-thread-people-collab@example.com", "task-source-thread-people-collab")
	collaboratorMemberID := TaskWorkspaceMemberIDForUser(t, runtime, workspaceID, collaborator.User.ID)

	var scopeChannel ChannelResponse
	response := DoJSON(t, client, http.MethodPost, server.URL+"/api/v1/workspaces/"+workspaceID+"/channels", map[string]any{
		"name":        "task-source-thread-people",
		"description": "task source thread people collaborator context",
	}, WorkspaceHeaders(owner.AccessToken, workspaceID), &scopeChannel)
	require.Equal(t, http.StatusOK, response.StatusCode)
	require.NotEmpty(t, scopeChannel.ID)

	response = DoJSON(t, client, http.MethodPost, server.URL+"/api/v1/workspaces/"+workspaceID+"/channels/"+scopeChannel.ID+"/members", map[string]any{
		"userId": collaborator.User.ID,
	}, WorkspaceHeaders(owner.AccessToken, workspaceID), nil)
	require.Equal(t, http.StatusOK, response.StatusCode)

	var machineResponse dto.CreateMachineResponse
	response = DoJSON(t, client, http.MethodPost, server.URL+"/api/v1/workspaces/"+workspaceID+"/machines", map[string]any{
		"name": "task-source-thread-people-machine",
	}, AuthHeaders(owner.AccessToken), &machineResponse)
	require.Equal(t, http.StatusOK, response.StatusCode)

	var alphaAgent agent.AppAgent
	response = DoJSON(t, client, http.MethodPost, server.URL+"/api/v1/workspaces/"+workspaceID+"/agents", map[string]any{
		"name":      "source-thread-people-agent",
		"runtime":   "codex",
		"machineId": machineResponse.Machine.ID,
	}, WorkspaceHeaders(owner.AccessToken, workspaceID), &alphaAgent)
	require.Equal(t, http.StatusOK, response.StatusCode)

	response = DoJSON(t, client, http.MethodPost, server.URL+"/api/v1/workspaces/"+workspaceID+"/channels/"+scopeChannel.ID+"/members", map[string]any{
		"agentId":    alphaAgent.ID,
		"visibility": "private",
	}, WorkspaceHeaders(owner.AccessToken, workspaceID), nil)
	require.Equal(t, http.StatusOK, response.StatusCode)

	var alphaMemberID string
	require.NoError(t, runtime.Pool.QueryRow(runtime.Ctx, `SELECT member_id FROM agents WHERE id = $1`, alphaAgent.ID).Scan(&alphaMemberID))
	var activeScopeMemberships int
	require.NoError(t, runtime.Pool.QueryRow(runtime.Ctx, `
SELECT COUNT(*)
FROM scope_memberships
WHERE scope_id = $1
  AND member_id = $2
  AND visibility = 'private'
  AND removed_at IS NULL
  AND archived_at IS NULL
  AND effective_to_seq IS NULL
`, scopeChannel.ID, alphaMemberID).Scan(&activeScopeMemberships))
	require.Equal(t, 1, activeScopeMemberships)

	var parentMessage message.AppMessage
	response = DoJSON(t, client, http.MethodPost, server.URL+WorkspaceScopeMessagesPath(workspaceID, scopeChannel.ID), map[string]any{
		"scopeId": scopeChannel.ID,
		"content": "source thread people parent",
	}, WorkspaceHeaders(owner.AccessToken, workspaceID), &parentMessage)
	require.Equal(t, http.StatusOK, response.StatusCode)

	var threadSummary message.ReplyThreadSummary
	response = DoJSON(t, client, http.MethodPost, server.URL+"/api/v1/workspaces/"+workspaceID+"/channels/"+scopeChannel.ID+"/threads", map[string]any{
		"parentMessageId": parentMessage.ID,
	}, WorkspaceHeaders(owner.AccessToken, workspaceID), &threadSummary)
	require.Equal(t, http.StatusOK, response.StatusCode)
	require.NotEmpty(t, threadSummary.ThreadChannelID)

	var addedPeople taskboard.AddTaskCollaboratorResponse
	response = DoJSON(t, client, http.MethodPost, server.URL+"/api/v1/workspaces/"+workspaceID+"/threads/"+threadSummary.ThreadChannelID+"/task-collaborators", map[string]any{
		"memberId": collaboratorMemberID,
		"role":     "contributor",
	}, WorkspaceHeaders(owner.AccessToken, workspaceID), &addedPeople)
	require.Equal(t, http.StatusOK, response.StatusCode)
	require.Equal(t, collaboratorMemberID, addedPeople.Collaborator.MemberID)
	require.True(t, addedPeople.Collaborator.Active)

	collaboratorContent := "people collaborator source thread clue"
	var collaboratorMessage message.AppMessage
	response = DoJSON(t, client, http.MethodPost, server.URL+WorkspaceScopeMessagesPath(workspaceID, threadSummary.ThreadChannelID), map[string]any{
		"scopeId": threadSummary.ThreadChannelID,
		"content": collaboratorContent,
	}, WorkspaceHeaders(collaborator.AccessToken, workspaceID), &collaboratorMessage)
	require.Equal(t, http.StatusOK, response.StatusCode)

	created, err := runtime.TaskBoard.CreateTasks(runtime.Ctx, taskboard.CreateTasksRequest{
		Context: taskboard.NewUserServiceContext(workspaceID, owner.User.ID, owner.User.Name),
		Board:   taskboard.BoardRef{ChannelID: scopeChannel.ID},
		Titles:  []string{"keep collaborator source context"},
		Metadata: &taskboard.CreateTaskMetadata{
			SourceThreadChannelID:       threadSummary.ThreadChannelID,
			SourceThreadParentChannelID: scopeChannel.ID,
			SourceThreadParentMessageID: parentMessage.ID,
			SourceThreadRef:             "#source-thread-people",
		},
	})
	require.NoError(t, err)
	require.Len(t, created.Tasks, 1)

	tasks := listAllChannelTasksForAPITest(
		t, client, server.URL, workspaceID, scopeChannel.ID, false, WorkspaceHeaders(owner.AccessToken, workspaceID),
	)
	require.Len(t, tasks, 1)
	taskID := tasks[0].ID
	require.NotNil(t, tasks[0].SourceThread)
	require.Equal(t, threadSummary.ThreadChannelID, tasks[0].SourceThread.ThreadChannelID)
	postTaskScopeDiscussionMessage(t, client, server.URL, owner.AccessToken, workspaceID, taskID, "@source-thread-people-agent use the collaborator clue.", nil)

	alphaMessages, err := runtime.Store.ReceiveAgentMessages(runtime.Ctx, agent.AuthenticatedAgent{
		AgentID:     alphaAgent.ID,
		WorkspaceID: workspaceID,
		MemberID:    alphaMemberID,
	}, 10)
	require.NoError(t, err)
	require.Len(t, alphaMessages, 1)
	require.Equal(t, message.MessageTypeTaskDiscussion, alphaMessages[0].MessageType)

	require.Empty(t, alphaMessages[0].Metadata)

	var ordinaryParticipantCount int
	require.NoError(t, runtime.Pool.QueryRow(runtime.Ctx, `
SELECT COUNT(*)
FROM scope_memberships
WHERE scope_id IN ($1, $2)
  AND member_id = $3
  AND visibility = 'public'
  AND removed_at IS NULL
  AND effective_to_seq IS NULL
`, scopeChannel.ID, threadSummary.ThreadChannelID, alphaMemberID).Scan(&ordinaryParticipantCount))
	require.Equal(t, 0, ordinaryParticipantCount)
}

func TestTaskDiscussionCommentWakesUnwatchedAssigneeByResponsibility(t *testing.T) {
	t.Parallel()

	runtime := StartRuntime(t)

	router := gin.New()
	runtime.RegisterRoutes(router)
	server := httptest.NewServer(router)
	defer server.Close()

	client := server.Client()
	auth, workspaceID, channelID := SetupTaskHTTPActor(t, client, server.URL, "task-discussion-default-wake@example.com", "task-discussion-default-wake")

	var machineResponse dto.CreateMachineResponse
	response := DoJSON(t, client, http.MethodPost, server.URL+"/api/v1/workspaces/"+workspaceID+"/machines", map[string]any{
		"name": "task-discussion-default-wake-machine",
	}, AuthHeaders(auth.AccessToken), &machineResponse)
	require.Equal(t, http.StatusOK, response.StatusCode)

	var alphaAgent agent.AppAgent
	response = DoJSON(t, client, http.MethodPost, server.URL+"/api/v1/workspaces/"+workspaceID+"/agents", map[string]any{
		"name":      "default-wake-agent",
		"runtime":   "codex",
		"machineId": machineResponse.Machine.ID,
	}, WorkspaceHeaders(auth.AccessToken, workspaceID), &alphaAgent)
	require.Equal(t, http.StatusOK, response.StatusCode)

	var alphaMemberID string
	require.NoError(t, runtime.Pool.QueryRow(runtime.Ctx, `SELECT member_id FROM agents WHERE id = $1`, alphaAgent.ID).Scan(&alphaMemberID))

	var created taskboard.TaskPage
	response = DoJSON(t, client, http.MethodPost, server.URL+"/api/v1/workspaces/"+workspaceID+"/channels/"+channelID+"/tasks", map[string]any{
		"tasks": []map[string]any{{"title": "Assignee discussion wake"}},
	}, WorkspaceHeaders(auth.AccessToken, workspaceID), &created)
	require.Equal(t, http.StatusOK, response.StatusCode)
	require.Len(t, created.Tasks, 1)
	taskID := created.Tasks[0].ID

	var updated struct {
		Task taskboard.Task `json:"task"`
	}
	response = DoJSON(t, client, http.MethodPatch, server.URL+"/api/v1/workspaces/"+workspaceID+"/tasks/"+taskID, map[string]any{
		"assigneeRef": alphaMemberID,
	}, WorkspaceHeaders(auth.AccessToken, workspaceID), &updated)
	require.Equal(t, http.StatusOK, response.StatusCode)
	require.NotNil(t, updated.Task.AssigneeProfile)
	require.Equal(t, alphaAgent.ID, updated.Task.AssigneeProfile.PrincipalID)

	assignmentMessages, err := runtime.Store.ReceiveAgentMessages(runtime.Ctx, agent.AuthenticatedAgent{
		AgentID:     alphaAgent.ID,
		WorkspaceID: workspaceID,
		MemberID:    alphaMemberID,
	}, 10)
	require.NoError(t, err)
	require.Len(t, assignmentMessages, 1)
	require.Equal(t, "task.assigned", assignmentMessages[0].MessageType)

	posted := postTaskScopeDiscussionMessage(t, client, server.URL, auth.AccessToken, workspaceID, taskID, "Please continue this task without changing ownership.", nil)
	require.NotNil(t, posted.Task.AssigneeProfile)
	require.Equal(t, alphaAgent.ID, posted.Task.AssigneeProfile.PrincipalID)
	require.Equal(t, "todo", posted.Task.Status)

	alphaMessages, err := runtime.Store.ReceiveAgentMessages(runtime.Ctx, agent.AuthenticatedAgent{
		AgentID:     alphaAgent.ID,
		WorkspaceID: workspaceID,
		MemberID:    alphaMemberID,
	}, 10)
	require.NoError(t, err)
	require.Len(t, alphaMessages, 1)
	require.Equal(t, message.MessageTypeTaskDiscussion, alphaMessages[0].MessageType)

	var assigneeUnwatchedRows int
	require.NoError(t, runtime.Pool.QueryRow(runtime.Ctx, `
SELECT COUNT(*)
FROM scope_notification_settings
WHERE scope_id = $1
  AND member_id = $2
  AND state = 'no-wake'
  AND task_watch_state = 'no-wake'
`, posted.Task.TaskScopeID, alphaMemberID).Scan(&assigneeUnwatchedRows))
	require.Equal(t, 1, assigneeUnwatchedRows, "assignee attention must not become a persistent Watch")

	require.Empty(t, alphaMessages[0].Metadata)
}

func TestTaskDiscussionCommentWakeDedupesAssigneeAndMentions(t *testing.T) {
	t.Parallel()

	runtime := StartRuntime(t)

	router := gin.New()
	runtime.RegisterRoutes(router)
	server := httptest.NewServer(router)
	defer server.Close()

	client := server.Client()
	auth, workspaceID, channelID := SetupTaskHTTPActor(t, client, server.URL, "task-discussion-dedupe@example.com", "task-discussion-dedupe")

	var machineResponse dto.CreateMachineResponse
	response := DoJSON(t, client, http.MethodPost, server.URL+"/api/v1/workspaces/"+workspaceID+"/machines", map[string]any{
		"name": "task-discussion-dedupe-machine",
	}, AuthHeaders(auth.AccessToken), &machineResponse)
	require.Equal(t, http.StatusOK, response.StatusCode)

	var alphaAgent agent.AppAgent
	response = DoJSON(t, client, http.MethodPost, server.URL+"/api/v1/workspaces/"+workspaceID+"/agents", map[string]any{
		"name":      "dedupe-alpha-agent",
		"runtime":   "codex",
		"machineId": machineResponse.Machine.ID,
	}, WorkspaceHeaders(auth.AccessToken, workspaceID), &alphaAgent)
	require.Equal(t, http.StatusOK, response.StatusCode)

	var betaAgent agent.AppAgent
	response = DoJSON(t, client, http.MethodPost, server.URL+"/api/v1/workspaces/"+workspaceID+"/agents", map[string]any{
		"name":      "dedupe-beta-agent",
		"runtime":   "codex",
		"machineId": machineResponse.Machine.ID,
	}, WorkspaceHeaders(auth.AccessToken, workspaceID), &betaAgent)
	require.Equal(t, http.StatusOK, response.StatusCode)

	var alphaMemberID string
	require.NoError(t, runtime.Pool.QueryRow(runtime.Ctx, `SELECT member_id FROM agents WHERE id = $1`, alphaAgent.ID).Scan(&alphaMemberID))
	var betaMemberID string
	require.NoError(t, runtime.Pool.QueryRow(runtime.Ctx, `SELECT member_id FROM agents WHERE id = $1`, betaAgent.ID).Scan(&betaMemberID))

	var created taskboard.TaskPage
	response = DoJSON(t, client, http.MethodPost, server.URL+"/api/v1/workspaces/"+workspaceID+"/channels/"+channelID+"/tasks", map[string]any{
		"tasks": []map[string]any{{"title": "Deduped discussion wake"}},
	}, WorkspaceHeaders(auth.AccessToken, workspaceID), &created)
	require.Equal(t, http.StatusOK, response.StatusCode)
	require.Len(t, created.Tasks, 1)
	taskID := created.Tasks[0].ID

	var updated struct {
		Task taskboard.Task `json:"task"`
	}
	response = DoJSON(t, client, http.MethodPatch, server.URL+"/api/v1/workspaces/"+workspaceID+"/tasks/"+taskID, map[string]any{
		"assigneeRef": alphaMemberID,
	}, WorkspaceHeaders(auth.AccessToken, workspaceID), &updated)
	require.Equal(t, http.StatusOK, response.StatusCode)
	require.NotNil(t, updated.Task.AssigneeProfile)
	require.Equal(t, alphaAgent.ID, updated.Task.AssigneeProfile.PrincipalID)

	assignmentMessages, err := runtime.Store.ReceiveAgentMessages(runtime.Ctx, agent.AuthenticatedAgent{
		AgentID:     alphaAgent.ID,
		WorkspaceID: workspaceID,
		MemberID:    alphaMemberID,
	}, 10)
	require.NoError(t, err)
	require.Len(t, assignmentMessages, 1)
	require.Equal(t, "task.assigned", assignmentMessages[0].MessageType)

	posted := postTaskScopeDiscussionMessage(t, client, server.URL, auth.AccessToken, workspaceID, taskID, "@dedupe-alpha-agent and @dedupe-beta-agent keep the task moving.", nil)

	alphaMessages, err := runtime.Store.ReceiveAgentMessages(runtime.Ctx, agent.AuthenticatedAgent{
		AgentID:     alphaAgent.ID,
		WorkspaceID: workspaceID,
		MemberID:    alphaMemberID,
	}, 10)
	require.NoError(t, err)
	require.Len(t, alphaMessages, 1)
	require.Equal(t, message.MessageTypeTaskDiscussion, alphaMessages[0].MessageType)

	betaMessages, err := runtime.Store.ReceiveAgentMessages(runtime.Ctx, agent.AuthenticatedAgent{
		AgentID:     betaAgent.ID,
		WorkspaceID: workspaceID,
		MemberID:    betaMemberID,
	}, 10)
	require.NoError(t, err)
	require.Len(t, betaMessages, 1)
	require.Equal(t, message.MessageTypeTaskDiscussion, betaMessages[0].MessageType)

	var alphaNoWakeRows int
	require.NoError(t, runtime.Pool.QueryRow(runtime.Ctx, `
SELECT COUNT(*)
FROM scope_notification_settings
WHERE scope_id = $1
  AND member_id = $2
  AND state = 'no-wake'
  AND task_watch_state = 'no-wake'
`, posted.Task.TaskScopeID, alphaMemberID).Scan(&alphaNoWakeRows))
	require.Equal(t, 1, alphaNoWakeRows)

	var betaNoWakeRows int
	require.NoError(t, runtime.Pool.QueryRow(runtime.Ctx, `
SELECT COUNT(*)
FROM scope_notification_settings
WHERE scope_id = $1
  AND member_id = $2
  AND state = 'no-wake'
  AND task_watch_state = 'no-wake'
`, posted.Task.TaskScopeID, betaMemberID).Scan(&betaNoWakeRows))
	require.Equal(t, 1, betaNoWakeRows)
}

func TestTaskDiscussionCommentWithoutAssigneeOrMentionStaysLocal(t *testing.T) {
	t.Parallel()

	runtime := StartRuntime(t)

	router := gin.New()
	runtime.RegisterRoutes(router)
	server := httptest.NewServer(router)
	defer server.Close()

	client := server.Client()
	auth, workspaceID, channelID := SetupTaskHTTPActor(t, client, server.URL, "task-discussion-local-only@example.com", "task-discussion-local-only")

	var machineResponse dto.CreateMachineResponse
	response := DoJSON(t, client, http.MethodPost, server.URL+"/api/v1/workspaces/"+workspaceID+"/machines", map[string]any{
		"name": "task-discussion-local-only-machine",
	}, AuthHeaders(auth.AccessToken), &machineResponse)
	require.Equal(t, http.StatusOK, response.StatusCode)

	var alphaAgent agent.AppAgent
	response = DoJSON(t, client, http.MethodPost, server.URL+"/api/v1/workspaces/"+workspaceID+"/agents", map[string]any{
		"name":      "local-only-agent",
		"runtime":   "codex",
		"machineId": machineResponse.Machine.ID,
	}, WorkspaceHeaders(auth.AccessToken, workspaceID), &alphaAgent)
	require.Equal(t, http.StatusOK, response.StatusCode)

	var alphaMemberID string
	require.NoError(t, runtime.Pool.QueryRow(runtime.Ctx, `SELECT member_id FROM agents WHERE id = $1`, alphaAgent.ID).Scan(&alphaMemberID))

	var created taskboard.TaskPage
	response = DoJSON(t, client, http.MethodPost, server.URL+"/api/v1/workspaces/"+workspaceID+"/channels/"+channelID+"/tasks", map[string]any{
		"tasks": []map[string]any{{"title": "Local-only discussion"}},
	}, WorkspaceHeaders(auth.AccessToken, workspaceID), &created)
	require.Equal(t, http.StatusOK, response.StatusCode)
	require.Len(t, created.Tasks, 1)
	taskID := created.Tasks[0].ID
	posted := postTaskScopeDiscussionMessage(t, client, server.URL, auth.AccessToken, workspaceID, taskID, "Keep this note on the task only.", nil)
	require.Nil(t, posted.Task.AssigneeProfile)

	alphaMessages, err := runtime.Store.ReceiveAgentMessages(runtime.Ctx, agent.AuthenticatedAgent{
		AgentID:     alphaAgent.ID,
		WorkspaceID: workspaceID,
		MemberID:    alphaMemberID,
	}, 10)
	require.NoError(t, err)
	require.Empty(t, alphaMessages)
}

func TestTaskDiscussionPeopleCollaboratorCanWakeOwnerPrivateAgent(t *testing.T) {
	t.Parallel()

	runtime := StartRuntime(t)

	router := gin.New()
	runtime.RegisterRoutes(router)
	server := httptest.NewServer(router)
	defer server.Close()

	client := server.Client()
	owner, workspaceID, channelID := SetupTaskHTTPActor(t, client, server.URL, "task-discussion-people-wake-owner@example.com", "task-discussion-people-wake-owner")
	member := AcceptTaskHTTPInvite(t, runtime, client, server.URL, owner.User.ID, workspaceID, "task-discussion-people-wake-member@example.com", "task-discussion-people-wake-member")
	ownerMemberID := TaskWorkspaceMemberIDForUser(t, runtime, workspaceID, owner.User.ID)
	memberID := TaskWorkspaceMemberIDForUser(t, runtime, workspaceID, member.User.ID)

	var machineResponse dto.CreateMachineResponse
	response := DoJSON(t, client, http.MethodPost, server.URL+"/api/v1/workspaces/"+workspaceID+"/machines", map[string]any{
		"name": "task-discussion-people-wake-machine",
	}, AuthHeaders(owner.AccessToken), &machineResponse)
	require.Equal(t, http.StatusOK, response.StatusCode)

	var alphaAgent agent.AppAgent
	response = DoJSON(t, client, http.MethodPost, server.URL+"/api/v1/workspaces/"+workspaceID+"/agents", map[string]any{
		"name":      "people-wake-agent",
		"runtime":   "codex",
		"machineId": machineResponse.Machine.ID,
	}, WorkspaceHeaders(owner.AccessToken, workspaceID), &alphaAgent)
	require.Equal(t, http.StatusOK, response.StatusCode)

	var alphaMemberID string
	require.NoError(t, runtime.Pool.QueryRow(runtime.Ctx, `SELECT member_id FROM agents WHERE id = $1`, alphaAgent.ID).Scan(&alphaMemberID))
	_, err := runtime.Pool.Exec(runtime.Ctx, `
UPDATE scope_memberships
SET visibility = 'private',
    owner_member_id = $2,
    updated_at = NOW()
WHERE scope_id = $1
  AND member_id = $3
`, channelID, ownerMemberID, alphaMemberID)
	require.NoError(t, err)

	var created taskboard.TaskPage
	response = DoJSON(t, client, http.MethodPost, server.URL+"/api/v1/workspaces/"+workspaceID+"/channels/"+channelID+"/tasks", map[string]any{
		"tasks": []map[string]any{{"title": "People collaborator wakes private agent"}},
	}, WorkspaceHeaders(owner.AccessToken, workspaceID), &created)
	require.Equal(t, http.StatusOK, response.StatusCode)
	require.Len(t, created.Tasks, 1)
	taskID := created.Tasks[0].ID
	var updated struct {
		Task taskboard.Task `json:"task"`
	}
	response = DoJSON(t, client, http.MethodPatch, server.URL+"/api/v1/workspaces/"+workspaceID+"/tasks/"+taskID, map[string]any{
		"assigneeRef": alphaMemberID,
	}, WorkspaceHeaders(owner.AccessToken, workspaceID), &updated)
	require.Equal(t, http.StatusOK, response.StatusCode)
	require.NotNil(t, updated.Task.AssigneeProfile)
	require.Equal(t, alphaAgent.ID, updated.Task.AssigneeProfile.PrincipalID)

	assignmentMessages, err := runtime.Store.ReceiveAgentMessages(runtime.Ctx, agent.AuthenticatedAgent{
		AgentID:     alphaAgent.ID,
		WorkspaceID: workspaceID,
		MemberID:    alphaMemberID,
	}, 10)
	require.NoError(t, err)
	require.Len(t, assignmentMessages, 1)
	require.Equal(t, "task.assigned", assignmentMessages[0].MessageType)

	var collaborator taskboard.AddTaskCollaboratorResponse
	response = DoJSON(t, client, http.MethodPost, server.URL+"/api/v1/workspaces/"+workspaceID+"/tasks/"+taskID+"/collaborators", map[string]any{
		"memberId": memberID,
		"role":     "contributor",
	}, WorkspaceHeaders(owner.AccessToken, workspaceID), &collaborator)
	require.Equal(t, http.StatusOK, response.StatusCode)
	require.True(t, collaborator.Collaborator.Active)
	postTaskScopeDiscussionMessage(t, client, server.URL, member.AccessToken, workspaceID, taskID, "People collaborator ordinary comment should reach the assigned agent.", nil)

	alphaMessages, err := runtime.Store.ReceiveAgentMessages(runtime.Ctx, agent.AuthenticatedAgent{
		AgentID:     alphaAgent.ID,
		WorkspaceID: workspaceID,
		MemberID:    alphaMemberID,
	}, 10)
	require.NoError(t, err)
	require.Len(t, alphaMessages, 1)
	require.Equal(t, message.MessageTypeTaskDiscussion, alphaMessages[0].MessageType)
	ordinaryDiscussionMessageID := alphaMessages[0].MessageID
	postTaskScopeDiscussionMessage(t, client, server.URL, member.AccessToken, workspaceID, taskID, "@people-wake-agent start from the people collaborator comment.", nil)

	alphaMessages, err = runtime.Store.ReceiveAgentMessages(runtime.Ctx, agent.AuthenticatedAgent{
		AgentID:     alphaAgent.ID,
		WorkspaceID: workspaceID,
		MemberID:    alphaMemberID,
	}, 10)
	require.NoError(t, err)
	require.Len(t, alphaMessages, 1)
	require.Equal(t, message.MessageTypeTaskDiscussion, alphaMessages[0].MessageType)
	mentionedDiscussionMessageID := alphaMessages[0].MessageID

	var taskDiscussionFanoutRows int64
	require.NoError(t, runtime.Pool.QueryRow(runtime.Ctx, `
SELECT COUNT(*)
FROM scope_fanout_outbox outbox
JOIN scope_messages sm
  ON sm.id = outbox.message_id
WHERE outbox.message_id IN ($1, $2)
  AND outbox.event_ref_type = 'message'
  AND outbox.event_ref_id = outbox.message_id
  AND outbox.fanout_type = 'scope_fanout'
  AND sm.message_type = 'task_discussion'
`, ordinaryDiscussionMessageID, mentionedDiscussionMessageID).Scan(&taskDiscussionFanoutRows))
	require.Equal(t, int64(2), taskDiscussionFanoutRows)
}

func TestAgentCreatedTaskDiscussionCollaboratorCanWakeOwnerPrivateAgent(t *testing.T) {
	t.Parallel()

	runtime := StartRuntime(t)

	router := gin.New()
	runtime.RegisterRoutes(router)
	server := httptest.NewServer(router)
	defer server.Close()

	client := server.Client()
	owner, workspaceID, channelID := SetupTaskHTTPActor(t, client, server.URL, "task-discussion-agent-created-owner@example.com", "task-discussion-agent-created-owner")
	member := AcceptTaskHTTPInvite(t, runtime, client, server.URL, owner.User.ID, workspaceID, "task-discussion-agent-created-member@example.com", "task-discussion-agent-created-member")
	ownerMemberID := TaskWorkspaceMemberIDForUser(t, runtime, workspaceID, owner.User.ID)
	memberID := TaskWorkspaceMemberIDForUser(t, runtime, workspaceID, member.User.ID)

	var machineResponse dto.CreateMachineResponse
	response := DoJSON(t, client, http.MethodPost, server.URL+"/api/v1/workspaces/"+workspaceID+"/machines", map[string]any{
		"name": "task-discussion-agent-created-machine",
	}, AuthHeaders(owner.AccessToken), &machineResponse)
	require.Equal(t, http.StatusOK, response.StatusCode)

	var alphaAgent agent.AppAgent
	response = DoJSON(t, client, http.MethodPost, server.URL+"/api/v1/workspaces/"+workspaceID+"/agents", map[string]any{
		"name":      "agent-created-wake-agent",
		"runtime":   "codex",
		"machineId": machineResponse.Machine.ID,
	}, WorkspaceHeaders(owner.AccessToken, workspaceID), &alphaAgent)
	require.Equal(t, http.StatusOK, response.StatusCode)

	var alphaMemberID string
	require.NoError(t, runtime.Pool.QueryRow(runtime.Ctx, `SELECT member_id FROM agents WHERE id = $1`, alphaAgent.ID).Scan(&alphaMemberID))
	_, err := runtime.Pool.Exec(runtime.Ctx, `
UPDATE scope_memberships
SET visibility = 'private',
    owner_member_id = $2,
    updated_at = NOW()
WHERE scope_id = $1
  AND member_id = $3
`, channelID, ownerMemberID, alphaMemberID)
	require.NoError(t, err)

	taskService := taskboard.New(runtime.Pool)
	created, err := taskService.CreateTasks(runtime.Ctx, taskboard.CreateTasksRequest{
		Context: taskboard.NewAgentServiceContext(workspaceID, alphaAgent.ID, alphaMemberID),
		Board:   taskboard.BoardRef{Channel: "#all"},
		Titles:  []string{"Agent-created task lets collaborators wake owner private agents"},
	})
	require.NoError(t, err)
	require.Len(t, created.Tasks, 1)
	taskID := created.Tasks[0].ID

	_, err = runtime.Pool.Exec(runtime.Ctx, `
UPDATE tasks
SET assignee_member_id = $2,
    assigned_at = NOW(),
    claimed_by_member_id = $2,
    claimed_by_ref = $2,
    claimed_at = NOW(),
    last_activity_at = NOW(),
    updated_at = NOW()
WHERE id = $1
`, taskID, alphaMemberID)
	require.NoError(t, err)

	var collaborator taskboard.AddTaskCollaboratorResponse
	response = DoJSON(t, client, http.MethodPost, server.URL+"/api/v1/workspaces/"+workspaceID+"/tasks/"+taskID+"/collaborators", map[string]any{
		"memberId": memberID,
		"role":     "contributor",
	}, WorkspaceHeaders(owner.AccessToken, workspaceID), &collaborator)
	require.Equal(t, http.StatusOK, response.StatusCode)
	require.True(t, collaborator.Collaborator.Active)

	var executionOwnerMemberID string
	require.NoError(t, runtime.Pool.QueryRow(runtime.Ctx, `
SELECT owner_member_id
FROM task_scope_executions
WHERE scope_type = 'task' AND scope_id = $1
`, taskID).Scan(&executionOwnerMemberID))
	require.Equal(t, ownerMemberID, executionOwnerMemberID)
	ordinary := postTaskScopeDiscussionMessage(t, client, server.URL, owner.AccessToken, workspaceID, taskID, "Please continue from the owner task discussion.", nil)
	require.Equal(t, "agent", ordinary.Task.AssigneeProfile.ActorType)
	require.Equal(t, alphaAgent.ID, ordinary.Task.AssigneeProfile.PrincipalID)

	alphaMessages, err := runtime.Store.ReceiveAgentMessages(runtime.Ctx, agent.AuthenticatedAgent{
		AgentID:     alphaAgent.ID,
		WorkspaceID: workspaceID,
		MemberID:    alphaMemberID,
	}, 10)
	require.NoError(t, err)
	require.Len(t, alphaMessages, 1)
	require.Equal(t, message.MessageTypeTaskDiscussion, alphaMessages[0].MessageType)
	postTaskScopeDiscussionMessage(t, client, server.URL, member.AccessToken, workspaceID, taskID, "@agent-created-wake-agent continue from the collaborator comment.", nil)

	alphaMessages, err = runtime.Store.ReceiveAgentMessages(runtime.Ctx, agent.AuthenticatedAgent{
		AgentID:     alphaAgent.ID,
		WorkspaceID: workspaceID,
		MemberID:    alphaMemberID,
	}, 10)
	require.NoError(t, err)
	require.Len(t, alphaMessages, 1)
	require.Equal(t, message.MessageTypeTaskDiscussion, alphaMessages[0].MessageType)
}

func TestTaskDiscussionDirectMentionDoesNotStartTask(t *testing.T) {
	t.Parallel()

	runtime := StartRuntime(t)

	router := gin.New()
	runtime.RegisterRoutes(router)
	server := httptest.NewServer(router)
	defer server.Close()

	client := server.Client()
	auth, workspaceID, channelID := SetupTaskHTTPActor(t, client, server.URL, "task-discussion-start@example.com", "task-discussion-start")

	var machineResponse dto.CreateMachineResponse
	response := DoJSON(t, client, http.MethodPost, server.URL+"/api/v1/workspaces/"+workspaceID+"/machines", map[string]any{
		"name": "task-discussion-start-machine",
	}, AuthHeaders(auth.AccessToken), &machineResponse)
	require.Equal(t, http.StatusOK, response.StatusCode)

	var alphaAgent agent.AppAgent
	response = DoJSON(t, client, http.MethodPost, server.URL+"/api/v1/workspaces/"+workspaceID+"/agents", map[string]any{
		"name":      "discussion-start-agent",
		"runtime":   "codex",
		"machineId": machineResponse.Machine.ID,
	}, WorkspaceHeaders(auth.AccessToken, workspaceID), &alphaAgent)
	require.Equal(t, http.StatusOK, response.StatusCode)

	var created taskboard.TaskPage
	response = DoJSON(t, client, http.MethodPost, server.URL+"/api/v1/workspaces/"+workspaceID+"/channels/"+channelID+"/tasks", map[string]any{
		"tasks": []map[string]any{{"title": "Start from task discussion"}},
	}, WorkspaceHeaders(auth.AccessToken, workspaceID), &created)
	require.Equal(t, http.StatusOK, response.StatusCode)
	require.Len(t, created.Tasks, 1)
	taskID := created.Tasks[0].ID
	posted := postTaskScopeDiscussionMessage(t, client, server.URL, auth.AccessToken, workspaceID, taskID, "@discussion-start-agent start now and keep updates in the task.", nil)
	require.Nil(t, posted.Task.AssigneeProfile)
	require.Equal(t, "todo", posted.Task.Status)

	var detailResponse struct {
		Detail dto.TaskDetail `json:"detail"`
	}
	response = DoJSON(t, client, http.MethodGet, server.URL+"/api/v1/workspaces/"+workspaceID+"/tasks/"+taskID, nil, WorkspaceHeaders(auth.AccessToken, workspaceID), &detailResponse)
	require.Equal(t, http.StatusOK, response.StatusCode)
	require.Nil(t, detailResponse.Detail.Task.AssigneeProfile)
	require.Equal(t, "todo", detailResponse.Detail.Task.Status)
	require.Len(t, taskDetailDiscussionEntries(detailResponse.Detail), 1)
}

func TestTaskAssigneeLifecycleUsesActivityAttentionWithoutLegacyDispatch(t *testing.T) {
	t.Parallel()

	runtime := StartRuntime(t)

	router := gin.New()
	runtime.RegisterRoutes(router)
	server := httptest.NewServer(router)
	defer server.Close()

	client := server.Client()
	auth, workspaceID, channelID := SetupTaskHTTPActor(t, client, server.URL, "task-dispatch-lifecycle@example.com", "task-dispatch-lifecycle")

	var machineResponse dto.CreateMachineResponse
	response := DoJSON(t, client, http.MethodPost, server.URL+"/api/v1/workspaces/"+workspaceID+"/machines", map[string]any{
		"name": "task-dispatch-lifecycle-machine",
	}, AuthHeaders(auth.AccessToken), &machineResponse)
	require.Equal(t, http.StatusOK, response.StatusCode)

	var alphaAgent agent.AppAgent
	response = DoJSON(t, client, http.MethodPost, server.URL+"/api/v1/workspaces/"+workspaceID+"/agents", map[string]any{
		"name":      "dispatch-lifecycle-agent",
		"runtime":   "codex",
		"machineId": machineResponse.Machine.ID,
	}, WorkspaceHeaders(auth.AccessToken, workspaceID), &alphaAgent)
	require.Equal(t, http.StatusOK, response.StatusCode)

	var alphaMemberID string
	require.NoError(t, runtime.Pool.QueryRow(runtime.Ctx, `SELECT member_id FROM agents WHERE id = $1`, alphaAgent.ID).Scan(&alphaMemberID))

	var created taskboard.TaskPage
	response = DoJSON(t, client, http.MethodPost, server.URL+"/api/v1/workspaces/"+workspaceID+"/channels/"+channelID+"/tasks", map[string]any{
		"tasks": []map[string]any{{"title": "Dispatch lifecycle task"}},
	}, WorkspaceHeaders(auth.AccessToken, workspaceID), &created)
	require.Equal(t, http.StatusOK, response.StatusCode)
	require.Len(t, created.Tasks, 1)

	taskID := created.Tasks[0].ID
	var updated struct {
		Task taskboard.Task `json:"task"`
	}
	response = DoJSON(t, client, http.MethodPatch, server.URL+"/api/v1/workspaces/"+workspaceID+"/tasks/"+taskID, map[string]any{
		"assigneeRef": alphaMemberID,
	}, WorkspaceHeaders(auth.AccessToken, workspaceID), &updated)
	require.Equal(t, http.StatusOK, response.StatusCode)
	require.Equal(t, "todo", updated.Task.Status)
	require.NotNil(t, updated.Task.AssigneeProfile)
	require.Equal(t, alphaAgent.ID, updated.Task.AssigneeProfile.PrincipalID)

	alphaMessages, err := runtime.Store.ReceiveAgentMessages(runtime.Ctx, agent.AuthenticatedAgent{
		AgentID:     alphaAgent.ID,
		WorkspaceID: workspaceID,
		MemberID:    alphaMemberID,
	}, 10)
	require.NoError(t, err)
	require.Len(t, alphaMessages, 1)
	require.Equal(t, "task.assigned", alphaMessages[0].MessageType)
	require.NotEqual(t, message.MessageTypeTaskDispatch, alphaMessages[0].MessageType)

	updated = struct {
		Task taskboard.Task `json:"task"`
	}{}
	response = DoJSON(t, client, http.MethodPatch, server.URL+"/api/v1/workspaces/"+workspaceID+"/tasks/"+taskID, map[string]any{
		"assigneeRef": "",
	}, WorkspaceHeaders(auth.AccessToken, workspaceID), &updated)
	require.Equal(t, http.StatusOK, response.StatusCode)
	require.Nil(t, updated.Task.AssigneeProfile)
	require.Equal(t, "todo", updated.Task.Status)

	alphaMessages, err = runtime.Store.ReceiveAgentMessages(runtime.Ctx, agent.AuthenticatedAgent{
		AgentID:     alphaAgent.ID,
		WorkspaceID: workspaceID,
		MemberID:    alphaMemberID,
	}, 10)
	require.NoError(t, err)
	require.Empty(t, alphaMessages)

	var legacyDispatchMessages int
	require.NoError(t, runtime.Pool.QueryRow(runtime.Ctx, `
SELECT COUNT(*)
FROM scope_messages
WHERE scope_id = $1
  AND message_type = 'task_dispatch'
`, created.Tasks[0].TaskScopeID).Scan(&legacyDispatchMessages))
	require.Zero(t, legacyDispatchMessages)
}

func TestTasksBlockAndUnblock(t *testing.T) {
	t.Parallel()

	runtime := StartRuntime(t)

	router := gin.New()
	runtime.RegisterRoutes(router)
	server := httptest.NewServer(router)
	defer server.Close()

	client := server.Client()
	auth, workspaceID, channelID := SetupTaskHTTPActor(t, client, server.URL, "task-block-owner@example.com", "task-block-owner")

	var created taskboard.TaskPage
	response := DoJSON(t, client, http.MethodPost, server.URL+"/api/v1/workspaces/"+workspaceID+"/channels/"+channelID+"/tasks", map[string]any{
		"tasks": []map[string]any{{"title": "Ship blocker handling"}},
	}, map[string]string{
		"Authorization":  fmt.Sprintf("Bearer %s", auth.AccessToken),
		"X-Workspace-Id": workspaceID,
	}, &created)
	require.Equal(t, http.StatusOK, response.StatusCode)
	require.Len(t, created.Tasks, 1)
	taskID := created.Tasks[0].ID

	var blockResponse struct {
		Task taskboard.Task `json:"task"`
	}
	response = DoJSON(t, client, http.MethodPatch, server.URL+"/api/v1/workspaces/"+workspaceID+"/tasks/"+taskID+"/block", map[string]any{
		"reason": "Waiting on daemon rollout",
	}, map[string]string{
		"Authorization":  fmt.Sprintf("Bearer %s", auth.AccessToken),
		"X-Workspace-Id": workspaceID,
	}, &blockResponse)
	require.Equal(t, http.StatusOK, response.StatusCode)
	require.Equal(t, "Waiting on daemon rollout", blockResponse.Task.BlockedReason)

	var detailResponse struct {
		Detail dto.TaskDetail `json:"detail"`
	}
	response = DoJSON(t, client, http.MethodGet, server.URL+"/api/v1/workspaces/"+workspaceID+"/tasks/"+taskID, nil, map[string]string{
		"Authorization":  fmt.Sprintf("Bearer %s", auth.AccessToken),
		"X-Workspace-Id": workspaceID,
	}, &detailResponse)
	require.Equal(t, http.StatusOK, response.StatusCode)
	require.Equal(t, "Waiting on daemon rollout", detailResponse.Detail.Task.BlockedReason)
	activityTypes := make([]string, 0, len(taskDetailActivityEvents(detailResponse.Detail)))
	for _, event := range taskDetailActivityEvents(detailResponse.Detail) {
		activityTypes = append(activityTypes, event.Type)
	}
	require.Contains(t, activityTypes, "blocked")

	var unblockResponse struct {
		Task taskboard.Task `json:"task"`
	}
	response = DoJSON(t, client, http.MethodPatch, server.URL+"/api/v1/workspaces/"+workspaceID+"/tasks/"+taskID+"/unblock", nil, map[string]string{
		"Authorization":  fmt.Sprintf("Bearer %s", auth.AccessToken),
		"X-Workspace-Id": workspaceID,
	}, &unblockResponse)
	require.Equal(t, http.StatusOK, response.StatusCode)
	require.Empty(t, unblockResponse.Task.BlockedReason)

	var unblockedDetailResponse struct {
		Detail dto.TaskDetail `json:"detail"`
	}
	response = DoJSON(t, client, http.MethodGet, server.URL+"/api/v1/workspaces/"+workspaceID+"/tasks/"+taskID, nil, map[string]string{
		"Authorization":  fmt.Sprintf("Bearer %s", auth.AccessToken),
		"X-Workspace-Id": workspaceID,
	}, &unblockedDetailResponse)
	require.Equal(t, http.StatusOK, response.StatusCode)
	require.Empty(t, unblockedDetailResponse.Detail.Task.BlockedReason)
	require.NotEmpty(t, taskDetailActivityEvents(unblockedDetailResponse.Detail))
	activityTypes = activityTypes[:0]
	for _, event := range taskDetailActivityEvents(unblockedDetailResponse.Detail) {
		activityTypes = append(activityTypes, event.Type)
	}
	require.Contains(t, activityTypes, "unblocked")
}

func TestTasksCreateSubtasks(t *testing.T) {
	t.Parallel()

	runtime := StartRuntime(t)

	router := gin.New()
	runtime.RegisterRoutes(router)
	server := httptest.NewServer(router)
	defer server.Close()

	client := server.Client()
	auth, workspaceID, channelID := SetupTaskHTTPActor(t, client, server.URL, "task-subtask-owner@example.com", "task-subtask-owner")

	var created taskboard.TaskPage
	response := DoJSON(t, client, http.MethodPost, server.URL+"/api/v1/workspaces/"+workspaceID+"/channels/"+channelID+"/tasks", map[string]any{
		"tasks": []map[string]any{{"title": "Ship parent task"}},
	}, map[string]string{
		"Authorization":  fmt.Sprintf("Bearer %s", auth.AccessToken),
		"X-Workspace-Id": workspaceID,
	}, &created)
	require.Equal(t, http.StatusOK, response.StatusCode)
	require.Len(t, created.Tasks, 1)
	parentTaskID := created.Tasks[0].ID

	var subtasks taskboard.TaskPage
	response = DoJSON(t, client, http.MethodPost, server.URL+"/api/v1/workspaces/"+workspaceID+"/tasks/"+parentTaskID+"/subtasks", map[string]any{
		"tasks": []map[string]any{
			{"title": "Child task one"},
			{"title": "Child task two"},
		},
	}, map[string]string{
		"Authorization":  fmt.Sprintf("Bearer %s", auth.AccessToken),
		"X-Workspace-Id": workspaceID,
	}, &subtasks)
	require.Equal(t, http.StatusOK, response.StatusCode)
	require.Len(t, subtasks.Tasks, 2)
	require.Equal(t, parentTaskID, subtasks.Tasks[0].ParentTaskRef)
	require.Equal(t, parentTaskID, subtasks.Tasks[1].ParentTaskRef)

	var detailResponse struct {
		Detail dto.TaskDetail `json:"detail"`
	}
	response = DoJSON(t, client, http.MethodGet, server.URL+"/api/v1/workspaces/"+workspaceID+"/tasks/"+parentTaskID, nil, map[string]string{
		"Authorization":  fmt.Sprintf("Bearer %s", auth.AccessToken),
		"X-Workspace-Id": workspaceID,
	}, &detailResponse)
	require.Equal(t, http.StatusOK, response.StatusCode)
	require.Len(t, detailResponse.Detail.Subtasks, 2)
	// Detail returns the first direct-children page in newest-first order
	// (created_at DESC, id DESC). The two children were created in one batch so
	// they may share a created_at timestamp; when that happens the unique id
	// tie-breaker decides the exact row order. Assert both children are present
	// and the returned order is canonical (non-increasing created_at, and id DESC
	// on a timestamp tie).
	gotTitles := map[string]bool{}
	for _, subtask := range detailResponse.Detail.Subtasks {
		gotTitles[subtask.Title] = true
	}
	require.True(t, gotTitles["Child task one"])
	require.True(t, gotTitles["Child task two"])
	first := detailResponse.Detail.Subtasks[0]
	second := detailResponse.Detail.Subtasks[1]
	require.False(t, second.CreatedAt.After(first.CreatedAt), "detail subtasks must be newest-first")
	if second.CreatedAt.Equal(first.CreatedAt) {
		require.Greater(t, first.ID, second.ID, "createdAt tie must be broken by id DESC")
	}
	require.False(t, detailResponse.Detail.SubtasksHasMore)
	require.Empty(t, detailResponse.Detail.SubtasksNextCursor)
}

func TestTasksCreateDetailedSubtask(t *testing.T) {
	t.Parallel()

	runtime := StartRuntime(t)

	router := gin.New()
	runtime.RegisterRoutes(router)
	server := httptest.NewServer(router)
	defer server.Close()

	client := server.Client()
	owner, workspaceID, channelID := SetupTaskHTTPActor(t, client, server.URL, "task-detailed-subtask-owner@example.com", "task-detailed-subtask-owner")
	var settings struct {
		PriorityOptions []taskboard.TaskFieldOption `json:"priorityOptions"`
		KindOptions     []taskboard.TaskFieldOption `json:"kindOptions"`
	}
	response := DoJSON(t, client, http.MethodGet, server.URL+"/api/v1/workspaces/"+workspaceID+"/task-board/options", nil, WorkspaceHeaders(owner.AccessToken, workspaceID), &settings)
	require.Equal(t, http.StatusOK, response.StatusCode)
	feature := FindTaskFieldOptionByKey(t, settings.KindOptions, "feature")
	p1 := FindTaskFieldOptionByKey(t, settings.PriorityOptions, "p1")

	var machineResponse dto.CreateMachineResponse
	response = DoJSON(t, client, http.MethodPost, server.URL+"/api/v1/workspaces/"+workspaceID+"/machines", map[string]any{
		"name": "detailed-subtask-machine",
	}, AuthHeaders(owner.AccessToken), &machineResponse)
	require.Equal(t, http.StatusOK, response.StatusCode)

	var alphaAgent agent.AppAgent
	response = DoJSON(t, client, http.MethodPost, server.URL+"/api/v1/workspaces/"+workspaceID+"/agents", map[string]any{
		"name":      "detailed-subtask-agent",
		"runtime":   "codex",
		"machineId": machineResponse.Machine.ID,
	}, WorkspaceHeaders(owner.AccessToken, workspaceID), &alphaAgent)
	require.Equal(t, http.StatusOK, response.StatusCode)

	var alphaMemberID string
	require.NoError(t, runtime.Pool.QueryRow(runtime.Ctx, `SELECT member_id FROM agents WHERE id = $1`, alphaAgent.ID).Scan(&alphaMemberID))

	var created taskboard.TaskPage
	response = DoJSON(t, client, http.MethodPost, server.URL+"/api/v1/workspaces/"+workspaceID+"/channels/"+channelID+"/tasks", map[string]any{
		"tasks": []map[string]any{{"title": "Parent rollout task"}},
	}, WorkspaceHeaders(owner.AccessToken, workspaceID), &created)
	require.Equal(t, http.StatusOK, response.StatusCode)
	require.Len(t, created.Tasks, 1)
	parentTaskID := created.Tasks[0].ID

	var subtasks taskboard.TaskPage
	response = DoJSON(t, client, http.MethodPost, server.URL+"/api/v1/workspaces/"+workspaceID+"/tasks/"+parentTaskID+"/subtasks", map[string]any{
		"task": map[string]any{
			"title":            "Convert report into markdown artifact",
			"description":      "Turn the current report into a markdown deliverable.",
			"kindOptionId":     feature.ID,
			"priorityOptionId": p1.ID,
			"assigneeRef":      alphaMemberID,
		},
	}, WorkspaceHeaders(owner.AccessToken, workspaceID), &subtasks)
	require.Equal(t, http.StatusOK, response.StatusCode)
	require.Len(t, subtasks.Tasks, 1)
	require.Equal(t, parentTaskID, subtasks.Tasks[0].ParentTaskRef)
	require.Equal(t, "Convert report into markdown artifact", subtasks.Tasks[0].Title)
	require.Equal(t, "Turn the current report into a markdown deliverable.", subtasks.Tasks[0].Description)
	require.Equal(t, "todo", subtasks.Tasks[0].Status)
	require.Equal(t, feature.Label, subtasks.Tasks[0].Kind)
	require.Equal(t, feature.ID, subtasks.Tasks[0].KindField.OptionID)
	require.Equal(t, p1.Label, subtasks.Tasks[0].Priority)
	require.Equal(t, p1.ID, subtasks.Tasks[0].PriorityField.OptionID)
	require.NotNil(t, subtasks.Tasks[0].AssigneeProfile)
	require.Equal(t, alphaAgent.ID, subtasks.Tasks[0].AssigneeProfile.PrincipalID)
}

func TestTasksUseHumanDisplayNamesInAppPayload(t *testing.T) {
	t.Parallel()

	runtime := StartRuntime(t)

	router := gin.New()
	runtime.RegisterRoutes(router)
	server := httptest.NewServer(router)
	defer server.Close()

	client := server.Client()

	var auth dto.AuthTokensResponse
	response := DoJSON(t, client, http.MethodPost, server.URL+"/api/v1/auth/register", map[string]any{
		"email":    "task-display@example.com",
		"password": TestUserPassword,
		"name":     "legacy-task-user",
	}, nil, &auth)
	require.Equal(t, http.StatusOK, response.StatusCode)

	var updatedUser dto.UserResponse
	response = DoJSON(t, client, http.MethodPatch, server.URL+"/api/v1/me", map[string]any{
		"displayName": "Visible Task User",
	}, map[string]string{
		"Authorization": fmt.Sprintf("Bearer %s", auth.AccessToken),
	}, &updatedUser)
	require.Equal(t, http.StatusOK, response.StatusCode)
	require.Equal(t, "legacy-task-user", updatedUser.Name)
	require.Equal(t, "Visible Task User", updatedUser.DisplayName)

	var workspaces []dto.WorkspaceResponse
	response = DoJSON(t, client, http.MethodGet, server.URL+"/api/v1/workspaces", nil, map[string]string{
		"Authorization": fmt.Sprintf("Bearer %s", auth.AccessToken),
	}, &workspaces)
	require.Equal(t, http.StatusOK, response.StatusCode)
	require.NotEmpty(t, workspaces)
	workspaceID := workspaces[0].ID

	var channels []ChannelResponse
	response = DoJSON(t, client, http.MethodGet, server.URL+"/api/v1/workspaces/"+workspaceID+"/channels", nil, map[string]string{
		"Authorization":  fmt.Sprintf("Bearer %s", auth.AccessToken),
		"X-Workspace-Id": workspaceID,
	}, &channels)
	require.Equal(t, http.StatusOK, response.StatusCode)
	require.NotEmpty(t, channels)
	channelID := channels[0].ID

	var created taskboard.TaskPage
	response = DoJSON(t, client, http.MethodPost, server.URL+"/api/v1/workspaces/"+workspaceID+"/channels/"+channelID+"/tasks", map[string]any{
		"tasks": []map[string]any{{"title": "Visible task labels only"}},
	}, map[string]string{
		"Authorization":  fmt.Sprintf("Bearer %s", auth.AccessToken),
		"X-Workspace-Id": workspaceID,
	}, &created)
	require.Equal(t, http.StatusOK, response.StatusCode)
	require.Len(t, created.Tasks, 1)
	require.Equal(t, auth.User.ID, created.Tasks[0].CreatedByID)
	require.Equal(t, "Visible Task User", created.Tasks[0].CreatedByName)
	require.NotEqual(t, updatedUser.Name, created.Tasks[0].CreatedByName)

	taskID := created.Tasks[0].ID

	var claimResponse struct {
		Task taskboard.Task `json:"task"`
	}
	response = DoJSON(t, client, http.MethodPatch, server.URL+"/api/v1/workspaces/"+workspaceID+"/tasks/"+taskID+"/claim", nil, map[string]string{
		"Authorization":  fmt.Sprintf("Bearer %s", auth.AccessToken),
		"X-Workspace-Id": workspaceID,
	}, &claimResponse)
	require.Equal(t, http.StatusOK, response.StatusCode)
	require.NotNil(t, claimResponse.Task.AssigneeProfile)
	require.Equal(t, auth.User.ID, claimResponse.Task.AssigneeProfile.PrincipalID)
	require.Equal(t, "Visible Task User", claimResponse.Task.AssigneeProfile.DisplayName)
	require.NotEqual(t, updatedUser.Name, claimResponse.Task.AssigneeProfile.DisplayName)

	listed := listAllChannelTasksForAPITest(t, client, server.URL, workspaceID, channelID, false, map[string]string{
		"Authorization":  fmt.Sprintf("Bearer %s", auth.AccessToken),
		"X-Workspace-Id": workspaceID,
	})
	require.Len(t, listed, 1)
	require.Equal(t, "Visible Task User", listed[0].CreatedByName)
	require.NotNil(t, listed[0].AssigneeProfile)
	require.Equal(t, "Visible Task User", listed[0].AssigneeProfile.DisplayName)
}

func TestTasksRejectDMAndReplyThreadChannels(t *testing.T) {
	runtime := StartRuntime(t)

	router := gin.New()
	runtime.RegisterRoutes(router)
	server := httptest.NewServer(router)
	defer server.Close()

	client := server.Client()

	var auth dto.AuthTokensResponse
	response := DoJSON(t, client, http.MethodPost, server.URL+"/api/v1/auth/register", map[string]any{
		"email":    "member@example.com",
		"password": TestUserPassword,
		"name":     "member",
	}, nil, &auth)
	require.Equal(t, http.StatusOK, response.StatusCode)

	var workspaces []dto.WorkspaceResponse
	response = DoJSON(t, client, http.MethodGet, server.URL+"/api/v1/workspaces", nil, map[string]string{
		"Authorization": fmt.Sprintf("Bearer %s", auth.AccessToken),
	}, &workspaces)
	require.Equal(t, http.StatusOK, response.StatusCode)
	require.NotEmpty(t, workspaces)
	workspaceID := workspaces[0].ID

	var channels []ChannelResponse
	response = DoJSON(t, client, http.MethodGet, server.URL+"/api/v1/workspaces/"+workspaceID+"/channels", nil, map[string]string{
		"Authorization":  fmt.Sprintf("Bearer %s", auth.AccessToken),
		"X-Workspace-Id": workspaceID,
	}, &channels)
	require.Equal(t, http.StatusOK, response.StatusCode)
	require.NotEmpty(t, channels)
	channelID := channels[0].ID

	var createdMessage message.AppMessage
	response = DoJSON(t, client, http.MethodPost, server.URL+WorkspaceScopeMessagesPath(workspaceID, channelID), map[string]any{
		"scopeId": channelID,
		"content": "parent message",
	}, map[string]string{
		"Authorization":  fmt.Sprintf("Bearer %s", auth.AccessToken),
		"X-Workspace-Id": workspaceID,
	}, &createdMessage)
	require.Equal(t, http.StatusOK, response.StatusCode)
	require.NotEmpty(t, createdMessage.ID)

	var replyThread message.ReplyThreadSummary
	response = DoJSON(t, client, http.MethodPost, server.URL+"/api/v1/workspaces/"+workspaceID+"/channels/"+channelID+"/threads", map[string]any{
		"parentMessageId": createdMessage.ID,
	}, map[string]string{
		"Authorization":  fmt.Sprintf("Bearer %s", auth.AccessToken),
		"X-Workspace-Id": workspaceID,
	}, &replyThread)
	require.Equal(t, http.StatusOK, response.StatusCode)
	require.NotEmpty(t, replyThread.ThreadChannelID)

	var machineResponse dto.CreateMachineResponse
	response = DoJSON(t, client, http.MethodPost, server.URL+"/api/v1/workspaces/"+workspaceID+"/machines", map[string]any{
		"name": "member-machine",
	}, map[string]string{
		"Authorization": fmt.Sprintf("Bearer %s", auth.AccessToken),
	}, &machineResponse)
	require.Equal(t, http.StatusOK, response.StatusCode)
	require.NotEmpty(t, machineResponse.Machine.ID)

	var createdAgent agent.AppAgent
	response = DoJSON(t, client, http.MethodPost, server.URL+"/api/v1/workspaces/"+workspaceID+"/agents", map[string]any{
		"name":      "member-agent",
		"runtime":   "codex",
		"machineId": machineResponse.Machine.ID,
	}, map[string]string{
		"Authorization":  fmt.Sprintf("Bearer %s", auth.AccessToken),
		"X-Workspace-Id": workspaceID,
	}, &createdAgent)
	require.Equal(t, http.StatusOK, response.StatusCode)
	require.NotEmpty(t, createdAgent.ID)

	var dm message.DMChannel
	response = DoJSON(t, client, http.MethodPost, server.URL+"/api/v1/workspaces/"+workspaceID+"/channels/dm", map[string]any{
		"agentId": createdAgent.ID,
	}, map[string]string{
		"Authorization":  fmt.Sprintf("Bearer %s", auth.AccessToken),
		"X-Workspace-Id": workspaceID,
	}, &dm)
	require.Equal(t, http.StatusOK, response.StatusCode)
	require.NotEmpty(t, dm.ID)

	testCases := []struct {
		name          string
		channelID     string
		expectedError string
	}{
		{name: "dm", channelID: dm.ID, expectedError: "dm does not support tasks"},
		{name: "reply-thread", channelID: replyThread.ThreadChannelID, expectedError: "threads do not support tasks"},
	}

	headers := map[string]string{
		"Authorization":  fmt.Sprintf("Bearer %s", auth.AccessToken),
		"X-Workspace-Id": workspaceID,
	}

	for _, testCase := range testCases {
		testCase := testCase

		t.Run("list "+testCase.name, func(t *testing.T) {
			var apiErr struct {
				Error string `json:"error"`
			}
			response := DoJSON(t, client, http.MethodGet, server.URL+"/api/v1/workspaces/"+workspaceID+"/channels/"+testCase.channelID+"/tasks", nil, headers, &apiErr)
			require.Equal(t, http.StatusBadRequest, response.StatusCode)
			require.Equal(t, testCase.expectedError, apiErr.Error)
		})

		t.Run("create "+testCase.name, func(t *testing.T) {
			var apiErr struct {
				Error string `json:"error"`
			}
			response := DoJSON(t, client, http.MethodPost, server.URL+"/api/v1/workspaces/"+workspaceID+"/channels/"+testCase.channelID+"/tasks", map[string]any{
				"tasks": []map[string]any{{"title": "Task A"}},
			}, headers, &apiErr)
			require.Equal(t, http.StatusBadRequest, response.StatusCode)
			require.Equal(t, testCase.expectedError, apiErr.Error)
		})
	}
}

func requireNoActiveScopeMembership(t *testing.T, runtime Runtime, scopeID string, memberID string) {
	t.Helper()

	var count int
	require.NoError(t, runtime.Pool.QueryRow(runtime.Ctx, `
SELECT COUNT(*)
FROM scope_memberships
WHERE scope_id = $1
  AND member_id = $2
  AND removed_at IS NULL
`, scopeID, memberID).Scan(&count))
	require.Zero(t, count)
}

func requireActiveScopeMembership(t *testing.T, runtime Runtime, scopeID string, memberID string) {
	t.Helper()

	var count int
	require.NoError(t, runtime.Pool.QueryRow(runtime.Ctx, `
SELECT COUNT(*)
FROM scope_memberships
WHERE scope_id = $1
  AND member_id = $2
  AND removed_at IS NULL
`, scopeID, memberID).Scan(&count))
	require.Equal(t, 1, count)
}
