fix(goal): emit bare GoalRef in fold lastRef and goal/changed notifications

goalChangeRef returned the full GoalSnapshot for every snapshot operation,
so foldGoal(...).lastRef and the goal/changed notification ref carried
objective, phase, and maxGoalRounds fields instead of the declared
GoalRef { id, revision }. Only the clear tombstone was bare. Emit an exact
{ id, revision } ref for snapshot changes and pin the contract with a
regression test covering create/edit/block notifications and the fold.
This commit is contained in:
Tianyi Cui
2026-08-02 02:25:24 +08:00
parent e10239b3fa
commit 23610c6abd
2 files changed
+23 -2

No files matched your search

+3 -1
View File
@@ -261,7 +261,9 @@ function validateSnapshotTransition(
* @returns stable identity used to reconcile a deferred change with its log event.
*/
export function goalChangeRef(change: GoalChangeMeta): GoalRef {
return change.operation === 'clear' ? change.cleared : change.goal
return change.operation === 'clear'
? change.cleared
: { id: change.goal.id, revision: change.goal.revision }
}
/**
+20 -1
View File
@@ -11,7 +11,7 @@ import GoalService, {
foldGoal,
renderGoalChange,
} from '@deepseek-ai/dsh-goal'
import type { GoalChangeMeta, GoalRef, GoalSnapshotChangeMeta } from '@deepseek-ai/dsh-goal'
import type { GoalChangeMeta, GoalChanged, GoalRef, GoalSnapshotChangeMeta } from '@deepseek-ai/dsh-goal'
type DeferredInjection = UserMessage
@@ -381,6 +381,25 @@ describe('GoalService mutations', () => {
expect(next.id).not.toBe(goal.id)
})
it('emits bare compare-and-set refs in folded lastRef and goal/changed notifications', async () => {
const { ctx, agent, session } = await harness()
const seen: GoalChanged['ref'][] = []
ctx.on('goal/changed', (_subject, change) => { seen.push(change.ref) })
const created = ctx.goals.create(agent, { objective: 'bare refs', maxGoalRounds: 3 })
const edited = ctx.goals.edit(agent, created, { objective: 'bare refs edited' })
const blocked = ctx.goals.block(agent, edited, { code: 'bare-blocker', message: 'Bare refs.' })
// GoalRef is exactly { id, revision }: every notification ref must be bare.
for (const ref of seen) {
expect(Object.keys(ref).sort()).toEqual(['id', 'revision'])
expect(ref).toEqual({ id: created.id, revision: ref.revision })
}
expect(seen).toHaveLength(3)
// The durable fold's lastRef is the same bare ref, not a full snapshot.
const folded = foldGoal(session.events)
expect(folded.lastRef).toEqual({ id: blocked.id, revision: blocked.revision })
expect(Object.keys(folded.lastRef as object).sort()).toEqual(['id', 'revision'])
})
it('keeps per-goal mutation timestamps monotonic when the wall clock moves backward', async () => {
vi.useFakeTimers()
vi.setSystemTime(100)