Merge origin/master (goal line #527) into web-permission-sandbox-merge-master

Shared-surface conflicts resolve as unions: the fixture serves all four
projection keys (title/todos/permissions/goal) with both the /permission
and /goal command mirrors (the goal-fixture placeholder retires with
master), apps/cli carries both lines' dependency additions, and the README
Model Experience allowlist keeps both entries. The connection specs assert
the four-key baseline and the shifted approval/question replay indices;
the module graph regenerates over the merged dependency set.
This commit is contained in:
imccyu
2026-07-29 02:27:11 +08:00
69 changed files with 2240 additions and 177 deletions

View File

@@ -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, 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
@@ -23,9 +23,12 @@ function scriptedApi(overrides: {
commands?: Partial<ApiProxy['commands']>
skills?: Partial<ApiProxy['skills']>
events?: Partial<ApiProxy['events']>
goals?: Partial<ApiProxy['goals']>
respond?: ApiProxy['respond']
} = {}): ApiProxy {
async function *empty<F>(): AsyncGenerator<RpcRequest<F>> { /* no frames */ }
const err = <T>(r: RpcRequest<unknown>): Promise<RpcResponse<T>> =>
Promise.resolve({ rpcId: r.rpcId, result: { ok: false, error: { code: 'internal' as const, message: 'stub', details: {} } } })
return {
sessions: {
list: r => ok(r, { items: [] }),
@@ -66,6 +69,15 @@ function scriptedApi(overrides: {
...overrides.commands,
},
skills: { list: r => ok(r, { skills: [] }), ...overrides.skills },
goals: {
create: err,
edit: err,
pause: err,
resume: err,
complete: err,
clear: err,
...overrides.goals,
},
events: { mux: () => empty<MuxFrame>(), host: () => empty<HostFrame>(), ...overrides.events },
respond: overrides.respond ?? (() => Promise.resolve({ accepted: false as const, reason: 'not-pending' as const })),
}
@@ -405,6 +417,67 @@ describe('SSE stream path', () => {
})
})
describe('goals unary surface', () => {
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 }[] = []
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: {
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 created = await c.goals.create({ sessionId: sid('s1'), objective: 'ship it', maxGoalRounds: 4 })
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: { 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.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 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)
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')
let editCalls = 0
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')
expect(editCalls).toBe(0)
})
})
describe('respond path', () => {
it('round-trips a client-response to a receipt', async () => {
const seen: unknown[] = []

View File

@@ -135,6 +135,26 @@ function fakeApi(overrides: Partial<{ muxFrames: MuxFrame[]; hostFrames: HostFra
return { rpcId: request.rpcId, result: { ok: true, value: { skills: [{ name: 'commit-helper', description: 'Git commits' }] } } }
},
},
goals: {
async create(request) {
return { rpcId: request.rpcId, result: { ok: false, error: { code: 'internal', message: 'stub', details: {} } } }
},
async edit(request) {
return { rpcId: request.rpcId, result: { ok: false, error: { code: 'internal', message: 'stub', details: {} } } }
},
async pause(request) {
return { rpcId: request.rpcId, result: { ok: false, error: { code: 'internal', message: 'stub', details: {} } } }
},
async resume(request) {
return { rpcId: request.rpcId, result: { ok: false, error: { code: 'internal', message: 'stub', details: {} } } }
},
async complete(request) {
return { rpcId: request.rpcId, result: { ok: false, error: { code: 'internal', message: 'stub', details: {} } } }
},
async clear(request) {
return { rpcId: request.rpcId, result: { ok: false, error: { code: 'internal', message: 'stub', details: {} } } }
},
},
events: {
mux: (_request, signal) => stream(muxFrames, signal),
host: (_request, signal) => stream(hostFrames, signal),

View File

@@ -28,6 +28,7 @@ import { skillEntrySchema, skillListRequestSchema, skillListValueSchema } from '
import { hostFrameSchema, muxFrameSchema, askUserQuestionItemSchema } from '../src/api/events.schema.ts'
import { approvalRequestIdSchema, approvalResponsePayloadSchema } from '../src/api/approvals.schema.ts'
import { askUserQuestionAnswerSchema, questionResponsePayloadSchema } from '../src/api/questions.schema.ts'
import { goalEditRequestSchema } from '../src/api/goals.schema.ts'
describe('RpcId', () => {
it('brands a raw string at zero runtime cost', () => {
@@ -63,11 +64,14 @@ describe('rpcErrorSchema', () => {
details: { provider: 'p', model: 'm' },
}).code).toBe('model-unavailable')
expect(rpcErrorSchema.parse({ code: 'agent-busy', message: 'm', details: { reason: 'r' } }).code).toBe('agent-busy')
expect(rpcErrorSchema.parse({ code: 'command-error', message: 'm', details: {} }).code).toBe('command-error')
expect(rpcErrorSchema.parse({ code: 'unknown-command', message: 'm', details: {} }).code).toBe('unknown-command')
expect(rpcErrorSchema.parse({ code: 'internal', message: 'm', details: {} }).code).toBe('internal')
})
it('rejects a known code with missing details', () => {
expect(() => rpcErrorSchema.parse({ code: 'agent-busy', message: 'm', details: {} })).toThrow()
expect(() => rpcErrorSchema.parse({ code: 'command-error', message: 'm' })).toThrow()
expect(() => rpcErrorSchema.parse({ code: 'nope', message: 'm', details: {} })).toThrow()
})
})
@@ -205,6 +209,11 @@ describe('sessions domain schemas', () => {
expect(prompt.mode).toBe('queue')
expect(() => sessionPromptRequestSchema.parse({ sessionId: 's1', mode: 'inject', content: [] })).toThrow()
expect(sessionPromptValueSchema.parse({ accepted: true }).accepted).toBe(true)
// The command slot appears only when the prompt dispatched a slash command.
const dispatched = sessionPromptValueSchema.parse({ accepted: true, command: { kind: 'success', text: 'Goal set' } })
expect(dispatched.command?.text).toBe('Goal set')
expect(sessionPromptValueSchema.parse({ accepted: true, command: { kind: 'success' } }).command).toEqual({ kind: 'success' })
expect(() => sessionPromptValueSchema.parse({ accepted: true, command: { kind: 'failure' } })).toThrow()
expect(sessionCancelRequestSchema.parse({ sessionId: 's1' }).sessionId).toBe('s1')
expect(sessionCancelValueSchema.parse({ accepted: true }).accepted).toBe(true)
expect(contentBlockSchema.parse({ type: 'text', text: 'x', extra: 1 })).toMatchObject({ extra: 1 })
@@ -312,6 +321,15 @@ describe('skills domain schemas', () => {
})
})
describe('goals domain schemas', () => {
it('requires at least one replacement field for goal.edit', () => {
const ref = { id: 'g1', revision: 1 }
expect(goalEditRequestSchema.parse({ sessionId: 's1', ref, objective: 'updated' }).objective).toBe('updated')
expect(goalEditRequestSchema.parse({ sessionId: 's1', ref, maxGoalRounds: 3 }).maxGoalRounds).toBe(3)
expect(() => goalEditRequestSchema.parse({ sessionId: 's1', ref })).toThrow()
})
})
describe('events frame schemas', () => {
it('accepts every mux frame branch', () => {
const frames = [