From b35917be8cc383f80f2f624cb9d7cadd1912defa Mon Sep 17 00:00:00 2001 From: ngthluu Date: Sat, 25 Jul 2026 17:51:21 +0700 Subject: [PATCH] fix: strip loope's own status comments from the injected issue content MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit FetchIssueContent rendered every comment on the issue into the prompt, including the daemon's own chatter. The pickup comment is posted before the fetch, so it landed in the prompt even on the first run; on a re-run the park comments (with their full error dumps), PR links and stop notices piled up too, so the model read a transcript of previous runs as if it were part of the report. Every status comment now ends with a hidden `` marker and FetchIssueContent drops marked comments. Two judgment calls, made headlessly: - needs-info is deliberately NOT marked: it carries the numbered questions a human answers in the following comment, so stripping it would orphan the answer. - comments posted before the marker existed are matched by the exact opening text of each template (legacyBotStatusPrefixes), so issues already carrying chatter β€” like #38 itself β€” are cleaned up too, without risking a human comment. The UAT checklist the tool appends to the issue *body* is left in place: it states what the change must satisfy, so it is context, not noise. Closes #38 --- ai/prompts/comments.md.tmpl | 16 +++++++--- github.go | 18 +++++++++-- github_test.go | 62 +++++++++++++++++++++++++++++++++++++ loop.go | 40 +++++++++++++++++++++++- prompts.go | 1 + prompts_golden_test.go | 18 ++++++----- 6 files changed, 140 insertions(+), 15 deletions(-) diff --git a/ai/prompts/comments.md.tmpl b/ai/prompts/comments.md.tmpl index 7add249..b2d0059 100644 --- a/ai/prompts/comments.md.tmpl +++ b/ai/prompts/comments.md.tmpl @@ -1,6 +1,10 @@ -{{define "pickup"}}πŸ€– Picked up ({{.Kind}} flow). Branch: `{{.Branch}}`{{end}} +{{define "pickup"}}πŸ€– Picked up ({{.Kind}} flow). Branch: `{{.Branch}}` -{{define "already-done"}}πŸ€– Already implemented β€” closing. {{.Reason}}{{end}} +{{.BotMarker}}{{end}} + +{{define "already-done"}}πŸ€– Already implemented β€” closing. {{.Reason}} + +{{.BotMarker}}{{end}} {{define "needs-info"}}πŸ€– Not confident enough to implement (confidence {{.Score}}/100). Answer the numbered questions below in a comment, then remove the `{{.Label}}` label to re-queue: @@ -18,9 +22,13 @@ Remove the `{{.Label}}` label to queue a fresh attempt β€” any worktree, branch {{.Error}} ```` -{{end}}{{end}} +{{end}} + +{{.BotMarker}}{{end}} + +{{define "pr-comment"}}πŸ€– PR: {{.URL}} -{{define "pr-comment"}}πŸ€– PR: {{.URL}}{{end}} +{{.BotMarker}}{{end}} {{define "pr-title"}}{{.Title}} (#{{.Number}}){{end}} diff --git a/github.go b/github.go index 58645e7..c127854 100644 --- a/github.go +++ b/github.go @@ -154,10 +154,22 @@ func (g *GitHub) FetchIssueContent(ctx context.Context, num int) (string, error) } var b strings.Builder fmt.Fprintf(&b, "# %s (#%d)\n\n%s\n", detail.Title, num, detail.Body) - if len(detail.Comments) > 0 { + // The daemon comments on its own issues (pickup, park + error dump, PR link, + // ...), so without this filter every re-run feeds the model a growing + // transcript of the previous runs' status chatter as if it were part of the + // report. Only human-written comments β€” and the bot's needs-info questions, + // which the human's answer refers back to β€” are context. + var comments []string + for _, c := range detail.Comments { + if isBotStatusComment(c.Body) { + continue + } + comments = append(comments, fmt.Sprintf("\n@%s: %s\n", c.Author.Login, c.Body)) + } + if len(comments) > 0 { b.WriteString("\n## Comments\n") - for _, c := range detail.Comments { - fmt.Fprintf(&b, "\n@%s: %s\n", c.Author.Login, c.Body) + for _, c := range comments { + b.WriteString(c) } } return b.String(), nil diff --git a/github_test.go b/github_test.go index fde1149..a291ed5 100644 --- a/github_test.go +++ b/github_test.go @@ -2,6 +2,7 @@ package main import ( "context" + "encoding/json" "errors" "strings" "testing" @@ -201,6 +202,67 @@ func TestFetchIssueContent(t *testing.T) { } } +// The daemon's own status chatter (pickup, park + error dump, PR link, ...) is +// noise in the prompt the issue content becomes β€” and it piles up on every +// re-run. It must be stripped, both when carried by the marker and, for +// comments posted before the marker existed, by its leading text. +func TestFetchIssueContentDropsLoopeStatusComments(t *testing.T) { + comments := []string{ + pickupComment("bug", "ai/issue-7"), + parkComment("ai-rework", "", "dial tcp: i/o timeout"), + prComment("https://example.test/pr/1"), + alreadyDoneComment("already there."), + stoppedComment(), + // Legacy: posted before the marker was introduced. + "πŸ€– Picked up (feature flow). Branch: `ai/issue-7`", + "⏸ Stopped by user. Worktree, logs and session are preserved.", + } + var arr []string + for _, c := range comments { + b, _ := json.Marshal(struct { + Author struct { + Login string `json:"login"` + } `json:"author"` + Body string `json:"body"` + }{Body: c}) + arr = append(arr, string(b)) + } + arr = append(arr, + `{"author": {"login": "alice"}, "body": "repro attached"}`, + `{"author": {"login": "loope"}, "body": `+mustJSON(needsInfoComment(42, "ai-needs-info", "Which database?"))+`}`, + `{"author": {"login": "alice"}, "body": "1a, 2c"}`, + ) + f := &fakeRunner{queue: []rresp{{stdout: `{ + "title": "Crash on save", + "body": "It crashes.", + "comments": [` + strings.Join(arr, ",") + `] + }`}}} + g := testGitHub(f) + got, err := g.FetchIssueContent(context.Background(), 7) + if err != nil { + t.Fatal(err) + } + for _, bad := range []string{"Picked up", "Parked as", "PR: https", "Already implemented", "Stopped by user", "i/o timeout", botMarker} { + if strings.Contains(got, bad) { + t.Errorf("content still carries loope status text %q:\n%s", bad, got) + } + } + // A human comment, and the questions its answer refers to, must survive. + for _, want := range []string{"repro attached", "Which database?", "1a, 2c"} { + if !strings.Contains(got, want) { + t.Errorf("content missing %q:\n%s", want, got) + } + } +} + +func mustJSON(s string) string { + b, err := json.Marshal(s) + if err != nil { + panic(err) + } + return string(b) +} + func TestCreatePRReturnsURL(t *testing.T) { f := &fakeRunner{queue: []rresp{{stdout: "https://github.com/org/repo/pull/9\n"}}} g := testGitHub(f) diff --git a/loop.go b/loop.go index ce0b020..8bd4dfd 100644 --- a/loop.go +++ b/loop.go @@ -396,7 +396,7 @@ func (o *Orchestrator) pause(ctx context.Context, n int) { // stoppedComment is the fixed notice posted when a run is stopped by the user. func stoppedComment() string { - return "⏸ Stopped by user. Worktree, logs and session are preserved. Press Continue to re-queue it; the run continues in the same worktree." + return "⏸ Stopped by user. Worktree, logs and session are preserved. Press Continue to re-queue it; the run continues in the same worktree.\n\n" + botMarker } // Continue re-queues a stopped issue: it only rewrites labels/state on disk β€” @@ -518,6 +518,44 @@ func (o *Orchestrator) abort(ctx context.Context, n int, cause error) error { return o.park(ctx, n, cause) } +// botMarker tags the daemon's own status chatter β€” pickup, park, PR link, +// already-done, stopped. Like uatMarker it is an HTML comment, so it is +// invisible on GitHub while staying greppable in the raw body, and it lets +// FetchIssueContent strip that chatter back out instead of feeding the model a +// transcript of its own past runs (see isBotStatusComment). +// +// The needs-info comment is deliberately NOT tagged: it carries the numbered +// questions a human answers in the next comment, so removing it would orphan +// the answer. +const botMarker = "" + +// legacyBotStatusPrefixes recognise status comments posted before botMarker +// existed. They are the exact opening text of each tagged template, so nothing +// a human writes is mistaken for chatter, and needs-info ("πŸ€– Not confident +// enough…") is left alone here too. +var legacyBotStatusPrefixes = []string{ + "πŸ€– Picked up (", + "πŸ€– Parked as `", + "πŸ€– PR: ", + "πŸ€– Already implemented β€” closing.", + "⏸ Stopped by user.", +} + +// isBotStatusComment reports whether a comment is the daemon's own status +// chatter and so should be kept out of the issue content handed to Claude. +func isBotStatusComment(body string) bool { + if strings.Contains(body, botMarker) { + return true + } + trimmed := strings.TrimSpace(body) + for _, p := range legacyBotStatusPrefixes { + if strings.HasPrefix(trimmed, p) { + return true + } + } + return false +} + func pickupComment(kind, branch string) string { d := promptData() d["Kind"] = kind diff --git a/prompts.go b/prompts.go index 1541f7d..b996978 100644 --- a/prompts.go +++ b/prompts.go @@ -61,5 +61,6 @@ func promptData() map[string]any { "UATBeginSentinel": uatBeginSentinel, "UATEndSentinel": uatEndSentinel, "UATMarker": uatMarker, + "BotMarker": botMarker, } } diff --git a/prompts_golden_test.go b/prompts_golden_test.go index 441e92b..962a5ee 100644 --- a/prompts_golden_test.go +++ b/prompts_golden_test.go @@ -182,12 +182,12 @@ Respond with ONLY a JSON object, no other text: func TestGoldenPickupComment(t *testing.T) { check(t, "pickupComment", pickupComment("feature", "ai/issue-12"), - "πŸ€– Picked up (feature flow). Branch: `ai/issue-12`") + "πŸ€– Picked up (feature flow). Branch: `ai/issue-12`\n\n"+botMarker) } func TestGoldenAlreadyDoneComment(t *testing.T) { check(t, "alreadyDoneComment", alreadyDoneComment("The flag already exists."), - "πŸ€– Already implemented β€” closing. The flag already exists.") + "πŸ€– Already implemented β€” closing. The flag already exists.\n\n"+botMarker) } func TestGoldenNeedsInfoComment(t *testing.T) { @@ -198,28 +198,32 @@ func TestGoldenNeedsInfoComment(t *testing.T) { const parkHead = "\U0001f916 Parked as `ai-rework` β€” this issue will not be retried automatically.\n\n" + "Remove the `ai-rework` label to queue a fresh attempt β€” any worktree, branch and logs this run produced are preserved and reused, so no work is lost." +// parkTail is the hidden marker every status comment ends with, so +// FetchIssueContent can strip it back out of the next run's issue content. +const parkTail = "\n\n" + botMarker + func TestGoldenParkCommentFull(t *testing.T) { check(t, "parkComment(guidance+error)", parkComment("ai-rework", "Cause: network outage. Re-queue once connectivity is back.", "dial tcp: i/o timeout"), parkHead+"\n\nCause: network outage. Re-queue once connectivity is back."+ - "\n\n
Error detail\n\n````\ndial tcp: i/o timeout\n````\n\n
") + "\n\n
Error detail\n\n````\ndial tcp: i/o timeout\n````\n\n
"+parkTail) } func TestGoldenParkCommentNoGuidance(t *testing.T) { check(t, "parkComment(error only)", parkComment("ai-rework", "", "boom"), - parkHead+"\n\n
Error detail\n\n````\nboom\n````\n\n
") + parkHead+"\n\n
Error detail\n\n````\nboom\n````\n\n
"+parkTail) } func TestGoldenParkCommentNoError(t *testing.T) { check(t, "parkComment(guidance only)", parkComment("ai-rework", "Cause: x.", ""), - parkHead+"\n\nCause: x.") + parkHead+"\n\nCause: x."+parkTail) } func TestGoldenParkCommentBare(t *testing.T) { - check(t, "parkComment(bare)", parkComment("ai-rework", "", ""), parkHead) + check(t, "parkComment(bare)", parkComment("ai-rework", "", ""), parkHead+parkTail) } func TestGoldenPRComment(t *testing.T) { - check(t, "prComment", prComment("https://example.test/pr/1"), "πŸ€– PR: https://example.test/pr/1") + check(t, "prComment", prComment("https://example.test/pr/1"), "πŸ€– PR: https://example.test/pr/1\n\n"+botMarker) } func TestGoldenPRTitle(t *testing.T) {