feat(web): re-land the goals wire domain as mutation-only ref acknowledgements
Six mutation RPCs (create/edit/pause/resume/complete/clear) move into
dsh-host-apiproxy (the PR's host/runtime carrier is gone): goalService()
resolves ctx.get('goals') with a loud absence error, mutateGoal() resolves
the session's agent (agentFor, the command.* implicit-resume precedent) and
acknowledges with the new CAS ref only. GoalError codes ride err.details.
goal.get and the wire GoalView/goalViewSchema are gone: the read side is the
'goal' session projection (whole values on the history tail page and
session/projection frames), so responses never feed client state — the rule
whose absence forced the original PR's write-revision fences.
This commit is contained in:
@@ -128,11 +128,11 @@ export class FakeApiClient implements IApiClient {
|
||||
}
|
||||
|
||||
readonly goals: IApiClient['goals'] = {
|
||||
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 }))),
|
||||
create: payload => this.record('goal.create', payload, Promise.resolve(ok({ ref: { id: 'fake-goal' as never, revision: 1 } }))),
|
||||
edit: payload => this.record('goal.edit', payload, Promise.resolve(ok({ ref: { id: 'fake-goal' as never, revision: 1 } }))),
|
||||
pause: payload => this.record('goal.pause', payload, Promise.resolve(ok({ ref: { id: 'fake-goal' as never, revision: 1 } }))),
|
||||
resume: payload => this.record('goal.resume', payload, Promise.resolve(ok({ ref: { id: 'fake-goal' as never, revision: 1 } }))),
|
||||
complete: payload => this.record('goal.complete', payload, Promise.resolve(ok({ ref: { id: 'fake-goal' as never, revision: 1 } }))),
|
||||
clear: payload => this.record('goal.clear', payload, Promise.resolve(ok({ cleared: true as const }))),
|
||||
}
|
||||
|
||||
|
||||
@@ -698,6 +698,13 @@ 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)
|
||||
expect((await client.goals.complete({ sessionId: id, ref })).result.ok).toBe(false)
|
||||
expect((await client.goals.clear({ sessionId: id, ref })).result.ok).toBe(false)
|
||||
})
|
||||
|
||||
it('maps empty, prompt-reject, and workspace-first query scenarios', async () => {
|
||||
@@ -745,13 +752,6 @@ describe('FixtureApiClient (protocol-level fake carrier)', () => {
|
||||
workspaceId: 'fx-ws-fixture' as WorkspaceId,
|
||||
sessionId: sid('fx-query-dropped'),
|
||||
})).rejects.toThrow(/dropped session\.create response/)
|
||||
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)
|
||||
expect((await client.goals.complete({ sessionId: id, ref })).result.ok).toBe(false)
|
||||
expect((await client.goals.clear({ sessionId: id, ref })).result.ok).toBe(false)
|
||||
})
|
||||
|
||||
it('fires onOpen at stream-iteration start and taps server-request full forms', async () => {
|
||||
|
||||
@@ -154,11 +154,11 @@ export class FakeApiClient implements IApiClient {
|
||||
}
|
||||
|
||||
readonly goals: IApiClient['goals'] = {
|
||||
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 }))),
|
||||
create: payload => this.record('goal.create', payload, Promise.resolve(ok({ ref: { id: 'fake-goal' as never, revision: 1 } }))),
|
||||
edit: payload => this.record('goal.edit', payload, Promise.resolve(ok({ ref: { id: 'fake-goal' as never, revision: 1 } }))),
|
||||
pause: payload => this.record('goal.pause', payload, Promise.resolve(ok({ ref: { id: 'fake-goal' as never, revision: 1 } }))),
|
||||
resume: payload => this.record('goal.resume', payload, Promise.resolve(ok({ ref: { id: 'fake-goal' as never, revision: 1 } }))),
|
||||
complete: payload => this.record('goal.complete', payload, Promise.resolve(ok({ ref: { id: 'fake-goal' as never, revision: 1 } }))),
|
||||
clear: payload => this.record('goal.clear', payload, Promise.resolve(ok({ cleared: true as const }))),
|
||||
}
|
||||
|
||||
|
||||
@@ -43,6 +43,7 @@
|
||||
"@deepseek-ai/dsh-agent": "workspace:^",
|
||||
"@deepseek-ai/dsh-brand": "workspace:^",
|
||||
"@deepseek-ai/dsh-commands": "workspace:^",
|
||||
"@deepseek-ai/dsh-goal": "workspace:^",
|
||||
"@deepseek-ai/dsh-llm": "workspace:^",
|
||||
"@deepseek-ai/dsh-session": "workspace:^",
|
||||
"@deepseek-ai/dsh-session-persistence": "workspace:^",
|
||||
|
||||
@@ -24,12 +24,15 @@ import {
|
||||
// Type-only: brings the `ctx.tools` Context merge into this program (viewFor reads presenters).
|
||||
import type {} from '@deepseek-ai/dsh-tools'
|
||||
import type {
|
||||
ApiProxy, HistoryEntry, HostFrame, ModelCatalogFailure, ModelProviderGroup, ModelReasoning,
|
||||
ApiProxy, GoalRef, HistoryEntry, HostFrame, ModelCatalogFailure, ModelProviderGroup, ModelReasoning,
|
||||
MuxFrame, QuestionResponsePayload, SessionProjectionsBlock, SessionSummary, ToolEventView,
|
||||
WorkspaceId, WorkspaceView,
|
||||
} from './api/index.ts'
|
||||
// Type-only: resolves `ctx.get('sessionProjections')` to the projection registry.
|
||||
import type {} from '@deepseek-ai/dsh-session-projection'
|
||||
// GoalError narrows domain rejections to their stable codes at the wire boundary.
|
||||
import { GoalError } from '@deepseek-ai/dsh-goal'
|
||||
import type { GoalRef as CoreGoalRef } from '@deepseek-ai/dsh-goal'
|
||||
// Type-only edges: resolve `ctx.get('commands')`, the `commands/change` event, and `ctx.get('skills')`.
|
||||
import type {} from '@deepseek-ai/dsh-commands'
|
||||
import type {} from '@deepseek-ai/dsh-skill'
|
||||
@@ -645,6 +648,38 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
|
||||
return operation
|
||||
}
|
||||
|
||||
/** Resolve the goal service; absent = the deployment did not compose @deepseek-ai/dsh-goal. */
|
||||
function goalService(): NonNullable<ReturnType<typeof ctx.get<'goals'>>> | { error: RpcError } {
|
||||
const goals = ctx.get('goals')
|
||||
if (goals === undefined) {
|
||||
return { error: { code: 'internal', message: 'goal service is absent: this deployment does not mount @deepseek-ai/dsh-goal in its composition (cordis.yml or explicit assembly)', details: {} } }
|
||||
}
|
||||
return goals
|
||||
}
|
||||
|
||||
/** Map one goal-domain rejection to the wire error (stable GoalError codes ride in details). */
|
||||
function goalError(request: RpcRequest<unknown>, error: unknown): RpcResponse<never> {
|
||||
const details = error instanceof GoalError ? { goalCode: error.code } : {}
|
||||
return err(request, { code: 'internal', message: String(error), details })
|
||||
}
|
||||
|
||||
/** Resolve a session's agent, apply one goal mutation, and acknowledge with the new CAS ref. */
|
||||
async function mutateGoal(
|
||||
request: RpcRequest<{ sessionId: SessionId }>,
|
||||
mutation: (goals: NonNullable<ReturnType<typeof ctx.get<'goals'>>>, agent: Agent) => CoreGoalRef,
|
||||
): Promise<RpcResponse<{ ref: GoalRef }>> {
|
||||
const goals = goalService()
|
||||
if ('error' in goals) return err(request, goals.error)
|
||||
const found = await agentFor(request.payload.sessionId)
|
||||
if ('error' in found) return err(request, found.error)
|
||||
try {
|
||||
const ref = mutation(goals, found.agent)
|
||||
return ok(request, { ref: { id: ref.id, revision: ref.revision } })
|
||||
} catch (error: unknown) {
|
||||
return goalError(request, error)
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
sessions: {
|
||||
// Attached sessions summarize from memory; persisted-but-unattached (cold)
|
||||
@@ -1090,6 +1125,54 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
|
||||
},
|
||||
},
|
||||
|
||||
goals: {
|
||||
// Mutations only — the read side is the 'goal' session projection.
|
||||
// Every verb resolves the session's agent (agentFor: implicit cold
|
||||
// resume, the command.* precedent) and acknowledges with the new CAS
|
||||
// ref; the committed goal/change event carries the whole value to every
|
||||
// client through the projection frames.
|
||||
async create(request) {
|
||||
const { objective, maxGoalRounds } = request.payload
|
||||
return mutateGoal(request, (goals, agent) => goals.create(agent, {
|
||||
objective,
|
||||
...(maxGoalRounds !== undefined ? { maxGoalRounds } : {}),
|
||||
}))
|
||||
},
|
||||
|
||||
async edit(request) {
|
||||
const { ref, objective, maxGoalRounds } = request.payload
|
||||
return mutateGoal(request, (goals, agent) => goals.edit(agent, ref, {
|
||||
...(objective !== undefined ? { objective } : {}),
|
||||
...(maxGoalRounds !== undefined ? { maxGoalRounds } : {}),
|
||||
}))
|
||||
},
|
||||
|
||||
async pause(request) {
|
||||
return mutateGoal(request, (goals, agent) => goals.pause(agent, request.payload.ref))
|
||||
},
|
||||
|
||||
async resume(request) {
|
||||
return mutateGoal(request, (goals, agent) => goals.resume(agent, request.payload.ref))
|
||||
},
|
||||
|
||||
async complete(request) {
|
||||
return mutateGoal(request, (goals, agent) => goals.complete(agent, request.payload.ref))
|
||||
},
|
||||
|
||||
async clear(request) {
|
||||
const goals = goalService()
|
||||
if ('error' in goals) return err(request, goals.error)
|
||||
const found = await agentFor(request.payload.sessionId)
|
||||
if ('error' in found) return err(request, found.error)
|
||||
try {
|
||||
goals.clear(found.agent, request.payload.ref)
|
||||
return ok(request, { cleared: true as const })
|
||||
} catch (error: unknown) {
|
||||
return goalError(request, error)
|
||||
}
|
||||
},
|
||||
},
|
||||
|
||||
skills: {
|
||||
// Skill lookup never touches the Agent registry: the session address
|
||||
// resolves to a canonical cwd from the host-resident session header, so
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
/**
|
||||
* goals domain zod schemas.
|
||||
* goals domain zod schemas. Mutation-only shapes: every value schema is a
|
||||
* `{ ref }` acknowledgement (clear: `{ cleared }`) — the current goal state
|
||||
* travels exclusively on the 'goal' session projection.
|
||||
*/
|
||||
|
||||
import { z } from 'zod'
|
||||
import type { Wire } from './rpc.schema.ts'
|
||||
import type { GoalRef, GoalView, RequestPayload, ResponseValue } from './index.ts'
|
||||
import type { GoalRef, RequestPayload, ResponseValue } from './index.ts'
|
||||
|
||||
/** GoalRef schema. */
|
||||
export const goalRefSchema = z.object({
|
||||
@@ -12,25 +14,8 @@ export const goalRefSchema = z.object({
|
||||
revision: z.number().int().positive(),
|
||||
}) as unknown as z.ZodType<Wire<GoalRef>>
|
||||
|
||||
/** Goal block reason schema. */
|
||||
export const goalBlockReasonSchema = z.object({
|
||||
code: z.string(),
|
||||
message: z.string(),
|
||||
})
|
||||
|
||||
/** GoalView schema. */
|
||||
export const goalViewSchema = z.object({
|
||||
id: z.string(),
|
||||
revision: z.number().int().positive(),
|
||||
objective: z.string(),
|
||||
phase: z.union([z.literal('active'), z.literal('paused'), z.literal('blocked'), z.literal('complete')]),
|
||||
blockedReason: goalBlockReasonSchema.optional(),
|
||||
maxGoalRounds: z.number().int().positive(),
|
||||
roundsStarted: z.number().int().nonnegative(),
|
||||
createdAt: z.number(),
|
||||
updatedAt: z.number(),
|
||||
activation: z.union([z.literal('armed'), z.literal('disarmed')]),
|
||||
}) as unknown as z.ZodType<Wire<GoalView>>
|
||||
/** Shared `{ ref }` acknowledgement value of every non-clear mutation. */
|
||||
const goalRefValueSchema = z.object({ ref: goalRefSchema })
|
||||
|
||||
/** goal.create request payload. */
|
||||
export const goalCreateRequestSchema = z.object({
|
||||
@@ -40,9 +25,7 @@ export const goalCreateRequestSchema = z.object({
|
||||
}) as unknown as z.ZodType<Wire<RequestPayload<'goal.create'>>>
|
||||
|
||||
/** goal.create response value. */
|
||||
export const goalCreateValueSchema = z.object({
|
||||
goal: goalViewSchema,
|
||||
}) as unknown as z.ZodType<Wire<ResponseValue<'goal.create'>>>
|
||||
export const goalCreateValueSchema = goalRefValueSchema as unknown as z.ZodType<Wire<ResponseValue<'goal.create'>>>
|
||||
|
||||
/** goal.edit request payload. */
|
||||
export const goalEditRequestSchema = z.object({
|
||||
@@ -55,9 +38,7 @@ export const goalEditRequestSchema = z.object({
|
||||
}) as unknown as z.ZodType<Wire<RequestPayload<'goal.edit'>>>
|
||||
|
||||
/** goal.edit response value. */
|
||||
export const goalEditValueSchema = z.object({
|
||||
goal: goalViewSchema,
|
||||
}) as unknown as z.ZodType<Wire<ResponseValue<'goal.edit'>>>
|
||||
export const goalEditValueSchema = goalRefValueSchema as unknown as z.ZodType<Wire<ResponseValue<'goal.edit'>>>
|
||||
|
||||
/** goal.pause request payload. */
|
||||
export const goalPauseRequestSchema = z.object({
|
||||
@@ -66,9 +47,7 @@ export const goalPauseRequestSchema = z.object({
|
||||
}) as unknown as z.ZodType<Wire<RequestPayload<'goal.pause'>>>
|
||||
|
||||
/** goal.pause response value. */
|
||||
export const goalPauseValueSchema = z.object({
|
||||
goal: goalViewSchema,
|
||||
}) as unknown as z.ZodType<Wire<ResponseValue<'goal.pause'>>>
|
||||
export const goalPauseValueSchema = goalRefValueSchema as unknown as z.ZodType<Wire<ResponseValue<'goal.pause'>>>
|
||||
|
||||
/** goal.resume request payload. */
|
||||
export const goalResumeRequestSchema = z.object({
|
||||
@@ -77,9 +56,7 @@ export const goalResumeRequestSchema = z.object({
|
||||
}) as unknown as z.ZodType<Wire<RequestPayload<'goal.resume'>>>
|
||||
|
||||
/** goal.resume response value. */
|
||||
export const goalResumeValueSchema = z.object({
|
||||
goal: goalViewSchema,
|
||||
}) as unknown as z.ZodType<Wire<ResponseValue<'goal.resume'>>>
|
||||
export const goalResumeValueSchema = goalRefValueSchema as unknown as z.ZodType<Wire<ResponseValue<'goal.resume'>>>
|
||||
|
||||
/** goal.complete request payload. */
|
||||
export const goalCompleteRequestSchema = z.object({
|
||||
@@ -88,9 +65,7 @@ export const goalCompleteRequestSchema = z.object({
|
||||
}) as unknown as z.ZodType<Wire<RequestPayload<'goal.complete'>>>
|
||||
|
||||
/** goal.complete response value. */
|
||||
export const goalCompleteValueSchema = z.object({
|
||||
goal: goalViewSchema,
|
||||
}) as unknown as z.ZodType<Wire<ResponseValue<'goal.complete'>>>
|
||||
export const goalCompleteValueSchema = goalRefValueSchema as unknown as z.ZodType<Wire<ResponseValue<'goal.complete'>>>
|
||||
|
||||
/** goal.clear request payload. */
|
||||
export const goalClearRequestSchema = z.object({
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
/**
|
||||
* goals domain contract. Method signatures are the source of truth:
|
||||
* unary methods take the RpcRequest<P> narrow form and the impl echoes rpcId.
|
||||
*
|
||||
* Mutations only: the read side is the 'goal' session projection (history
|
||||
* tail-page projections block + session/projection frames), so there is no
|
||||
* goal.get and no wire goal view — responses acknowledge with the new CAS
|
||||
* ref and never feed client state (the committed goal/change event reaches
|
||||
* every client through the mux stream carrying the same whole value).
|
||||
*/
|
||||
|
||||
import type { Branded } from '@deepseek-ai/dsh-brand'
|
||||
@@ -16,69 +22,27 @@ export interface GoalRef {
|
||||
readonly revision: number
|
||||
}
|
||||
|
||||
/** Durable continuation phase. */
|
||||
export type GoalPhase =
|
||||
| 'active'
|
||||
| 'paused'
|
||||
| 'blocked'
|
||||
| 'complete'
|
||||
|
||||
/** Machine-routable and human-readable explanation for a blocked goal. */
|
||||
export interface GoalBlockReason {
|
||||
readonly code: string
|
||||
readonly message: string
|
||||
}
|
||||
|
||||
/** Whether this live process may automatically continue an active goal. */
|
||||
export type GoalActivation = 'armed' | 'disarmed'
|
||||
|
||||
/** Current goal projection, including values derived from the session log. */
|
||||
export interface GoalView {
|
||||
readonly id: GoalId
|
||||
readonly revision: number
|
||||
readonly objective: string
|
||||
readonly phase: GoalPhase
|
||||
readonly blockedReason?: GoalBlockReason
|
||||
readonly maxGoalRounds: number
|
||||
readonly roundsStarted: number
|
||||
readonly createdAt: number
|
||||
readonly updatedAt: number
|
||||
readonly activation: GoalActivation
|
||||
}
|
||||
|
||||
/** Input whose omitted round cap is resolved by the service configuration. */
|
||||
export interface CreateGoalRequest {
|
||||
readonly objective: string
|
||||
readonly maxGoalRounds?: number
|
||||
}
|
||||
|
||||
/** Fields changed by an edit; at least one must be present. */
|
||||
export interface EditGoalRequest {
|
||||
readonly objective?: string
|
||||
readonly maxGoalRounds?: number
|
||||
}
|
||||
|
||||
/** Goal-domain unary methods (mutations only: the read side is the 'goal' session projection). */
|
||||
/** Goal-domain unary methods (every mutation resolves the session's agent and applies one CAS-guarded verb). */
|
||||
export interface GoalsApi {
|
||||
/** Create and arm a goal. */
|
||||
create(request: RpcRequest<{ sessionId: SessionId; objective: string; maxGoalRounds?: number }>):
|
||||
Promise<RpcResponse<{ goal: GoalView }>>
|
||||
Promise<RpcResponse<{ ref: GoalRef }>>
|
||||
|
||||
/** Edit objective and/or round cap without changing phase. */
|
||||
edit(request: RpcRequest<{ sessionId: SessionId; ref: GoalRef; objective?: string; maxGoalRounds?: number }>):
|
||||
Promise<RpcResponse<{ goal: GoalView }>>
|
||||
Promise<RpcResponse<{ ref: GoalRef }>>
|
||||
|
||||
/** Pause an active goal and disarm automatic continuation. */
|
||||
pause(request: RpcRequest<{ sessionId: SessionId; ref: GoalRef }>):
|
||||
Promise<RpcResponse<{ goal: GoalView }>>
|
||||
Promise<RpcResponse<{ ref: GoalRef }>>
|
||||
|
||||
/** Resume and arm a stopped goal. */
|
||||
resume(request: RpcRequest<{ sessionId: SessionId; ref: GoalRef }>):
|
||||
Promise<RpcResponse<{ goal: GoalView }>>
|
||||
Promise<RpcResponse<{ ref: GoalRef }>>
|
||||
|
||||
/** Mark a current non-complete goal complete and disarm it. */
|
||||
complete(request: RpcRequest<{ sessionId: SessionId; ref: GoalRef }>):
|
||||
Promise<RpcResponse<{ goal: GoalView }>>
|
||||
Promise<RpcResponse<{ ref: GoalRef }>>
|
||||
|
||||
/** Clear the current goal while retaining a durable tombstone and history. */
|
||||
clear(request: RpcRequest<{ sessionId: SessionId; ref: GoalRef }>):
|
||||
|
||||
@@ -36,7 +36,7 @@ export type { WorkspaceApi, WorkspaceId, WorkspaceView } from './workspace.ts'
|
||||
export type { CommandsApi, CommandDescriptor } from './commands.ts'
|
||||
export type { SkillsApi, SkillEntry } from './skills.ts'
|
||||
export type { EventsApi, MuxFrame, HostFrame, ToolCallView, ToolEventView, ToolResultView } from './events.ts'
|
||||
export type { GoalsApi, GoalView, GoalRef, GoalPhase, GoalBlockReason, CreateGoalRequest, EditGoalRequest } from './goals.ts'
|
||||
export type { GoalsApi, GoalId, GoalRef } from './goals.ts'
|
||||
export type { ApprovalResponsePayload } from './approvals.ts'
|
||||
export type { QuestionResponsePayload } from './questions.ts'
|
||||
|
||||
|
||||
@@ -92,7 +92,6 @@ export interface IApiClient {
|
||||
host(payload: Parameters<ApiProxy['events']['host']>[0]['payload'], signal: AbortSignal, onOpen?: () => void): AsyncIterable<RpcRequest<HostFrame>>
|
||||
}
|
||||
goals: {
|
||||
get(payload: RequestPayload<'goal.get'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'goal.get'>>>
|
||||
create(payload: RequestPayload<'goal.create'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'goal.create'>>>
|
||||
edit(payload: RequestPayload<'goal.edit'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'goal.edit'>>>
|
||||
pause(payload: RequestPayload<'goal.pause'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'goal.pause'>>>
|
||||
@@ -353,7 +352,6 @@ export abstract class AbstractApiClient implements IApiClient {
|
||||
}
|
||||
|
||||
readonly goals: IApiClient['goals'] = {
|
||||
get: (payload, signal) => this.callUnary('goal.get', payload, signal),
|
||||
create: (payload, signal) => this.callUnary('goal.create', payload, signal),
|
||||
edit: (payload, signal) => this.callUnary('goal.edit', payload, signal),
|
||||
pause: (payload, signal) => this.callUnary('goal.pause', payload, signal),
|
||||
|
||||
@@ -57,6 +57,7 @@ export class ApiProxyService extends Service implements ApiProxy {
|
||||
readonly workspace: ApiProxy['workspace']
|
||||
readonly host: ApiProxy['host']
|
||||
readonly commands: ApiProxy['commands']
|
||||
readonly goals: ApiProxy['goals']
|
||||
readonly skills: ApiProxy['skills']
|
||||
readonly events: ApiProxy['events']
|
||||
readonly respond: ApiProxy['respond']
|
||||
@@ -74,6 +75,7 @@ export class ApiProxyService extends Service implements ApiProxy {
|
||||
this.workspace = api.workspace
|
||||
this.host = api.host
|
||||
this.commands = api.commands
|
||||
this.goals = api.goals
|
||||
this.skills = api.skills
|
||||
this.events = api.events
|
||||
// createApiProxy returns closures (no `this` capture); bind only satisfies
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import type { SessionId } from '@deepseek-ai/dsh-session'
|
||||
import type { ApiProxy, GoalRef, GoalView, HostFrame, MuxFrame, RpcMessage, RpcRequest, RpcResponse } from '@deepseek-ai/dsh-host-apiproxy'
|
||||
import type { ApiProxy, GoalRef, 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
|
||||
@@ -418,19 +418,9 @@ 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 }
|
||||
/** The `{ ref }` acknowledgement every non-clear mutation answers (state travels on the projection). */
|
||||
const ack = { ref: { id: 'goal-1' as GoalRef['id'], revision: 2 } }
|
||||
|
||||
it('round-trips every goal method with its own payload and value shape', async () => {
|
||||
const seen: { method: string; payload: unknown }[] = []
|
||||
@@ -441,43 +431,33 @@ describe('goals unary surface', () => {
|
||||
}
|
||||
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 } })),
|
||||
create: record('goal.create', r => ok(r, ack)),
|
||||
edit: record('goal.edit', r => ok(r, { ref: { ...ack.ref, revision: 3 } })),
|
||||
pause: record('goal.pause', r => ok(r, ack)),
|
||||
resume: record('goal.resume', r => ok(r, ack)),
|
||||
complete: record('goal.complete', r => ok(r, ack)),
|
||||
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 } })
|
||||
expect(created.result).toEqual({ ok: true, value: ack })
|
||||
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')
|
||||
expect(edited.result).toEqual({ ok: true, value: { ref: { ...ack.ref, revision: 3 } } })
|
||||
expect((await c.goals.pause({ sessionId: sid('s1'), ref })).result).toEqual({ ok: true, value: ack })
|
||||
expect((await c.goals.resume({ sessionId: sid('s1'), ref })).result).toEqual({ ok: true, value: ack })
|
||||
expect((await c.goals.complete({ sessionId: sid('s1'), ref })).result).toEqual({ ok: true, value: ack })
|
||||
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' })
|
||||
expect(seen.map(s => s.method)).toEqual(['goal.create', 'goal.edit', 'goal.pause', 'goal.resume', 'goal.complete', 'goal.clear'])
|
||||
expect(seen[0]?.payload).toEqual({ sessionId: 's1', objective: 'ship it', maxGoalRounds: 4 })
|
||||
expect(seen[1]?.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 } })
|
||||
|
||||
it('passes business errors through as results, not throws', async () => {
|
||||
// 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)
|
||||
@@ -490,7 +470,7 @@ describe('goals unary surface', () => {
|
||||
if (!response.result.ok) expect(response.result.error.code).toBe('bad-request')
|
||||
|
||||
let editCalls = 0
|
||||
const api = scriptedApi({ goals: { edit: (r) => { editCalls++; return ok(r, { goal: view }) } } })
|
||||
const api = scriptedApi({ goals: { edit: (r) => { editCalls++; return ok(r, ack) } } })
|
||||
const emptyEdit = await client(api).goals.edit({ sessionId: sid('s1'), ref })
|
||||
expect(emptyEdit.result.ok).toBe(false)
|
||||
if (!emptyEdit.result.ok) expect(emptyEdit.result.error.code).toBe('bad-request')
|
||||
|
||||
@@ -136,9 +136,6 @@ function fakeApi(overrides: Partial<{ muxFrames: MuxFrame[]; hostFrames: HostFra
|
||||
},
|
||||
},
|
||||
goals: {
|
||||
async get(request) {
|
||||
return { rpcId: request.rpcId, result: { ok: false, error: { code: 'internal', message: 'stub', details: {} } } }
|
||||
},
|
||||
async create(request) {
|
||||
return { rpcId: request.rpcId, result: { ok: false, error: { code: 'internal', message: 'stub', details: {} } } }
|
||||
},
|
||||
|
||||
@@ -8,6 +8,9 @@
|
||||
"src"
|
||||
],
|
||||
"references": [
|
||||
{
|
||||
"path": "../../goal/goal"
|
||||
},
|
||||
{
|
||||
"path": "../../../vendor/cordis"
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user