Merge remote-tracking branch 'origin/master' into feat/web-message-feedback-ui

Adapt to two contract changes master introduced:

- The generated Remote face now wraps every business result in
  RemoteResult, folding carrier failures into an ok:false branch instead
  of rejecting. The controller reads that envelope at its three call
  sites and maps a carrier failure onto the same settled shape the
  controls already render; three specs cover the new branch.
- Client packages split their tsconfig into host and client halves, and
  the host aggregate now compiles any test not named *.client.spec.*.
  Rename this package's specs to the client convention and drop the
  ../connection project reference, which pointed at a solution file that
  no longer carries the client sources.

Keep master's mount loop with its rollback-on-failure in api-remotes and
add messageFeedbackRemote to it.
This commit is contained in:
Chinesezjc
2026-08-12 10:43:23 +08:00
parent 47f254a252
commit b462d5fd69
507 changed files with 3130 additions and 2238 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,16 @@ 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([]) }
// The service subscribes its cache-invalidation events on construction, so
// the Remote face needs `$on` even where this spec dispatches none.
ctx.provide('remote', { commands: commandsRemote, $on: () => () => {} })
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 +54,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. */
@@ -41,25 +40,54 @@ interface BenchOptions {
addressed?: SessionId
}
/**
* Fold one programmed answer into the generated Remote face's outcome: a
* resolved value is the ok branch, a rejection is the transport failure the
* carrier reports in the error branch instead of throwing at the caller.
* @param produce - the scripted answer for one Remote method.
* @returns the carried result the service reads.
*/
async function carried<T>(produce: () => Promise<T>) {
try {
return { ok: true as const, value: await produce() }
} catch (error) {
return {
ok: false as const,
error: {
code: 'internal',
message: error instanceof Error ? error.message : String(error),
details: {},
},
}
}
}
async function bench(opts: BenchOptions = {}) {
const ctx = new Context()
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)
// 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 })
return await carried(async () => {
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 } }
},
})))({ sessionId })
return value.commands
})
},
execute: async (sessionId: SessionId, line: string) => {
executeCalls.push({ sessionId, line })
return await carried(async () => {
const fallback = (): Promise<ExecuteValue> => Promise.resolve({ matched: true })
const value = await (opts.execute ?? fallback)({ sessionId, line })
return value.matched
? { commandId: value.commandId ?? 'fake-command', result: { kind: 'success' as const } }
: undefined
})
},
}
ctx.provide('slash', {
@@ -78,10 +106,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 +553,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"
},