Bläddra i källkod

test(gui): goals RPC surface and goal bar coverage tails

_Kerman 2 månader sedan
förälder
incheckning
feeea91bf8

+ 31 - 0
packages/client/ui-conversation/tests/goalbar.spec.tsx

@@ -83,6 +83,30 @@ describe('GoalBar', () => {
     expect(screen.getByText('Ongoing Goal')).toBeTruthy()
     expect(screen.getByText('Ongoing Goal')).toBeTruthy()
   })
   })
 
 
+  it('the cancel button exits the form and drops the draft (re-edit starts from the objective)', () => {
+    const actions = makeActions()
+    render(<GoalBar goal={makeGoal()} {...actions} />)
+    fireEvent.click(screen.getByRole('button', { name: 'Edit goal' }))
+    fireEvent.change(screen.getByRole('textbox', { name: 'Goal objective' }), { target: { value: 'abandoned draft' } })
+    fireEvent.click(screen.getByRole('button', { name: 'Cancel edit' }))
+    expect(actions.onEdit).not.toHaveBeenCalled()
+    expect(screen.getByText('Ongoing Goal')).toBeTruthy()
+
+    fireEvent.click(screen.getByRole('button', { name: 'Edit goal' }))
+    expect((screen.getByRole('textbox', { name: 'Goal objective' }) as HTMLInputElement).value).toBe('Ship the redesign')
+  })
+
+  it('Enter with a blank draft neither saves nor closes the form', () => {
+    const actions = makeActions()
+    render(<GoalBar goal={makeGoal()} {...actions} />)
+    fireEvent.click(screen.getByRole('button', { name: 'Edit goal' }))
+    const box = screen.getByRole('textbox', { name: 'Goal objective' })
+    fireEvent.change(box, { target: { value: '   ' } })
+    fireEvent.keyDown(box, { key: 'Enter' })
+    expect(actions.onEdit).not.toHaveBeenCalled()
+    expect(screen.getByRole('textbox', { name: 'Goal objective' })).toBeTruthy()
+  })
+
   it('paused goal: "Paused Goal" with a resume action before edit', () => {
   it('paused goal: "Paused Goal" with a resume action before edit', () => {
     const actions = makeActions()
     const actions = makeActions()
     render(<GoalBar goal={makeGoal({ phase: 'paused' })} {...actions} />)
     render(<GoalBar goal={makeGoal({ phase: 'paused' })} {...actions} />)
@@ -113,4 +137,11 @@ describe('GoalBar', () => {
     expect(screen.getByText('Blocked Goal')).toBeTruthy()
     expect(screen.getByText('Blocked Goal')).toBeTruthy()
     expect(screen.getByText('Blocked Goal').closest('[title]')?.getAttribute('title')).toBe('No progress in 3 rounds')
     expect(screen.getByText('Blocked Goal').closest('[title]')?.getAttribute('title')).toBe('No progress in 3 rounds')
   })
   })
+
+  it('blocked goal without a reason carries no tooltip', () => {
+    const actions = makeActions()
+    render(<GoalBar goal={makeGoal({ phase: 'blocked' })} {...actions} />)
+    expect(screen.getByText('Blocked Goal')).toBeTruthy()
+    expect(screen.getByText('Blocked Goal').closest('[title]')).toBeNull()
+  })
 })
 })

+ 75 - 1
packages/host/apiproxy/tests/client-handler.spec.ts

@@ -7,7 +7,7 @@
 
 
 import { describe, expect, it, vi } from 'vitest'
 import { describe, expect, it, vi } from 'vitest'
 import type { SessionId } from '@deepseek-ai/dsh-session'
 import type { SessionId } from '@deepseek-ai/dsh-session'
