From 755ce21334abb87c54288eb452160aaf5b3bf164 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Tue, 28 Jul 2026 01:37:45 +0800 Subject: [PATCH] refactor: brand the command lifecycle pairing id as CommandId commandId crosses three boundaries (session log, wire admission response, client flow pairing), so per the branded-id rule it becomes Branded<'CommandId'>, declared in a new pure @deepseek-ai/dsh-commands/brand outlet (the dsh-llm/brand shape: type + constructor, no Context merges, so wire and client programs can name it without loading the host plugin). The event payloads, CommandExecution, and the executor mint carry the brand; the wire schema gains commandIdSchema as the domain's single brand-cast point (the approvals precedent); CommandNode and the fixture's fabrication cast follow type-only. --- packages/client/connection/package.json | 1 + .../client/connection/src/client/fixture.ts | 5 +++- packages/client/connection/tests/fake-api.ts | 3 +- packages/client/connection/tsconfig.json | 3 ++ packages/client/runtime/package.json | 1 + .../src/client/sessions/conversation.ts | 3 +- .../src/client/sessions/fold-adapter.ts | 5 ++-- packages/client/runtime/tests/fake-api.ts | 3 +- packages/client/runtime/tsconfig.json | 3 ++ .../ui-conversation/tests/chat-view.spec.tsx | 8 ++--- .../host/apiproxy/src/api/commands.schema.ts | 6 +++- packages/host/apiproxy/src/api/commands.ts | 3 +- .../host/apiproxy/tests/fetch-carrier.spec.ts | 3 +- packages/ui/commands/package.json | 7 +++++ packages/ui/commands/src/brand.ts | 29 +++++++++++++++++++ packages/ui/commands/src/index.ts | 13 +++++---- packages/ui/commands/tsconfig.json | 3 ++ pnpm-lock.yaml | 9 ++++++ tsconfig.base.json | 1 + 19 files changed, 91 insertions(+), 18 deletions(-) create mode 100644 packages/ui/commands/src/brand.ts diff --git a/packages/client/connection/package.json b/packages/client/connection/package.json index c26cafb143..3fba84aab2 100644 --- a/packages/client/connection/package.json +++ b/packages/client/connection/package.json @@ -30,6 +30,7 @@ "license": "BSD-3-Clause", "dependencies": { "@deepseek-ai/dsh-host-apiproxy": "workspace:^", + "@deepseek-ai/dsh-commands": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-tools": "workspace:^" diff --git a/packages/client/connection/src/client/fixture.ts b/packages/client/connection/src/client/fixture.ts index 7db0750027..88e6e2b066 100644 --- a/packages/client/connection/src/client/fixture.ts +++ b/packages/client/connection/src/client/fixture.ts @@ -7,6 +7,9 @@ import type { ContentBlock } from '@deepseek-ai/dsh-llm/types' import type { SessionEvent, SessionId, TodoItem } from '@deepseek-ai/dsh-session/types' +// Type-only: the brand constructor is host-side; the fixture casts at its +// wire-fabrication boundary (the schema layer's one-cast-point posture). +import type { CommandId } from '@deepseek-ai/dsh-commands/brand' import type { ApiProxy, ClientRequest, ClientResponse, HistoryEntry, HostFrame, MuxFrame, RpcReceipt, RpcRequest, RpcResponse, RpcResult, ServerRequest, ServerResponse, SessionSummary, @@ -838,7 +841,7 @@ export function createFixtureApi(options: FixtureOptions = {}): ApiProxy { } const text = name === undefined ? undefined : outcomes[name] if (name === undefined || text === undefined) return ok(request, { matched: false as const }) - const commandId = `fx-cmd-${logOf(id).length}` + const commandId = `fx-cmd-${logOf(id).length}` as CommandId append(id, { type: 'command/run', data: { commandId, name, args, source: { kind: 'user' } } }) append(id, { type: 'command/done', data: { commandId, kind: 'success', ...text === '' ? {} : { text } } }) return ok(request, { matched: true as const, commandId }) diff --git a/packages/client/connection/tests/fake-api.ts b/packages/client/connection/tests/fake-api.ts index c6e7d65204..33d460213c 100644 --- a/packages/client/connection/tests/fake-api.ts +++ b/packages/client/connection/tests/fake-api.ts @@ -1,6 +1,7 @@ // Test-local programmable IApiClient fake (NOT the fixture: fixture is a demo // data source on a real clock; behavior tests need per-case responses and // deferred-controlled timing). Streams are hand pumps: pushMux/pushHost. +import type { CommandId } from '@deepseek-ai/dsh-commands/brand' import type { CommandDescriptor, HostFrame, IApiClient, MuxFrame, RpcRequest, RpcResponse, SessionId, SkillEntry, @@ -94,7 +95,7 @@ export class FakeApiClient implements IApiClient { // wire shapes so cases can program catalogs and skill lists without casts. onCommandList: (payload: unknown) => Promise> = () => Promise.resolve(ok({ commands: [] })) - onCommandExecute: (payload: unknown) => Promise> + onCommandExecute: (payload: unknown) => Promise> = () => Promise.resolve(ok({ matched: false })) onSkillList: (payload: unknown) => Promise> = () => Promise.resolve(ok({ skills: [] })) diff --git a/packages/client/connection/tsconfig.json b/packages/client/connection/tsconfig.json index 97d020dc53..6ff7fbfb25 100644 --- a/packages/client/connection/tsconfig.json +++ b/packages/client/connection/tsconfig.json @@ -15,6 +15,9 @@ { "path": "../../core/session" }, + { + "path": "../../ui/commands" + }, { "path": "../../util/brand" }, diff --git a/packages/client/runtime/package.json b/packages/client/runtime/package.json index a1f3cc9cd2..60e2eddf09 100644 --- a/packages/client/runtime/package.json +++ b/packages/client/runtime/package.json @@ -32,6 +32,7 @@ "license": "BSD-3-Clause", "dependencies": { "@deepseek-ai/dsh-client-connection": "workspace:^", + "@deepseek-ai/dsh-commands": "workspace:^", "@deepseek-ai/dsh-client-ui-slots": "workspace:^", "@deepseek-ai/dsh-host-apiproxy": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", diff --git a/packages/client/runtime/src/client/sessions/conversation.ts b/packages/client/runtime/src/client/sessions/conversation.ts index 5cc672c906..f5f0717236 100644 --- a/packages/client/runtime/src/client/sessions/conversation.ts +++ b/packages/client/runtime/src/client/sessions/conversation.ts @@ -3,6 +3,7 @@ // substructures keep their references (the React.memo premise). callId/approvalId stay plain // string here (narrow to real brands when convenient). +import type { CommandId } from '@deepseek-ai/dsh-commands/brand' import type { ContentBlock } from '@deepseek-ai/dsh-llm/types' import type { TodoItem } from '@deepseek-ai/dsh-session/types' import type { @@ -136,7 +137,7 @@ export interface CommandNode { /** Unix epoch ms of the anchoring event. */ time: number /** Pairing id minted by the host executor. */ - commandId: string + commandId: CommandId /** Command name (run payload's structured field); null when the run fell outside the window. */ name: string | null /** Verbatim rawInput after the name, separator whitespace included (run payload); null when the run fell outside the window. */ diff --git a/packages/client/runtime/src/client/sessions/fold-adapter.ts b/packages/client/runtime/src/client/sessions/fold-adapter.ts index 635d043525..5c79bbf702 100644 --- a/packages/client/runtime/src/client/sessions/fold-adapter.ts +++ b/packages/client/runtime/src/client/sessions/fold-adapter.ts @@ -8,6 +8,7 @@ import type { SessionEvent } from '@deepseek-ai/dsh-session/types' // go through it — the package root points at lib/index.js (needs a build) which the vite // browser bundle cannot resolve; surface.ts has no Node dependencies. import { SurfaceManager, isSurfaceEligibleType } from '@deepseek-ai/dsh-session/surface' +import type { CommandId } from '@deepseek-ai/dsh-commands/brand' import type { ToolCallView, ToolEventView, ToolResultView } from '@deepseek-ai/dsh-client-connection/client' import type { CommandNode, ConversationNode } from './conversation.ts' import { toAssistantBlocks } from './conversation.ts' @@ -229,7 +230,7 @@ export class FoldAdapter { // enter the client program, so this wire consumer narrows structurally // (the same posture as tool/code-dispatch in session.ts). if ((event.type as string) === 'command/run') { - const data = event.data as unknown as { commandId: string; name: string; args: string } + const data = event.data as unknown as { commandId: CommandId; name: string; args: string } this.commandIdx.set(data.commandId, { kind: 'command', seq: event.seq, time: event.time, commandId: data.commandId, name: data.name, args: data.args, outcome: null, @@ -237,7 +238,7 @@ export class FoldAdapter { return } if ((event.type as string) !== 'command/done') return - const data = event.data as unknown as { commandId: string; kind: 'success' | 'error'; text?: string } + const data = event.data as unknown as { commandId: CommandId; kind: 'success' | 'error'; text?: string } const run = this.commandIdx.get(data.commandId) const outcome = { kind: data.kind, ...data.text === undefined ? {} : { text: data.text } } if (run === undefined) { diff --git a/packages/client/runtime/tests/fake-api.ts b/packages/client/runtime/tests/fake-api.ts index 085f2dbfc0..e955e2629b 100644 --- a/packages/client/runtime/tests/fake-api.ts +++ b/packages/client/runtime/tests/fake-api.ts @@ -1,6 +1,7 @@ // Test-local programmable IApiClient fake (NOT the fixture: fixture is a demo // data source on a real clock; behavior tests need per-case responses and // deferred-controlled timing). Streams are hand pumps: pushMux/pushHost. +import type { CommandId } from '@deepseek-ai/dsh-commands/brand' import type { ClientResponse, CommandDescriptor, HostFrame, IApiClient, MuxFrame, RpcError, RpcReceipt, RpcRequest, RpcResponse, SessionId, SkillEntry, @@ -119,7 +120,7 @@ export class FakeApiClient implements IApiClient { // skill lists without casts. onCommandList: (payload: unknown) => Promise> = () => Promise.resolve(ok({ commands: [] })) - onCommandExecute: (payload: unknown) => Promise> + onCommandExecute: (payload: unknown) => Promise> = () => Promise.resolve(ok({ matched: false })) onSkillList: (payload: unknown) => Promise> = () => Promise.resolve(ok({ skills: [] })) diff --git a/packages/client/runtime/tsconfig.json b/packages/client/runtime/tsconfig.json index eea6a26f03..7d3f05e6c7 100644 --- a/packages/client/runtime/tsconfig.json +++ b/packages/client/runtime/tsconfig.json @@ -23,6 +23,9 @@ { "path": "../../host/apiproxy" }, + { + "path": "../../ui/commands" + }, { "path": "../../session-projection/session-projection" }, diff --git a/packages/client/ui-conversation/tests/chat-view.spec.tsx b/packages/client/ui-conversation/tests/chat-view.spec.tsx index 8a55a3733d..601deacc51 100644 --- a/packages/client/ui-conversation/tests/chat-view.spec.tsx +++ b/packages/client/ui-conversation/tests/chat-view.spec.tsx @@ -366,7 +366,7 @@ describe('ChatView', () => { it('renders command nodes as durable rows: settled text, error state, executing spinner, run-less soft-fall', () => { const command = (over: Partial): CommandNode => ({ - kind: 'command', seq: 5, time: 5_000, commandId: 'cmd-1', + kind: 'command', seq: 5, time: 5_000, commandId: 'cmd-1' as CommandNode['commandId'], name: 'plan', args: '', outcome: { kind: 'success', text: '已进入 plan mode' }, ...over, }) @@ -378,7 +378,7 @@ describe('ChatView', () => { // Error outcome flips the row state; a text-less error gets the default copy. const failed = makeHarness({ - nodes: [command({ seq: 6, commandId: 'cmd-2', outcome: { kind: 'error' } })], + nodes: [command({ seq: 6, commandId: 'cmd-2' as CommandNode['commandId'], outcome: { kind: 'error' } })], }) const fv = render() expect(fv.container.querySelector('[data-state="error"]')).not.toBeNull() @@ -386,7 +386,7 @@ describe('ChatView', () => { // Still executing: running state with the executing copy. const executing = makeHarness({ - nodes: [command({ seq: 7, commandId: 'cmd-3', outcome: null })], + nodes: [command({ seq: 7, commandId: 'cmd-3' as CommandNode['commandId'], outcome: null })], }) const xv = render() expect(xv.container.querySelector('[data-state="running"]')).not.toBeNull() @@ -394,7 +394,7 @@ describe('ChatView', () => { // Cross-window soft-fall (run page truncated): generic title, outcome preserved. const orphan = makeHarness({ - nodes: [command({ seq: 8, commandId: 'cmd-4', name: null, args: null, outcome: { kind: 'success' } })], + nodes: [command({ seq: 8, commandId: 'cmd-4' as CommandNode['commandId'], name: null, args: null, outcome: { kind: 'success' } })], }) const ov = render() expect(ov.getByText('命令')).toBeTruthy() diff --git a/packages/host/apiproxy/src/api/commands.schema.ts b/packages/host/apiproxy/src/api/commands.schema.ts index 9d2acb7c20..81d9df2a9f 100644 --- a/packages/host/apiproxy/src/api/commands.schema.ts +++ b/packages/host/apiproxy/src/api/commands.schema.ts @@ -4,6 +4,7 @@ */ import { z } from 'zod' +import type { CommandId } from '@deepseek-ai/dsh-commands/brand' import type { RequestPayload, ResponseValue } from './rpc-map.ts' import type { Wire } from './rpc.schema.ts' import { sessionIdSchema } from './sessions.schema.ts' @@ -32,8 +33,11 @@ export const commandExecuteRequestSchema = z.object({ line: z.string(), }) satisfies z.ZodType>> +/** CommandId: one brand cast after shape validation (the only cast point in this domain). */ +export const commandIdSchema = z.string().min(1) as unknown as z.ZodType + /** command.execute response value: pure admission — outcomes ride the logged lifecycle events; commandId (present exactly when matched) correlates with them. */ export const commandExecuteValueSchema = z.object({ matched: z.boolean(), - commandId: z.string().min(1).optional(), + commandId: commandIdSchema.optional(), }) satisfies z.ZodType>> diff --git a/packages/host/apiproxy/src/api/commands.ts b/packages/host/apiproxy/src/api/commands.ts index 933e753797..994d9196d4 100644 --- a/packages/host/apiproxy/src/api/commands.ts +++ b/packages/host/apiproxy/src/api/commands.ts @@ -5,6 +5,7 @@ * together), so there is no agent-less surface on this wire. */ +import type { CommandId } from '@deepseek-ai/dsh-commands/brand' import type { SessionId } from '@deepseek-ai/dsh-session/types' import type { RpcRequest, RpcResponse } from './rpc.ts' @@ -43,5 +44,5 @@ export interface CommandsApi { * wire: the fetch carrier's request signal cancels the running handler. */ execute(request: RpcRequest<{ sessionId: SessionId; line: string }>, signal: AbortSignal): - Promise> + Promise> } diff --git a/packages/host/apiproxy/tests/fetch-carrier.spec.ts b/packages/host/apiproxy/tests/fetch-carrier.spec.ts index e38951a274..d77cbd9dc4 100644 --- a/packages/host/apiproxy/tests/fetch-carrier.spec.ts +++ b/packages/host/apiproxy/tests/fetch-carrier.spec.ts @@ -1,3 +1,4 @@ +import { CommandId } from '@deepseek-ai/dsh-commands/brand' import { describe, expect, it, vi } from 'vitest' import type { ApiProxy, HostFrame, MuxFrame } from '../src/api/index.ts' import type { ClientResponse, RpcMessage, RpcReceipt, RpcRequest } from '../src/api/rpc.ts' @@ -91,7 +92,7 @@ function fakeApi(overrides: Partial<{ muxFrames: MuxFrame[]; hostFrames: HostFra return { rpcId: request.rpcId, result: { ok: false, error: { code: 'cancelled', message: 'aborted', details: {} } } } } if (request.payload.line.startsWith('/plan')) { - return { rpcId: request.rpcId, result: { ok: true, value: { matched: true, commandId: 'cmd-x' } } } + return { rpcId: request.rpcId, result: { ok: true, value: { matched: true, commandId: CommandId('cmd-x') } } } } return { rpcId: request.rpcId, result: { ok: true, value: { matched: false } } } }, diff --git a/packages/ui/commands/package.json b/packages/ui/commands/package.json index 6282f9ae08..e22dee8f3b 100644 --- a/packages/ui/commands/package.json +++ b/packages/ui/commands/package.json @@ -15,12 +15,17 @@ "types": "./lib/types/invariant.d.ts", "default": "./lib/invariant.js" }, + "./brand": { + "types": "./lib/types/brand.d.ts", + "default": "./lib/types/brand.js" + }, "./src/*": "./src/*", "./package.json": "./package.json" }, "files": [ "lib/index.js", "lib/invariant.js", + "lib/types/**/*.js", "lib/types/**/*.d.ts", "lib/types/**/*.d.ts.map", "src" @@ -28,6 +33,7 @@ "license": "BSD-3-Clause", "peerDependencies": { "@deepseek-ai/dsh-agent": "^0.0.1", + "@deepseek-ai/dsh-brand": "^0.0.1", "@deepseek-ai/dsh-invariants": "^0.0.1", "@deepseek-ai/dsh-scope": "^0.0.1", "@deepseek-ai/dsh-session": "^0.0.1", @@ -35,6 +41,7 @@ }, "devDependencies": { "@deepseek-ai/dsh-agent": "workspace:^", + "@deepseek-ai/dsh-brand": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-scope": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", diff --git a/packages/ui/commands/src/brand.ts b/packages/ui/commands/src/brand.ts new file mode 100644 index 0000000000..d9232d5c11 --- /dev/null +++ b/packages/ui/commands/src/brand.ts @@ -0,0 +1,29 @@ +/** + * dsh-commands' owned branded id: command lifecycle pairing across the + * session log, the wire admission response, and client-side flow pairing. + * + * The `Branded` primitive lives in `@deepseek-ai/dsh-brand`; this module + * is a pure type/constructor outlet (no cordis imports, no module + * augmentation) so wire and client programs can name the brand without + * loading the host plugin's Context merges — the `dsh-llm/brand` shape. + * + * @module @deepseek-ai/dsh-commands/brand + */ + +import type { Branded } from '@deepseek-ai/dsh-brand' + +/** + * Pairs one command execution's `command/run`/`command/done` lifecycle + * records with each other and with the `command.execute` admission response. + * Minted by the executor, monotonic per service instance. + */ +export type CommandId = Branded<'CommandId'> + +/** + * Brand a string as a {@link CommandId}. + * @param id - the executor-minted pairing id. + * @returns the same string, branded; no validation is performed. + */ +export function CommandId(id: string): CommandId { + return id as CommandId +} diff --git a/packages/ui/commands/src/index.ts b/packages/ui/commands/src/index.ts index 3f3ed6f037..b9d38cf55e 100644 --- a/packages/ui/commands/src/index.ts +++ b/packages/ui/commands/src/index.ts @@ -8,6 +8,9 @@ import type { Agent } from '@deepseek-ai/dsh-agent' import { NamedEntries, ScopedLayers } from '@deepseek-ai/dsh-scope' import type { ScopeKey, ScopeLayer } from '@deepseek-ai/dsh-scope' import type { Session, SessionEvent, SessionEventMap } from '@deepseek-ai/dsh-session' +import { CommandId } from './brand.ts' + +export { CommandId } from './brand.ts' export const name = 'commands' @@ -55,7 +58,7 @@ export type CommandResult = */ export interface CommandExecution { /** Pairing id carried by this execution's lifecycle events. */ - readonly commandId: string + readonly commandId: CommandId /** The handler's normalized outcome. */ readonly result: CommandResult } @@ -131,13 +134,13 @@ declare module '@deepseek-ai/dsh-session' { * folding its own command records, a rich command card) never re-parses * a line. */ - 'command/run': { commandId: string; name: string; args: string; source: CommandSource } + 'command/run': { commandId: CommandId; name: string; args: string; source: CommandSource } /** * The paired command settled. `kind`/`text` carry the handler's verbatim * outcome (a thrown/aborted handler settles as `kind: 'error'` with the * rendered failure); presentation stays client-computed at render time. */ - 'command/done': { commandId: string; kind: 'success' | 'error'; text?: string } + 'command/done': { commandId: CommandId; kind: 'success' | 'error'; text?: string } } interface OutOfBandSessionEventMap { @@ -397,9 +400,9 @@ export class CommandService extends Service { } /** Mint the next pairing id (monotonic; instance-token-prefixed so a resumed log never repeats one). */ - private mintCommandId(): string { + private mintCommandId(): CommandId { this.commandSeq += 1 - return `cmd-${this.instanceToken}-${this.commandSeq}` + return CommandId(`cmd-${this.instanceToken}-${this.commandSeq}`) } /** diff --git a/packages/ui/commands/tsconfig.json b/packages/ui/commands/tsconfig.json index 470acd72df..901c76a377 100644 --- a/packages/ui/commands/tsconfig.json +++ b/packages/ui/commands/tsconfig.json @@ -23,6 +23,9 @@ { "path": "../../core/session" }, + { + "path": "../../util/brand" + }, { "path": "../../support/invariants" } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index db1865ae81..0f34f40a76 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -776,6 +776,9 @@ importers: packages/client/connection: dependencies: + '@deepseek-ai/dsh-commands': + specifier: workspace:^ + version: link:../../ui/commands '@deepseek-ai/dsh-host-apiproxy': specifier: workspace:^ version: link:../../host/apiproxy @@ -868,6 +871,9 @@ importers: '@deepseek-ai/dsh-client-ui-slots': specifier: workspace:^ version: link:../ui-slots + '@deepseek-ai/dsh-commands': + specifier: workspace:^ + version: link:../../ui/commands '@deepseek-ai/dsh-host-apiproxy': specifier: workspace:^ version: link:../../host/apiproxy @@ -4365,6 +4371,9 @@ importers: '@deepseek-ai/dsh-agent': specifier: workspace:^ version: link:../../core/agent + '@deepseek-ai/dsh-brand': + specifier: workspace:^ + version: link:../../util/brand '@deepseek-ai/dsh-invariants': specifier: workspace:^ version: link:../../support/invariants diff --git a/tsconfig.base.json b/tsconfig.base.json index 9785cda512..c6d94ec998 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -48,6 +48,7 @@ "@deepseek-ai/dsh-session-title/client": ["./packages/session-title/session-title/src/client.ts"], "@deepseek-ai/dsh-llm/types": ["./packages/llm/llm/src/types.ts"], "@deepseek-ai/dsh-llm/brand": ["./packages/llm/llm/src/brand.ts"], + "@deepseek-ai/dsh-commands/brand": ["./packages/ui/commands/src/brand.ts"], "@deepseek-ai/dsh-tools/presentation": ["./packages/core/tools/src/presentation.ts"], "@deepseek-ai/dsh-user-approval/types": ["./packages/ui/user-approval/src/types.ts"], "@deepseek-ai/dsh-user-interaction/types": ["./packages/ui/user-interaction/src/types.ts"],