feat(gui): goal wire domain, client goal state, and docked goal bar
- apiproxy goals RPC domain (get/create/edit/pause/resume/complete/clear) with CAS refs, zod schemas, and fetch client/handler wiring - client runtime session goal state: live goal/change meta triggers a coalesced refetch; mutations fold transport errors into RpcResult - web GoalBar: docked strip above the composer (sparkle, phase label, truncated objective, inline edit, clear; resume when paused); creation stays on the /goal command - GoalBarActions in the ui-conversation contract layer; IconSparkle16 moves to ui-primitives icons
This commit is contained in:
@@ -74,6 +74,16 @@ export class FakeApiClient implements IApiClient {
|
||||
describe: (payload: unknown) => this.record('host.describe', payload, this.onDescribe(payload)),
|
||||
}
|
||||
|
||||
readonly goals: IApiClient['goals'] = {
|
||||
get: payload => this.record('goal.get', payload, Promise.resolve(ok({ goal: null }))),
|
||||
create: payload => this.record('goal.create', payload, Promise.resolve(ok({ goal: null as unknown as never }))),
|
||||
edit: payload => this.record('goal.edit', payload, Promise.resolve(ok({ goal: null as unknown as never }))),
|
||||
pause: payload => this.record('goal.pause', payload, Promise.resolve(ok({ goal: null as unknown as never }))),
|
||||
resume: payload => this.record('goal.resume', payload, Promise.resolve(ok({ goal: null as unknown as never }))),
|
||||
complete: payload => this.record('goal.complete', payload, Promise.resolve(ok({ goal: null as unknown as never }))),
|
||||
clear: payload => this.record('goal.clear', payload, Promise.resolve(ok({ cleared: true as const }))),
|
||||
}
|
||||
|
||||
/** When true, streams never fire onOpen (misbehaving-carrier material for the handshake timeout guard). */
|
||||
suppressStreamOpen = false
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
*/
|
||||
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import type { GoalView } from '@deepseek-ai/dsh-client-connection/client'
|
||||
import type { SessionEvent } from '@deepseek-ai/dsh-session/types'
|
||||
import type { SessionId } from '@deepseek-ai/dsh-client-connection/client'
|
||||
import { Session } from '../src/client/sessions/session.ts'
|
||||
@@ -623,3 +624,233 @@ describe('reference stability (the memo contract)', () => {
|
||||
expect(resolved.pending).toBe(after.pending)
|
||||
})
|
||||
})
|
||||
|
||||
describe('goal session methods', () => {
|
||||
const GID = 'g-1' as never
|
||||
function makeGoal(overrides: Partial<GoalView> = {}): GoalView {
|
||||
return {
|
||||
id: GID, revision: 1, objective: 'test-goal', phase: 'active',
|
||||
maxGoalRounds: 256, roundsStarted: 0, createdAt: 100, updatedAt: 100,
|
||||
activation: 'armed',
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
it('createGoal calls the API and updates snapshot.goal', async () => {
|
||||
const { api, session } = makeSession()
|
||||
api.onHistory = () => histResponse(plainTurn(0, 0, 'a', 'b'))
|
||||
await session.open()
|
||||
const goal = makeGoal()
|
||||
api.goals.create = () => Promise.resolve(ok({ goal }))
|
||||
const r = await session.createGoal('test-goal')
|
||||
expect(r).toEqual({ ok: true, value: { goal } })
|
||||
expect(session.getSnapshot().goal).toEqual(goal)
|
||||
})
|
||||
|
||||
it('editGoal sends the current ref and updates snapshot', async () => {
|
||||
const { api, session } = makeSession()
|
||||
api.onHistory = () => histResponse(plainTurn(0, 0, 'a', 'b'))
|
||||
await session.open()
|
||||
const goal = makeGoal()
|
||||
api.goals.create = () => Promise.resolve(ok({ goal }))
|
||||
await session.createGoal('test-goal')
|
||||
const edited = makeGoal({ revision: 2, objective: 'edited' })
|
||||
api.goals.edit = () => Promise.resolve(ok({ goal: edited }))
|
||||
const r = await session.editGoal('edited')
|
||||
expect(r).toEqual({ ok: true, value: { goal: edited } })
|
||||
expect(session.getSnapshot().goal).toEqual(edited)
|
||||
})
|
||||
|
||||
it('editGoal returns an error when no goal exists', async () => {
|
||||
const { api, session } = makeSession()
|
||||
api.onHistory = () => histResponse(plainTurn(0, 0, 'a', 'b'))
|
||||
await session.open()
|
||||
const r = await session.editGoal('nothing')
|
||||
expect(r.ok).toBe(false)
|
||||
expect((r as { error: { code: string } }).error.code).toBe('internal')
|
||||
})
|
||||
|
||||
it('pauseGoal pauses and updates snapshot', async () => {
|
||||
const { api, session } = makeSession()
|
||||
api.onHistory = () => histResponse(plainTurn(0, 0, 'a', 'b'))
|
||||
await session.open()
|
||||
const goal = makeGoal()
|
||||
api.goals.create = () => Promise.resolve(ok({ goal }))
|
||||
await session.createGoal('test-goal')
|
||||
const paused = makeGoal({ revision: 2, phase: 'paused', activation: 'disarmed' })
|
||||
api.goals.pause = () => Promise.resolve(ok({ goal: paused }))
|
||||
const r = await session.pauseGoal()
|
||||
expect(r).toEqual({ ok: true, value: { goal: paused } })
|
||||
expect(session.getSnapshot().goal).toEqual(paused)
|
||||
})
|
||||
|
||||
it('resumeGoal resumes a paused goal', async () => {
|
||||
const { api, session } = makeSession()
|
||||
api.onHistory = () => histResponse(plainTurn(0, 0, 'a', 'b'))
|
||||
await session.open()
|
||||
const goal = makeGoal({ phase: 'paused', activation: 'disarmed' })
|
||||
api.goals.create = () => Promise.resolve(ok({ goal }))
|
||||
await session.createGoal('test-goal')
|
||||
const resumed = makeGoal({ revision: 2, phase: 'active', activation: 'armed' })
|
||||
api.goals.resume = () => Promise.resolve(ok({ goal: resumed }))
|
||||
const r = await session.resumeGoal()
|
||||
expect(r).toEqual({ ok: true, value: { goal: resumed } })
|
||||
expect(session.getSnapshot().goal).toEqual(resumed)
|
||||
})
|
||||
|
||||
it('completeGoal marks the goal complete', async () => {
|
||||
const { api, session } = makeSession()
|
||||
api.onHistory = () => histResponse(plainTurn(0, 0, 'a', 'b'))
|
||||
await session.open()
|
||||
const goal = makeGoal()
|
||||
api.goals.create = () => Promise.resolve(ok({ goal }))
|
||||
await session.createGoal('test-goal')
|
||||
const completed = makeGoal({ revision: 2, phase: 'complete', activation: 'disarmed' })
|
||||
api.goals.complete = () => Promise.resolve(ok({ goal: completed }))
|
||||
const r = await session.completeGoal()
|
||||
expect(r).toEqual({ ok: true, value: { goal: completed } })
|
||||
expect(session.getSnapshot().goal).toEqual(completed)
|
||||
})
|
||||
|
||||
it('clearGoal removes the goal from snapshot', async () => {
|
||||
const { api, session } = makeSession()
|
||||
api.onHistory = () => histResponse(plainTurn(0, 0, 'a', 'b'))
|
||||
await session.open()
|
||||
const goal = makeGoal()
|
||||
api.goals.create = () => Promise.resolve(ok({ goal }))
|
||||
await session.createGoal('test-goal')
|
||||
api.goals.clear = () => Promise.resolve(ok({ cleared: true as const }))
|
||||
const r = await session.clearGoal()
|
||||
expect(r).toEqual({ ok: true, value: { cleared: true } })
|
||||
expect(session.getSnapshot().goal).toBeNull()
|
||||
})
|
||||
|
||||
it('error responses from the API do not mutate local state', async () => {
|
||||
const { api, session } = makeSession()
|
||||
api.onHistory = () => histResponse(plainTurn(0, 0, 'a', 'b'))
|
||||
await session.open()
|
||||
const goal = makeGoal()
|
||||
api.goals.create = () => Promise.resolve(ok({ goal }))
|
||||
await session.createGoal('test-goal')
|
||||
const before = session.getSnapshot().goal
|
||||
api.goals.pause = () => Promise.resolve(err({ code: 'agent-busy', message: 'stale revision', details: { reason: 'stale revision' } }))
|
||||
const r = await session.pauseGoal()
|
||||
expect(r.ok).toBe(false)
|
||||
expect(session.getSnapshot().goal).toBe(before)
|
||||
})
|
||||
|
||||
it('fetchGoal runs on session open and populates goal', async () => {
|
||||
const goal = makeGoal()
|
||||
const { api, session } = makeSession()
|
||||
api.onHistory = () => histResponse([])
|
||||
api.goals.get = () => Promise.resolve(ok({ goal }))
|
||||
await session.open()
|
||||
expect(session.getSnapshot().goal).toEqual(goal)
|
||||
})
|
||||
|
||||
const goalChangeEvent = (seq: number, operation: string): SessionEvent =>
|
||||
at(seq, {
|
||||
type: 'context/message', surfaceOp: 'append',
|
||||
data: {
|
||||
content: [{ type: 'text', text: `goal ${operation}` }],
|
||||
source: { kind: 'goal', goalId: 'g-1', revision: 1, round: 0 },
|
||||
meta: {
|
||||
kind: 'goal/change', version: 1, operation,
|
||||
goal: { id: 'g-1', revision: 1, objective: 'x', phase: 'active', maxGoalRounds: 256 },
|
||||
roundsStarted: 0, createdAt: 100, updatedAt: 100,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
// Clear tombstones carry no goal key — the trigger matches the meta kind, not a goal field.
|
||||
const goalClearEvent = (seq: number): SessionEvent =>
|
||||
at(seq, {
|
||||
type: 'context/message', surfaceOp: 'append',
|
||||
data: {
|
||||
content: [{ type: 'text', text: 'goal cleared' }],
|
||||
source: { kind: 'goal', goalId: 'g-1', revision: 2, round: 0 },
|
||||
meta: { kind: 'goal/change', version: 1, operation: 'clear', cleared: { id: 'g-1', revision: 2 }, clearedAt: 100 },
|
||||
},
|
||||
})
|
||||
|
||||
it('live goal-change meta triggers one coalesced refetch; window replays never refetch', async () => {
|
||||
const { api, session } = makeSession()
|
||||
// The history window replays goal-change meta (a snapshot change AND a clear tombstone).
|
||||
api.onHistory = () => histResponse([...plainTurn(0, 0, 'a', 'b'), goalChangeEvent(6, 'create'), goalClearEvent(7)])
|
||||
await session.open()
|
||||
await Promise.resolve()
|
||||
expect(api.callsOf('goal.get')).toHaveLength(1) // the eager open fetch only — no replay storm
|
||||
|
||||
// A plain live context message is not a goal change.
|
||||
let refetches = 0
|
||||
const gate = deferred<Awaited<ReturnType<FakeApiClient['goals']['get']>>>()
|
||||
api.goals.get = () => { refetches++; return gate.promise } // replacement bypasses record(): count locally
|
||||
session.handleMuxEnvelope('r0' as never, {
|
||||
type: 'session/event', sessionId: SID,
|
||||
event: at(8, { type: 'context/message', surfaceOp: 'append', data: { content: [{ type: 'text', text: 'plain' }], source: { kind: 'system' } } }),
|
||||
})
|
||||
expect(refetches).toBe(0)
|
||||
|
||||
// Two live goal events (change + clear tombstone) coalesce into a single refetch.
|
||||
session.handleMuxEnvelope('r1' as never, { type: 'session/event', sessionId: SID, event: goalChangeEvent(9, 'edit') })
|
||||
session.handleMuxEnvelope('r2' as never, { type: 'session/event', sessionId: SID, event: goalClearEvent(10) })
|
||||
expect(refetches).toBe(1)
|
||||
const goal = makeGoal({ revision: 3 })
|
||||
gate.resolve(ok({ goal }))
|
||||
await vi.waitFor(() => { expect(session.getSnapshot().goal).toEqual(goal) })
|
||||
})
|
||||
|
||||
it('drops a goal.get result older than a mutation response that landed mid-flight', async () => {
|
||||
const { api, session } = makeSession()
|
||||
api.onHistory = () => histResponse(plainTurn(0, 0, 'a', 'b'))
|
||||
await session.open()
|
||||
const goal = makeGoal()
|
||||
api.goals.create = () => Promise.resolve(ok({ goal }))
|
||||
await session.createGoal('test-goal')
|
||||
|
||||
let refetches = 0
|
||||
const gate = deferred<Awaited<ReturnType<FakeApiClient['goals']['get']>>>()
|
||||
api.goals.get = () => { refetches++; return gate.promise } // replacement bypasses record(): count locally
|
||||
session.handleMuxEnvelope('r' as never, { type: 'session/event', sessionId: SID, event: goalChangeEvent(6, 'edit') })
|
||||
expect(refetches).toBe(1) // the live refetch is parked on the gate
|
||||
const completed = makeGoal({ revision: 2, phase: 'complete', activation: 'disarmed' })
|
||||
api.goals.complete = () => Promise.resolve(ok({ goal: completed }))
|
||||
await session.completeGoal() // newer write lands while the get is in flight
|
||||
gate.resolve(ok({ goal: makeGoal({ objective: 'stale-read' }) }))
|
||||
await new Promise(resolve => setTimeout(resolve, 0))
|
||||
expect(session.getSnapshot().goal).toEqual(completed) // the stale read never overwrote it
|
||||
})
|
||||
|
||||
it('folds transport rejections from goal mutations into { ok: false } without rejecting', async () => {
|
||||
const { api, session } = makeSession()
|
||||
api.onHistory = () => histResponse(plainTurn(0, 0, 'a', 'b'))
|
||||
await session.open()
|
||||
api.goals.create = () => Promise.reject(new Error('goal wire down'))
|
||||
const created = await session.createGoal('test-goal')
|
||||
expect(created).toMatchObject({ ok: false, error: { code: 'internal', message: 'goal wire down' } })
|
||||
expect(session.getSnapshot().goal).toBeNull()
|
||||
|
||||
const goal = makeGoal()
|
||||
api.goals.create = () => Promise.resolve(ok({ goal }))
|
||||
await session.createGoal('test-goal')
|
||||
api.goals.pause = () => Promise.reject(new Error('pause wire down'))
|
||||
const paused = await session.pauseGoal()
|
||||
expect(paused).toMatchObject({ ok: false, error: { code: 'internal', message: 'pause wire down' } })
|
||||
expect(session.getSnapshot().goal).toEqual(goal) // local state untouched
|
||||
})
|
||||
|
||||
it('a goal.get transport rejection on a live refetch is logged and swallowed', async () => {
|
||||
const { api, session } = makeSession()
|
||||
api.onHistory = () => histResponse(plainTurn(0, 0, 'a', 'b'))
|
||||
await session.open()
|
||||
api.goals.get = () => Promise.reject(new Error('get wire down'))
|
||||
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined)
|
||||
try {
|
||||
session.handleMuxEnvelope('r' as never, { type: 'session/event', sessionId: SID, event: goalChangeEvent(6, 'create') })
|
||||
await vi.waitFor(() => { expect(errorSpy).toHaveBeenCalled() })
|
||||
expect(session.getSnapshot().goal).toBeNull() // fail-soft: state untouched
|
||||
} finally {
|
||||
errorSpy.mockRestore()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user