From 23610c6abdc5cc7c8fdb453a946a1dc4d2a40c5e Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sun, 2 Aug 2026 02:25:24 +0800 Subject: [PATCH] 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. --- packages/goal/goal/src/fold.ts | 4 +++- packages/goal/goal/tests/goal.spec.ts | 21 ++++++++++++++++++++- 2 files changed, 23 insertions(+), 2 deletions(-) diff --git a/packages/goal/goal/src/fold.ts b/packages/goal/goal/src/fold.ts index 2ea83029cf..ee765aaeab 100644 --- a/packages/goal/goal/src/fold.ts +++ b/packages/goal/goal/src/fold.ts @@ -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 } } /** diff --git a/packages/goal/goal/tests/goal.spec.ts b/packages/goal/goal/tests/goal.spec.ts index 7c4b4d9b28..82793618c4 100644 --- a/packages/goal/goal/tests/goal.spec.ts +++ b/packages/goal/goal/tests/goal.spec.ts @@ -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)