From 0477f33079331b58140ff131c7e1ba2854d18988 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Wed, 29 Jul 2026 01:40:23 +0800 Subject: [PATCH] =?UTF-8?q?feat(fixture):=20mirror=20the=20goal=20domain?= =?UTF-8?q?=20=E2=80=94=20/goal=20command,=20six=20verbs,=20projection=20u?= =?UTF-8?q?nit?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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). --- .../client/connection/src/client/fixture.ts | 175 +++++++++++++++++- .../connection/tests/fixture-commands.spec.ts | 6 +- .../client/connection/tests/fixture.spec.ts | 41 ++-- .../client/ui-goal/src/client/GoalBar.tsx | 4 +- .../client/ui-goal/tests/goalbar.spec.tsx | 6 +- 5 files changed, 204 insertions(+), 28 deletions(-) diff --git a/packages/client/connection/src/client/fixture.ts b/packages/client/connection/src/client/fixture.ts index 8131d09a16..bacdcbf9bb 100644 --- a/packages/client/connection/src/client/fixture.ts +++ b/packages/client/connection/src/client/fixture.ts @@ -311,6 +311,8 @@ function projectionValuesOf(log: readonly SessionEvent[]): Record= 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 { push(envelope: RpcRequest): 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(`${JSON.stringify(payload)}`), + { 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> => { + const missing = requireSession(request) + if (missing !== undefined) return missing as Promise> + 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; 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: '' } }, + { name: 'goal', description: 'set or view the goal for a long-running task', input: { hint: '' } }, ], }) }, @@ -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 ' : `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 = { 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) { diff --git a/packages/client/connection/tests/fixture-commands.spec.ts b/packages/client/connection/tests/fixture-commands.spec.ts index bd66d124a4..d64e6705cb 100644 --- a/packages/client/connection/tests/fixture-commands.spec.ts +++ b/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' } }) }) diff --git a/packages/client/connection/tests/fixture.spec.ts b/packages/client/connection/tests/fixture.spec.ts index fd51123a77..ae50006a21 100644 --- a/packages/client/connection/tests/fixture.spec.ts +++ b/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 () => { diff --git a/packages/client/ui-goal/src/client/GoalBar.tsx b/packages/client/ui-goal/src/client/GoalBar.tsx index 43aded14a8..76308734fc 100644 --- a/packages/client/ui-goal/src/client/GoalBar.tsx +++ b/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. diff --git a/packages/client/ui-goal/tests/goalbar.spec.tsx b/packages/client/ui-goal/tests/goalbar.spec.tsx index 7dc85c9ccc..ece447f8b0 100644 --- a/packages/client/ui-goal/tests/goalbar.spec.tsx +++ b/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() 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() 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() }) })