diff --git a/packages/client/ui-conversation/tests/goalbar.spec.tsx b/packages/client/ui-conversation/tests/goalbar.spec.tsx index c928da5558..f20a6de3a2 100644 --- a/packages/client/ui-conversation/tests/goalbar.spec.tsx +++ b/packages/client/ui-conversation/tests/goalbar.spec.tsx @@ -83,6 +83,30 @@ describe('GoalBar', () => { 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() + 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() + 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', () => { const actions = makeActions() render() @@ -113,4 +137,11 @@ describe('GoalBar', () => { expect(screen.getByText('Blocked Goal')).toBeTruthy() 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() + expect(screen.getByText('Blocked Goal')).toBeTruthy() + expect(screen.getByText('Blocked Goal').closest('[title]')).toBeNull() + }) }) diff --git a/packages/host/apiproxy/tests/client-handler.spec.ts b/packages/host/apiproxy/tests/client-handler.spec.ts index 9552a93b4f..6b651bc386 100644 --- a/packages/host/apiproxy/tests/client-handler.spec.ts +++ b/packages/host/apiproxy/tests/client-handler.spec.ts @@ -7,7 +7,7 @@ import { describe, expect, it, vi } from 'vitest' 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' 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 = (method: string, respond: (r: RpcRequest

) => Promise>) => + (r: RpcRequest

): Promise> => { + 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', () => { it('round-trips a client-response to a receipt', async () => { const seen: unknown[] = [] diff --git a/packages/host/runtime/tests/api-proxy-command.spec.ts b/packages/host/runtime/tests/api-proxy-command.spec.ts index e2e5fbaf63..baa0c35ca3 100644 --- a/packages/host/runtime/tests/api-proxy-command.spec.ts +++ b/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 * errors so the client restores the composer's draft. Non-command prompts * 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' @@ -16,6 +21,7 @@ import GoalService from '@deepseek-ai/dsh-goal' import * as commandGoal from '@deepseek-ai/dsh-command-goal' import type { ContentBlock, MessageSource } from '@deepseek-ai/dsh-llm' 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 { RpcId } from '@deepseek-ai/dsh-host-apiproxy/api/rpc' import { createApiProxy } from '../src/api-proxy.ts' @@ -195,3 +201,145 @@ describe('sessions.prompt slash-command dispatch', () => { expect(test.sent).toEqual([empty, nonText]) }) }) + +describe('goals RPC surface', () => { + /** Unwrap an ok goal value or fail the test. */ + function goalOf(response: Awaited>): 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>): 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) + } + }) +})