Преглед на файлове

feat(fixture): mirror the goal domain — /goal command, six verbs, projection unit

The keyless fixture now carries the goal chain end to end, mirroring the
host: /goal enters the command catalog and its execute path brackets a
goal/change create with the command lifecycle pair; the six mutation verbs
CAS-check the projected current goal and append whole-value changes (the
shared append path broadcasts the session event and the goal projection
frame); the tail-page projections block and the mux-open baseline always
carry the goal key (null before create / after clear). Connection specs
follow: the lifecycle round-trip replaces the not-implemented stubs, and
the mux baseline expects the third unit frame.

Also: GoalBar inline errors use ASCII parens around the code (review
feedback on #842).
imccyu преди 2 месеца
родител
ревизия
0477f33079

+ 167 - 8
packages/client/connection/src/client/fixture.ts

@@ -311,6 +311,8 @@ function projectionValuesOf(log: readonly SessionEvent[]): Record<string, unknow
   }
   // Always present (tool-todo unit composed): null when no plan stands.
   values['todos'] = backscanTodos(log) ?? null
+  // Always present (GoalService unit composed): null before create / after clear.
+  values['goal'] = backscanGoal(log)
   return values
 }
 
@@ -323,6 +325,14 @@ function projectionFramesOf(id: SessionId, log: readonly SessionEvent[], event:
     if (!Object.hasOwn(values, 'title')) return []
     return [{ type: 'session/projection', sessionId: id, key: 'title', value: values['title'], seq: event.seq }]
   }
