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).
This commit is contained in:
imccyu
2026-07-29 01:40:23 +08:00
parent b0fc3b971b
commit 0477f33079
5 changed files with 204 additions and 28 deletions

View File

@@ -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' } })
})

View File

@@ -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 () => {