mirror of
https://github.com/multica-ai/multica.git
synced 2026-07-06 14:00:09 +02:00
* feat(comments): resolve-aware fold for agent comment reads (MUL-3555) Agents reading a long issue paid tokens for settled discussion. The human timeline already folds resolved threads, but the agent read path (`comment list`) ignored resolved_at entirely — humans saw the conclusion, agents got the full raw discussion. Add an opt-in `fold=true` projection to ListComments that collapses each resolved thread to root + conclusion (reply-resolved) or root only (root-resolved), reusing the human timeline's deriveThreadResolution semantics. The resolved thread's root carries `thread_resolved` + `folded_count`; `--full` brings the dropped comments back. Fold is rejected on partial-thread reads (since/tail) and roots_only, where a resolution comment could be unfetched and silently dropped. CLI `comment list` folds by default on the complete-thread reads (default, --recent, untailed --thread) with a `--full` escape hatch; the agent prompts and runtime brief document the fold + escape. No new endpoint, no human UI change, no SQL/migration change — in-memory projection, same precedent as summary/roots_only. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Co-authored-by: multica-agent <github@multica.ai> * refactor(daemon): dedupe fold prompt restatements per review (MUL-3555) Howard's PR review flagged DRY redundancy: the resolve-fold rule was restated in full in the task prompt (prompt.go:41/:182) and the brief workflow steps (runtime_config.go:673/:692, reply_instructions cold hint) even though the canonical command catalog (runtime_config.go:477) — always present in the brief — already documents it in full, and the task prompt explicitly defers to it ("follow the rule in your runtime workflow file"). Keep the catalog entry full (the canonical reference); shrink the five inline restatements to a short "resolved threads come back folded — `--full` to expand" pointer. No loss of signal (the agent always has the full catalog in context), ~80-120 tokens/run saved on the worst-case assignment / cold paths. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Co-authored-by: multica-agent <github@multica.ai> --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com> Co-authored-by: multica-agent <github@multica.ai>
250 lines
9.5 KiB
Go
250 lines
9.5 KiB
Go
package handler
|
|
|
|
import (
|
|
"context"
|
|
"net/http"
|
|
"net/url"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
// setCommentResolvedAt stamps resolved_at (and a member resolver) on a comment
|
|
// directly, so a fold test can choose exactly which comment in a thread is the
|
|
// resolution and control the resolved_at ordering. Going through SQL rather
|
|
// than the resolve handler lets a test deliberately leave two resolutions in
|
|
// one thread to exercise the projection's latest-wins tiebreak — the write-side
|
|
// invariant (ClearOtherThreadResolutions) would otherwise collapse them to one.
|
|
func setCommentResolvedAt(t *testing.T, id string, at time.Time) {
|
|
t.Helper()
|
|
if _, err := testPool.Exec(context.Background(),
|
|
`UPDATE comment SET resolved_at = $2, resolved_by_type = 'member', resolved_by_id = $3 WHERE id = $1`,
|
|
id, at, testUserID,
|
|
); err != nil {
|
|
t.Fatalf("set resolved_at for %s: %v", id, err)
|
|
}
|
|
}
|
|
|
|
// byID indexes a response slice for per-comment fold-field assertions.
|
|
func byID(rows []CommentResponse) map[string]CommentResponse {
|
|
m := make(map[string]CommentResponse, len(rows))
|
|
for _, r := range rows {
|
|
m[r.ID] = r
|
|
}
|
|
return m
|
|
}
|
|
|
|
// assertFolded asserts a comment carries the resolved-thread fold annotation
|
|
// with the expected dropped count.
|
|
func assertFolded(t *testing.T, c CommentResponse, wantCount int, ctx string) {
|
|
t.Helper()
|
|
if c.ThreadResolved == nil || !*c.ThreadResolved {
|
|
t.Fatalf("%s: expected thread_resolved=true on %s, got %v", ctx, c.ID, c.ThreadResolved)
|
|
}
|
|
if c.FoldedCount == nil {
|
|
t.Fatalf("%s: expected folded_count on %s, got nil", ctx, c.ID)
|
|
}
|
|
if *c.FoldedCount != wantCount {
|
|
t.Fatalf("%s: folded_count on %s got %d want %d", ctx, c.ID, *c.FoldedCount, wantCount)
|
|
}
|
|
}
|
|
|
|
// assertNotFolded asserts a comment carries no fold annotation (every comment
|
|
// outside a resolved thread, and every non-root comment).
|
|
func assertNotFolded(t *testing.T, c CommentResponse, ctx string) {
|
|
t.Helper()
|
|
if c.ThreadResolved != nil {
|
|
t.Fatalf("%s: expected no thread_resolved on %s, got %v", ctx, c.ID, *c.ThreadResolved)
|
|
}
|
|
if c.FoldedCount != nil {
|
|
t.Fatalf("%s: expected no folded_count on %s, got %v", ctx, c.ID, *c.FoldedCount)
|
|
}
|
|
}
|
|
|
|
// TestListComments_FoldReplyResolvedKeepsRootAndConclusion pins the core
|
|
// reply-resolved contract: a thread whose CONCLUSION is a reply collapses to
|
|
// root + that reply, dropping every other reply (including ones posted after the
|
|
// conclusion — matches the human timeline's foldedReplies filter). The fixture's
|
|
// thread1 (root1 → r1a, r1b → r1b1) has its conclusion on r1b; r1a and the
|
|
// nested r1b1 fold away. The unresolved thread2 is returned in full.
|
|
func TestListComments_FoldReplyResolvedKeepsRootAndConclusion(t *testing.T) {
|
|
if testHandler == nil || testPool == nil {
|
|
t.Skip("database not available")
|
|
}
|
|
fx := newCommentListFixture(t)
|
|
setCommentResolvedAt(t, fx.R1b, fx.Base.Add(20*time.Minute))
|
|
|
|
_, rows := listComments(t, fx.IssueID, "fold=true")
|
|
// Order is the chronological default with the folded replies removed.
|
|
eqIDs(t, ids(rows), []string{fx.Root1, fx.R1b, fx.Root2, fx.R2a, fx.R2b}, "reply-resolved fold")
|
|
|
|
m := byID(rows)
|
|
assertFolded(t, m[fx.Root1], 2, "reply-resolved") // r1a + r1b1 dropped
|
|
assertNotFolded(t, m[fx.R1b], "conclusion reply is not annotated")
|
|
assertNotFolded(t, m[fx.Root2], "unresolved thread root")
|
|
assertNotFolded(t, m[fx.R2a], "unresolved reply")
|
|
}
|
|
|
|
// TestListComments_FoldRootResolvedKeepsRootOnly pins the root-resolved
|
|
// contract: resolving the ROOT collapses the whole thread to a single line (the
|
|
// root), dropping every reply. thread2's root2 is resolved; r2a and r2b fold
|
|
// away. thread1 stays full.
|
|
func TestListComments_FoldRootResolvedKeepsRootOnly(t *testing.T) {
|
|
if testHandler == nil || testPool == nil {
|
|
t.Skip("database not available")
|
|
}
|
|
fx := newCommentListFixture(t)
|
|
setCommentResolvedAt(t, fx.Root2, fx.Base.Add(20*time.Minute))
|
|
|
|
_, rows := listComments(t, fx.IssueID, "fold=true")
|
|
eqIDs(t, ids(rows), []string{fx.Root1, fx.R1a, fx.R1b, fx.R1b1, fx.Root2}, "root-resolved fold")
|
|
|
|
m := byID(rows)
|
|
assertFolded(t, m[fx.Root2], 2, "root-resolved") // r2a + r2b dropped
|
|
for _, id := range []string{fx.Root1, fx.R1a, fx.R1b, fx.R1b1} {
|
|
assertNotFolded(t, m[id], "unresolved thread1 comment")
|
|
}
|
|
}
|
|
|
|
// TestListComments_FoldNoOpWhenNothingResolved guards the cost-saving promise's
|
|
// floor: on an issue with no resolutions, fold=true returns the exact same set
|
|
// as the default list, with no annotations. Folding only ever removes settled
|
|
// discussion; it never changes an active issue's read.
|
|
func TestListComments_FoldNoOpWhenNothingResolved(t *testing.T) {
|
|
if testHandler == nil || testPool == nil {
|
|
t.Skip("database not available")
|
|
}
|
|
fx := newCommentListFixture(t)
|
|
|
|
_, rows := listComments(t, fx.IssueID, "fold=true")
|
|
eqIDs(t, ids(rows),
|
|
[]string{fx.Root1, fx.R1a, fx.R1b, fx.R1b1, fx.Root2, fx.R2a, fx.R2b},
|
|
"fold no-op")
|
|
for _, r := range rows {
|
|
assertNotFolded(t, r, "no resolutions present")
|
|
}
|
|
}
|
|
|
|
// TestListComments_FoldLatestReplyWins proves the projection is total even if
|
|
// the single-resolution-per-thread write invariant is ever violated: with both
|
|
// r1a and r1b resolved, the LATEST resolved_at (r1b) is THE conclusion, matching
|
|
// deriveThreadResolution. root1 + r1b survive; r1a (the earlier resolution) and
|
|
// the nested r1b1 fold away.
|
|
func TestListComments_FoldLatestReplyWins(t *testing.T) {
|
|
if testHandler == nil || testPool == nil {
|
|
t.Skip("database not available")
|
|
}
|
|
fx := newCommentListFixture(t)
|
|
setCommentResolvedAt(t, fx.R1a, fx.Base.Add(5*time.Minute))
|
|
setCommentResolvedAt(t, fx.R1b, fx.Base.Add(20*time.Minute)) // later → wins
|
|
|
|
_, rows := listComments(t, fx.IssueID, "fold=true")
|
|
eqIDs(t, ids(rows), []string{fx.Root1, fx.R1b, fx.Root2, fx.R2a, fx.R2b}, "latest reply wins")
|
|
assertFolded(t, byID(rows)[fx.Root1], 2, "latest-wins fold")
|
|
}
|
|
|
|
// TestListComments_FoldComposesWithRecent confirms fold applies per thread under
|
|
// the --recent grouping (the assignment-entry read the prompt steers agents to).
|
|
// recent returns both threads; the reply-resolved thread1 folds to root1 + r1b
|
|
// while the unresolved thread2 stays full.
|
|
func TestListComments_FoldComposesWithRecent(t *testing.T) {
|
|
if testHandler == nil || testPool == nil {
|
|
t.Skip("database not available")
|
|
}
|
|
fx := newCommentListFixture(t)
|
|
setCommentResolvedAt(t, fx.R1b, fx.Base.Add(20*time.Minute))
|
|
|
|
v := url.Values{}
|
|
v.Set("fold", "true")
|
|
v.Set("recent", "10")
|
|
_, rows := listComments(t, fx.IssueID, v.Encode())
|
|
eqIDs(t, ids(rows), []string{fx.Root1, fx.R1b, fx.Root2, fx.R2a, fx.R2b}, "fold + recent")
|
|
assertFolded(t, byID(rows)[fx.Root1], 2, "fold + recent")
|
|
}
|
|
|
|
// TestListComments_FoldComposesWithSummary confirms the two agent-token
|
|
// projections stack: fold drops the settled replies, then summary's per-comment
|
|
// clip still runs over what remains. The surviving root carries BOTH the fold
|
|
// annotation and the summary's content_truncated marker.
|
|
func TestListComments_FoldComposesWithSummary(t *testing.T) {
|
|
if testHandler == nil || testPool == nil {
|
|
t.Skip("database not available")
|
|
}
|
|
fx := newCommentListFixture(t)
|
|
setCommentResolvedAt(t, fx.R1b, fx.Base.Add(20*time.Minute))
|
|
|
|
v := url.Values{}
|
|
v.Set("fold", "true")
|
|
v.Set("summary", "true")
|
|
_, rows := listComments(t, fx.IssueID, v.Encode())
|
|
eqIDs(t, ids(rows), []string{fx.Root1, fx.R1b, fx.Root2, fx.R2a, fx.R2b}, "fold + summary")
|
|
|
|
root1 := byID(rows)[fx.Root1]
|
|
assertFolded(t, root1, 2, "fold + summary")
|
|
if root1.ContentTruncated == nil {
|
|
t.Fatalf("fold + summary: expected content_truncated marker on root1, got nil")
|
|
}
|
|
}
|
|
|
|
// TestListComments_FoldRejectsPartialThreadModes pins the safety boundary: fold
|
|
// needs whole threads to compute a resolution, so it is rejected on the
|
|
// partial-thread reads (since, tail) and on roots_only (no replies to fold).
|
|
// Returning 400 — rather than silently folding a partial thread — is what stops
|
|
// the projection from dropping a resolution that was never fetched.
|
|
func TestListComments_FoldRejectsPartialThreadModes(t *testing.T) {
|
|
if testHandler == nil || testPool == nil {
|
|
t.Skip("database not available")
|
|
}
|
|
fx := newCommentListFixture(t)
|
|
|
|
since := fx.Base.Add(5 * time.Minute).UTC().Format(time.RFC3339Nano)
|
|
for _, tc := range []struct {
|
|
name string
|
|
query func() string
|
|
}{
|
|
{name: "fold + since", query: func() string {
|
|
v := url.Values{}
|
|
v.Set("fold", "true")
|
|
v.Set("since", since)
|
|
return v.Encode()
|
|
}},
|
|
{name: "fold + tail", query: func() string {
|
|
v := url.Values{}
|
|
v.Set("fold", "true")
|
|
v.Set("thread", fx.Root1)
|
|
v.Set("tail", "2")
|
|
return v.Encode()
|
|
}},
|
|
{name: "fold + roots_only", query: func() string {
|
|
v := url.Values{}
|
|
v.Set("fold", "true")
|
|
v.Set("roots_only", "true")
|
|
return v.Encode()
|
|
}},
|
|
} {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
w, _ := listComments(t, fx.IssueID, tc.query())
|
|
if w.Code != http.StatusBadRequest {
|
|
t.Fatalf("%s: expected 400, got %d: %s", tc.name, w.Code, w.Body.String())
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestListComments_FoldComposesWithThread confirms fold applies to an untailed
|
|
// single-thread read: reading just the reply-resolved thread1 returns root1 +
|
|
// conclusion, dropping the intermediate replies.
|
|
func TestListComments_FoldComposesWithThread(t *testing.T) {
|
|
if testHandler == nil || testPool == nil {
|
|
t.Skip("database not available")
|
|
}
|
|
fx := newCommentListFixture(t)
|
|
setCommentResolvedAt(t, fx.R1b, fx.Base.Add(20*time.Minute))
|
|
|
|
v := url.Values{}
|
|
v.Set("fold", "true")
|
|
v.Set("thread", fx.R1a) // anchor on a reply; server resolves to root1
|
|
_, rows := listComments(t, fx.IssueID, v.Encode())
|
|
eqIDs(t, ids(rows), []string{fx.Root1, fx.R1b}, "fold + thread")
|
|
assertFolded(t, byID(rows)[fx.Root1], 2, "fold + thread")
|
|
}
|