Compare definitions by meaning, and stop miscounting what was recorded
Two problems found by deploying the previous commits to production.
FIRST: the rewrite guard compared raw Markdown, so it refused a publish over
formatting. The authoring UI re-serialises a definition when somebody saves it
— writing `webSearch: false` where the hand-authored file omitted the key, and
ordering the frontmatter its own way — and the parser reads absent and false
identically (agent.go: `data["webSearch"] == true`). A definition nobody
meaningfully changed stopped a deploy. Refusing a change that is not a change
is still a bug, even though it fails safe.
definition.SameAgent and SameSkill compare the parsed definition instead, and
are deliberately conservative, because the two ways of being wrong are not
equally bad. A false difference blocks a deploy: visible, recoverable. A false
SAMENESS lets a changed agent overwrite an approved version silently, which is
the thing versioning exists to prevent. So:
- The body is compared verbatim. Agent.Body carries `json:"-"`, so a
comparison that only marshalled the struct would call a completely
rewritten system prompt "unchanged". There is a test that fails loudly on
exactly that, because it is the mistake this design invites.
- List ORDER stays significant. loader.go resolves Skills in order and that
order reaches prompt assembly, so two definitions listing the same skills
differently are still different. A deploy that only reorders still has to
raise its version. That is a limit, recorded in a test rather than left to
be discovered: loosening it needs somebody to decide skill order cannot
matter, which is not a decision to bury in a comparison function.
What it absorbs is exactly what the round trip produces: frontmatter key order,
whitespace, and a defaulted value written out in full.
SECOND: importagents reported "9 agent version(s) recorded" on a run that
recorded nothing. The counter incremented on every successful Snapshot call,
and Snapshot returns nil for the idempotent no-op as well as for a real insert.
The skill counter was already honest; the agent one was not. snapshotAgent now
distinguishes recorded / conflict / already-present, and only the first counts.
Verified locally: 1 on the run that added activity-agent v2, 0 on the re-run,
where it previously said 9. A number that says nine every time is one nobody
checks on the day it matters.
Full suite green against PostgreSQL, only TestLive* skipped.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PJvibeSc1JYXjatankqM1g
This commit is contained in:
@@ -1113,3 +1113,65 @@ Nothing here is published.
|
||||
t.Errorf("an inactive skill was versioned: %d, want 0", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestReserialisedRepublishIsNotARewrite is the other half of
|
||||
// TestPublishedVersionCannotBeRewritten.
|
||||
//
|
||||
// The guard against rewriting a published version compared raw Markdown, so it
|
||||
// refused a definition that had been through the authoring UI and come back
|
||||
// re-serialised — same agent, different bytes. In production that stopped a
|
||||
// deploy on a `webSearch: false` written out where the hand-authored file had
|
||||
// left the key absent, which the parser defaults to false anyway.
|
||||
//
|
||||
// Refusing a change that is not a change is still a bug, even though it fails
|
||||
// safe. The comparison is definition.SameAgent now; this pins the behaviour at
|
||||
// the API rather than in a unit test, because it is the deploy that broke.
|
||||
func TestReserialisedRepublishIsNotARewrite(t *testing.T) {
|
||||
r := newRBAC(t)
|
||||
|
||||
const published = `---
|
||||
id: reserialised-agent
|
||||
name: Reserialised Agent
|
||||
description: published once, saved again by the editor
|
||||
status: published
|
||||
version: 1
|
||||
pages:
|
||||
- candidates
|
||||
---
|
||||
|
||||
## Instructions
|
||||
The instructions, unchanged throughout.
|
||||
`
|
||||
res := r.as(r.admin, "POST", "/api/v1/agent-definitions", map[string]any{
|
||||
"markdown": published, "visibility": "personal",
|
||||
})
|
||||
if res.code != http.StatusCreated {
|
||||
t.Fatalf("create: status %d (%v)", res.code, res.body)
|
||||
}
|
||||
id, _ := res.record(t)["id"].(string)
|
||||
|
||||
// What the editor writes back: the same agent, with a defaulted key made
|
||||
// explicit. Nothing about the agent has changed.
|
||||
reserialised := strings.Replace(published,
|
||||
"pages:\n - candidates\n", "pages:\n - candidates\nwebSearch: false\n", 1)
|
||||
if reserialised == published {
|
||||
t.Fatal("fixture did not change; the test is not testing anything")
|
||||
}
|
||||
res = r.as(r.admin, "PATCH", "/api/v1/agent-definitions/"+id,
|
||||
map[string]any{"markdown": reserialised})
|
||||
if res.code != http.StatusOK {
|
||||
t.Fatalf("a re-serialised republish was refused: status %d, want 200 (%v)",
|
||||
res.code, res.body)
|
||||
}
|
||||
|
||||
// And the guard is still armed: a real change at the same version is
|
||||
// still refused.
|
||||
changed := strings.Replace(reserialised,
|
||||
"The instructions, unchanged throughout.", "Different instructions.", 1)
|
||||
res = r.as(r.admin, "PATCH", "/api/v1/agent-definitions/"+id,
|
||||
map[string]any{"markdown": changed})
|
||||
if res.code != http.StatusConflict {
|
||||
t.Errorf("a real change at a published version: status %d, want 409 (%v)",
|
||||
res.code, res.body)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user