+  // Goal fold: a round-zero goal-sourced user message advances the goal unit.
+  if (type === 'user/message') {
+    const source = (event as unknown as { data?: { source?: { kind?: string; round?: number } } }).data?.source
+    if (source?.kind === 'goal' && source.round === 0) {
+      return [{ type: 'session/projection', sessionId: id, key: 'goal', value: backscanGoal(log), seq: event.seq }]
+    }
+    return []
+  }
   // Standing-plan fold: writes replace the list; turn/start clears it (null).
   if (type === 'todo/write' || type === 'turn/start') {
     return [{
@@ -381,6 +391,44 @@ function backscanTodos(log: readonly SessionEvent[]): TodoItem[] | undefined {
   return undefined
 }
 
+/** Fixture-local mirror of the goal projection value (dsh-goal's GoalProjection shape). */
+interface FxGoalProjection {
+  goal: {
+    id: string
+    revision: number
+    objective: string
+    phase: 'active' | 'paused' | 'blocked' | 'complete'
+    maxGoalRounds: number
+  }
+  roundsStarted: number
+  createdAt: number
+  updatedAt: number
+}
+
+/** One durable goal change riding a round-zero goal-sourced user message. */
+type FxGoalChange =
+  | { kind: 'goal/change'; version: 1; operation: 'clear'; cleared: { id: string; revision: number }; clearedAt: number }
+  | { kind: 'goal/change'; version: 1; operation: 'create' | 'edit' | 'pause' | 'resume' | 'complete'
+      goal: FxGoalProjection['goal']; roundsStarted: number; createdAt: number; updatedAt: number }
+
+/**
+ * Current goal projection over the full log (host parallel: the GoalService
+ * unit's last-wins fold of goal/change whole values; clear returns null).
+ */
+function backscanGoal(log: readonly SessionEvent[]): FxGoalProjection | null {
+  for (let i = log.length - 1; i >= 0; i--) {
+    const event = log[i] as unknown as { type: string; data?: { source?: { kind?: string; round?: number; change?: FxGoalChange } } }
+    if (event === undefined || event.type !== 'user/message') continue
+    const source = event.data?.source
+    if (source?.kind !== 'goal' || source.round !== 0) continue
+    const change = source.change
+    if (change === undefined || change.kind !== 'goal/change') continue
+    if (change.operation === 'clear') return null
+    return { goal: change.goal, roundsStarted: change.roundsStarted, createdAt: change.createdAt, updatedAt: change.updatedAt }
+  }
+  return null
+}
+
 interface StreamConn<F> {
   push(envelope: RpcRequest<F>): void
 }
@@ -572,6 +620,47 @@ export function createFixtureApi(options: FixtureOptions = {}): ApiProxy {
     for (const frame of projectionFramesOf(id, log, event)) emitMux(frame)
   }
 
+  /** Append one goal/change as its round-zero goal-sourced user message (host GoalService parallel). */
+  const appendGoalChange = (id: SessionId, change: FxGoalChange): FxGoalProjection => {
+    const ref = change.operation === 'clear' ? change.cleared : change.goal
+    const payload = change.operation === 'clear'
+      ? { cleared: change.cleared, clearedAt: change.clearedAt }
+      : { goal: change.goal, roundsStarted: change.roundsStarted, createdAt: change.createdAt, updatedAt: change.updatedAt }
+    append(id, {
+      type: 'user/message', surfaceOp: 'append',
+      data: userMessage(
+        text(`<goal_state>${JSON.stringify(payload)}</goal_state>`),
+        { kind: 'goal', goalId: ref.id, revision: ref.revision, round: 0, change } as unknown as MessageSource,
+      ),
+    })
+    return backscanGoal(logOf(id)) as FxGoalProjection
+  }
+
+  /** Shared CAS mutation path of the goal verbs (undefined next = invalid transition). */
+  const fxMutateGoal = (
+    request: RpcRequest<{ sessionId: SessionId; ref: { id: string; revision: number } }>,
+    ref: { id: string; revision: number },
+    next: (current: FxGoalProjection) => FxGoalProjection['goal'] | undefined,
+  ): Promise<RpcResponse<{ ref: { id: never; revision: number } }>> => {
+    const missing = requireSession(request)
+    if (missing !== undefined) return missing as Promise<RpcResponse<{ ref: { id: never; revision: number } }>>
+    const id = request.payload.sessionId
+    const current = backscanGoal(logOf(id))
+    if (current === null || current.goal.id !== ref.id || current.goal.revision !== ref.revision) {
+      return err(request, { code: 'internal', message: 'stale or missing goal revision', details: { goalCode: 'GOAL_STALE_REVISION' } })
+    }
+    const goal = next(current)
+    if (goal === undefined) {
+      return err(request, { code: 'internal', message: `invalid goal transition from "${current.goal.phase}"`, details: { goalCode: 'GOAL_INVALID_TRANSITION' } })
+    }
+    const projection = appendGoalChange(id, {
+      kind: 'goal/change', version: 1,
+      operation: goal.phase === current.goal.phase ? 'edit' : goal.phase === 'paused' ? 'pause' : goal.phase === 'active' ? 'resume' : 'complete',
+      goal, roundsStarted: current.roundsStarted, createdAt: current.createdAt, updatedAt: Date.now(),
+    })
+    return ok(request, { ref: { id: projection.goal.id as never, revision: projection.goal.revision } })
+  }
+
   /** At most one in-flight replay per session; cancel clears it. */
   const replays = new Map<SessionId, { timer: ReturnType<typeof setTimeout>; finish(aborted: boolean): void }>()
 
@@ -934,7 +1023,7 @@ export function createFixtureApi(options: FixtureOptions = {}): ApiProxy {
           commands: [
             { name: 'compact', description: 'fixture:压缩当前会话上下文' },
             { name: 'echo', description: 'fixture:回显参数', input: { hint: 'text to echo' } },
-            { name: 'goal-fixture', description: 'fixture:目标样本命令', input: { hint: '<objective>' } },
+            { name: 'goal', description: 'set or view the goal for a long-running task', input: { hint: '<objective>' } },
           ],
         })
       },
@@ -950,10 +1039,32 @@ export function createFixtureApi(options: FixtureOptions = {}): ApiProxy {
         const match = /^\/(\S+)((?:\s.*)?)$/.exec(request.payload.line.trim())
         const name = match?.[1]
         const args = match?.[2] ?? ''
+        if (name === 'goal') {
+          // Host parallel: /goal with an objective creates (or reports) the
+          // current goal; the command lifecycle pair brackets the mutation.
+          const commandId = `fx-cmd-${logOf(id).length}` as CommandId
+          append(id, { type: 'command/run', data: { commandId, name, args, source: { kind: 'user' } } })
+          const objective = args.trim()
+          const current = backscanGoal(logOf(id))
+          let text: string
+          if (objective === '') {
+            text = current === null ? 'No goal is set. Usage: /goal <objective>' : `Current goal: ${current.goal.objective}`
+          } else if (current !== null && current.goal.phase !== 'complete') {
+            text = `A goal already exists (${current.goal.objective}). Clear it first.`
+          } else {
+            const created = appendGoalChange(id, {
+              kind: 'goal/change', version: 1, operation: 'create',
+              goal: { id: `fx-goal-${logOf(id).length}`, revision: 1, objective, phase: 'active', maxGoalRounds: 256 },
+              roundsStarted: 0, createdAt: Date.now(), updatedAt: Date.now(),
+            })
+            text = `Goal created: ${created.goal.objective}`
+          }
+          append(id, { type: 'command/done', data: { commandId, kind: 'success', text } })
+          return ok(request, { matched: true as const, commandId })
+        }
         const outcomes: Record<string, string> = {
           compact: 'fixture:已压缩(假动作)',
           echo: args.trim(),
-          'goal-fixture': `fixture:goal 已设置(${id})`,
         }
         const text = name === undefined ? undefined : outcomes[name]
         if (name === undefined || text === undefined) return ok(request, { matched: false as const })
@@ -975,12 +1086,60 @@ export function createFixtureApi(options: FixtureOptions = {}): ApiProxy {
       },
     },
     goals: {
-      create: request => err(request, { code: 'internal', message: 'fixture: goals not implemented', details: {} }),
-      edit: request => err(request, { code: 'internal', message: 'fixture: goals not implemented', details: {} }),
-      pause: request => err(request, { code: 'internal', message: 'fixture: goals not implemented', details: {} }),
-      resume: request => err(request, { code: 'internal', message: 'fixture: goals not implemented', details: {} }),
-      complete: request => err(request, { code: 'internal', message: 'fixture: goals not implemented', details: {} }),
-      clear: request => err(request, { code: 'internal', message: 'fixture: goals not implemented', details: {} }),
+      // Mutation-only mirror of the host handlers: each verb CAS-checks the
+      // projected current goal, appends the whole-value change (the mux
+      // stream and projection frame ride the shared append path), and
+      // acknowledges with the new ref only.
+      create: (request) => {
+        const missing = requireSession(request)
+        if (missing !== undefined) return missing
+        const id = request.payload.sessionId
+        const current = backscanGoal(logOf(id))
+        if (current !== null && current.goal.phase !== 'complete') {
+          return err(request, { code: 'internal', message: `goal "${current.goal.id}" already exists`, details: { goalCode: 'GOAL_ALREADY_EXISTS' } })
+        }
+        const projection = appendGoalChange(id, {
+          kind: 'goal/change', version: 1, operation: 'create',
+          goal: { id: `fx-goal-${logOf(id).length}`, revision: 1, objective: request.payload.objective, phase: 'active', maxGoalRounds: request.payload.maxGoalRounds ?? 256 },
+          roundsStarted: 0, createdAt: Date.now(), updatedAt: Date.now(),
+        })
+        return ok(request, { ref: { id: projection.goal.id as never, revision: projection.goal.revision } })
+      },
+      edit: request => fxMutateGoal(request, request.payload.ref, current => ({
+        ...current.goal,
+        revision: current.goal.revision + 1,
+        ...request.payload.objective === undefined ? {} : { objective: request.payload.objective },
+        ...request.payload.maxGoalRounds === undefined ? {} : { maxGoalRounds: request.payload.maxGoalRounds },
+      })),
+      pause: request => fxMutateGoal(request, request.payload.ref, current => (
+        current.goal.phase === 'active'
+          ? { ...current.goal, revision: current.goal.revision + 1, phase: 'paused' }
+          : undefined
+      )),
+      resume: request => fxMutateGoal(request, request.payload.ref, current => (
+        current.goal.phase === 'paused' || current.goal.phase === 'blocked' || current.goal.phase === 'active'
+          ? { ...current.goal, revision: current.goal.revision + 1, phase: 'active' }
+          : undefined
+      )),
+      complete: request => fxMutateGoal(request, request.payload.ref, current => (
+        current.goal.phase === 'complete'
+          ? undefined
+          : { ...current.goal, revision: current.goal.revision + 1, phase: 'complete' }
+      )),
+      clear: (request) => {
+        const missing = requireSession(request)
+        if (missing !== undefined) return missing
+        const id = request.payload.sessionId
+        const current = backscanGoal(logOf(id))
+        if (current === null || current.goal.id !== request.payload.ref.id || current.goal.revision !== request.payload.ref.revision) {
+          return err(request, { code: 'internal', message: 'stale or missing goal revision', details: { goalCode: 'GOAL_STALE_REVISION' } })
+        }
+        appendGoalChange(id, {
+          kind: 'goal/change', version: 1, operation: 'clear',
+          cleared: { id: current.goal.id, revision: current.goal.revision + 1 }, clearedAt: Date.now(),
+        })
+        return ok(request, { cleared: true as const })
+      },
     },
     events: {
       async *mux(_request, signal) {

+ 3 - 3
packages/client/connection/tests/fixture-commands.spec.ts

@@ -23,7 +23,7 @@ describe('createFixtureApi commands/skills', () => {
     expect(response.rpcId).toBe(request.rpcId)
     if (!response.result.ok) throw new Error('list failed')
     const commands = response.result.value.commands
-    expect(commands.map(c => c.name)).toEqual(['compact', 'echo', 'goal-fixture'])
+    expect(commands.map(c => c.name)).toEqual(['compact', 'echo', 'goal'])
     // input hint rides only the commands declaring it.
     const echo = commands.find(c => c.name === 'echo')
     expect(echo?.input?.hint).toBeTruthy()
@@ -64,11 +64,11 @@ describe('createFixtureApi commands/skills', () => {
 
   it('addresses execute to the session; an unknown session errs', async () => {
     const api = createFixtureApi()
-    const hit = await api.commands.execute(req({ sessionId: sid('fx-alpha'), line: '/goal-fixture ship' }), signal)
+    const hit = await api.commands.execute(req({ sessionId: sid('fx-alpha'), line: '/goal ship' }), signal)
     if (!hit.result.ok) throw new Error('execute failed')
     expect(hit.result.value.matched).toBe(true)
 
-    const missing = await api.commands.execute(req({ sessionId: sid('fx-nope'), line: '/goal-fixture ship' }), signal)
+    const missing = await api.commands.execute(req({ sessionId: sid('fx-nope'), line: '/goal ship' }), signal)
     expect(missing.result).toMatchObject({ ok: false, error: { code: 'session-not-found' } })
   })
 

+ 29 - 12
packages/client/connection/tests/fixture.spec.ts

@@ -71,7 +71,7 @@ describe('createFixtureApi', () => {
     if (!empty.result.ok) throw new Error('empty failed')
     // Fixture composes the todos unit (host parallel when tool-todo is mounted): null before any write.
     expect(empty.result.value).toEqual({
-      events: [], hasMore: false, projections: { asOfSeq: -1, values: { todos: null } },
+      events: [], hasMore: false, projections: { asOfSeq: -1, values: { goal: null, todos: null } },
     })
   })
 
@@ -214,13 +214,14 @@ describe('createFixtureApi', () => {
     const second = await openOnce()
     expect(first[0]?.payload).toMatchObject({ type: 'session/subscribed', sessionId: 'fx-alpha' })
     expect((first[0]?.payload as { lastSeq: number }).lastSeq).toBeGreaterThan(0)
-    // Projection baseline frames follow the subscribed frame (title + todos units).
+    // Projection baseline frames follow the subscribed frame (title + todos + goal units).
     expect(first[1]?.payload).toMatchObject({ type: 'session/projection', sessionId: 'fx-alpha', key: 'title', value: 'Fixture 历史会话' })
     expect(first[2]?.payload).toMatchObject({ type: 'session/projection', sessionId: 'fx-alpha', key: 'todos' })
-    expect(first[3]?.payload).toMatchObject({ type: 'approval/requested', toolName: 'dangerous_tool' })
-    expect(second[3]?.rpcId).toBe(first[3]?.rpcId) // stable rpcId across replays (host replay semantics)
-    expect(first[4]?.payload).toMatchObject({ type: 'question/requested', sessionId: 'fx-alpha' })
-    expect(second[4]?.rpcId).toBe(first[4]?.rpcId)
+    expect(first[3]?.payload).toMatchObject({ type: 'session/projection', sessionId: 'fx-alpha', key: 'goal', value: null })
+    expect(first[4]?.payload).toMatchObject({ type: 'approval/requested', toolName: 'dangerous_tool' })
+    expect(second[4]?.rpcId).toBe(first[4]?.rpcId) // stable rpcId across replays (host replay semantics)
+    expect(first[5]?.payload).toMatchObject({ type: 'question/requested', sessionId: 'fx-alpha' })
+    expect(second[5]?.rpcId).toBe(first[5]?.rpcId)
   })
 
   it('steer with no replay in flight falls through to a fresh queued turn; non-text blocks stringify empty', async () => {
@@ -698,13 +699,29 @@ describe('FixtureApiClient (protocol-level fake carrier)', () => {
     const moved = await client.workspace.insertSessionBefore({ workspaceId: wsid, sessionId: attached.result.value.sessionId })
     if (!moved.result.ok) throw new Error('workspace move failed')
     expect(moved.result.value.workspace.sessionIds).toEqual([attached.result.value.sessionId])
-    const ref = { id: 'fx-goal-1' as never, revision: 1 }
-    expect((await client.goals.create({ sessionId: id, objective: 'x' })).result.ok).toBe(false)
-    expect((await client.goals.edit({ sessionId: id, ref, objective: 'x' })).result.ok).toBe(false)
-    expect((await client.goals.pause({ sessionId: id, ref })).result.ok).toBe(false)
-    expect((await client.goals.resume({ sessionId: id, ref })).result.ok).toBe(false)
+    // Goal lifecycle over the fixture fold: create → edit → pause → resume → complete → clear;
+    // every mutation acknowledges with the NEW CAS ref (state rides the projection frames).
+    const goalCreated = await client.goals.create({ sessionId: id, objective: 'ship it' })
+    if (!goalCreated.result.ok) throw new Error('goal create failed')
+    let ref = goalCreated.result.value.ref
+    expect(ref.revision).toBe(1)
+    const edited = await client.goals.edit({ sessionId: id, ref, objective: 'ship it v2' })
+    if (!edited.result.ok) throw new Error('goal edit failed')
+    ref = edited.result.value.ref
+    const paused = await client.goals.pause({ sessionId: id, ref })
+    if (!paused.result.ok) throw new Error('goal pause failed')
+    ref = paused.result.value.ref
+    const resumed = await client.goals.resume({ sessionId: id, ref })
+    if (!resumed.result.ok) throw new Error('goal resume failed')
+    ref = resumed.result.value.ref
+    // A stale ref loses the CAS check.
+    expect((await client.goals.pause({ sessionId: id, ref: { ...ref, revision: 1 } })).result.ok).toBe(false)
+    const completed = await client.goals.complete({ sessionId: id, ref })
+    if (!completed.result.ok) throw new Error('goal complete failed')
+    ref = completed.result.value.ref
+    // complete → complete is an invalid transition.
     expect((await client.goals.complete({ sessionId: id, ref })).result.ok).toBe(false)
-    expect((await client.goals.clear({ sessionId: id, ref })).result.ok).toBe(false)
+    expect((await client.goals.clear({ sessionId: id, ref })).result).toEqual({ ok: true, value: { cleared: true } })
   })
 
   it('maps empty, prompt-reject, and workspace-first query scenarios', async () => {

+ 2 - 2
packages/client/ui-goal/src/client/GoalBar.tsx

@@ -52,7 +52,7 @@ export function GoalBar({ goal, onEdit, onResume, onClear }: GoalBarProps) {
     if (result.ok) {
       setEditing(false)
     } else {
-      setActionError(`${result.error.message}(${result.error.code})`)
+      setActionError(`${result.error.message} (${result.error.code})`)
     }
   }, [draft, onEdit])
 
@@ -61,7 +61,7 @@ export function GoalBar({ goal, onEdit, onResume, onClear }: GoalBarProps) {
     setActionError(null)
     const result = await action()
     setPending(false)
-    if (!result.ok) setActionError(`${result.error.message}(${result.error.code})`)
+    if (!result.ok) setActionError(`${result.error.message} (${result.error.code})`)
   }, [])
 
   // Loading, absent, and complete goals have no strip at all.

+ 3 - 3
packages/client/ui-goal/tests/goalbar.spec.tsx

@@ -150,7 +150,7 @@ describe('GoalBar', () => {
     fireEvent.change(box, { target: { value: 'retry this draft' } })
     fireEvent.click(screen.getByRole('button', { name: 'Save goal' }))
 
-    expect((await screen.findByRole('alert')).textContent).toBe('stale revision(agent-busy)')
+    expect((await screen.findByRole('alert')).textContent).toBe('stale revision (agent-busy)')
     expect(screen.getByRole('textbox', { name: 'Goal objective' })).toHaveProperty('value', 'retry this draft')
   })
 
@@ -159,12 +159,12 @@ describe('GoalBar', () => {
     actions.onResume.mockResolvedValue({ ok: false, error: { code: 'internal', message: 'resume failed' } })
     const { rerender } = render(<GoalBar goal={makeGoal({ phase: 'paused' })} {...actions} />)
     fireEvent.click(screen.getByRole('button', { name: 'Resume goal' }))
-    expect((await screen.findByRole('alert')).textContent).toBe('resume failed(internal)')
+    expect((await screen.findByRole('alert')).textContent).toBe('resume failed (internal)')
 
     actions.onClear.mockResolvedValue({ ok: false, error: { code: 'agent-busy', message: 'clear failed' } })
     rerender(<GoalBar goal={makeGoal()} {...actions} />)
     fireEvent.click(screen.getByRole('button', { name: 'Clear goal' }))
-    expect((await screen.findByRole('alert')).textContent).toBe('clear failed(agent-busy)')
+    expect((await screen.findByRole('alert')).textContent).toBe('clear failed (agent-busy)')
     expect(screen.getByText('Ship the redesign')).toBeTruthy()
   })
 })