refactor(commands): move the command service to Remote

`CommandService.list` and `execute` carry the wire contract directly through
`@Remote`, and the Client assembly mounts the generated commands
contribution. The legacy API Proxy route, its schemas, the map rows, the
generated client methods and the fixture's command domain are removed, so the
catalog and the admission call have one owner again.

`Session.command()` keeps a result-shaped public face for parity with the
prompt, cancel and attachment neighbours it sits beside, and reads the
generated namespace through one `SessionRemotes` parameter. The Session
cluster declares that face against the owning business package rather than the
generated contribution: the Host compiler aggregate builds this package, and
it runs before any contribution is emitted.

Migrated calls lose the `title-invalid` class of protocol-only error codes and
report `internal`; no production caller branched on them.
This commit is contained in:
imccyu
2026-08-11 19:10:31 +08:00
parent a2981207b0
commit 070a2a7f1e
71 changed files with 648 additions and 1129 deletions

View File

@@ -32,11 +32,11 @@
"dsh": {
"client": {
"inject": [
"@deepseek-ai/dsh-api-remotes",
"@deepseek-ai/dsh-client-runtime",
"@deepseek-ai/dsh-client-locale",
"@deepseek-ai/dsh-client-ui-slash",
"@deepseek-ai/dsh-client-ui-conversation",
"@deepseek-ai/dsh-api-remotes"
"@deepseek-ai/dsh-client-ui-conversation"
],
"platform": "web"
}
@@ -50,16 +50,16 @@
"clsx": "^2.0.0"
},
"peerDependencies": {
"@deepseek-ai/cordis": "workspace:^",
"@deepseek-ai/dsh-api-remotes": "workspace:^",
"@deepseek-ai/dsh-client-connection": "workspace:^",
"@deepseek-ai/dsh-client-locale": "workspace:^",
"@deepseek-ai/dsh-client-runtime": "workspace:^",
"@deepseek-ai/dsh-client-ui-conversation": "workspace:^",
"@deepseek-ai/dsh-client-ui-primitives": "workspace:^",
"@deepseek-ai/dsh-client-ui-slash": "workspace:^",
"@deepseek-ai/dsh-client-ui-slots": "workspace:^",
"@deepseek-ai/dsh-commands": "workspace:^",
"@deepseek-ai/dsh-invariants": "workspace:^",
"@deepseek-ai/cordis": "workspace:^",
"react": "^18.2.0"
},
"devDependencies": {
@@ -72,6 +72,7 @@
"@deepseek-ai/dsh-client-ui-primitives": "workspace:^",
"@deepseek-ai/dsh-client-ui-slash": "workspace:^",
"@deepseek-ai/dsh-client-ui-slots": "workspace:^",
"@deepseek-ai/dsh-commands": "workspace:^",
"@deepseek-ai/dsh-invariants": "workspace:^",
"@types/react": "~18.3.1",
"@deepseek-ai/cordis": "workspace:^",

View File

@@ -5,13 +5,10 @@
* / epoch-guard behavior of the original global cache; the session-key axis
* is the only extra dimension.
*/
import type { IApiClient, SessionId } from '@deepseek-ai/dsh-client-connection/client'
import type { CommandDescriptor } from '@deepseek-ai/dsh-commands/types'
import type { SessionId } from '@deepseek-ai/dsh-client-runtime/client'
/** command.list success value, derived so the wire type authority stays in apiproxy. */
type ListValue = Extract<Awaited<ReturnType<IApiClient['commands']['list']>>['result'], { ok: true }>['value']
/** One host command descriptor as served to the client. */
export type CommandDescriptor = ListValue['commands'][number]
export type { CommandDescriptor } from '@deepseek-ai/dsh-commands/types'
/**
* cold = never pulled; pending = pull in flight with nothing servable;

View File

@@ -44,8 +44,8 @@ declare module '@deepseek-ai/dsh-client-ui-slots' {
/** Dictionary namespace owned by this plugin. */
const NS = 'command'
/** Required services: the '/' source registry plus the scope + wire faces the service reads, and the copy's locale registry. */
export const inject = ['slash', 'sessions', 'connection', 'locale', 'remote']
/** Required services: the '/' source registry, session scopes, commands Remote, and locale registry. */
export const inject = ['slash', 'sessions', 'remote', 'remote.commands', 'locale']
/**
* Client plugin body: mount the service, then register the popupSelect shell

View File

@@ -9,11 +9,10 @@
*/
import { Service } from '@deepseek-ai/cordis'
import type { Context } from '@deepseek-ai/cordis'
import type { ConnectionHandle, SessionId } from '@deepseek-ai/dsh-client-connection/client'
import type { ClientContext, ISessions } from '@deepseek-ai/dsh-client-runtime/client'
// Type-only: pulls the ctx.remote merge and the forwarded-event key face
// (`commands/change` rides the allowlist) into this program.
import type {} from '@deepseek-ai/dsh-api-remotes/client'
import type { ClientContext, ISessions, SessionId } from '@deepseek-ai/dsh-client-runtime/client'
import type {
CandidateRequest, ClientSessionContext, CommandClaim, PickOutcome, SlashCandidate, SlashPick,
SubmitOutcome,
@@ -96,7 +95,7 @@ function fuzzyCandidates(candidates: readonly SlashCandidate[], rawQuery: string
/** Command surface: session-keyed directory + '/' source + contribution registry + per-session popups. */
export class CommandService extends Service implements CommandServiceContract {
static inject = ['slash', 'sessions', 'connection', 'remote']
static inject = ['slash', 'sessions', 'remote', 'remote.commands']
private readonly directory: CommandDirectory
private readonly live: LiveState = { contributions: new Map(), decorations: new Map(), popups: new Map() }
@@ -107,13 +106,11 @@ export class CommandService extends Service implements CommandServiceContract {
*/
constructor(ctx: Context) {
super(ctx, 'command')
const connection = ctx.get('connection') as ConnectionHandle | undefined
if (connection === undefined) throw new Error('ui-command: connection service unavailable')
this.directory = new CommandDirectory(async (sessionId) => {
if (this.sessions().subagentAddress(sessionId) !== undefined) return []
const { result } = await connection.api.commands.list({ sessionId })
const result = await ctx.remote.commands.list(sessionId)
if (!result.ok) throw new Error(`command.list failed: ${result.error.code}: ${result.error.message}`)
return result.value.commands
return result.value
})
const slash = ctx.get('slash')
if (slash === undefined) throw new Error('ui-command: slash service unavailable')
@@ -351,10 +348,9 @@ export class CommandService extends Service implements CommandServiceContract {
session: ClientSessionContext,
line: string,
): Promise<SubmitOutcome> {
const connection = this.ctx.get('connection') as ConnectionHandle
const { result } = await connection.api.commands.execute({ sessionId: session.sessionId, line })
const result = await this.ctx.remote.commands.execute(session.sessionId, line)
if (!result.ok) throw new Error(`command.execute failed: ${result.error.code}: ${result.error.message}`)
if (!result.value.matched) return { kind: 'error', text: `unknown or malformed command: ${line}` }
if (result.value === undefined) return { kind: 'error', text: `unknown or malformed command: ${line}` }
return { kind: 'success' }
}

View File

@@ -14,7 +14,6 @@ import type { SlashSource } from '@deepseek-ai/dsh-client-ui-slash/client'
import type { CommandServiceContract } from '../src/client/contract.ts'
import type { PopupSelectInjected } from '../src/client/PopupSelectView.tsx'
import { LocaleService } from '@deepseek-ai/dsh-client-locale/client'
import { TestRemote } from '@deepseek-ai/dsh-client-test-runtime'
import { apply, CommandService, inject } from '../src/client/index.ts'
const sid = (k: string): SessionId => k as SessionId
@@ -33,14 +32,14 @@ async function bench() {
scope: (id: SessionId) => scopes.get(id),
scopeOf: (c: Context) => scopeOf(c),
})
ctx.provide('connection', { api: { commands: { list: () => Promise.resolve({ result: { ok: true, value: { commands: [] } } }) } } })
const commandsRemote = { list: () => Promise.resolve([]) }
ctx.provide('remote', { commands: commandsRemote })
ctx.provide('remote.commands', commandsRemote)
await ctx.plugin(SlotsService).await()
ctx.slots.register({
name: 'root', children: { 'conversation.input.overlay': { kind: 'list', scope: 'session' } },
} as never, (() => null) as never)
ctx.provide('locale', new LocaleService(ctx))
// CommandService injects `remote` for the forwarded directory invalidation.
new TestRemote(ctx)
const fiber = ctx.plugin({ inject: [...inject], apply })
await fiber.await()
const mint = (key: string) => {
@@ -53,7 +52,7 @@ async function bench() {
describe('apply', () => {
it('declares the services it binds', () => {
expect(inject).toEqual(['slash', 'sessions', 'connection', 'locale', 'remote'])
expect(inject).toEqual(['slash', 'sessions', 'remote', 'remote.commands', 'locale'])
})
it('mounts ctx.command, registers the source and the overlay entry, and folds up on disposal', async () => {

View File

@@ -6,7 +6,7 @@
* gate, and the per-key ensureReady strong-wait policy.
*/
import { describe, expect, it } from 'vitest'
import type { SessionId } from '@deepseek-ai/dsh-client-connection/client'
import type { SessionId } from '@deepseek-ai/dsh-api-remotes/client'
import type { CommandDescriptor } from '../src/client/directory.ts'
import { CommandDirectory } from '../src/client/directory.ts'

View File

@@ -10,7 +10,6 @@
import { Context } from '@deepseek-ai/cordis'
import { describe, expect, it, vi } from 'vitest'
import { createScope, scopeOf } from '@deepseek-ai/dsh-client-runtime/client'
import { TestRemote } from '@deepseek-ai/dsh-client-test-runtime'
import type { SessionId } from '@deepseek-ai/dsh-client-runtime/client'
import type { ClientSessionContext, ConsumeTokenRequest, SlashPick, SlashSource } from '@deepseek-ai/dsh-client-ui-slash/client'
import type { CommandContribution, CommandDecoration, CommandUiSpec, SelectOption } from '../src/client/contract.ts'
@@ -32,7 +31,7 @@ const S2_CMDS: CommandDescriptor[] = [
{ name: 'attach', description: 'scoped shadow', input: { hint: 'path' } },
]
type ExecuteValue = { matched: boolean }
type ExecuteValue = { matched: boolean; commandId?: string }
interface BenchOptions {
/** Scripted catalog per list payload; default serves the fixed catalogs by session. */
@@ -46,20 +45,26 @@ async function bench(opts: BenchOptions = {}) {
const registered = new Map<string, SlashSource>()
const listCalls: Array<{ sessionId: SessionId }> = []
const executeCalls: Array<{ sessionId: SessionId; line: string }> = []
const api = {
commands: {
list: async (payload: { sessionId: SessionId }) => {
listCalls.push(payload)
const value = await (opts.commands ?? (p => Promise.resolve({
commands: p.sessionId === sid('s2') ? S2_CMDS : S1_CMDS,
})))(payload)
return { result: { ok: true as const, value } }
},
execute: async (payload: { sessionId: SessionId; line: string }) => {
executeCalls.push(payload)
const value = await (opts.execute ?? (() => Promise.resolve({ matched: true })))(payload)
return { result: { ok: true as const, value } }
},
// The service reads the generated commands Remote, which delivers the
// carrier's outcome, so a programmed failure answers the error branch.
const commandsRemote = {
list: async (sessionId: SessionId) => {
listCalls.push({ sessionId })
const value = await (opts.commands ?? (p => Promise.resolve({
commands: p.sessionId === sid('s2') ? S2_CMDS : S1_CMDS,
})))({ sessionId })
return { ok: true as const, value: value.commands }
},
execute: async (sessionId: SessionId, line: string) => {
executeCalls.push({ sessionId, line })
const fallback = (): Promise<ExecuteValue> => Promise.resolve({ matched: true })
const value = await (opts.execute ?? fallback)({ sessionId, line })
return {
ok: true as const,
value: value.matched
? { commandId: value.commandId ?? 'fake-command', result: { kind: 'success' as const } }
: undefined,
}
},
}
ctx.provide('slash', {
@@ -78,10 +83,20 @@ async function bench(opts: BenchOptions = {}) {
? { parentSessionId: sid('parent'), childSessionId: id, mode: 'continuable' as const }
: undefined,
})
ctx.provide('connection', { api })
// CommandService injects `remote`; the directory invalidation arrives on the
// same `$dispatch` handoff the connection sink makes.
new TestRemote(ctx)
const forwarded = new Map<string, Array<(...args: never[]) => void>>()
ctx.provide('remote', {
commands: commandsRemote,
$on: (event: string, listener: (...args: never[]) => void) => {
const listeners = forwarded.get(event) ?? []
listeners.push(listener)
forwarded.set(event, listeners)
return () => { forwarded.set(event, listeners.filter(entry => entry !== listener)) }
},
$dispatch: (event: string, args: readonly unknown[]) => {
for (const listener of forwarded.get(event) ?? []) listener(...args as never[])
},
})
ctx.provide('remote.commands', commandsRemote)
/** Notices the fake conversation face collected (runDetached routing). */
const notices: Array<{ scope: SessionId | undefined; level: 'info' | 'error'; text: string }> = []
ctx.provide('conversation', {
@@ -515,7 +530,13 @@ describe('detached admission notices', () => {
mode = 'reject'
menuPick(source, 'plan', proj('s1'))
await flush()
expect(notices).toEqual([{ scope: sid('s1'), level: 'error', text: 'network down' }])
// A dead Remote call and a rejected one now read alike: both arrive as a
// failed result, so the notice names the endpoint either way.
expect(notices).toEqual([{
scope: sid('s1'),
level: 'error',
text: 'command.execute failed: internal: network down',
}])
})
it('a torn-down scope drops the failure notice', async () => {

View File

@@ -9,10 +9,10 @@
],
"references": [
{
"path": "../../../vendor/cordis"
"path": "../../api/remotes/tsconfig.client.json"
},
{
"path": "../connection"
"path": "../../../vendor/cordis"
},
{
"path": "../locale"
@@ -32,6 +32,9 @@
{
"path": "../ui-slots"
},
{
"path": "../../interaction/commands"
},
{
"path": "../../support/invariants"
},