Files
multica/server/internal/handler/issue_thread_summary_progress_test.go
Claude 13af0e9e3e feat(issues): show the agent's latest progress checklist in the feed
Before: The feed gave no sense of how far an agent had got. Agents could
not post a progress report that the feed understands: `comment add` had no
way to set a comment type, and the run brief told agents not to post
progress at all.

After: Each feed message shows a compact checklist under its thread bar
(done/total, a small bar, the first 5 items, "+N more"), and the issue peek
shows the same block under its title. Finished issues (done or closed
category) fold to a single "done/total" line with a chevron to expand. A
progress report is a `progress_update` comment by an agent whose body is a
Markdown task list; the newest one per issue wins.

How: The thread-summaries endpoint gains `latest_progress`
({comment_id, content, created_at} | null) from one batched DISTINCT ON
query, so there is no new endpoint. Core adds the type, a tolerant zod
schema, `parseProgressChecklist`, and a realtime patch for new reports.
Views add `FeedProgressCard`, mounted from the thread bar and the peek, with
strings in all five locales. `multica issue comment add --type
progress_update` lets agents post a report, and the run brief documents it as
the one exception to the single-comment rule.

Co-Authored-By: Claude Fable 5.1 <[email protected]>
Claude-Session: https://claude.ai/code/session_017deksZDSzZBA4QSqZZoBip
2026-10-21 06:10:12 +00:00

130 lines
4.5 KiB
Go

package handler
import (
"testing"
"time"
"github.com/google/uuid"
"github.com/multica-ai/multica/server/internal/testutil"
)
const progressChecklist = "Plan\n\n- [x] read the code\n- [ ] write the fix\n"
func progressCols(at time.Time, extra ...testutil.Cols) testutil.Cols {
cols := testutil.Cols{
"type": "progress_update",
"author_type": "agent",
"author_id": uuid.NewString(),
"created_at": at,
}
for _, e := range extra {
for k, v := range e {
cols[k] = v
}
}
return cols
}
// latestProgressFor returns the single summary row's latest_progress. The
// issue always carries a plain member comment so a row exists even when no
// progress report qualifies.
func latestProgressFor(t *testing.T, issueID string) *LatestProgressResponse {
t.Helper()
got := listThreadSummaries(t, issueID)
if len(got.Summaries) != 1 {
t.Fatalf("summaries = %+v, want exactly one row", got.Summaries)
}
return got.Summaries[0].LatestProgress
}
func TestListIssueThreadSummaries_LatestProgress_NewestWins(t *testing.T) {
issueID := dbfx.Issue(t, "progress newest")
at := agentQuestionClock()
dbfx.Comment(t, issueID, "- [ ] old item", progressCols(at(1)))
newest := dbfx.Comment(t, issueID, progressChecklist, progressCols(at(3)))
dbfx.Comment(t, issueID, "- [x] middle item", progressCols(at(2)))
got := latestProgressFor(t, issueID)
if got == nil {
t.Fatal("latest_progress = nil, want the newest report")
}
if got.CommentID != newest || got.Content != progressChecklist {
t.Errorf("latest_progress = %+v, want comment %s with its full body", got, newest)
}
created, err := time.Parse(time.RFC3339Nano, got.CreatedAt)
if err != nil || !created.Equal(at(3)) {
t.Errorf("created_at = %q (err %v), want %s", got.CreatedAt, err, at(3).Format(time.RFC3339Nano))
}
}
func TestListIssueThreadSummaries_LatestProgress_NullWhenNone(t *testing.T) {
issueID := dbfx.Issue(t, "progress none")
dbfx.Comment(t, issueID, "just a note")
if got := latestProgressFor(t, issueID); got != nil {
t.Fatalf("latest_progress = %+v, want null", got)
}
}
func TestListIssueThreadSummaries_LatestProgress_MemberAuthoredIgnored(t *testing.T) {
issueID := dbfx.Issue(t, "progress member")
dbfx.Comment(t, issueID, progressChecklist, testutil.Cols{"type": "progress_update"})
if got := latestProgressFor(t, issueID); got != nil {
t.Fatalf("latest_progress = %+v, want null for a member-authored report", got)
}
}
func TestListIssueThreadSummaries_LatestProgress_DeletedIgnored(t *testing.T) {
issueID := dbfx.Issue(t, "progress deleted")
at := agentQuestionClock()
older := dbfx.Comment(t, issueID, "- [ ] still open", progressCols(at(1)))
dbfx.Comment(t, issueID, progressChecklist, progressCols(at(2), testutil.Cols{"deleted_at": at(3)}))
got := latestProgressFor(t, issueID)
if got == nil || got.CommentID != older {
t.Fatalf("latest_progress = %+v, want the older report %s once the newest is deleted", got, older)
}
}
func TestListIssueThreadSummaries_LatestProgress_PlainCommentTypeIgnored(t *testing.T) {
issueID := dbfx.Issue(t, "progress plain type")
dbfx.Comment(t, issueID, progressChecklist, testutil.Cols{
"author_type": "agent", "author_id": uuid.NewString(),
})
if got := latestProgressFor(t, issueID); got != nil {
t.Fatalf("latest_progress = %+v, want null for a type=comment checklist", got)
}
}
func TestListIssueThreadSummaries_LatestProgress_RequiresTaskList(t *testing.T) {
issueID := dbfx.Issue(t, "progress task list")
at := agentQuestionClock()
checklist := dbfx.Comment(t, issueID, " * [X] nested and starred", progressCols(at(1)))
dbfx.Comment(t, issueID, "Working on it, no checklist yet.\n- plain bullet", progressCols(at(2)))
got := latestProgressFor(t, issueID)
if got == nil || got.CommentID != checklist {
t.Fatalf("latest_progress = %+v, want the checklist %s; a newer report without a task list must not shadow it", got, checklist)
}
}
func TestListIssueThreadSummaries_LatestProgress_PerIssue(t *testing.T) {
a := dbfx.Issue(t, "progress a")
b := dbfx.Issue(t, "progress b")
at := agentQuestionClock()
progressA := dbfx.Comment(t, a, "- [ ] a", progressCols(at(1)))
dbfx.Comment(t, b, "plain")
got := listThreadSummaries(t, a, b)
byIssue := map[string]*LatestProgressResponse{}
for _, s := range got.Summaries {
byIssue[s.IssueID] = s.LatestProgress
}
if byIssue[a] == nil || byIssue[a].CommentID != progressA {
t.Errorf("issue a latest_progress = %+v, want %s", byIssue[a], progressA)
}
if byIssue[b] != nil {
t.Errorf("issue b latest_progress = %+v, want null", byIssue[b])
}
}