Suggestions: stop re-nagging resolved edits + fix typographic anchoring
Two fixes for the "accept Petal's change, then it nags about the same sentence moments later" report: - replacePending now suppresses any freshly-generated suggestion whose original->replacement matches one the user already accepted or dismissed for that doc. The model has no memory between passes, so without this it re-proposes the identical edit on the next checkpoint. - findRange anchored suggestions by exact string match, which missed whenever the model echoed an `original` with plain ASCII (straight quotes, --, ...) while the document held the Typography-converted glyphs (curly quotes, em-dash, single-char ellipsis). The miss meant no highlight AND a silent no-op on accept, which then fed the re-nag above. foldTypography canonicalizes those variants (with a source index map for length changes) so matching survives the mismatch. Covered by a server-side regression test (accept+dismiss then re-check returns nothing) and frontend unit tests for the fold and anchoring. Claude-Session: https://claude.ai/code/session_016Yr6jELuRc7hyzYLccQKZd
This commit is contained in:
@@ -127,6 +127,44 @@ func TestCheckpointFlow(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestResolvedSuggestionsNotReproposed proves a checkpoint won't re-nag a
|
||||
// sentence the user already accepted or dismissed: the model returns the same
|
||||
// raw batch on the next pass (it has no memory), but the resolved edits are
|
||||
// suppressed. This is the "accept it, then it nags again moments later" bug.
|
||||
func TestResolvedSuggestionsNotReproposed(t *testing.T) {
|
||||
client := &stubClient{response: `{"suggestions":[
|
||||
{"original":"I has","replacement":"I have","explanation":"subject-verb agreement","type":"grammar"},
|
||||
{"original":"two apple","replacement":"two apples","explanation":"plural noun","type":"grammar"}
|
||||
]}`}
|
||||
srv, docID, h := newTestServer(t, client)
|
||||
// Zero the checkpoint floor so the second pass runs instead of being throttled.
|
||||
h.Limit = llm.NewRateLimiter(0)
|
||||
|
||||
rec := do(t, srv, http.MethodPost, "/docs/"+docID+"/check", "")
|
||||
var got []db.Suggestion
|
||||
_ = json.Unmarshal(rec.Body.Bytes(), &got)
|
||||
if len(got) != 2 {
|
||||
t.Fatalf("first pass: want 2, got %d", len(got))
|
||||
}
|
||||
|
||||
// Accept one, dismiss the other.
|
||||
do(t, srv, http.MethodPost, "/suggestions/"+got[0].ID+"/accept", "")
|
||||
do(t, srv, http.MethodPost, "/suggestions/"+got[1].ID+"/dismiss", "")
|
||||
|
||||
// Second checkpoint returns the identical raw batch — both must be suppressed.
|
||||
rec = do(t, srv, http.MethodPost, "/docs/"+docID+"/check", "")
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("second check: code=%d body=%s", rec.Code, rec.Body)
|
||||
}
|
||||
var again []db.Suggestion
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &again); err != nil {
|
||||
t.Fatalf("decode: %v", err)
|
||||
}
|
||||
if len(again) != 0 {
|
||||
t.Fatalf("resolved suggestions should not be re-proposed, got %d: %+v", len(again), again)
|
||||
}
|
||||
}
|
||||
|
||||
// TestVoicePassCoexists proves the voice pass and the grammar checkpoint own
|
||||
// disjoint pending families: running one never wipes the other's flags, and
|
||||
// each endpoint returns the unified pending set.
|
||||
|
||||
Reference in New Issue
Block a user