-import type { ApiProxy, HostFrame, MuxFrame, RpcMessage, RpcRequest, RpcResponse } from '@deepseek-ai/dsh-host-apiproxy'
+import type { ApiProxy, GoalRef, GoalView, HostFrame, MuxFrame, RpcMessage, RpcRequest, RpcResponse } from '@deepseek-ai/dsh-host-apiproxy'
 import { InProcessApiClient, RpcId, toFetchHandler } from '@deepseek-ai/dsh-host-apiproxy'
 import { InProcessApiClient, RpcId, toFetchHandler } from '@deepseek-ai/dsh-host-apiproxy'
 
 
 const sid = (id: string): SessionId => id as SessionId
 const sid = (id: string): SessionId => id as SessionId
@@ -354,6 +354,80 @@ describe('SSE stream path', () => {
   })
   })
 })
 })
 
 
+describe('goals unary surface', () => {
+  /** A wire-valid GoalView the scripted impl hands back. */
+  const view: GoalView = {
+    id: 'goal-1' as GoalView['id'],
+    revision: 2,
+    objective: 'ship it',
+    phase: 'active' as const,
+    maxGoalRounds: 4,
+    roundsStarted: 1,
+    createdAt: 10,
+    updatedAt: 20,
+    activation: 'armed' as const,
+  }
+  const ref: GoalRef = { id: 'goal-1' as GoalRef['id'], revision: 1 }
+
+  it('round-trips every goal method with its own payload and value shape', async () => {
+    const seen: { method: string; payload: unknown }[] = []
+    const record = <P, V>(method: string, respond: (r: RpcRequest<P>) => Promise<RpcResponse<V>>) =>
+      (r: RpcRequest<P>): Promise<RpcResponse<V>> => {
+        seen.push({ method, payload: r.payload })
+        return respond(r)
+      }
+    const api = scriptedApi({
+      goals: {
+        get: record('goal.get', r => ok(r, { goal: view })),
+        create: record('goal.create', r => ok(r, { goal: view })),
+        edit: record('goal.edit', r => ok(r, { goal: { ...view, revision: 3 } })),
+        pause: record('goal.pause', r => ok(r, { goal: { ...view, phase: 'paused' as const, activation: 'disarmed' as const } })),
+        resume: record('goal.resume', r => ok(r, { goal: view })),
+        complete: record('goal.complete', r => ok(r, { goal: { ...view, phase: 'complete' as const, activation: 'disarmed' as const } })),
+        clear: record('goal.clear', r => ok(r, { cleared: true as const })),
+      },
+    })
+    const c = client(api)
+
+    const got = await c.goals.get({ sessionId: sid('s1') })
+    expect(got.result).toEqual({ ok: true, value: { goal: view } })
+    const created = await c.goals.create({ sessionId: sid('s1'), objective: 'ship it', maxGoalRounds: 4 })
+    expect(created.result).toEqual({ ok: true, value: { goal: view } })
+    const edited = await c.goals.edit({ sessionId: sid('s1'), ref, objective: 'ship v2' })
+    expect(edited.result).toEqual({ ok: true, value: { goal: { ...view, revision: 3 } } })
+    const paused = await c.goals.pause({ sessionId: sid('s1'), ref })
+    expect(paused.result.ok && paused.result.value.goal.phase).toBe('paused')
+    const resumed = await c.goals.resume({ sessionId: sid('s1'), ref })
+    expect(resumed.result.ok && resumed.result.value.goal.phase).toBe('active')
+    const completed = await c.goals.complete({ sessionId: sid('s1'), ref })
+    expect(completed.result.ok && completed.result.value.goal.phase).toBe('complete')
+    const cleared = await c.goals.clear({ sessionId: sid('s1'), ref })
+    expect(cleared.result).toEqual({ ok: true, value: { cleared: true } })
+
+    // The handler dispatched each call through its own route row: payload parsed per method.
+    expect(seen.map(s => s.method)).toEqual(['goal.get', 'goal.create', 'goal.edit', 'goal.pause', 'goal.resume', 'goal.complete', 'goal.clear'])
+    expect(seen[1]?.payload).toEqual({ sessionId: 's1', objective: 'ship it', maxGoalRounds: 4 })
+    expect(seen[2]?.payload).toEqual({ sessionId: 's1', ref, objective: 'ship v2' })
+  })
+
+  it('passes a null goal and business errors through the goal.get route', async () => {
+    const api = scriptedApi({ goals: { get: r => ok(r, { goal: null }) } })
+    const response = await client(api).goals.get({ sessionId: sid('s-empty') })
+    expect(response.result).toEqual({ ok: true, value: { goal: null } })
+
+    // Default scripted goals impl answers an err result: it must arrive as a result, not a throw.
+    const failed = await client(scriptedApi()).goals.pause({ sessionId: sid('s1'), ref })
+    expect(failed.result.ok).toBe(false)
+    if (!failed.result.ok) expect(failed.result.error.code).toBe('internal')
+  })
+
+  it('rejects an invalid goal payload at the handler as bad-request', async () => {
+    const response = await client(scriptedApi()).goals.create({ sessionId: sid('s1'), objective: '' })
+    expect(response.result.ok).toBe(false)
+    if (!response.result.ok) expect(response.result.error.code).toBe('bad-request')
+  })
+})
+
 describe('respond path', () => {
 describe('respond path', () => {
   it('round-trips a client-response to a receipt', async () => {
   it('round-trips a client-response to a receipt', async () => {
     const seen: unknown[] = []
     const seen: unknown[] = []

+ 148 - 0
packages/host/runtime/tests/api-proxy-command.spec.ts

@@ -5,6 +5,11 @@
  * return ok with the command slot; usage errors and unknown names return RPC
  * return ok with the command slot; usage errors and unknown names return RPC
  * errors so the client restores the composer's draft. Non-command prompts
  * errors so the client restores the composer's draft. Non-command prompts
  * still route to agent.send/steer.
  * still route to agent.send/steer.
+ *
+ * The second suite covers the goals RPC surface over the same live harness:
+ * get/create/edit/pause/resume/complete/clear project the goal service onto
+ * the wire, service rejections (stale ref, duplicate create) become internal
+ * RPC errors, and an unservable session id is an RPC error on every method.
  */
  */
 
 
 import { describe, expect, it } from 'vitest'
 import { describe, expect, it } from 'vitest'
@@ -16,6 +21,7 @@ import GoalService from '@deepseek-ai/dsh-goal'
 import * as commandGoal from '@deepseek-ai/dsh-command-goal'
 import * as commandGoal from '@deepseek-ai/dsh-command-goal'
 import type { ContentBlock, MessageSource } from '@deepseek-ai/dsh-llm'
 import type { ContentBlock, MessageSource } from '@deepseek-ai/dsh-llm'
 import { Session, SessionId } from '@deepseek-ai/dsh-session'
 import { Session, SessionId } from '@deepseek-ai/dsh-session'
+import type { ApiProxy, GoalView } from '@deepseek-ai/dsh-host-apiproxy/api'
 import type { RpcRequest } from '@deepseek-ai/dsh-host-apiproxy/api/rpc'
 import type { RpcRequest } from '@deepseek-ai/dsh-host-apiproxy/api/rpc'
 import { RpcId } from '@deepseek-ai/dsh-host-apiproxy/api/rpc'
 import { RpcId } from '@deepseek-ai/dsh-host-apiproxy/api/rpc'
 import { createApiProxy } from '../src/api-proxy.ts'
 import { createApiProxy } from '../src/api-proxy.ts'
@@ -195,3 +201,145 @@ describe('sessions.prompt slash-command dispatch', () => {
     expect(test.sent).toEqual([empty, nonText])
     expect(test.sent).toEqual([empty, nonText])
   })
   })
 })
 })
+
+describe('goals RPC surface', () => {
+  /** Unwrap an ok goal value or fail the test. */
+  function goalOf(response: Awaited<ReturnType<ApiProxy['goals']['get']>>): GoalView {
+    if (!response.result.ok) throw new Error(`expected ok, got ${response.result.error.message}`)
+    if (response.result.value.goal === null) throw new Error('expected a current goal')
+    return response.result.value.goal
+  }
+
+  it('get returns null when no goal is set', async () => {
+    const test = await harness()
+    const api = createApiProxy(test.ctx, { provider: 'p', model: 'm', cwd: '/tmp' })
+
+    const response = await api.goals.get(request({ sessionId: test.session.id }))
+    expect(response.result.ok).toBe(true)
+    if (!response.result.ok) throw new Error('unreachable')
+    expect(response.result.value.goal).toBeNull()
+  })
+
+  it('create arms a goal, defaulting and honoring the round cap; a duplicate create is an internal error', async () => {
+    const test = await harness()
+    const api = createApiProxy(test.ctx, { provider: 'p', model: 'm', cwd: '/tmp' })
+
+    const created = goalOf(await api.goals.create(request({ sessionId: test.session.id, objective: 'first' })))
+    expect(created.phase).toBe('active')
+    expect(created.activation).toBe('armed')
+    expect(created.maxGoalRounds).toBe(256) // service default
+
+    const duplicate = await api.goals.create(request({ sessionId: test.session.id, objective: 'second' }))
+    expect(duplicate.result.ok).toBe(false)
+    if (duplicate.result.ok) throw new Error('unreachable')
+    expect(duplicate.result.error.code).toBe('internal')
+    expect(duplicate.result.error.message).toContain('already exists')
+
+    const cleared = await api.goals.clear(request({ sessionId: test.session.id, ref: created }))
+    expect(cleared.result.ok).toBe(true)
+
+    const capped = goalOf(await api.goals.create(request({ sessionId: test.session.id, objective: 'capped', maxGoalRounds: 4 })))
+    expect(capped.maxGoalRounds).toBe(4)
+  })
+
+  it('get projects the live goal, including the durable blocked reason', async () => {
+    const test = await harness()
+    const api = createApiProxy(test.ctx, { provider: 'p', model: 'm', cwd: '/tmp' })
+    const created = goalOf(await api.goals.create(request({ sessionId: test.session.id, objective: 'block me' })))
+
+    const before = goalOf(await api.goals.get(request({ sessionId: test.session.id })))
+    expect(before.objective).toBe('block me')
+    expect('blockedReason' in before).toBe(false)
+
+    test.ctx.goals.block(test.agent, created, { code: 'stalled', message: 'no progress' })
+    const after = goalOf(await api.goals.get(request({ sessionId: test.session.id })))
+    expect(after.phase).toBe('blocked')
+    expect(after.blockedReason).toEqual({ code: 'stalled', message: 'no progress' })
+  })
+
+  it('edit replaces the objective and/or the round cap, one field at a time', async () => {
+    const test = await harness()
+    const api = createApiProxy(test.ctx, { provider: 'p', model: 'm', cwd: '/tmp' })
+    const created = goalOf(await api.goals.create(request({ sessionId: test.session.id, objective: 'v1', maxGoalRounds: 4 })))
+
+    const renamed = goalOf(await api.goals.edit(request({ sessionId: test.session.id, ref: created, objective: 'v2' })))
+    expect(renamed.objective).toBe('v2')
+    expect(renamed.maxGoalRounds).toBe(4)
+    expect(renamed.revision).toBe(created.revision + 1)
+
+    const recapped = goalOf(await api.goals.edit(request({ sessionId: test.session.id, ref: renamed, maxGoalRounds: 8 })))
+    expect(recapped.objective).toBe('v2')
+    expect(recapped.maxGoalRounds).toBe(8)
+  })
+
+  it('pause, resume, complete, and clear drive the phase machine over the wire', async () => {
+    const test = await harness()
+    const api = createApiProxy(test.ctx, { provider: 'p', model: 'm', cwd: '/tmp' })
+    const created = goalOf(await api.goals.create(request({ sessionId: test.session.id, objective: 'lifecycle' })))
+
+    const paused = goalOf(await api.goals.pause(request({ sessionId: test.session.id, ref: created })))
+    expect([paused.phase, paused.activation]).toEqual(['paused', 'disarmed'])
+
+    const resumed = goalOf(await api.goals.resume(request({ sessionId: test.session.id, ref: paused })))
+    expect([resumed.phase, resumed.activation]).toEqual(['active', 'armed'])
+
+    const completed = goalOf(await api.goals.complete(request({ sessionId: test.session.id, ref: resumed })))
+    expect([completed.phase, completed.activation]).toEqual(['complete', 'disarmed'])
+
+    const cleared = await api.goals.clear(request({ sessionId: test.session.id, ref: completed }))
+    expect(cleared.result.ok).toBe(true)
+    if (!cleared.result.ok) throw new Error('unreachable')
+    expect(cleared.result.value.cleared).toBe(true)
+    expect(goalOfNull(await api.goals.get(request({ sessionId: test.session.id })))).toBeNull()
+  })
+
+  /** Unwrap a get value (goal or null) or fail the test. */
+  function goalOfNull(response: Awaited<ReturnType<ApiProxy['goals']['get']>>): GoalView | null {
+    if (!response.result.ok) throw new Error('unreachable')
+    return response.result.value.goal
+  }
+
+  it('a stale ref surfaces as an internal RPC error on every mutating method', async () => {
+    const test = await harness()
+    const api = createApiProxy(test.ctx, { provider: 'p', model: 'm', cwd: '/tmp' })
+    const created = goalOf(await api.goals.create(request({ sessionId: test.session.id, objective: 'cas' })))
+    const stale = { id: created.id, revision: created.revision + 99 }
+
+    const attempts = [
+      () => api.goals.edit(request({ sessionId: test.session.id, ref: stale, objective: 'nope' })),
+      () => api.goals.pause(request({ sessionId: test.session.id, ref: stale })),
+      () => api.goals.resume(request({ sessionId: test.session.id, ref: stale })),
+      () => api.goals.complete(request({ sessionId: test.session.id, ref: stale })),
+      () => api.goals.clear(request({ sessionId: test.session.id, ref: stale })),
+    ]
+    for (const attempt of attempts) {
+      const response = await attempt()
+      expect(response.result.ok).toBe(false)
+      if (!response.result.ok) expect(response.result.error.code).toBe('internal')
+    }
+    // None of the failed mutations touched the goal.
+    const current = goalOfNull(await api.goals.get(request({ sessionId: test.session.id })))
+    expect([current?.objective, current?.revision]).toEqual(['cas', created.revision])
+  })
+
+  it('an unservable session id is an RPC error on every goal method', async () => {
+    const test = await harness()
+    const api = createApiProxy(test.ctx, { provider: 'p', model: 'm', cwd: '/tmp' })
+    const missing = 'no-such-session'
+    const ref = { id: 'goal-x' as GoalView['id'], revision: 1 }
+
+    const attempts = [
+      () => api.goals.get(request({ sessionId: missing })),
+      () => api.goals.create(request({ sessionId: missing, objective: 'x' })),
+      () => api.goals.edit(request({ sessionId: missing, ref, objective: 'x' })),
+      () => api.goals.pause(request({ sessionId: missing, ref })),
+      () => api.goals.resume(request({ sessionId: missing, ref })),
+      () => api.goals.complete(request({ sessionId: missing, ref })),
+      () => api.goals.clear(request({ sessionId: missing, ref })),
+    ]
+    for (const attempt of attempts) {
+      const response = await attempt()
+      expect(response.result.ok).toBe(false)
+    }
+  })
+})