mirror of
https://github.com/deepseek-ai/deepseek-harness
synced 2026-08-15 21:04:50 +00:00
Merge remote-tracking branch 'origin/master' into doc/host-client-group-readmes
This commit is contained in:
@@ -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,7 +24,7 @@ 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'
|
||||
@@ -32,6 +32,9 @@ import type {
|
||||
import type {} from '@deepseek-ai/dsh-session-projection'
|
||||
// Type-only: resolves `ctx.get('sessionProjectionCache')` (the cold listing column).
|
||||
import type {} from '@deepseek-ai/dsh-session-projection-cache'
|
||||
// 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'
|
||||
@@ -138,13 +141,24 @@ function subscribeSession(queue: FrameQueue<RpcRequest<MuxFrame>>, session: Sess
|
||||
queue.push(frame({ type: 'session/subscribed', sessionId: session.id, lastSeq: session.seq - 1 }))
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the session's conversation has started: no turn has run yet (a
|
||||
* turn is one model-loop execution). Standalone plugin events — command
|
||||
* lifecycle records, plan/mode, titles, goals — never open a turn, so
|
||||
* running `/plan` or `/goal` on a fresh session keeps it blank
|
||||
* (list-hidden, reusable).
|
||||
*/
|
||||
function sessionBlank(session: Session): boolean {
|
||||
return !session.events.some(event => event.type === 'turn/start')
|
||||
}
|
||||
|
||||
/** SessionSummary projection for attached (in-memory) sessions. */
|
||||
function summarize(session: Session, running: boolean): SessionSummary {
|
||||
return {
|
||||
sessionId: session.id,
|
||||
updatedAt: session.events.at(-1)?.time ?? session.header.createdAt,
|
||||
running,
|
||||
blank: session.events.length === 0,
|
||||
blank: sessionBlank(session),
|
||||
...session.header.parentSession === undefined ? {} : { parentSessionId: session.header.parentSession },
|
||||
...session.header.cwd === undefined ? {} : { cwd: session.header.cwd },
|
||||
}
|
||||
@@ -169,8 +183,9 @@ async function summarizeCold(persistence: SessionPersistence, meta: SessionHeade
|
||||
sessionId: meta.id,
|
||||
updatedAt,
|
||||
running: false,
|
||||
// Lazy persistence keeps never-appended sessions out of list(): a cold
|
||||
// session necessarily has events, so blank is constantly false here.
|
||||
// Lazy persistence keeps never-appended sessions out of list(); reading
|
||||
// a cold log to check for turns would defeat the index read, so a listed
|
||||
// cold session is served as not-blank (its log holds its conversation).
|
||||
blank: false,
|
||||
...meta.parentSession === undefined ? {} : { parentSessionId: meta.parentSession },
|
||||
/* v8 ignore next -- the empty arm needs a cwd-less meta, but list()
|
||||
@@ -669,6 +684,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)
|
||||
@@ -1126,6 +1173,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
|
||||
@@ -1245,8 +1340,8 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
|
||||
type: 'host/session-added',
|
||||
sessionId: session.id,
|
||||
// Derived at frame time like summarize(); a just-created session
|
||||
// has no events yet, so this is constantly true in practice.
|
||||
blank: session.events.length === 0,
|
||||
// has run no turn yet, so this is constantly true in practice.
|
||||
blank: sessionBlank(session),
|
||||
...session.header.parentSession === undefined ? {} : { parentSessionId: session.header.parentSession },
|
||||
// cwd rides the frame so the client list needs no refresh to group the new session.
|
||||
...session.header.cwd === undefined ? {} : { cwd: session.header.cwd },
|
||||
|
||||
79
packages/host/apiproxy/src/api/goals.schema.ts
Normal file
79
packages/host/apiproxy/src/api/goals.schema.ts
Normal file
@@ -0,0 +1,79 @@
|
||||
/**
|
||||
* 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, RequestPayload, ResponseValue } from './index.ts'
|
||||
|
||||
/** GoalRef schema. */
|
||||
export const goalRefSchema = z.object({
|
||||
id: z.string(),
|
||||
revision: z.number().int().positive(),
|
||||
}) as unknown as z.ZodType<Wire<GoalRef>>
|
||||
|
||||
/** Shared `{ ref }` acknowledgement value of every non-clear mutation. */
|
||||
const goalRefValueSchema = z.object({ ref: goalRefSchema })
|
||||
|
||||
/** goal.create request payload. */
|
||||
export const goalCreateRequestSchema = z.object({
|
||||
sessionId: z.string(),
|
||||
objective: z.string().min(1),
|
||||
maxGoalRounds: z.number().int().positive().optional(),
|
||||
}) as unknown as z.ZodType<Wire<RequestPayload<'goal.create'>>>
|
||||
|
||||
/** goal.create response value. */
|
||||
export const goalCreateValueSchema = goalRefValueSchema as unknown as z.ZodType<Wire<ResponseValue<'goal.create'>>>
|
||||
|
||||
/** goal.edit request payload. */
|
||||
export const goalEditRequestSchema = z.object({
|
||||
sessionId: z.string(),
|
||||
ref: goalRefSchema,
|
||||
objective: z.string().min(1).optional(),
|
||||
maxGoalRounds: z.number().int().positive().optional(),
|
||||
}).refine(value => value.objective !== undefined || value.maxGoalRounds !== undefined, {
|
||||
message: 'goal.edit requires objective or maxGoalRounds',
|
||||
}) as unknown as z.ZodType<Wire<RequestPayload<'goal.edit'>>>
|
||||
|
||||
/** goal.edit response value. */
|
||||
export const goalEditValueSchema = goalRefValueSchema as unknown as z.ZodType<Wire<ResponseValue<'goal.edit'>>>
|
||||
|
||||
/** goal.pause request payload. */
|
||||
export const goalPauseRequestSchema = z.object({
|
||||
sessionId: z.string(),
|
||||
ref: goalRefSchema,
|
||||
}) as unknown as z.ZodType<Wire<RequestPayload<'goal.pause'>>>
|
||||
|
||||
/** goal.pause response value. */
|
||||
export const goalPauseValueSchema = goalRefValueSchema as unknown as z.ZodType<Wire<ResponseValue<'goal.pause'>>>
|
||||
|
||||
/** goal.resume request payload. */
|
||||
export const goalResumeRequestSchema = z.object({
|
||||
sessionId: z.string(),
|
||||
ref: goalRefSchema,
|
||||
}) as unknown as z.ZodType<Wire<RequestPayload<'goal.resume'>>>
|
||||
|
||||
/** goal.resume response value. */
|
||||
export const goalResumeValueSchema = goalRefValueSchema as unknown as z.ZodType<Wire<ResponseValue<'goal.resume'>>>
|
||||
|
||||
/** goal.complete request payload. */
|
||||
export const goalCompleteRequestSchema = z.object({
|
||||
sessionId: z.string(),
|
||||
ref: goalRefSchema,
|
||||
}) as unknown as z.ZodType<Wire<RequestPayload<'goal.complete'>>>
|
||||
|
||||
/** goal.complete response value. */
|
||||
export const goalCompleteValueSchema = goalRefValueSchema as unknown as z.ZodType<Wire<ResponseValue<'goal.complete'>>>
|
||||
|
||||
/** goal.clear request payload. */
|
||||
export const goalClearRequestSchema = z.object({
|
||||
sessionId: z.string(),
|
||||
ref: goalRefSchema,
|
||||
}) as unknown as z.ZodType<Wire<RequestPayload<'goal.clear'>>>
|
||||
|
||||
/** goal.clear response value. */
|
||||
export const goalClearValueSchema = z.object({
|
||||
cleared: z.literal(true),
|
||||
}) as unknown as z.ZodType<Wire<ResponseValue<'goal.clear'>>>
|
||||
50
packages/host/apiproxy/src/api/goals.ts
Normal file
50
packages/host/apiproxy/src/api/goals.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
/**
|
||||
* 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'
|
||||
import type { SessionId } from '@deepseek-ai/dsh-session/types'
|
||||
import type { RpcRequest, RpcResponse } from './rpc.ts'
|
||||
|
||||
/** Identifies one goal across its durable revisions. */
|
||||
export type GoalId = Branded<'GoalId'>
|
||||
|
||||
/** Compare-and-set identity for one exact goal revision. */
|
||||
export interface GoalRef {
|
||||
readonly id: GoalId
|
||||
readonly revision: number
|
||||
}
|
||||
|
||||
/** 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<{ ref: GoalRef }>>
|
||||
|
||||
/** Edit objective and/or round cap without changing phase. */
|
||||
edit(request: RpcRequest<{ sessionId: SessionId; ref: GoalRef; objective?: string; maxGoalRounds?: number }>):
|
||||
Promise<RpcResponse<{ ref: GoalRef }>>
|
||||
|
||||
/** Pause an active goal and disarm automatic continuation. */
|
||||
pause(request: RpcRequest<{ sessionId: SessionId; ref: GoalRef }>):
|
||||
Promise<RpcResponse<{ ref: GoalRef }>>
|
||||
|
||||
/** Resume and arm a stopped goal. */
|
||||
resume(request: RpcRequest<{ sessionId: SessionId; ref: GoalRef }>):
|
||||
Promise<RpcResponse<{ ref: GoalRef }>>
|
||||
|
||||
/** Mark a current non-complete goal complete and disarm it. */
|
||||
complete(request: RpcRequest<{ sessionId: SessionId; ref: GoalRef }>):
|
||||
Promise<RpcResponse<{ ref: GoalRef }>>
|
||||
|
||||
/** Clear the current goal while retaining a durable tombstone and history. */
|
||||
clear(request: RpcRequest<{ sessionId: SessionId; ref: GoalRef }>):
|
||||
Promise<RpcResponse<{ cleared: true }>>
|
||||
}
|
||||
@@ -10,6 +10,7 @@ import type { WorkspaceApi } from './workspace.ts'
|
||||
import type { CommandsApi } from './commands.ts'
|
||||
import type { SkillsApi } from './skills.ts'
|
||||
import type { EventsApi } from './events.ts'
|
||||
import type { GoalsApi } from './goals.ts'
|
||||
import type { ClientResponse, RpcReceipt } from './rpc.ts'
|
||||
|
||||
/** Root interface of the unified API surface. New client-request domain = one new file pair + one field here + one map row. */
|
||||
@@ -20,6 +21,7 @@ export interface ApiProxy {
|
||||
commands: CommandsApi
|
||||
skills: SkillsApi
|
||||
events: EventsApi
|
||||
goals: GoalsApi
|
||||
/** Response entry for server-requests (client-response, echoing their rpcId); not a domain method (four-quadrant model). */
|
||||
respond(message: ClientResponse): Promise<RpcReceipt>
|
||||
}
|
||||
@@ -34,6 +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, GoalId, GoalRef } from './goals.ts'
|
||||
export type { ApprovalResponsePayload } from './approvals.ts'
|
||||
export type { QuestionResponsePayload } from './questions.ts'
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ import type { HostApi } from './host.ts'
|
||||
import type { WorkspaceApi } from './workspace.ts'
|
||||
import type { CommandsApi } from './commands.ts'
|
||||
import type { SkillsApi } from './skills.ts'
|
||||
import type { GoalsApi } from './goals.ts'
|
||||
import type { RpcResponse } from './rpc.ts'
|
||||
|
||||
/**
|
||||
@@ -35,6 +36,12 @@ export interface RpcMethodMap {
|
||||
'command.list': CommandsApi['list']
|
||||
'command.execute': CommandsApi['execute']
|
||||
'skill.list': SkillsApi['list']
|
||||
'goal.create': GoalsApi['create']
|
||||
'goal.edit': GoalsApi['edit']
|
||||
'goal.pause': GoalsApi['pause']
|
||||
'goal.resume': GoalsApi['resume']
|
||||
'goal.complete': GoalsApi['complete']
|
||||
'goal.clear': GoalsApi['clear']
|
||||
}
|
||||
|
||||
/** Business request payload of method K (reaches through the RpcRequest narrow form to payload). */
|
||||
|
||||
@@ -43,6 +43,8 @@ export const rpcErrorSchema: z.ZodType<RpcError> = z.discriminatedUnion('code',
|
||||
z.object({ code: z.literal('workspace-name-conflict'), message: z.string(), details: z.object({ name: z.string() }) }),
|
||||
z.object({ code: z.literal('workspace-move-invalid'), message: z.string(), details: z.object({ workspaceId: z.string(), sessionId: z.string(), beforeSessionId: z.string().optional() }) }),
|
||||
z.object({ code: z.literal('agent-busy'), message: z.string(), details: z.object({ reason: z.string() }) }),
|
||||
z.object({ code: z.literal('command-error'), message: z.string(), details: z.object({}) }),
|
||||
z.object({ code: z.literal('unknown-command'), message: z.string(), details: z.object({}) }),
|
||||
z.object({ code: z.literal('internal'), message: z.string(), details: z.object({}) }),
|
||||
]) as unknown as z.ZodType<RpcError>
|
||||
|
||||
|
||||
@@ -40,6 +40,10 @@ export interface RpcErrorDetailsMap {
|
||||
'workspace-name-conflict': { name: string }
|
||||
'workspace-move-invalid': { workspaceId: string; sessionId: SessionId; beforeSessionId?: SessionId }
|
||||
'agent-busy': { reason: string }
|
||||
/** A known slash command reported a usage/state error; the message is the command's own text. */
|
||||
'command-error': {}
|
||||
/** A leading-/ prompt named no registered command; the message names the token. */
|
||||
'unknown-command': {}
|
||||
'internal': {}
|
||||
}
|
||||
|
||||
|
||||
@@ -193,9 +193,13 @@ export const sessionPromptRequestSchema = z.object({
|
||||
content: z.array(contentBlockSchema),
|
||||
}) as unknown as z.ZodType<RequestPayload<'session.prompt'>>
|
||||
|
||||
/** session.prompt response value. */
|
||||
/** session.prompt response value (the command slot appears only when the prompt dispatched a slash command). */
|
||||
export const sessionPromptValueSchema = z.object({
|
||||
accepted: z.literal(true),
|
||||
command: z.object({
|
||||
kind: z.literal('success'),
|
||||
text: z.string().optional(),
|
||||
}).optional(),
|
||||
}) satisfies z.ZodType<Wire<ResponseValue<'session.prompt'>>>
|
||||
|
||||
/** session.cancel request payload. */
|
||||
|
||||
@@ -132,11 +132,13 @@ export interface SessionSummary {
|
||||
/** Status of the attached agent; always false for cold (unattached) sessions. */
|
||||
running: boolean
|
||||
/**
|
||||
* Derived emptiness bit: true while the session log holds zero events (no
|
||||
* user message yet). Clients hide blank sessions from lists and reuse them
|
||||
* for New Session on the same workspace. Always false for cold sessions —
|
||||
* lazy persistence keeps a never-appended session out of the store, so a
|
||||
* listed cold session necessarily has events.
|
||||
* Derived conversation-not-started bit: true while no turn has run (no
|
||||
* prompt was accepted yet). Standalone plugin events — command lifecycle
|
||||
* records, plan/mode, titles, goals — do not open a turn and therefore do
|
||||
* not clear it. Clients hide blank sessions from lists and reuse them for
|
||||
* New Session on the same workspace. Always false for cold sessions —
|
||||
* lazy persistence keeps a never-appended session out of the store, and a
|
||||
* listed cold session's log holds its turns.
|
||||
*/
|
||||
blank: boolean
|
||||
/** fork/spawn lineage (session.header.parentSession passthrough); absent for root sessions. */
|
||||
@@ -206,9 +208,16 @@ export interface SessionsApi {
|
||||
}>):
|
||||
Promise<RpcResponse<{ selected: ModelTarget }>>
|
||||
|
||||
/** Sends a message. content is core's ContentBlock[] verbatim; mode maps 1:1 — queue→send, steer→steer. */
|
||||
/**
|
||||
* Sends a message. content is core's ContentBlock[] verbatim; mode maps 1:1 — queue→send, steer→steer.
|
||||
* A prompt whose content is exactly one text block starting with '/' is a slash command: the host
|
||||
* executes it through the command registry (mode-agnostic) and it is never sent to the model. A
|
||||
* successful command returns ok with the command slot (its success text, when the command produced
|
||||
* one — carried for future rendering; the state change is the feedback). A usage/state error is an
|
||||
* RPC error with code command-error; an unrecognized name is an RPC error with code unknown-command.
|
||||
*/
|
||||
prompt(request: RpcRequest<{ sessionId: SessionId; mode: 'queue' | 'steer'; content: ContentBlock[] }>):
|
||||
Promise<RpcResponse<{ accepted: true }>>
|
||||
Promise<RpcResponse<{ accepted: true; command?: { kind: 'success'; text?: string } }>>
|
||||
|
||||
/** Stops: clears both FIFOs + aborts the current step (1:1 with agent.cancel). */
|
||||
cancel(request: RpcRequest<{ sessionId: SessionId }>): Promise<RpcResponse<{ accepted: true }>>
|
||||
|
||||
@@ -34,6 +34,14 @@ import {
|
||||
} from '../api/workspace.schema.ts'
|
||||
import { commandExecuteValueSchema, commandListValueSchema } from '../api/commands.schema.ts'
|
||||
import { skillListValueSchema } from '../api/skills.schema.ts'
|
||||
import {
|
||||
goalCreateValueSchema,
|
||||
goalEditValueSchema,
|
||||
goalPauseValueSchema,
|
||||
goalResumeValueSchema,
|
||||
goalCompleteValueSchema,
|
||||
goalClearValueSchema,
|
||||
} from '../api/goals.schema.ts'
|
||||
|
||||
/**
|
||||
* Client consumption face of the contract (shape a): same domain tree as ApiProxy, but unary
|
||||
@@ -83,6 +91,14 @@ export interface IApiClient {
|
||||
mux(payload: Parameters<ApiProxy['events']['mux']>[0]['payload'], signal: AbortSignal, onOpen?: () => void): AsyncIterable<RpcRequest<MuxFrame>>
|
||||
host(payload: Parameters<ApiProxy['events']['host']>[0]['payload'], signal: AbortSignal, onOpen?: () => void): AsyncIterable<RpcRequest<HostFrame>>
|
||||
}
|
||||
goals: {
|
||||
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'>>>
|
||||
resume(payload: RequestPayload<'goal.resume'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'goal.resume'>>>
|
||||
complete(payload: RequestPayload<'goal.complete'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'goal.complete'>>>
|
||||
clear(payload: RequestPayload<'goal.clear'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'goal.clear'>>>
|
||||
}
|
||||
/** client-response passthrough (rpcId is a backfill of the server-request's id — never minted here). */
|
||||
respond(message: ClientResponse, signal?: AbortSignal): Promise<RpcReceipt>
|
||||
}
|
||||
@@ -110,6 +126,12 @@ const UNARY_VALUE_SCHEMAS: { [K in keyof RpcMethodMap]: z.ZodType<Wire<ResponseV
|
||||
'command.list': commandListValueSchema,
|
||||
'command.execute': commandExecuteValueSchema,
|
||||
'skill.list': skillListValueSchema,
|
||||
'goal.create': goalCreateValueSchema,
|
||||
'goal.edit': goalEditValueSchema,
|
||||
'goal.pause': goalPauseValueSchema,
|
||||
'goal.resume': goalResumeValueSchema,
|
||||
'goal.complete': goalCompleteValueSchema,
|
||||
'goal.clear': goalClearValueSchema,
|
||||
}
|
||||
|
||||
/** Default unary timeout (rpc-compare 2026-07-19: a hung host must not leave callers pending forever). */
|
||||
@@ -329,6 +351,15 @@ export abstract class AbstractApiClient implements IApiClient {
|
||||
list: (payload, signal) => this.callUnary('skill.list', payload, signal),
|
||||
}
|
||||
|
||||
readonly goals: IApiClient['goals'] = {
|
||||
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),
|
||||
resume: (payload, signal) => this.callUnary('goal.resume', payload, signal),
|
||||
complete: (payload, signal) => this.callUnary('goal.complete', payload, signal),
|
||||
clear: (payload, signal) => this.callUnary('goal.clear', payload, signal),
|
||||
}
|
||||
|
||||
readonly events: IApiClient['events'] = {
|
||||
mux: (payload, signal, onOpen) => this.openMux(payload, signal, onOpen),
|
||||
host: (payload, signal, onOpen) => this.openHost(payload, signal, onOpen),
|
||||
|
||||
@@ -35,6 +35,14 @@ import {
|
||||
} from '../api/workspace.schema.ts'
|
||||
import { commandExecuteRequestSchema, commandListRequestSchema } from '../api/commands.schema.ts'
|
||||
import { skillListRequestSchema } from '../api/skills.schema.ts'
|
||||
import {
|
||||
goalCreateRequestSchema,
|
||||
goalEditRequestSchema,
|
||||
goalPauseRequestSchema,
|
||||
goalResumeRequestSchema,
|
||||
goalCompleteRequestSchema,
|
||||
goalClearRequestSchema,
|
||||
} from '../api/goals.schema.ts'
|
||||
|
||||
/**
|
||||
* Unary dispatch table, keyed by (and compiler-locked to) RpcMethodMap: a map row without a
|
||||
@@ -71,6 +79,12 @@ const UNARY_ROUTES: UnaryRoutes = {
|
||||
'command.list': { schema: commandListRequestSchema, invoke: (api, r) => api.commands.list(r) },
|
||||
'command.execute': { schema: commandExecuteRequestSchema, invoke: (api, r, signal) => api.commands.execute(r, signal) },
|
||||
'skill.list': { schema: skillListRequestSchema, invoke: (api, r) => api.skills.list(r) },
|
||||
'goal.create': { schema: goalCreateRequestSchema, invoke: (api, r) => api.goals.create(r) },
|
||||
'goal.edit': { schema: goalEditRequestSchema, invoke: (api, r) => api.goals.edit(r) },
|
||||
'goal.pause': { schema: goalPauseRequestSchema, invoke: (api, r) => api.goals.pause(r) },
|
||||
'goal.resume': { schema: goalResumeRequestSchema, invoke: (api, r) => api.goals.resume(r) },
|
||||
'goal.complete': { schema: goalCompleteRequestSchema, invoke: (api, r) => api.goals.complete(r) },
|
||||
'goal.clear': { schema: goalClearRequestSchema, invoke: (api, r) => api.goals.clear(r) },
|
||||
}
|
||||
|
||||
/** Route lookup that narrows an arbitrary path segment to a map key (single cast point for the string→key refinement). */
|
||||
|
||||
@@ -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
|
||||
|
||||
77
packages/host/apiproxy/tests/api-proxy-blank.spec.ts
Normal file
77
packages/host/apiproxy/tests/api-proxy-blank.spec.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
/**
|
||||
* The summary blank bit means "conversation not started" (no turn has run),
|
||||
* not "log empty": standalone plugin events — command lifecycle records,
|
||||
* plan/mode, session titles — never flip it, so running /plan or /goal on a
|
||||
* fresh session keeps it list-hidden and reusable, while the first accepted
|
||||
* prompt's turn/start clears it. The host/session-added frame shares the
|
||||
* same predicate function (covered by the workspace spec's frame assertion).
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import AgentRegistry from '@deepseek-ai/dsh-agent'
|
||||
import type { Agent } from '@deepseek-ai/dsh-agent'
|
||||
import SessionStore from '@deepseek-ai/dsh-session'
|
||||
import type { Session } from '@deepseek-ai/dsh-session'
|
||||
import UserInteractionService from '@deepseek-ai/dsh-user-interaction'
|
||||
import { CommandId } from '@deepseek-ai/dsh-commands/brand'
|
||||
import type { ApiProxy, RpcRequest } from '@deepseek-ai/dsh-host-apiproxy/api'
|
||||
import { RpcId } from '@deepseek-ai/dsh-host-apiproxy/api/rpc'
|
||||
import { createApiProxy } from '@deepseek-ai/dsh-host-apiproxy'
|
||||
|
||||
let nextRpc = 1
|
||||
function request<P>(payload: P): RpcRequest<P> {
|
||||
return { rpcId: RpcId(`blank-${String(nextRpc++)}`), payload }
|
||||
}
|
||||
|
||||
async function harness(): Promise<{ ctx: Context; api: ApiProxy; attach: (session: Session) => void }> {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SessionStore)
|
||||
await ctx.plugin(UserInteractionService)
|
||||
await ctx.plugin(AgentRegistry)
|
||||
return {
|
||||
ctx,
|
||||
api: createApiProxy(ctx, { provider: 'p', model: 'm', cwd: '/tmp', workspaceRoot: '/tmp' }),
|
||||
attach: (session) => {
|
||||
ctx.agents.register({ id: session.id, session, status: 'idle', ctx } as Agent)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/** Append the standalone (non-conversation) event family a fresh session can accumulate. */
|
||||
function appendStandalone(session: Session): void {
|
||||
session.append('command/run', {
|
||||
commandId: CommandId('blank-cmd-1'), name: 'plan', args: '', source: { kind: 'user' },
|
||||
})
|
||||
session.append('plan/mode', { active: true })
|
||||
session.append('command/done', { commandId: CommandId('blank-cmd-1'), kind: 'success', text: 'Plan mode on.' })
|
||||
session.append('session/title', {
|
||||
title: 'standalone title', messageSeqs: [], source: { kind: 'fallback' },
|
||||
})
|
||||
}
|
||||
|
||||
async function listBlank(api: ApiProxy, id: string): Promise<boolean | undefined> {
|
||||
const response = await api.sessions.list(request({}))
|
||||
if (!response.result.ok) throw new Error('list failed')
|
||||
return response.result.value.items.find(item => item.sessionId === id)?.blank
|
||||
}
|
||||
|
||||
describe('summary blank = conversation not started', () => {
|
||||
it('standalone events (command lifecycle, plan/mode, title) keep the session blank', async () => {
|
||||
const { ctx, api, attach } = await harness()
|
||||
const session = ctx.sessions.create()
|
||||
attach(session)
|
||||
expect(await listBlank(api, session.id)).toBe(true)
|
||||
appendStandalone(session)
|
||||
expect(await listBlank(api, session.id)).toBe(true)
|
||||
})
|
||||
|
||||
it('the first turn clears blank', async () => {
|
||||
const { ctx, api, attach } = await harness()
|
||||
const session = ctx.sessions.create()
|
||||
attach(session)
|
||||
appendStandalone(session)
|
||||
session.append('turn/start', { turn: 0, trigger: { kind: 'message', source: { kind: 'user' } } })
|
||||
expect(await listBlank(api, session.id)).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -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 })),
|
||||
}
|
||||
@@ -423,6 +435,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[] = []
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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 = [
|
||||
|
||||
@@ -8,6 +8,9 @@
|
||||
"src"
|
||||
],
|
||||
"references": [
|
||||
{
|
||||
"path": "../../goal/goal"
|
||||
},
|
||||
{
|
||||
"path": "../../../vendor/cordis"
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user