Files
pansy/web/src/lib/history.test.ts
T
steveandClaude Opus 4.8 42246fd6b7
Build image / build-and-push (push) Successful in 9s
Gadfly review (reusable) / review (pull_request) Canceled after 5m38s
Adversarial Review (Gadfly) / review (pull_request) Canceled after 5m38s
Undo reported "nothing left to undo" after a successful undo
Found by running the real thing: the agent replaced a bed of garlic with
cucumbers, Undo restored the garlic correctly — and then said "Nothing left to
undo — this was already reversed."

The revert response carries the change set commitScope just inserted, and that
row has no per-op tally: Counts is populated by the history LIST query, not by
the insert. describeUndo treated a zero tally as "nothing happened", so a real
undo with eight revisions behind it reported itself as a no-op. Telling someone
an action did nothing when it just worked is about the worst thing a safety
feature can say, because the obvious next move is to do it again.

Fixed on both sides. The client now treats a NULL change set as the no-op
signal, which is what the server actually means by it — an empty tally is not
the same thing and must not be read as one. And RevertChangeSet now fills in the
counts, so the revert response and the list read describe the same change set
the same way, and a partial revert can say "2 of 3" from either.

The unit test missed it because the fixture I wrote populated counts, so it was
testing my assumption about the response rather than its actual shape. The new
tests pin the real thing: the service asserts a revert's counts match the fill
it undid AND match what the list read reports, and the client asserts an empty
tally still reads as "Undone."

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01H3zbym8Doka2d7D48maSgZ
2026-07-21 08:19:15 -04:00

151 lines
5.7 KiB
TypeScript

import { describe, expect, it } from 'vitest'
import { describeConflict, describeCounts, describeUndo, totalChanges, type ChangeSet } from './history'
function changeSet(over: Partial<ChangeSet> = {}): ChangeSet {
return {
id: 1,
gardenId: 1,
actorId: 1,
actorName: 'Steve',
source: 'ui',
summary: 'Did a thing',
counts: [],
createdAt: '2026-07-21T00:00:00Z',
...over,
}
}
describe('describeCounts', () => {
it('pluralizes per entity and names the operation', () => {
const cs = changeSet({
counts: [
{ entityType: 'planting', op: 'create', n: 3 },
{ entityType: 'object', op: 'update', n: 1 },
],
})
expect(describeCounts(cs)).toBe('3 plantings added, 1 bed changed')
})
it('says nothing rather than "0 changes"', () => {
expect(describeCounts(changeSet())).toBe('')
expect(describeCounts(changeSet({ counts: [{ entityType: 'object', op: 'create', n: 0 }] }))).toBe('')
})
})
describe('describeConflict', () => {
it('names the entity when it has a name', () => {
expect(describeConflict({ entityType: 'object', entityId: 4, reason: 'changed', name: 'North Bed' })).toBe(
'“North Bed” was edited since, so it was left alone',
)
})
it('falls back to the entity kind when it has no name', () => {
expect(describeConflict({ entityType: 'planting', entityId: 9, reason: 'missing' })).toBe(
'that planting no longer exists',
)
})
})
describe('describeUndo', () => {
const target = changeSet({ counts: [{ entityType: 'object', op: 'update', n: 3 }] })
// The bug this pair exists for: a real revert response carries a change set
// whose counts the server didn't populate. Reading that as "nothing happened"
// told the user a successful undo had done nothing.
it('trusts the change set, not the tally, for whether anything happened', () => {
const out = describeUndo({ id: 5 }, { changeSet: changeSet({ id: 6, counts: [] }), conflicts: [] })
expect(out).toEqual({ tone: 'ok', message: 'Undone.' })
})
it('is a plain success when nothing conflicted', () => {
const out = describeUndo(target, {
changeSet: changeSet({ id: 2, counts: [{ entityType: 'object', op: 'update', n: 3 }] }),
conflicts: [],
})
expect(out).toEqual({ tone: 'ok', message: 'Undone.' })
})
// The server answers 200 with a null change set when every revision was
// already a no-op — reachable by undoing a creation whose object is gone.
// Claiming "Undone." there reports work that didn't happen.
it("doesn't claim to have undone a no-op", () => {
// A NULL change set — not an empty tally — is the server's no-op signal.
const out = describeUndo(target, { changeSet: null, conflicts: [] })
expect(out.tone).toBe('ok')
expect(out.message).toBe('Nothing left to undo — this was already reversed.')
})
// The case the issue was specific about: a partial revert really did change
// things, so reporting a bare failure would be a lie about what applied.
it('says how much applied and what was skipped', () => {
const out = describeUndo(target, {
changeSet: changeSet({ id: 2, counts: [{ entityType: 'object', op: 'update', n: 2 }] }),
conflicts: [{ entityType: 'object', entityId: 7, reason: 'changed', name: 'North Bed' }],
})
expect(out.tone).toBe('partial')
expect(out.message).toBe('2 of 3 changes undone — “North Bed” was edited since, so it was left alone.')
})
it('is an error when nothing applied at all', () => {
const out = describeUndo(target, {
changeSet: null,
conflicts: [{ entityType: 'object', entityId: 7, reason: 'changed', name: 'North Bed' }],
})
expect(out.tone).toBe('error')
expect(out.message).toBe('Nothing was undone — “North Bed” was edited since, so it was left alone.')
})
it('lists every conflict, not just the first', () => {
const out = describeUndo(target, {
changeSet: changeSet({ id: 2, counts: [{ entityType: 'object', op: 'update', n: 1 }] }),
conflicts: [
{ entityType: 'object', entityId: 7, reason: 'changed', name: 'North Bed' },
{ entityType: 'planting', entityId: 8, reason: 'missing' },
],
})
expect(out.message).toContain('“North Bed”')
expect(out.message).toContain('that planting no longer exists')
})
})
describe('totalChanges', () => {
it('sums every tally', () => {
expect(
totalChanges(
changeSet({
counts: [
{ entityType: 'planting', op: 'create', n: 12 },
{ entityType: 'object', op: 'update', n: 1 },
],
}),
),
).toBe(13)
})
})
describe('describeUndo with an unknown total', () => {
// The chat panel offers Undo on a turn knowing only its change set id — the
// agent's reply carries the id, not the tally. Inventing a denominator to
// fill the sentence would be worse than not having one.
it("doesn't invent a denominator it was never given", () => {
const out = describeUndo(
{ id: 5 },
{
changeSet: changeSet({ id: 6, counts: [{ entityType: 'planting', op: 'delete', n: 2 }] }),
conflicts: [{ entityType: 'object', entityId: 7, reason: 'changed', name: 'North Bed' }],
},
)
expect(out.tone).toBe('partial')
expect(out.message).toBe('Partly undone — “North Bed” was edited since, so it was left alone.')
expect(out.message).not.toMatch(/\d+ of \d+/)
})
it('still reports a clean undo the same way', () => {
const out = describeUndo(
{ id: 5 },
{ changeSet: changeSet({ id: 6, counts: [{ entityType: 'object', op: 'update', n: 1 }] }), conflicts: [] },
)
expect(out).toEqual({ tone: 'ok', message: 'Undone.' })
})
})