mirror of
https://gh-proxy.org/https://github.com/klinsmaya/multica.git
synced 2026-11-09 01:46:46 +08:00
Status leaves the compared set, so the snapshot is now just the two body fields. The comparison answers one question — must the agent re-read the issue BODY? — and status never helped answer it: its current value ships on every claim, so the agent learns it without a read. Worse, comparing it worked against the feature. The workflow has the agent set in_progress when it starts and in_review when it delivers, so the agent's OWN bookkeeping came back the next turn as "changed: status" and demanded a body re-read that nothing in the body justified. Replaying this issue's 21 follow-ups, that alone cut "unchanged" from 17 to 4. The earlier rationale for keeping it — a free signal that somebody intervened — was wrong on both counts: it is not free, and the agent can see an intervention by comparing the shipped status against the value it last set itself. issueSnapshotVersion stays 1, and this time rows in the old shape DO exist. That is still correct: removing a field is backward compatible, because every field the new code reads is present in an old row and still means the same thing, and the stale "status" key is ignored. The comment now states the actual bump rule rather than "the set changed" — bump when an old row can no longer answer the current question, which means adding a field or changing how a value is derived, not removing one. A test pins the legacy shape comparing correctly in both the unchanged and the changed direction, so that claim is checked rather than asserted. No migration: the column is JSONB and the schema is unchanged. TestClaimTaskByRuntime_AssigneeAndPriorityAreOutOfScope becomes NonBodyFieldsAreOutOfScope and now moves status alongside assignee and priority, asserting the current status still ships with its NEW value — without that, the fixture's UPDATE could silently not apply and the empty changed-list would prove nothing. Co-authored-by: multica-agent <[email protected]>
111 lines
4.3 KiB
Go
111 lines
4.3 KiB
Go
package execenv
|
|
|
|
import (
|
|
"strings"
|
|
"testing"
|
|
)
|
|
|
|
const issueStateTestIssueID = "55555555-6666-7777-8888-999999999999"
|
|
|
|
// coldIssueRead is the instruction every non-resumed run has always been
|
|
// handed. It is spelled out here rather than taken from the helper so a silent
|
|
// reword of the cold path fails this test instead of passing it.
|
|
const coldIssueRead = "Start by running `multica issue get " + issueStateTestIssueID +
|
|
" --output json` to understand your task, then decide how to proceed.\n\n"
|
|
|
|
// TestBuildIssueStateHintUnchangedReplacesTheRead pins the one rendering that
|
|
// may stand in for workflow step 1: a real resume plus a server-computed
|
|
// comparison that found nothing. The read survives as a conditional fallback,
|
|
// never as an imperative.
|
|
func TestBuildIssueStateHintUnchangedReplacesTheRead(t *testing.T) {
|
|
t.Parallel()
|
|
hint := BuildIssueStateHint(issueStateTestIssueID, "in_progress", "agent", "agent-1", nil, true, true)
|
|
|
|
for _, want := range []string{
|
|
"The issue is unchanged since your last run",
|
|
"the server compared title and description",
|
|
"status: in_progress; assignee: agent agent-1",
|
|
"That answers workflow step 1",
|
|
"only if resumed memory is not enough",
|
|
} {
|
|
if !strings.Contains(hint, want) {
|
|
t.Errorf("unchanged hint missing %q\n---\n%s", want, hint)
|
|
}
|
|
}
|
|
if strings.Contains(hint, "Start by running") {
|
|
t.Errorf("unchanged hint must not keep the unconditional read imperative\n---\n%s", hint)
|
|
}
|
|
}
|
|
|
|
// TestBuildIssueStateHintNamesChangedFields: when the comparison DID find
|
|
// something, the agent is told which fields moved and reads the issue. Naming
|
|
// them is the point — a bare "it changed" leaves the agent diffing blind.
|
|
func TestBuildIssueStateHintNamesChangedFields(t *testing.T) {
|
|
t.Parallel()
|
|
hint := BuildIssueStateHint(issueStateTestIssueID, "todo", "member", "user-9",
|
|
[]string{"description", "status"}, true, true)
|
|
|
|
for _, want := range []string{
|
|
"Since your last run the issue changed: description, status",
|
|
"status: todo; assignee: member user-9",
|
|
"Read it: `multica issue get " + issueStateTestIssueID + " --output json`",
|
|
} {
|
|
if !strings.Contains(hint, want) {
|
|
t.Errorf("changed hint missing %q\n---\n%s", want, hint)
|
|
}
|
|
}
|
|
if strings.Contains(hint, "unchanged") {
|
|
t.Errorf("changed hint must not claim the issue is unchanged\n---\n%s", hint)
|
|
}
|
|
}
|
|
|
|
// TestBuildIssueStateHintFallsBackToTheRead pins the safe default. Every state
|
|
// that is not "resumed AND compared" keeps the pre-existing instruction byte
|
|
// for byte: no prior context to continue from, or nothing looked.
|
|
func TestBuildIssueStateHintFallsBackToTheRead(t *testing.T) {
|
|
t.Parallel()
|
|
cases := []struct {
|
|
name string
|
|
status string
|
|
changed []string
|
|
known, resume bool
|
|
}{
|
|
{name: "cold start", status: "", known: false, resume: false},
|
|
{name: "resumed but delta unknown", status: "todo", known: false, resume: true},
|
|
{name: "compared but resume dropped", status: "todo", known: true, resume: false},
|
|
// An old server sends no issue state at all. Reporting a status we do
|
|
// not have would be worse than the extra read, so the empty status
|
|
// forces the cold rendering even with the flag somehow set.
|
|
{name: "known flag without issue state", status: "", known: true, resume: true},
|
|
{name: "changed fields without a real resume", status: "todo", changed: []string{"title"}, known: true, resume: false},
|
|
}
|
|
for _, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
t.Parallel()
|
|
hint := BuildIssueStateHint(issueStateTestIssueID, tc.status, "agent", "agent-1", tc.changed, tc.known, tc.resume)
|
|
if hint != coldIssueRead {
|
|
t.Errorf("expected the unconditional read, got:\n%q", hint)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestBuildIssueStateHintRendersUnassigned: a half-populated assignee pair is
|
|
// not an assignment, and must not read as one.
|
|
func TestBuildIssueStateHintRendersUnassigned(t *testing.T) {
|
|
t.Parallel()
|
|
for _, tc := range []struct{ name, typ, id string }{
|
|
{"both empty", "", ""},
|
|
{"type without id", "agent", ""},
|
|
{"id without type", "", "agent-1"},
|
|
} {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
t.Parallel()
|
|
hint := BuildIssueStateHint(issueStateTestIssueID, "todo", tc.typ, tc.id, nil, true, true)
|
|
if !strings.Contains(hint, "assignee: unassigned") {
|
|
t.Errorf("expected an unassigned label, got:\n%s", hint)
|
|
}
|
|
})
|
|
}
|
|
}
|