Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 12 additions & 4 deletions ai/prompts/comments.md.tmpl
Original file line number Diff line number Diff line change
@@ -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:

Expand All @@ -18,9 +22,13 @@ Remove the `{{.Label}}` label to queue a fresh attempt — any worktree, branch
{{.Error}}
````

</details>{{end}}{{end}}
</details>{{end}}

{{.BotMarker}}{{end}}

{{define "pr-comment"}}🤖 PR: {{.URL}}

{{define "pr-comment"}}🤖 PR: {{.URL}}{{end}}
{{.BotMarker}}{{end}}

{{define "pr-title"}}{{.Title}} (#{{.Number}}){{end}}

Expand Down
18 changes: 15 additions & 3 deletions github.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
62 changes: 62 additions & 0 deletions github_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package main

import (
"context"
"encoding/json"
"errors"
"strings"
"testing"
Expand Down Expand Up @@ -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)
Expand Down
40 changes: 39 additions & 1 deletion loop.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 —
Expand Down Expand Up @@ -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 = "<!-- loope:bot -->"

// 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
Expand Down
1 change: 1 addition & 0 deletions prompts.go
Original file line number Diff line number Diff line change
Expand Up @@ -61,5 +61,6 @@ func promptData() map[string]any {
"UATBeginSentinel": uatBeginSentinel,
"UATEndSentinel": uatEndSentinel,
"UATMarker": uatMarker,
"BotMarker": botMarker,
}
}
18 changes: 11 additions & 7 deletions prompts_golden_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -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<details><summary>Error detail</summary>\n\n````\ndial tcp: i/o timeout\n````\n\n</details>")
"\n\n<details><summary>Error detail</summary>\n\n````\ndial tcp: i/o timeout\n````\n\n</details>"+parkTail)
}

func TestGoldenParkCommentNoGuidance(t *testing.T) {
check(t, "parkComment(error only)", parkComment("ai-rework", "", "boom"),
parkHead+"\n\n<details><summary>Error detail</summary>\n\n````\nboom\n````\n\n</details>")
parkHead+"\n\n<details><summary>Error detail</summary>\n\n````\nboom\n````\n\n</details>"+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) {
Expand Down
Loading